Build Pipeline 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.
Host ORCHESTRATION that three targets must agree on, left in one target's host file — the second target then either re-derives it or drops it, and both answers are wrong
(the chart legend-placement instance, 2026-09). chrome.ts crossed WHAT a legend lists and WHAT a tap says, and that fixed the divergences it was written for. WHERE the legend goes stayed in the web canvas-host.tsx as a four-branch block, so the native emitters had two options: re-derive four placements, or draw every legend at the top and warn that legendPosition does not lower. They did the second — and the ONE placement both targets did implement disagreed anyway: the emit drew the legend at x: 0 across the full width while the web host inset it by 8 on each side and pushed the plot 8 further down. A legend 8px left of where a browser puts it, on every native chart with a legend, reported by nothing. Rule: a decision both a web host and a code generator have to make is engine work, not host work. If a second implementation would have to READ the first to stay right, there should only be one — and the tell that you are looking at such a decision is a prop that "does not lower yet" while every primitive it needs already crosses. placeLegend(entries, area, position, opts, measure) -> { cmds, top, bottom, left, right, boxes } is now the single implementation; the emitters pass a position and read insets. Two things kept the change honest. (1) Only the insets a position can actually take are emitted, so the default (top) keeps the exact one-axis pyreonShiftCmds(p, top) and height it had — a uniform four-inset emit would have churned every lock for nothing and made the diff unreadable. (2) The tap offset travels WITH the placement: a left legend indents the plot, so a tap's x has to come back out of it, and folding that into the chrome's existing tapX (which already folds the RTL unmirror) is what stops four of the five hosts that read a tap from forgetting — the same pairing argument the mirror/tapX comment already makes. The lock that matters asserts the emitters call placeLegend and NEVER renderLegend, because re-deriving is exactly what they must not be able to do again. Reference: packages/fundamentals/charts/src/engine/legend.ts:placeLegend + emit-{swift,kotlin}.ts chrome; locked by legend-place.test.ts (web) + chart-legend-position.test.ts (both targets, incl. real swiftc/kotlinc compiles of all four positions). The follow-on bug is the sharper half, and my own commit message argued FOR it: folding the new indent into the chrome's shared tapX — reasoned as "the five hosts would each have to remember" — broke the CHROME hits, because a legend's own entry boxes, its pager and the preset strip are laid out in CANVAS coordinates (drawn BEFORE the plot is shifted). Asking them about a plot-space point misses by the legend's whole width, so a left legend would have silently stopped toggling. Rule: an offset folded into a shared accessor must be classified by the SPACE each caller reads, not by how many callers there are — tapY had drawn exactly this line for years (chrome reads raw, the plot subtracts the title) and the x side only needed it once something moved the plot HORIZONTALLY. Two detection notes: the first regression spec PASSED against the bug, because the raw-x string is a PREFIX of the indented one and toContain matched both (assert through the next argument's separator); and a DEVICE assertion that taps a hardcoded coordinate goes stale with any layout change — the iOS legend tap at (20, 6) fell outside the entry box once the 8px pad moved it to y 8…19.
A user type whose NAME matches a GENERATED type shadows it silently — and "an example caught it once" is not a gate
(the chart-engine Slice instance, 2026-09). PMTC merges the generated chart engine's 137 struct/enum declarations into any file importing @pyreon/charts/plot, and the emit constructs them by BARE name (Slice(value:label:)). A page that declared its own interface Slice therefore shadowed the pie datum's: invalid redeclaration of 'Slice' in the single-file compile gates, a type mismatch at every engine call in a real two-module app — with zero warnings, because nothing compared the user's declarations against the generated ones. It was found by native-examples-compile.test.ts, which covers this repo's examples and nobody else's, and the follow-up sat unbuilt for a week because a compile error in an example reads as that example's problem. Rule: when a compiler MERGES generated declarations into user source, the merge point is where the name collision must be detected — the generator already knows every name it emits, so publish that list beside the declarations and diff it against the user's at merge time. The scope is where the honesty is: only TYPES belong in such a list here. Engine FUNCTIONS can overload on both targets, so a shared name is fatal only when the signature matches too; the engine's module constants are emitted private, so they collide in the concatenated compile GATES but not in a real app build — warning on either would tell a user to rename working code, which is how a real diagnostic becomes noise people mute. Reference: packages/native/compiler/scripts/gen-chart-engine.ts (CHART_ENGINE_DECLARED_NAMES, generated from the same parse as the struct list so a new engine type cannot arrive without its entry) + index.ts:chartEngineShadowWarnings; locked by chart-engine-shadow.test.ts — whose load-bearing spec asserts the predicted compile FAILURE on both real toolchains and that the renamed twin passes, since a fixture that only fails proves nothing about the collision. Bisect-verified in both directions (drop the diff → the warning vanishes; drop structs from the registry → the totality spec fails naming 134 types).
A DECLINE whose reason names a DATA SHAPE is a request to change the data shape, not the compiler — and the remedy the diagnostic prints must itself compile
(the <MapChart> instance, 2026-09). <MapChart> was the last unlowered @pyreon/charts/plot host, declined by name with a good reason: GeoJSON's geometry is a Polygon | MultiPolygon union whose coordinates are number[][][] and number[][][][], one field at two array depths, which the fat-struct lowering correctly refuses to merge. The reason even named the unblock ("normalise the two geometry kinds to ONE representation before crossing"), and it still sat as a decline for a release — because a decline READS as a compiler limitation, so nobody scheduled it as product work. The fix touched no lowering at all: the public prop gained a THIRD accepted shape (GeoShape[] — that union already normalised to rings), the two web-only shapes refuse individually, and the host lowers. Rule: when a decline's stated reason is about the SHAPE of the data crossing, re-read it as a proposal for the public API. Widening a prop is usually cheaper than teaching the lowering a union, and it puts the choice where the user can see it. Two sub-lessons, both cheap to hit. (1) The remedy the warning prints must be run through the compiler. The first draft told the user to write map={geoShapes(json)} — and geoShapes reads GeoJSON, so it is web-only too and warns by name: following the advice trades one warning for another. Compile the recommended snippet, don't just write it. Sibling of "guidance that claims a capability must name the IMPORT". (2) A theme token that means "inherit" is not a colour, and a default derived from it must resolve at the SOURCE. background: '' means "inherit the page"; the web host spelled theme.background === '' ? '#ffffff' : theme.background inline in the one host that needed it, and a table-driven native default emitted borderColor: "". Fixing it in the CONSUMER could not work — by then the value is the emitter's runtime colour-scheme conditional, not a colour — so the resolution belongs where the light/dark values are still raw (chartThemeFields derives pageGround once, correct for the literal, named-theme and runtime paths alike). Reference: packages/native/compiler/src/chart-hosts.ts (geoShapesAdapter / geoValuesAdapter / CHART_THEME_SOURCE.borderColor); locked by chart-hosts.test.ts (9 specs, three of them real swiftc/kotlinc compiles), bisect-verified in three directions.
A plain object used as a DICTIONARY must not be reached with ordinary property operations — Object.prototype's members are inherited, so both the read and the write are wrong (TWO independent instances, one week, 2026-09)
the class is one sentence — when the KEYS come from data rather than from the program, key in obj, obj[key] and obj[key] = v all consult the prototype chain, and __proto__ additionally hits an accessor. It has shown up twice from opposite directions. Read side (#3182, @pyreon/validate): .strict()'s unknown-key scan used key in known, and in walks the chain — so an input key named toString / constructor / hasOwnProperty / valueOf counted as KNOWN and slipped past strict mode, which is the one thing .strict() exists to prevent. Fixed with Object.hasOwn. Write side (@pyreon/lathe's YAML input): both mapping paths assigned map[key] = value, and __proto__ is an accessor inherited from Object.prototype, so the assignment REPLACED the object's prototype instead of adding a key. Measured: Object.keys(node) lost the key entirely while node.polluted resolved through the injected prototype — a spec property that vanishes from the IR, and generator inputs the spec author never wrote. That input is untrusted (lathe pull <url> fetches a spec over the network) and the emitters turn the IR into SOURCE, so a dropped field is a missing field in a generated client. Fixed with Object.defineProperty. The tell, in both cases, was a SIBLING that was already right: parseSpecText routes .json to JSON.parse, which is specified to DefineOwnProperty and therefore makes __proto__ an ordinary own key — so lathe's two input formats disagreed about the same document, and the YAML answer was the wrong one. General rule: pick the predicate by where the KEY came from, not by what reads naturally. Program-supplied key → ordinary access is fine. Data-supplied key → Object.hasOwn to test, Object.defineProperty (or a null-prototype object) to write. And when a module has two readers for the same data, the divergence between them is the cheapest place to find the bug. Reference: packages/fundamentals/validate/src/composition/object.ts + packages/tools/lathe/src/input/yaml.ts:setKey; bisect-verified in validate/src/tests/strict-prototype-keys.test.ts and lathe/src/tests/yaml-prototype-keys.test.ts (3 of 4 fail on revert — the constructor companion passes broken, because plain assignment DOES define that one, which is why it is a companion and not the lock).
Two passes rewriting the SAME node, one deciding syntax from the AST PARENT while the other RELOCATED the text
(the collapse × absorbed-hole brace bug, 2026-08). templatizeComponentChildren preserves a component child as a HOLE and splices its transformed text into a call argument (_mountChild(<Button/>, __root, null)). collapseRocketstyle rewrites that same <Button/> into a call and decides whether to wrap it in JSX braces by asking findParent(node) — still the enclosing <div>, so YES. Result: _mountChild({__rsCollapse("…", …)}, __root, null), which is not parseable JavaScript. Neither pass is wrong on its own; they disagree about where the node's text ends up, and a node's AST parent stops answering that question the moment another pass moves it. Rule: a syntax decision that depends on emit POSITION (braces, parens, semicolons, return) must be derived from where the text is going, not from where the node was written — the two are the same only while nothing relocates it. Fix: the relocating pass declares it (JS marks the hole nodes in argPositionNodes before walking them; Rust clears its parent_is_jsx frame flag across the hole walk, SAVED and RESTORED because holes nest), and every brace decision consults that. Two detection lessons. (1) It was invisible to the whole compiler + runtime suite and to every e2e, because it needs BOTH features at once and templatizeComponentChildren was opt-in; verify-modes caught it the moment the default flipped — 17 syntax errors on ui-showcase × spa — which is the gate that builds every example × mode and the reason a default flip must run it. (2) The bisect nearly certified a one-sided fix. Reverting the RUST guard left all five original specs GREEN, because at top level parent_is_jsx is already false — the native backend only breaks when the templatized element is ITSELF a JSX child (a component's sole child, the _lc path). The corpus had no such case, so the Rust half looked like dead code; it took constructing that shape to show native emitting the braced form and diverging from JS. A per-backend bisect that passes is a claim about your CORPUS, not about the code. Reference: compiler/src/jsx.ts:bracesForParent + native/src/lib.rs (hole walk); locked by compiler/src/tests/collapse-absorbed-hole.test.ts, which gates on REPARSING (oxc-parser) rather than a string match, because the failure mode is "emits text that is not JavaScript".
A _tpl bind runs at EXPRESSION-EVALUATION time, so it may build DOM but must not MOUNT COMPONENTS
(the templatize-component-children attempt, 2026-08 — measured, root-caused, NOT shipped). The template emitter bails on a component child, so <div class="branch"><Node/><Node/></div> lowers to h() → mountElement. Baking the element and APPENDING the children instead — _tpl("<div class=\"branch\"></div>", (__root) => { _mountChild(<Node …/>, __root, null) }), which is byte-for-byte the shape Solid's compiler emits — is worth a LOT: measured A/B on the 2,047-component deep-tree mount, production builds, real Chromium, Vanilla+Solid as in-run controls agreeing within CI, 4.46ms → 3.90ms (−12.6%), closing 42% of the whole remaining gap to Solid (1.26ms → 0.73ms; standing 1.39× → 1.23×). It is still WRONG, for a reason no amount of compiler-side care removes. _tpl(html, bind) invokes bind when the CALL EXPRESSION is evaluated, and Pyreon passes component children EAGERLY (h(Comp, props, ...children)), so in <PyreonUI theme={t}>{_tpl(…)}</PyreonUI> the bind is an argument — it runs, and mounts the entire route subtree, BEFORE PyreonUI ever executes its provide(theme). Every rocketstyle descendant then reads an unprovided theme and dies on Cannot read properties of undefined (reading 'base') (the same signature as the deferred-island owner bug). Measured: ui-showcase-regression went 26/26 → 4/26. Solid emits the identical template+insert and is safe ONLY because its compiler thunks component children — verified by running the installed babel-preset-solid, not assumed: _$createComponent(Provider, { theme: t, get children() { var _el$ = _tmpl$(); _$insert(_el$, _$createComponent(Child, {})); return _el$ } }). The lazy getter is the whole mechanism. So the invariant is: a compiled template may only construct DOM; the moment its bind performs COMPONENT SETUP, mount order stops matching the mount pipeline's order, and no static eligibility rule fixes it — "the element is in return position" leaks through <Comp>{helper()}</Comp>, where the helper's own body is in return position but its call site is an eager argument. Prerequisite for retrying: make component children lazy (thunked) the way Solid does, or defer the appended mounts to when the NativeItem is INSERTED (a _tpl/NativeItem contract change every consumer — mountChild, hydrateChild, mountFor, mountKeyedList, KeepAlive, TransitionGroup, _setChild — must honour, or components silently never mount). A SECOND, independent blocker to price in: templatizing an element converts a hydration ADOPT boundary into a SWAP one for its whole subtree, so <tbody><For/></tbody> took a 1,000-row SSR table from adopting every row to rebuilding every row (runtime.tpl.adopt 3 → 0 on hydrate-tpl-adoption.test.tsx, which is what caught it; SSR-node retention 2/6 → 0/6 on a 3-level tree). Excluding control-flow components by name papers over the gated case and leaves the general one. Detection lesson: the whole compiler suite (2,047 tests incl. native-equivalence + the 300-seed differential fuzz), runtime-dom (1,249), a purpose-written 54-spec regression file with a 40-seed SSR↔hydration parity sweep, and the ssr-node e2e were ALL GREEN against the broken build. Only the real-app ui-showcase-regression gate failed — because the bug needs a provider ABOVE a templatized element, which no synthetic fixture had. A template-emission change is a real-app-e2e change. UPDATE — the ordering half of this is now CLOSED and the emit SHIPPED opt-in as templatizeComponentChildren (see the entry below). #2916 made a component's SOLE child lazy (_lc), which covers the <PyreonUI>{_tpl(…)}</PyreonUI> shape above; the remaining eager-argument positions (multi-child component parent, member/namespaced tag parent, fragment, expression container) are handled by BAILING to h(), which is a static eligibility rule — so the "no static eligibility rule fixes it" claim above holds only for the leak it names (a helper whose call site is an eager argument), not for the emit as a whole. Re-measured against the real compiled emit: 4.53ms → 3.94ms (−13.0%), 41% of the gap — the prediction here was right, but note it was derived from the hand-written Pyreon (tpl append) arm, and an early build of the shipped emit read 3.19ms ONLY because it had silently dropped the _rp wrappers (a faster arm that had stopped doing the work). The SECOND blocker named here — hydration adopt→swap — is still open and is exactly why the option is DEFAULT OFF.
A name-shadow check that matches only a SIMPLE declaration misses destructured bindings — and an injecting transform then collides with them
(the jsxAutoImport Text instance, 2026-08). @pyreon/vite-plugin's JSX auto-import skips a name that is "already imported OR shadowed by a local declaration", and its shadow regex required the name immediately after the keyword (const ${name}\b). const { Form, Text } = createForm(schema) binds Text and matched NOTHING, so the pass injected import { Text } from '@pyreon/primitives' on top of it and the build died with Identifier 'Text' has already been declared — pointing at a line the author never wrote. A factory returning named components is an entirely ordinary shape; this broke a real app's own vite build, independent of the tool that found it. Two rules. (1) A shadow/binding scan must cover every BINDING FORM the language offers — simple, object-destructured, array-destructured, renamed — not just the one the author had in mind; enumerate the grammar, don't pattern-match the example. (2) Bias an injecting transform toward NOT injecting: skipping an auto-import costs an explicit import the author can add, injecting a colliding one costs them a build, so a false positive (const { Text: Renamed } treated as shadowing Text) is the correct trade. Bisect-verified in vite-plugin/src/tests/jsx-auto-import.test.ts (revert → expected 'import { Stack, Text }' to contain 'import { Stack }'). Same family as the _rp injected-import collision entry — a transform that writes imports into user source must reconcile with every name already in that scope.
A config file at the repo ROOT can import almost nothing — and root configs are exactly where frameworks put theme/wrapper
(the atlas.config.ts instance, 2026-08). A package manager links a dependency only into packages that DECLARE it, and a monorepo root's package.json typically declares a linter and a CLI and nothing else. So a root config could import neither the project's own workspace packages (@acme/ui-theme) nor @pyreon/core (needed to build a wrapper's vnode) — the one file that unlocks rocketstyle discovery and provider-wrapped mounting, structurally unable to import what it needs. Measured on a real 78-package workspace: the config errored, and the tool then reported "no config" and carried on. The fix is a LOOKUP, not a resolution algorithm — the workspace already declares where its packages are and each declares its name, so match the two; for anything else, run Node's real resolution from a base that legitimately declares the dependency. Scope it to the config: a COMPONENT that cannot resolve an import has a real dependency bug, and resolving it from some other package would hide it. Companion trap: one entryFromExports helper cannot answer both "where are the types" and "what do I load". Preferring types is right for prop-type resolution and lands on index.d.ts for a loader — a declaration file whose relative imports point at chunks that do not exist, so the failure reads as a missing file rather than a wrong pick. Make the caller state which it wants. Bisect-verified in atlas/src/discover/tests/config-resolution.test.ts.
A compiler transform that injects a bare framework-package import into USER source, turned DEFAULT-ON, crashes any app that can't resolve that package to the RIGHT instance
(the ssrTemplate compile-to-string default-on 500, 2026-07). The SSR compile-to-string fast path lowers eligible JSX to _ssr([...],…) and INJECTS import { _ssr, _esc, … } from "@pyreon/runtime-server" into the app's own .tsx. As an OPT-IN flag this was fine (apps that enabled it knew to make @pyreon/runtime-server resolvable). Flipped DEFAULT-ON, it deterministically 500'd examples/islands-showcase at SSR: Cannot find module '@pyreon/runtime-server' imported from '…/App.tsx' — the app declares @pyreon/server (which depends on runtime-server TRANSITIVELY) but the bun isolated store never exposes a transitive dep to the app's source files, so the injected bare import is unresolvable. Two compounding traps: (1) the injected import's resolvability is NOT guaranteed across package-manager layouts — a strict/isolated layout (bun isolated, pnpm strict) only exposes DIRECT deps, so @pyreon/runtime-server (a transitive dep of @pyreon/server/@pyreon/zero) is not importable from app source even though it's "installed"; (2) the plugin-location resolveId fallback (the dev-error-printer @pyreon/compiler/diagnose pattern) is UNSAFE here because _ssr/_ssrChildren return a RawHtml object that renderToString recognizes via node instanceof RawHtml (runtime-server/src/index.ts:71,591,824) — an instanceof check is INSTANCE-SENSITIVE, so a second @pyreon/runtime-server copy resolved from the plugin's own node_modules would produce a RawHtml the app's renderToString doesn't recognize → verbatim-HTML concat breaks. So the injected import MUST resolve to the app's OWN instance, not a framework-provided one. The rule: a default-ON compiler transform must NEVER inject a package import whose resolution (or instance identity) it can't guarantee for the target app. A safe default-on needs a BUILD-TIME capability gate — the vite-plugin probes whether @pyreon/runtime-server resolves from the app (reuse scanPyreonDepsTransitive) and only passes ssrTemplate: true when it does, else falls back to the h() SSR path (graceful degradation, never a 500). The compiler PRIMITIVE default stays OPT-IN (ssrTemplate === true) — a bare transformJSX({ ssr: true }) must not silently inject an import a direct consumer didn't ask for. Detection: ssr-node + ssr-showcase e2e did NOT cover this — the islands-showcase suite did (it uses @pyreon/server directly, not @pyreon/zero, so its transitive layout differs). A default-on SSR-compile change MUST run EVERY SSR e2e suite (ssr-node / ssr-showcase / islands-showcase), because resolvability depends on which framework entry (@pyreon/server vs @pyreon/zero) the app declares. Bisect-verified: default-on → islands page.goto('/') returns 500 (deterministic across retries); opt-in → 9/9 green. The native _ssr parity (both backends emit byte-identical _ssr) is orthogonal + safe — this trap is purely the default-ON import injection, so native parity ships opt-in and default-on is a tracked follow-up (vite-plugin resolvability gate).
require(...) in an ESM (type: module) published package — a runtime crash in every browser bundle, invisible to node tests + the no-view unit path
(the @pyreon/code foldAll instance). editor.foldAll()/unfoldAll() lazily did const { foldAll } = require('@codemirror/language') — but the package is type: module, so require is undefined in a real browser and the call threw ReferenceError: require is not defined. It shipped uncaught because the ONLY test coverage was the no-view bail path (if (!v) return before the require), which never reaches it — and node/bun define require even in ESM, so a node vitest run wouldn't have caught it either. Rule: an ESM package must never require() — statically import the symbol at module top (foldAll/unfoldAll ARE exported from @codemirror/language; the lazy require bought nothing). Detection: any method that only runs against a live browser resource (a mounted view, a canvas, a real layout) needs a real-Chromium test that EXERCISES it, not just the no-resource bail. Bisect-verified in code.browser.test.tsx (revert → not.toThrow fails with the exact require is not defined). SECOND instance, 2026-08, with a WORSE failure mode — a SERVER-side module where the throw is swallowed by its own catch, so there is no crash to notice at all. @pyreon/zero's https/cert.ts:expiryOf did require('node:crypto') inside a try { … } catch { return null }: under Node the ReferenceError was caught, null returned, and the caller's ?? new Date(Date.now() + 24h) fallback silently gave every dev certificate a 24-HOUR expiry instead of its real 825 days — so the cache reissued daily, the browser interstitial returned every day, and any manually-trusted certificate stopped working. Two additions to the rule. (a) Bun defines require in ESM as a convenience, so a bun-run vitest suite CANNOT catch this — 27 passing specs and a real dev-server boot all missed it; reproduce with a .mjs under real node (ReferenceError: require is not defined). (b) A behavioural test is structurally incapable of locking it for the same reason, so the regression lock is STATIC: assert no require( appears in the module's sources (https-internals.test.ts "ESM discipline", bisect-verified). Generalised rule: a require() inside a try/catch that degrades is strictly more dangerous than one that crashes — the crash is a bug report, the degradation is a mystery. Grep for require( in any type: module package rather than trusting a green suite. THIRD instance, 2026-08 — in the LINTER, found by the rule written to close the class, on its first run. @pyreon/lint is "type": "module" and builds to ESM, and both its LSP (_findProjectRoot) and its own require-browser-smoke-test rule read the filesystem through require('node:fs'). Both sit inside try/catch, so this is the degrading variant again — and the degradation is the worst one available: loadBrowserPackages' fallback is an EMPTY SET, so under Node the rule matched zero packages and could never fire, while .claude/rules/browser-packages.json's 28 entries were silently ignored. That is the "structurally-dead rule" entry above, arriving through this door, under one runtime only. Measured on the SHIPPED lib/ with identical input: bun 1 diagnostic, node 0 — so npx pyreon-lint (Node) enforced less than bun did, invisibly. Two details worth carrying: the motives were mundane and both looked reasonable in review — one file already imported node:fs statically three lines up and reached for require only for readFileSync, and the LSP's sites carried a biome-ignore … noNodejsModules comment, i.e. the require existed to DODGE A LINT RULE and traded a warning for a runtime failure (the repo has since moved to oxlint, so the suppression was dead too). And the bundler makes it harder to see, not easier: rolldown rewrites the call to a __require shim that resolves to undefined under ESM instead of leaving a grep-able require(. Now caught statically by pyreon/no-require-in-esm (error), which gates on the owning package's type field with .cjs/.mjs beating the manifest, stays quiet on typeof require detection and on a locally-bound require, and is proven against both shipped shapes.
Detecting a library's state via a DOM class the library doesn't actually add
(the @pyreon/code minimap dark-mode instance). The canvas minimap picked its background from view.dom.classList.contains('cm-dark') — but CodeMirror 6 applies HASHED style-mod classes (ͼ1 ͼ3 …), never a literal cm-dark, so the check was ALWAYS false and a dark editor always rendered a LIGHT minimap. Rule: read a library's state through its own API, not a guessed DOM signal — CM6 exposes view.state.facet(EditorView.darkTheme) (set by EditorView.theme(spec, { dark: true })). A DOM-class heuristic silently rots when the library changes/hashes its class names. Detection: assert the actual rendered EFFECT (here: spy the first canvas fillRect's fillStyle → the chosen bg color), not hasAttribute/classList — a class-existence assertion would false-pass on the broken code. Bisect-verified in code.browser.test.tsx (revert → dark editor paints #f8fafc).
TWO owners for one build post-step — a CLI wrapper re-implementing work a plugin's closeBundle already does
(the 0.43.x zero build defect). @pyreon/zero's ssr-plugin owns the SSR post-step (server bundle → dist/server/entry-server.js + template.html staging + adapter dispatch — the path verify-modes + the ssr-node/isr-node e2e exercise). The zero build CLI ALSO ran its own vite build --ssr pass, prerender pass, and adapter.build() → dist/output — each wrapped in a bare catch { /* optional */ }. Result: the SSR bundle built TWICE into divergent trees (with a user entry: FOUR copies), the CLI's deployed dist/output server had NO template.html (→ DEFAULT_TEMPLATE + /src/entry-client.ts — the "Production SSR shipping the DEV client entry" class: server-renders, never hydrates), zero-config apps got NO server bundle at the documented location at all, and every failure was swallowed into a green "Build completed" (the "silent-filter" shape, in the very commit meant to kill silent failures). Fix: ONE owner — the CLI is now exactly one vite build; the plugin chain owns client + server + template + prerender + adapter, and an EXPLICITLY-configured adapter failure rethrows (fails the build) while auto-selected adapter failures stay console-error-only. Rules: (a) a CLI wrapping a plugin-driven build must DELEGATE, never duplicate, the plugin's post-step — two owners produce divergent artifact trees and which one deploys is platform-config luck; (b) never catch {} a deploy-artifact step — surface + fail when the user explicitly asked for that artifact; (c) recursion-gate env-flag literals shared across plugins live in ONE module (build-flags.ts), not kept in sync by comment; a flag LEAKED from a parent process (vs set by an in-process sub-build — discriminated by an in-process marker) prints a one-line notice instead of silently disabling the whole post-step. Reference: packages/zero/cli/src/commands/build.ts, packages/zero/zero/src/{build-flags.ts,ssr-plugin.ts,ssg-plugin.ts}; regression locks (all bisect-verified): cli/src/commands/build.test.ts (end invariants: ONE server bundle, template next to THE entry, no dist/output) + zero/src/tests/integration/build-post-step.test.ts (real builds; explicit-adapter rejects, auto continues).
An uncapped >=X dependency range on a library that uses a version-specific API silently opts into the next breaking MAJOR the day it becomes latest
(the TS7 ESNext crash, 2026-07). @pyreon/{compiler,mcp,cli} declared typescript: ">=5.0.0" (deliberately wide, per a stale "don't narrow it" note from when TS7 was only on the rc tag). When TypeScript 7.0.2 became latest on npm, every fresh bunx @pyreon/mcp / clean install resolved >=5.0.0 → TS7 — which REMOVED the classic Compiler API (createSourceFile/ScriptTarget), so ts.createSourceFile(f, code, ts.ScriptTarget.ESNext, …) threw the cryptic Cannot read properties of undefined (reading 'ESNext') in every parse-backed tool (validate/migrate/audit). A project pinned to 5.x/6.x kept working, which MASKED it until a fresh install hit latest. Three durable rules: (a) a library that depends on a version-SPECIFIC API surface must EXCLUDE by range every major that removed it — ">=5.0.0 <7.0.0", never a bare >=X that trusts the ecosystem not to ship a breaking latest; (b) a dependency the shipped lib UNCONDITIONALLY imports + calls is a real dependency, not a peer — a peer the code hard-requires only "works" because some consumer happens to re-declare it (compiler's typescript was a peer while its lib always import ts from 'typescript'); (c) add a self-diagnosing GUARD at the API-use boundary (a plain function assertClassicTs() before each ts.createSourceFile, NOT an import-time throw — the server + non-parsing tools stay up) so a force-pinned bad major fails with [Pyreon] needs TypeScript 5.x/6.x … pin ">=5.0.0 <7.0.0" instead of a mystifying undefined.ESNext. Fix: packages/core/compiler/src/ts.ts:assertClassicTs + the 4 capped package.json ranges + a diagnose.ts ERROR_PATTERNS entry for reading 'ESNext'; guard unit-testable against a synthetic TS7-shaped module (bisect-verified). Note: bun install --frozen-lockfile tolerates the pre-existing stale bun.lock (range metadata lags package.json), so the cap needs NO lockfile change — bun re-serializes churn on every install; don't stage it.
Divergent traversal REACHABILITY between the dual compiler backends — and "agreement-on-broken" hiding in the untested overlap
(the 2026-07 auto-call fuzz campaign; fixed in both backends + locked by fuzz-equivalence.test.ts). The signal auto-call pass had DIFFERENT reachability per backend: the JS gate (referencesSignalVar) skipped nested Arrow/Function children; the Rust collector skipped function bodies AND nested JSX entirely. Result: three user-facing bug shapes, all invisible to the 386-test hand-curated equivalence corpus because BOTH backends agreed (on broken output) in the overlap — (1) onClick={() => count.set(count + 1)} (the canonical counter) emitted count.set(count + 1) — adds the signal FUNCTION — on the SHIPPED native backend; (2) title={sig ? "a" : "b"} inside .map re-emits was stuck forever in BOTH backends (bare signal fn is always truthy); (3) {cond() ? <span id={v${sig}}> : null} stringified the signal's SOURCE into the DOM on native. A related class in static_attr_to_html: a _ => Some(String::new()) catch-all treated "static but unrecognized" as handled-empty → the native backend silently DROPPED tabIndex={-1} / title={1+2} / id={("x")} attributes; JS dropped no-subst template attrs. Three durable rules: (a) any pass that REWRITES source in both backends must have its traversal reachability defined ONCE and mirrored exactly — a reachability difference is a byte-divergence factory; (b) never write a catch-all arm that returns "handled, emit nothing" — unrecognized shapes must fall through to the dynamic/runtime path (None/null), else attributes/content silently vanish; (c) a hand-curated equivalence corpus locks KNOWN shapes only — the combinatoric space between them needs a seeded grammar fuzzer (packages/core/compiler/src/tests/fuzz-equivalence.test.ts, 300 seeds × client/SSR in CI; the discovery campaign ran 10k seeds × 2 modes → 0 divergence). The unified auto-call rule the fix locked: reach nested function bodies (shadow-aware) + nested JSX; EXACTLY-BARE signal (parens/TS-layer transparent) as a DOM-element attr/child in a RE-EMITTED region stays bare (both runtimes treat callables as reactive accessors → fine-grained binding, no branch remount — bisect-proven by element-identity assertions); template-path bindings still call (the emit assigns the VALUE). Duplicate plain JSX attrs dedupe LAST-wins in the template path (baking both hands the decision to the HTML parser = FIRST-wins, the opposite of JSX object semantics) + emit duplicate-jsx-attr.
Compiler template fast-path value emit diverging from the runtime applyProp normalization
— the JSX compiler has TWO ways a prop reaches the DOM: the optimized _tpl template path (the attrSetter in compiler/src/jsx.ts + the Rust mirror in native/src/lib.rs) and the runtime applyProp path (runtime-dom/src/props.ts, used by h()/component props/spreads). When the two DISAGREE on value normalization, a binding works through one path and is silently broken through the other. The instance: applyProp normalized class via typeof v === 'string' ? v : cx(v) and applied object styles per-property, but the template attrSetter assigned the RAW value — so class={[a(), 'b']} rendered "a,b" (no cx), class={{active:a()}} rendered "[object Object]", style={() => ({...})} set cssText to an object → "[object Object]" → no styles, and style={{color:theme()}} was a one-shot Object.assign (never reactive). All worked through h()/applyProp (so component-prop spreads were fine) but broke in compiled element JSX — and were untested (no class-array/object-style mount test existed). Fix: the template setters now produce the SAME normalization the runtime does — class via typeof v === 'string' ? v : _cx(v), and style by DELEGATING to a shared exported runtime helper _setStyle (= applyStyleProp) rather than re-implementing it inline (so number→px, kebab-casing, and stale-key removal come for free and can't drift). General rule: any value normalization the runtime applyProp performs (cx, style-object handling incl. number→px + key-removal, URL-guards, kebab-casing) MUST be mirrored in the compiler's template path — and the cleanest way to guarantee that is to EXPORT the runtime's normalizer and have the compiler emit a call to it, not duplicate the logic. The two paths are a divergence-prone pair; when you touch one, check the other. Lock parity with a runtime DOM mount test (not just a compiled-string assertion — the string can look plausible while the DOM is wrong, e.g. className = [array]). Sub-lesson — alias injected PUBLIC-name imports. When the compiler injects an import for a PUBLIC export the user may already import (here cx from @pyreon/core), inject it under an internal alias (import { cx as _cx }) — a bare injected import { cx } collides with a hand-written component's own cx import ("Identifier cx has already been declared", which broke the docs build). _-prefixed internal names (_rp, _tpl, _setStyle) never collide; public names must be aliased. Reference: packages/core/compiler/src/jsx.ts:attrSetter + native/src/lib.rs:attr_setter ↔ packages/core/runtime-dom/src/props.ts:applyStyleProp (exported as _setStyle); regression runtime-dom/src/tests/compiler-integration.test.tsx (class/style binding fidelity, incl. the cx-collision spec) + compiler/src/tests/native-equivalence.test.ts. [DONE — the aria/boolean/null sibling, 2026-07] The SAME class hid one more divergence: the attrSetter GENERIC branch emitted a raw setAttribute(name, expr) with NO null/boolean guard, so a DYNAMIC aria-disabled={x ? 'true' : undefined} (the recommended ARIA shape every @pyreon/ui-primitives interactive base ships) rendered the literal aria-disabled="undefined" on the nullish branch (an INVALID aria value assistive tech reads as the OPPOSITE state), and a dynamic boolean hidden={cond} rendered hidden="false" (present → still hidden). applyStaticProp was already correct (value==null → removeAttribute; boolean-aria → "true"/"false"; boolean → presence/absence), and SSR's renderPropValue already matched it — so the template path was the sole diverger, ALSO a latent SSR↔client hydration mismatch (SSR absent vs client ="undefined"). Fix (identical shape to class/style): export the runtime normalizer applyAttrProp as _setAttr and EMIT A CALL to it from both backends' attrSetter/attr_setter generic branch + the _bindDirect bare-signal updater, instead of the raw setAttribute. _setAttr mirrors ONLY setStaticProp's value==null/boolean-aria/boolean tail (the compiler already routes class→_setClass, style→_setStyle, DOM_PROPS→property before reaching it; static literals still bake via staticAttrToHtml — the guard fires only for DYNAMIC values). MASKED because the primitives' vitest.browser.config.ts uses the oxc AUTO JSX runtime (importSource:'@pyreon/core') which routes through h()→applyProps→applyStaticProp (correct) — NOT the real compiler; the regression compiles through the REAL transformJSX + mounts (bisect-verified: revert → aria-disabled="undefined" present / hidden="false" present → tests fail; restore → pass). Reference: packages/core/runtime-dom/src/props.ts:applyAttrProp (_setAttr) + jsx.ts:attrSetter / native/src/lib.rs:attr_setter (generic branch) + runtime-dom/src/tests/compiler-integration.test.tsx (_setAttr null/boolean-aria normalization) + SSR-parity lock runtime-server/src/tests/ssr.test.ts (aria-disabled={undefined} branch is ABSENT) + a diagnose-catalog entry. [DONE — the FUNCTION-VALUE sibling, 2026-07, upstream-reported] One more member of the same class: applyProp treats a callable prop value as a reactive accessor (renderEffect-wrap) and SSR's renderProp resolves it, but applyAttrProp/_setAttr had NO function branch — so a BARE IDENTIFIER holding an accessor (aria-selected={active} where active = props.active; the compiler wraps only syntactically-visible functions/signal calls) stringified the CLOSURE SOURCE into the attribute (aria-selected="() => …") on the compiled template path, an SSR↔client hydration mismatch. Fix: applyAttrProp resolves function values first; inside the compiler's _bind(() => _setAttr(…)) wrapper (any prop-derived value) the call is tracked → fully live. Residual (documented, deliberate): a STATIC-emit callable (a provably-local fn binding) resolves ONCE — correct value, no liveness; use a signal call or prop-derived expression for reactive attrs. Locked by the two aria-selected={accessor} specs in compiler-integration.test.tsx (bisect-verified: resolver disabled → expected '() => "true"' to be 'true').
The property-vs-attribute decision must be driven by measured REFLECTION, not by the prop's NAME — and key in el is unsafe for a read-only IDL accessor under strict mode (a CRASH), 2026-08.
Same divergence family as the entry above, one layer down: the runtime setStaticProp (runtime-dom/src/props.ts) routes a prop to a PROPERTY when key in el, else to an attribute, while SSR can only ever serialize an ATTRIBUTE and the compiler's tag-blind DOM_PROPS routes a fixed name set to a property. Three distinct bugs fall out of treating the name as the discriminator. (1) CRASH [FIXED]: key in el answers "does this property EXIST?" but the assignment needs "is it WRITABLE?", and in is true for a getter-only IDL accessor. Framework code is ESM, hence STRICT MODE, so <input list="dl"> — an ADVERTISED JSX prop (list?: string in jsx-runtime.ts) — threw TypeError: Cannot set property list of #<HTMLInputElement> which has only a getter and took the whole mount down. Reachable identically via form, select.options, table.rows, video.buffered, input.labels, input.validity, and via ANY DOM-element spread (_applyProps funnels into the same helper). The COMPILED path was unaffected (it routes generic attrs through _setAttr), so vite-plugin apps were safe while @pyreon/testing, the auto-JSX-runtime browser suites and the compat layers were not — the mirror image of the usual compiled-is-broken direction, and a reminder that this pair diverges BOTH ways. Fix: try { el[key] = value } catch { el.setAttribute(key, String(value)) }. The attribute is the CORRECT destination, not mere crash-avoidance — list/form ARE content attributes whose IDL properties are read-only precisely because they return the RESOLVED element. Chosen over a descriptor/writability probe on measurement, not taste: over 200k Chromium assignments try/catch costs 0.96-1.01x of a bare assign (V8 zero-cost-on-success), a prototype-chain getOwnPropertyDescriptor walk 1.70-3.23x, and a WeakMap-cached walk still 1.18-1.36x while adding a module-level cache. A hardcoded name list was rejected outright (the "gate input list is a silent-hole generator" class). Same shape as Preact's shipped dist/preact.js (l in n) try{n[l]=…}catch(n){} falling through to setAttribute); React sets the attribute too. Accepted trade-off, documented at the call site: a genuine SETTER exception (input.valueAsNumber = 5 on a text input) is also swallowed into an attribute write — strictly better than downing the mount, and deliberately silent because for list/form the fallback is the right answer. (2) The discriminator is measured REFLECTION. Non-reflecting (property assign does NOT update the content attribute, so SSR-serialized markup and a client mount produce the same control state from DIFFERENT HTML): input.value/checked/indeterminate/selectionStart, textarea.value, select.value/selectedIndex, option.selected, media.muted/volume/currentTime/playbackRate/srcObject, table.tHead. Reflecting, hence safe: disabled, readOnly, required, placeholder, type, multiple, autoplay, loop, open, noValidate, href, src, canvas.width, ol.start and every default*. value is TAG-DEPENDENT — non-reflecting on input/textarea/select, reflecting on option/button/progress/meter/li/data/param/output — so the compiler's tag-blind DOM_PROPS is harmless on those tags BY LUCK, not by design. (input.files is settable and merely non-reflecting, NOT read-only — it does not belong in the crash set above.) (3) A DOM-TEXT parity oracle structurally CANNOT see a behavioural divergence. <video muted> renders byte-identical HTML on both paths yet diverges in behaviour: a muted attribute set AFTER element creation does not update the IDL property, so a hydrated page reads .muted === true while a client-navigated one reads false — the client-navigated page plays audio when the hydrated one is silent. Left as-is deliberately (React/Preact/Solid all diverge identically), and measurement REFUTED the obvious cheap fix: defaultMuted = true alone still yields .muted === false, so it is a no-op here; only setting BOTH muted and defaultMuted reproduces parse semantics, which is a user-visible behaviour change (it starts silencing audio that plays today) and belongs in its own PR. The durable half is the bound this puts on the gate: hydration-parity-fuzz.test.tsx compares comment-normalized innerHTML, so no amount of seeding can ever make it catch this class. SSR has its own name-driven sibling (open): it kebab-cases every camelCase prop into an attribute, which is right exactly when the prop REFLECTS (readOnly → readonly) and bogus when it does not — measured defaultValue → default-value="dv" (should be value), plus meaningless indeterminate, volume="0.5", current-time="5", playback-rate="2". General rule: any code that decides property-vs-attribute must key on whether the prop REFLECTS on THAT TAG — a name list, a key in el existence test, or a blind camelCase→kebab rewrite each answer a different question than the one being asked. Reference: packages/core/runtime-dom/src/props.ts:setStaticProp (the guarded property branch); locks runtime-dom/src/tests/readonly-idl-prop.test.ts + .browser.test.tsx (both bisect-verified: revert → Cannot set property list … which has only a getter), and hydration-parity-fuzz.test.tsx now GENERATES value-bearing controls (41.2% of 5000 seeds) behind an exported KNOWN_ATTR_PARITY_DIVERGENCES mask naming only input.value/textarea.value/select.value — deleting an entry re-arms that shape (each bisect-verified to fail), and checked/selected stay armed. That fuzzer builds trees with h(), never transformJSX, so it reaches the RUNTIME path only; a companion COMPILED-path gate (compiler-integration.test.tsx is the precedent) is still owed.
A ref inside a SPREAD on a BARE DOM element is dropped on the compiled template path (works on h()) — the same h()/compiled divergence class as the entry above
(0.49 audit, upstream-surfaced). <div {...props}> on a DOM element lowers to _tpl(html, __root => { _applyProps(__root, props) }). applyProps deliberately SKIPS ref (not a DOM attribute), and the h()/hydrate paths (mountElement/hydrateElement) wire ref THEMSELVES after calling applyProps — but the compiled template path had no companion step, so a ref living inside a spread object was SILENTLY DROPPED. Real hits: @pyreon/ui-primitives CalendarBase's getDayProps() returns { ref } feeding its _cellEls focus registry (roving arrow-key focus DEAD in the compiled ui-showcase /calendar — registry never populates, moveFocus's _cellEls.get(k)?.focus() no-ops), and SpoilerBase spreads useElementSize's measured ref (height stuck at 0 → the "show more" toggle NEVER appears). MASKED because every unit/browser test uses the auto-JSX h() runtime (which wires the ref via mountElement), not the real compiler — the exact wrong-transform trap; only a test compiled through transformJSX (or the real vite-plugin) reproduces it. Fix (TWO parts — now spread is fully first-class out of the box): (1) the EXPORTED _applyProps is a distinct wrapper (applyPropsWithRef) = applyProps + wire the spread's ref (mirrors the direct <div ref={fn}> codegen); mountElement/hydrate call the INTERNAL applyProps (not the _ export), so they never double-fire. (2) the COMPILER now CAPTURES the returned cleanup (which disposes the spread's reactive bindings + nulls the ref) instead of discarding it — a STATIC/identifier spread ({...props}) emits const __dN = _applyProps(__root, props) (applied once, disposer captured), and a DYNAMIC/call spread ({...make()}) emits const __dN = _bindSpread(__root, () => (make())) (a renderEffect that re-applies on dep change, disposing each pass's cleanup BEFORE the next AND at unmount — because _bind/renderEffect don't open an onCleanup window, the cleanup is threaded EXPLICITLY in the _bindSpread helper, not via onCleanup inside the compiled _bind). Both backends byte-identical (native-equivalence + fuzz). General rule: any per-element wiring the h()/hydrate path does OUTSIDE applyProps (today just ref) must be mirrored on the compiled template-spread path — _applyProps is that path's sole entry, so it must be a superset of applyProps, not an alias; and any runtime helper the template path calls that RETURNS a cleanup must have that cleanup CAPTURED by the bind fn, not discarded. Reference: packages/core/runtime-dom/src/props.ts:applyPropsWithRef/bindSpread + index.ts (applyPropsWithRef as _applyProps, bindSpread as _bindSpread) + compiler/src/jsx.ts (processOneAttr JSXSpreadAttribute) + native/src/lib.rs; regression runtime-dom/src/tests/ref-in-dom-spread.test.tsx (compiled through the REAL transform — identifier + call lifecycle: reactive props update AND dispose, ref fires AND nulls; bisect-verified) + .browser.test.tsx (real-Chromium focus) + compiler/src/tests/jsx.test.ts (emit-shape lock).
_tpl() was SVG-namespace-blind, AND the compiler's template class binding used the HTML-only el.className = — TWO bugs that only combine to break in a real browser (the @pyreon/flow edges-don't-render bug).
The compiler lowers any DOM subtree with ≥1 element to _tpl("<html-string>"); the runtime's _tpl did template.innerHTML = html and cloned the root. Bug 1 (runtime): template.innerHTML runs the HTML parser, which only enters SVG foreign-content mode on a literal <svg>. A template rooted at a BARE SVG child — <g>, <path>, <rect> (what a flow EDGE lowers to: _tpl("<g><path…"), because the <svg> container has reactive <For> children so it is NOT fully templatized and each edge <g> is its own _tpl) — was parsed in the HTML namespace, so the cloned nodes were inert HTMLUnknownElements that render nothing (nodes visible, connecting lines gone; the MiniMap's node-dot <rect>s the same). Fix: _tpl detects an SVG-rooted string (leading tag ∈ SVG_TAGS, excluding svg/title) and parses it inside a <svg> wrapper, then MOVES the children into the cache template (moving + cloning preserve namespaceURI). Bug 2 (compiler), which Bug 1's fix EXPOSED: the template attrSetter emitted el.className = … for class={…}. On an HTMLElement className is a writable string; on a real SVGElement it is a read-only SVGAnimatedString, so the assignment THROWS — the reactive _bind effect throws, mountFor skips the edge item, and you get ZERO edges (worse than the 3 invisible HTML ones). Before Bug 1's fix the elements were HTML (writable className), so Bug 2 was latent. Fix: finish what _setStyle started — export applyClassProp as _setClass and have BOTH compiler backends emit _setClass(el, v) (which uses setAttribute("class", …), valid on HTML AND SVG) instead of the inline .className =. This removes the last .className = / injected _cx from the template path (see the _setStyle divergence entry directly above — class was the one attr still inlined). THE PARITY TRAP that hid both: (a) happy-dom does not implement SVGAnimatedString — svgEl.className = 'x' is a writable no-throw there, so a happy-dom mount test of the edge PASSES while Chromium throws; (b) happy-dom parses <g>-rooted innerHTML in the HTML namespace too, so happy-dom can't even see Bug 1; (c) the flow e2e masked it with page.locator('svg path').count() >= 3 — a CSS type selector matches by localName, so it counted the broken HTML-namespaced <path> too and stayed green. The load-bearing tests MUST assert the real-SVG discriminators in a REAL browser: path instanceof SVGPathElement, namespaceURI === 'http://www.w3.org/2000/svg', getTotalLength() > 0 (methods that exist only on genuine SVG geometry elements) — a querySelector('path') count proves nothing. General rule: any runtime code that builds DOM from an HTML STRING (innerHTML, <template>) is SVG/MathML-blind unless the string is rooted at <svg>/<math>; and any per-element property assignment the compiler inlines (className, and by extension any read-only SVG DOM property) must go through a runtime helper that uses setAttribute so it's namespace-safe — mirror it in BOTH backends + rebuild the native binary, and verify in real Chromium, not happy-dom. Reference: packages/core/runtime-dom/src/template.ts:_tpl (isSvgRooted wrap) + props.ts:applyClassProp (exported _setClass) ↔ compiler/src/jsx.ts:attrSetter + native/src/lib.rs:attr_setter; regressions runtime-dom/src/tests/tpl-svg-namespace.{test.ts,browser.test.tsx} + compiler-integration.test.tsx (bare-<g> real-compiler mount, namespace-asserted, bisect-verified) + the strengthened e2e/app-showcase-flow.spec.ts (asserts isSvgPath/getTotalLength, bisect-verified: revert _tpl fix → isSvgPath:false; revert _setClass → 0 edges).
A per-file validation gate structurally cannot see a CROSS-file collision — and the thing that eventually notices is the slowest gate you have.
@pyreon/native-runtime-kotlin verifies each module by compiling ONE source file against hand-written stubs (verify-kotlin.ts --service=X), and that isolation is the POINT: it is how a module gets type-checked with no Android SDK. But every native example adds runtime-kotlin + router-kotlin as Gradle srcDirs, so on a real build all those files compile as ONE module, where two top-level declarations of the same name in the same package is a hard Redeclaration: error. The per-file gate cannot express that question. Shipped instance: PyreonDatabase.kt added an object PyreonJson for its file-backend codec, unaware PyreonJson.kt already existed for the WebView bridge — every local gate green, gradle assembleDebug red after an 8-minute CI round trip, on a workflow that does not even run for every change. The rule: whenever a verification gate deliberately narrows its scope (one file, one module, one package) to avoid a dependency, ask what QUESTIONS that narrowing makes unanswerable — and add a cheap separate check for the ones that matter. Here the answer is a declaration-name scan (runtime-kotlin/scripts/check-duplicate-declarations.ts): every top-level package::name across both Kotlin source roots, milliseconds, no toolchain, no flakiness. Deliberately NOT a whole-source-set kotlinc compile — that would be stricter but needs every stub at once, and the stubs intentionally disagree (several declare their own minimal android.content.Context with only the members their module touches, because a superset stub masks — see the entry below); unifying them to satisfy a new gate would weaken the gates that already work. A narrow scan that catches one class exactly beats a broad gate that forces the others to get worse. Three details that make it load-bearing: it runs UNCONDITIONALLY (outside the command -v kotlinc guard AND outside the typecheck script's CI-skip branch — CI is precisely where the expensive round trip happens); functions key on name + parameter list so legal overloads (PyreonDatabase(context) / PyreonDatabase(backend)) do not false-fire while types key on the bare name; and an empty scan or missing source root is a FAILURE, not a pass. Bisect-verified + 9 unit tests on the pure scan/collision logic.
An in-memory DEFAULT for something whose entire purpose is outliving the process is a data-loss bug, not a conservative starting point
(two shipped instances, both found by asking "what does the shortest thing a user can write actually DO?"). useDatabase() and useStorage() on Android both defaulted to an in-memory map. Every gate was green — the emit compiled, the stub typecheck passed, the unit tests passed — because they all asserted the facade's CONTRACT (insert then get returns the record) and never that the value survived the process. useStorage's docs even pointed at a DataStoreBackend for "actual cross-launch persistence" that did not exist anywhere in the repo, and no example ever assigned the registry: a documented escape hatch that is the ONLY path to the advertised behaviour, with nobody on it. Three durable rules. (1) A default must deliver the capability the API is NAMED for; if useDatabase exists over useStorage because data outlives the process, then an ephemeral default is the API failing to do its one job. (2) A test asserting a facade round-trip proves nothing about persistence — construct a SECOND backend over the same directory, which is exactly what a relaunch is. The Android device gate asserted todosPersistAcrossActivityRecreation and passed: activity recreation keeps the PROCESS, so the in-memory map survived it. A green test named "persist" was measuring the one form of persistence that needs no persistence layer. (3) When you add a real default, it may only ever replace the UNCONFIGURED one — an app that assigned its own backend in Application.onCreate (Room, an encrypted store) must keep it, or the fix is a worse bug than the defect. That guard needs its own test in BOTH directions; the first cut here had none and deleting it entirely left every test green. Two structural notes worth copying: the persistence logic and the install POLICY live in a dependency-free file so the Kotlin test gate actually RUNS them (run-kotlin-tests.ts only executes modules importing no androidx.*/android.*/kotlinx.*, and anything beside a @Composable can only ever be typechecked); and the JSON codec is hand-written because this runtime compiles against MINIMAL STUBS in CI, where a stubbed org.json/kotlinx-serialization would make every persistence assertion vacuous while still reporting green. Reference: runtime-swift/Sources/PyreonRuntime/PyreonDatabase.swift:FileDatabaseBackend, runtime-kotlin/.../PyreonDatabase.kt, .../PyreonStorageBackends.kt.
An object literal PMTC cannot type falls back to a TUPLE, which is invalid Kotlin and a non-Codable Swift value — six ordinary data shapes hit it, silently
(2026-08). Struct synthesis needs a type for every field; when one field defeats it, both emitters wrote a tuple and the two targets failed DIFFERENTLY, which is what hid the class: Kotlin's (id = "a", parent = null) is named arguments with no constructor, so the Gradle build dies; Swift's (id: "a", parent: nil) typed Any COMPILES, and a tuple is not Codable, so PyreonJSON.encode, a <WebView data=> push, or a Saver silently produce the wrong bytes. Compiling and being wrong is the worse half. The shapes: an empty array field ({ nodes, edges: [] } — a graph with no edges yet), a LONE empty array ({ nodes: [] }, which swiftc rejects outright — "cannot create a single-element tuple with an element label"), a null or undefined field ({ id, parent: null } — a tree node), a NESTED empty array, a mixed-type array, an array of arrays. The fix is a warning at the BAIL SITE, not a parser pattern-match — the first cut enumerated the one shape that had been observed and would have needed five more rules; the emitters already know which field defeated them, so asking there is one rule for the whole class including shapes nobody has hit. The remedy the warning gives is VERIFIED, not suggested: an annotated declaration (signal<Shape>({…}), const x: Shape = {…}) already lowers to a real struct on both targets, and a spec asserts it still does. Companion lowering: <WebView data={…}> hands its value straight to encode, so an object/array LITERAL there IS JSON, not a model — routing it through struct synthesis was a detour that failed on exactly the payloads JSON exists to carry (an ECharts option object: heterogeneous nesting, empty objects, arrays of differently-shaped records). It now lowers to compile-time JSON with runtime parts interpolated; a non-literal value keeps the plain encode(expr) form. Detection lesson, and the reason this shipped: the coverage gate's evidence for the whole webview-host mechanism was "examples/native-viz emits 24 PyreonWebView calls, 0 warnings" — a warning count standing in for a compile nobody ran. Running it showed native-viz did not build on Android at all. The four webview-host entries now carry snippets hosting their own real payload shapes, so the compile pass checks the mechanism instead of describing it. Reference: packages/native/compiler/src/expr-utils.ts:explainUntypeableField/buildJsonLiteralParts + the warnUntypeableObjectLiteral bail sites in both emitters; scripts/check-native-coverage.ts webview-host snippets. General rule: when a diagnostic's first instance is a SHAPE, ask what the whole class is before writing the rule — and put the rule where the failure is DECIDED, not where the shape is spelled.
A recognizer that only checks "could a struct be synthesized" misses "is it the RIGHT struct" — a call-site whose parameter is a NOMINAL type needs its own total warning, not the untypeable-literal one
(2026-09, db.insert). PMTC recognizes db.insert(collection, { id, fields: {…} }) and lowers it to a real PyreonRecord; any literal that doesn't match that exact shape falls through to the generic struct-synthesis path a few lines below — and warnUntypeableObjectLiteral (the entry above) only fires when synthesis itself FAILS. The flat domain object people naturally write — { id, description, amount }, no fields key — has every field individually typeable, so synthesis SUCCEEDS: a real, compiling __Obj0 struct, zero warnings, that CANNOT satisfy insert's PyreonRecord parameter, because Swift and Kotlin are nominally typed and a structurally-identical struct with a different name is still a different type. Proven by compiling, not by reading the emit: error: cannot convert value of type '__Obj0' to expected argument type 'PyreonRecord' (Swift), argument type mismatch: actual type is '__Obj0', but 'PyreonRecord' was expected (Kotlin) — identical shape on both targets. This is why the warning has to be TOTAL rather than best-effort, and why it lives at the recognizer's fall-through, not inside explainUntypeableField: every field-level check in that function answers "can this VALUE be typed", and the answer here is always yes — the defect is that the recognized shape didn't match, which is a fact only the recognizer knows. native-finance's own showcase hit this and retreated to string-keyed delete/count ops rather than ever inserting a real row — so the app that was supposed to prove the database works never called the one method that puts data in, and every "does it persist" test on it was measuring hardcoded seed data. General rule (companion to the entry above): "no struct could be synthesized" and "a struct was synthesized but doesn't satisfy this call site's NOMINAL parameter type" are two different failure classes needing two different warnings — a recognizer with a specific target type must warn on its own fall-through, because the generic synthesis path structurally cannot know what type was actually required. Reference: warnDatabaseInsertShape in both emit-swift.ts and emit-kotlin.ts; regression + real-compile lock in native-database-insert-record.test.ts (bisect-verified: reverting both emitters fails exactly the 4 new warning-presence specs while the 13 shape/compile specs — including the "recognized shapes stay silent" control — stay green).
A Compose pointerInput(Unit) lambda keeps the FIRST composition's captures — key it on every state it reads
(the #3294 navigator tap, 2026-09-07, eight emulator rounds). The emitted plot tap read pyreonSpec / pyreonRange / the legend and preset layouts — plain vals recomputed on every recomposition — from inside pointerInput(Unit) { detectTapGestures { … } }. That coroutine starts ONCE, so after a pinch, a preset tap or the navigator drag the tap still resolved against the original spec with from = 0, while every compile gate and every unit spec stayed green. SwiftUI never had the bug: its lets are re-bound on each body evaluation and a gesture closure sees them fresh, which is why iOS passed the same assertion from round one. Rule: a pointerInput block that reads composition-scoped values must be keyed on them (pointerInput(pyreonSpec, pyreonZoom)), or read them through rememberUpdatedState; only delegated state (var x by remember { mutableStateOf }) reads live from an unkeyed block. Two companions from the same saga: (a) inside a drag, read positionChange() BEFORE consume() — Compose reports the UNCONSUMED delta, so the other order accumulates zero on every step; (b) six rounds were blind because the only observable was a TAP downstream of the drag — lower the state the gesture changes (onZoom → a text) and assert THAT first, so a failure names which half broke. Reference: emit-kotlin.ts (plot host tapKeys, the navigator/brush awaitEachGesture drags); tests/chart-hosts.test.ts + the native-tasks device tests (stats-zoom).
A structural-hazard check that inspects only a container's DIRECT children misses the same hazard one level down — and the miss costs a device crash, not a compile error
(PMTC <Scroll>/<For>, 2026-09). A <For> lowers to a Compose LazyColumn, and a LazyColumn nested inside Column(Modifier.verticalScroll()) throws at MEASURE time ("measured with an infinity maximum height"). The emitter warned about it — but its test was e.children.some(isLazyList), DIRECT children only, so <Scroll><Stack><Text/><For/></Stack></Scroll> (a padded page, the most ordinary layout there is) nested the identical LazyColumn under the identical scroller, threw the identical exception, and produced no diagnostic. Found the expensive way: native-tasks' stats page crashed the Android emulator on #3294, and the author's own workaround comment named the gap ("the emitter's warning only inspects the <Scroll>'s DIRECT children") — then fixed the EXAMPLE rather than the checker, which is the instance-not-class shape this catalog exists to name. The rule: when a hazard is defined by a nesting RELATIONSHIP (X anywhere under Y), the check must walk the subtree, stopping ONLY at boundaries that actually reset the relationship in the EMIT, not in the source. My first cut treated every nested <Scroll> as a boundary — wrong in exactly the case that matters: a nested lazy-only <Scroll><For/></Scroll> is UNWRAPPED by the lazyOnly fast path into a bare LazyColumn with no wrapper, so it lands directly under the outer verticalScroll and crashes identically; only a nested MIXED <Scroll> keeps its own modifier and is a real boundary (whose hazard is its own, reported once by its own emit). A boundary is a property of what gets emitted, and a walker over the source must ask the emitter which shapes still exist after lowering. Depth is not a property Compose consults, so a check keyed on depth-1 is a check keyed on luck. Two test halves are load-bearing: the deep shapes must FIRE, and a nested-<Stack>-with-no-<For> control must stay SILENT (every padded page has a Stack under its Scroll; a warning that fires on nesting alone is worse than the gap). Bisect-verified: reverting to .some(...) fails exactly the two depth specs while the boundary, control, direct-child and kotlinc specs stay green. Reference: emit-kotlin.ts:containsLazyListDeep + native-scroll-lazy-nesting.test.ts.
A Compose performClick does not scroll, so a test click on a node past the fold does nothing — silently
(2026-08). A <Scroll> page lowers to a Column with verticalScroll, which keeps every child COMPOSED however far down it sits. So assertTextEquals reads a node past the fold perfectly well — the semantics tree does not care about the viewport — while performClick on the SAME node has no effect, because the tap is injected at the node's real coordinates and those are off-screen. No error: the tap lands on nothing and a later assertion fails somewhere unrelated, with an empty semantics dump that says nothing about scrolling. Found on the native-tasks toolkit screen when a WebView below a form GREW the layout on load and pushed the machine toggle past the fold — 21 clicks in that file and not one performScrollTo(). Fix: performScrollTo() before every interaction on a scrollable page. This is the Android half of a divergence worth stating as one: a screen that outgrows a phone breaks differently per target. On iOS the failure is loud (kAXScrollToVisibleAction fails) and wrapping the page in <Scroll> fixes it outright; on Android <Scroll> only makes the node reachable and the TEST must still scroll to it. Also note an assertion that passes because it matches the DEFAULT cannot tell you the click landed — the same file's form-submit click was followed by an assertion whose expected value the untouched form already had.
Spreading an OPTIONAL object in a crossing file — TypeScript accepts it, both emitters lower it, and neither toolchain compiles the result.
{ a: "x", ...o } where o: Opts | undefined emits Swift { var c = o; c.a = "x"; return c }() (c is Opts?, so the member assign AND the return type fail) and Kotlin o.copy(a = "x") (.copy on a nullable receiver is rejected). JS semantics make the source legal — { ...undefined } contributes nothing — so nothing upstream objects, and until 2026-09 the emit was SILENT on both targets: the first signal was a swiftc/kotlinc gate whose error text names GENERATED SWIFT rather than the line that produced it. One such spread in candlestick-chart.ts cost 35 compile failures across every chart suite. Fix at the call site: build the object field by field (upColor: options?.upColor ?? theme.positive) instead of spreading an optional — the emitters now WARN by name (optionalSpreadWarning in expr-utils.ts, wired at the single-identifier-spread site in both), and the emit is deliberately unchanged, because there is no honest fallback: JS says the spread contributes nothing when the source is nullish, but the emitted struct still needs every field and the defaults for the ones the literal does not name are not knowable there. General rule: a construct TypeScript accepts is not thereby lowerable, and the ones that lower to uncompilable code in silence are worse than the ones that bail — when a bail has no correct alternative, NAME the shape rather than emitting something the toolchain will reject with a message pointing somewhere else. Note {} already warned ("an EMPTY object literal has no native lowering"), so the gap was specifically the optional-spread shape. Reference: packages/native/compiler/src/expr-utils.ts:isNullableType/optionalSpreadWarning; regression tests/optional-spread-warns.test.ts (bisect-verified: neutering both guards fails the 4 warn specs with expected [] to have a length of 1, while the non-optional controls stay silent — that half is what stops the rule passing by firing on every spread).
PMTC Kotlin emit producing an androidx symbol the kotlinc validate stubs MASK but the real gradle assembleDebug can't resolve
(the stub-masked-symbol class — FOUR instances now: the fetch-arc's withContext/Dispatchers/Json, the phantom pyreonIcon, Color/RoundedCornerShape, and the <Heading> Material-3-vs-2 typography mismatch). A second sub-variant beyond missing-imports — a WRONG SYMBOL NAME on an already-imported object: the <Heading> emit produced MaterialTheme.typography.headlineLarge (a Material 3 name), but the emit's whole base is Material 2 (import androidx.compose.material.*; Button/Text/Icon resolve from there), and Material 2's Typography has no headlineLarge — so a <Heading> app failed gradle assembleDebug with Unresolved reference 'headlineLarge'. The stub's object MaterialTheme.typography FAKED the M3 names, so it typechecked green in the validate loop while uncompilable against real M2; no example used <Heading>, so the device gate never exercised it (found by a real local Android build of a richer scaffolded app during ship-readiness revalidation). Fix (this variant): correct the emitted name to the real library's surface (M2 h4/h5/h6/subtitle1/body1/body2) AND tighten the stub to list EXACTLY the real members (M2 Typography: h1–h6/subtitle1/2/body1/2/button/caption/overline) — a stub that's a SUPERSET of the real surface MASKS; a stub that mirrors it CATCHES (the tightened stub now fails the validate-kotlin gate on an M3 regression, double-protecting alongside the device build). Reference: packages/native/compiler/src/emit-kotlin.ts:HEADING_TYPOGRAPHY + kotlin-stubs.ts:MaterialTheme.typography. General rule for stub design: a kotlinc/swiftc validation stub must mirror the real library's EXACT public surface, never a convenient superset — a superset stub is itself a masking source. The native compiler's validate-kotlin test loop CONCATENATES kotlin-stubs.ts into the same file before kotlinc, so any symbol with a stub resolves regardless of whether the emitted code carries a real import. The REAL Android build (packages/native/cli → host gradle assembleDebug) has NO stubs — it needs the actual import androidx.compose.…. So an emit that produces e.g. Color(0xFF…) (any color= prop → tint/background/text color) compiles green in the validate loop AND ships to the device gate RED with Unresolved reference 'Color'. The masking is doubly silent because androidx.compose.ui.* is a SINGLE-package star import — it does NOT pull androidx.compose.ui.graphics.Color, androidx.compose.foundation.shape.RoundedCornerShape, etc., which live in sub-packages. The trap surfaces only when an example/fixture first USES the prop on a REAL build — Color() had a stub from inception but no example used a color= prop reaching gradle until the icons-arc header <Icon color="primary">. Fix: add the symbol to the CLI's content-keyed conditional imports (packages/native/cli/src/build.ts:conditionalKotlinImports) — if (emitted.includes('Color(')) imports.push('import androidx.compose.ui.graphics.Color'). General rule for the PMTC Kotlin emit: every androidx symbol the emit can produce that does NOT live in one of the unconditional star-imported packages MUST have a conditional import keyed on its emitted text — a kotlinc-stub is for the VALIDATE loop's convenience, never a substitute for the real import the device build needs. When adding a new emit that references an androidx type, ask "is this in a star-imported package?" — if not, add the conditional import in the SAME change, or the device gate (not the validate loop) catches it one round later. The device gate is the only thing that exercises the real-import path; treat a validate-green/device-red Kotlin delta as this class by default. Reference: packages/native/cli/src/build.ts:conditionalKotlinImports (Color/RoundedCornerShape/Icons.Filled/coroutines/serialization-json arms) + tests/build.test.ts (per-symbol conditional-import specs).
A tag the dispatcher does not CLAIM falls through to the generic component emit — and the only thing between it and an uncompilable symbol is an unrelated warning
(the <MapChart> instance, 2026-09). isChartHostTag listed every table (lowered, accessor, frame, DECLINED-by-name); MapChart was in none, so the element went to the generic component emit and produced MapChart(regions: [], height: 200) — a symbol that exists on no target — with only the import-level "web-only symbol" warning standing in for a real diagnostic. Rule: a family whose members are enumerated by tables needs a TOTALITY check — every public member is in exactly one table, the decline table included — and the decline table is the default for a new member, never absence. Same sweep: the "lowered chrome" policy said tooltip lowered on every table-driven host while one (Parallel) had no crossing tip function, and a rich-hit onSelect vanished on eleven hosts because the tap matched only selectindex. A policy that answers per-CLASS must be checked against each MEMBER's actual capability. Reference: packages/native/compiler/src/chart-hosts.ts (UNLOWERED_CHART_HOSTS, chartChromeUnlowered, chartRichSelectWarning); locked by chart-native-parity.test.ts.
A lowering that ships COMPLETE but that no import path can reach — the phantom capability
(the <Transition> instance, 2026-08). Both PMTC emitters have lowered <Transition> / <TransitionGroup> to real platform animation since M2.7/M2.8 (SwiftUI .transition(…) + .animation(_:value:), Compose AnimatedVisibility), with five test files, preset mapping, asymmetric timing and device proof. And both dispatch on the TAG NAME alone — so every one of those tests, which writes the tag BARE or imports it from @pyreon/runtime-dom, passed while saying nothing about whether an app could import it. @pyreon/primitives exported neither name, and the only runtime export lived in @pyreon/runtime-dom — a package the compiler correctly flags WEB-ONLY. So the one import that resolved on web WARNED on native, the import native accepted did not exist on web, and the capability was fully built with no door. The compiler's own web-only warning even advertised it: @pyreon/kinetic's rationale said its preset vocabulary "does cross, via <Transition name>" — naming a tag, and pointing at an import that was broken on the target the reader was on. Two rules. (1) A compiler that dispatches on a tag NAME cannot tell you whether that tag is IMPORTABLE, so an emit test written with a bare tag proves the lowering and nothing about reach — assert the import path a real app writes, and assert it emits ZERO warnings. (2) Guidance that claims a capability must name the IMPORT, not the tag: "it crosses via <Transition name>" is unfalsifiable prose, while "import it from @pyreon/primitives" is a claim a test can hold — and the test must assert the package's OWN rationale, since the blanket warning suffix already names @pyreon/primitives for every web-only package and a bare "mentions primitives" check passes against the broken text. Same never-wired class as useOnline (built, documented, never called). A SECOND instance, 2026-09, where the unreached lowering was not merely undelivered but WRONG. PMTC maps a string-literal union alias to a native enum (G6), and the comparison emit rewrote a string literal to an enum case for exactly ONE operand shape — an enum-typed SIGNAL read (filter()). Every other shape — a function PARAMETER, a struct FIELD, a literal on the LEFT — emitted p == "top", which is a hard error on both targets (cannot convert value of type 'Position' to expected argument type 'String' / operator '==' cannot be applied to 'Position' and 'String'). So any shared source that BRANCHES on a union type — the ordinary reason to declare one — emitted uncompilable native. It survived because the only in-tree consumer of a union-alias enum is the generated chart engine, which DECLARES two (TreeOrient, GanttTickUnit) and compares against NEITHER: they are dead declarations, so the path had never once been exercised. The sharper form of the rule: a capability with no consumer is not merely unproven, it is unmeasured — and "we ship this" plus "nothing uses it" is a prediction, not a fact. When a lowering's only in-tree use is a DECLARATION, ask what happens at the USE site before counting it as working. A one-line grep answers it: an enum whose name never appears again in the generated output is telling you the emit path is unexercised. Reference: packages/native/compiler/src/{emit-swift,emit-kotlin}.ts (enumTypeOfExpr + the two-tier detection in the comparison case); regression compiler/src/tests/enum-comparison-lowering.test.ts (bisect-verified: disabling tier 2 fails 7 specs, including the real kotlinc run reporting the operator error verbatim). The residual named there is worth its own note: the inference ctx's struct table is built PER COMPONENT, so a file of pure top-level helpers — which is what a GENERATED ENGINE is — types every member read as unknown, and a local bound from one cannot be recovered. Seeding a file-scope ctx was measured and is NOT a free win: it deletes a batch of redundant Double(...) wraps from the generated chart engine and simultaneously stops two of its sites compiling, because better operand types change which Int×Double coercions fire. Reference: packages/core/primitives/src/web/{Transition,TransitionGroup}.tsx + types/animation.ts; regression primitives/src/tests/transition.test.tsx + native-transition-primitives-import.test.ts (bisect-verified: dropping the export fails 33 web specs with Cannot read properties of null (reading 'tagName'); reverting either rationale fails the two guidance specs; the emit is asserted BYTE-IDENTICAL to the bare-tag form, so reach changed and nothing else did).
An idiom handled in its INLINE form but not its NAMED form is an invisible gap
(PMTC accessor children, 2026-08). <Text>{() => shout()}</Text> was unwrapped to the value; const shout = () => … used as <Text>{shout}</Text> — the SAME accessor idiom by reference, and the one an author reaches for once the expression has a name — emitted the FUNCTION. Swift only WARNED ("string interpolation produces a debug description for a function value") and rendered garbage; Kotlin hard-errored (function invocation 'shout()' expected). So one shared source built on iOS and failed to build on Android, with nothing said at emit time. What kept it hidden is that a bare SIGNAL child ({raw}) had always been correct: the two shapes are indistinguishable in the source, and only one was handled — so the gap reads as "my code is wrong" rather than "the compiler missed a form". Rule: when you add support for an idiom, enumerate its SPELLINGS, not just the one in front of you — inline vs named, called vs referenced, literal vs const-bound. A fix that lands on one spelling leaves a hole shaped exactly like the feature, and the person who finds it will assume the feature does not exist. Two sub-lessons. (a) The per-target severity split is the real detection hazard: a shape that only WARNS on swiftc is invisible to a -typecheck gate, so an emit is proven only when BOTH toolchains compile it — treat a swiftc-warning as a failure when its Kotlin twin is an error. (b) Scope the rewrite by POSITION and ARITY: text/child position only (a bare reference in prop position — onPress={handler} — must stay a reference, or the side effect fires at composition time), arity zero only (a function taking arguments is not an accessor). Reference: emit-swift.ts:_zeroArgFnNames/resolveAccessorChild + the emit-kotlin.ts mirror; regression native-text-bare-fn-accessor.test.ts (bisect-verified: the neutered rewrite fails exactly the two string-shape specs plus the kotlinc compile, reproducing function invocation 'shout()' expected verbatim, while the bare-signal / arity / prop-position controls stay green).
A type ANNOTATION must never override the evidence sitting beside it
(PMTC inline-object Double fields, 2026-08). signal<{ id: number; price: number }[]>([{ id: 1, price: 2.5 }]) — ordinary TypeScript on ordinary data — synthesised a struct with price: Int and initialised it with 2.5: invalid on BOTH targets, with everything downstream inheriting it (a reduce over the column typed Int against a Double accumulation, an imperative let acc = 0 loop the same). The only fix was to DELETE the annotation, which is the worst possible incentive — the annotation is where a compiler should be MOST confident — and there was no way to spell it correctly either, since 0.0 is Number.isInteger and reads as an integer literal. Rule: a TS number carries no int/float distinction, so defaulting it to Int is right when there is no other evidence — but a default must yield to evidence, and the initializer in the same declaration IS evidence. Generalise past numbers: any place a lowering picks a narrower target type from an annotation should ask what the initializer proves. The structural lesson is the repetition: an inline object type produces NO StructIR at parse time (the emitters synthesise it later), and THREE separate refinement passes each resolved element types through a NAMED struct only — one via structNameOfType, one via an outright structs.length === 0 early bail, one via a typeRef-only element check. Each was written independently and each made the same assumption, so the shape had to be fixed three times to work once; fixing any one alone still left the app broken. When a data shape can arrive named OR anonymous, grep every pass that resolves it before assuming your fix is the only one needed. Detection note: the ordering half regressed on exactly ONE target — a Swift reduce(0, …) literal coerces to Double while Kotlin's fold(0, …) binds Int strictly — so a pass-ordering mistake here is invisible unless both toolchains gate it. Reference: parse.ts refineStructFloatsFromInitializers / refineInlineObjectFloats / refineReduceSeedFloats; regression native-inline-object-float-fields.test.ts (asserts the annotated and bare spellings emit IDENTICALLY — the invariant that would have caught this from the start).
A refinement that exists for two members of a family and not the third
(PMTC accumulator seeds, 2026-08). signal(0) written a Double was widened (widenFloatSignals); a reduce seed over a Double column was widened (refineReduceSeedFloats); the plain imperative let acc = 0; for (…) acc += it.price was not, and did not compile on EITHER target. Worse, it could not be SPELLED correctly — 0.0 is Number.isInteger, so it reads as an integer literal too, and the only workaround was to abandon the loop for reduce. Rule: when you fix a class for one syntactic form, enumerate the other forms of the same semantic. "Accumulate a fractional value into an integer-seeded binding" has at least three spellings (a signal, a reduce seed, a local); fixing two leaves the third as a cliff whose existence is invisible precisely BECAUSE the neighbours work. This is the sibling of the inline-vs-named accessor entry and the named-vs-inline struct entry above — three separate instances, in one week, of a fix landing on one spelling of a shape. Second, quieter lesson — a marker only helps where it is READ: the IR already had a float flag on numeric literals and both emitters honoured it, so marking the seed made them print 0.0 — but inferType still typed the expression from the literal's VALUE (0, an integer), so the digits changed and the emitted -> Int return type did not. A flag that means "this integer-valued literal is a Double" is worthless to every consumer that does not consult it; when you add one, grep who reads the field it is meant to override. Reference: infer-type.ts widenFloatLocals + the literal case honouring expr.float; regression native-accumulator-float-seed.test.ts (bisect-verified: 4 of 5 specs fail on revert INCLUDING both toolchain compiles, while the integer-accumulator control stays green).
A JS contract that is LOOSER than the native one breaks on the values the loose form allows
(PMTC Double comparators, 2026-08). sort((a, b) => a.price - b.price) — sorting a ledger by amount — did not compile on Android. A JS comparator returns any NUMBER and only its SIGN is meaningful; Kotlin's Comparator.compare must return Int. The emit passed the difference through verbatim on the v1 assumption (stated in its own comment) that "the JS comparator's Int" was what arrived, which holds for the example everyone writes first — a.id - b.id over an integer column — and fails for every fractional one. Rule: when a JS API's contract is WIDER than the native API you lower it to, the emit must NARROW it explicitly, not assume the common case. Enumerate what the JS side actually permits (any Number, not Int) rather than what the first test used. The asymmetry is the detection hazard, and it recurs: Swift converts the difference into the Bool its sorted(by:) wants, so the Swift path never sees the comparator's own type and compiled the whole time — one target green, one red, from the same source line. Treat "compiles on one target" as no evidence about the other. Fix shape worth copying: gate the narrowing on INFERRED float rather than applying it always — an Int comparator then emits byte-identically (no churn in existing output) and a non-numeric body (a.name > b.name ? 1 : -1) is left alone, which a blanket compareTo(0.0) would have broken. Reference: emit-kotlin.ts case 'sort'; regression native-kotlin-double-comparator.test.ts (bisect-verified: the fractional spec + the kotlinc compile fail on revert, reproducing the shipped error, while the Int-unchanged and Swift-untouched controls stay green).
"Deliberately not mapped" is only a decision if something CATCHES the shape
(PMTC str.replace, 2026-08). The emitter left JS replace unmapped on purpose, with a comment explaining why: JS replace(string, string) is FIRST-only while both native idioms replace ALL, so no honest 1:1 existed. The reasoning was correct. What was missing is that an unmapped method falls through to a VERBATIM emit, so the decision did not produce "unsupported" — it produced two DIFFERENT wrong answers from one line of shared source: swiftc rejected s.replace(a, b) outright (no such signature — missing argument label 'with:'), while kotlinc compiled the identical-looking line and silently replaced every occurrence. No warning on either target. Rule: when you decline to lower a shape, make the decline OBSERVABLE — a named warning, or a mapping that is honest even if awkward. A fallthrough to verbatim emit turns "we chose not to support this" into "we ship it broken, differently, per platform." The same file already had the pattern right for regex literals (a named warning plus a safe fallback), which is the shape to copy. Here a faithful mapping did exist and was simply harder than a one-liner: Kotlin replaceFirst; Swift an IIFE over replacingOccurrences(of:with:options:range:) bounded to range(of:), with operands bound as parameters so the receiver is evaluated once. Sub-lesson on detection: the Swift half was a hard compile error and the Kotlin half was not, so a gate that runs both toolchains flags one and passes the other — the string-shape assertion is what covers a target that compiles the WRONG function. Sibling found in the same pass: replaceAll and repeat LOWER but were absent from the string return-type table, so a helper wrapping either emitted a Swift func with no return type (Void); it hid because Swift will interpolate () into a string. When adding a method to an emitter, add it to the inference table in the same change. Reference: emit-swift.ts/emit-kotlin.ts case 'replace' + infer-type.ts string-method switch; regression native-string-replace-first.test.ts (bisect-verified: 6 specs fail on revert, incl. the swiftc stub compile; the kotlinc compile spec PASSES broken, which is the asymmetry).
A bail that says "not knowable at compile time" is often answering the wrong question — ask whether the value is needed at COMPILE time at all
(the PMTC runtime path param, 2026-08). PMTC resolved an @pyreon/http endpoint URL to a compile-time constant, so getUser.query({ params: { id: props.userId } }) warned "can't be baked into the URL at compile time" and stayed web — making the most ordinary thing an API screen does (fetch the record named by a prop) the one thing that did not cross. The premise was true and beside the point: the web does not know the URL at compile time either, it BUILDS one at request time. The emit only had to do the same — native string interpolation plus a runtime encoder. The tell is a diagnostic that describes the COMPILER's limitation rather than the user's mistake: it is a statement about the implementation, so re-read it as a design question. Two halves make it correct rather than merely possible. (1) The RE-RUN semantics decide which hook may take it, and the two differ. useQuery lowers to a harness KEYED on the query key, so a key carrying the runtime value re-fetches when it changes — the web's behaviour. useFetch lowers to a ONE-SHOT task with nothing to re-run it, so the same input there would fetch once and freeze at the first value while the web kept re-fetching: silently wrong, strictly worse than the bail. Lower it where the semantics hold, keep bailing where they don't, and make that warning NAME the hook to switch to. (2) The runtime value must reach the CACHE KEY, not just the URL. With the URL templated and the key left constant, every id collapses onto ONE cache entry (the first record fetched is served for every other id) and nothing re-fetches, because the harness re-runs on key change — an emit that looks completely correct. (3) A runtime encoder is a new cross-platform CONTRACT, so prove it by execution: the web spells it encodeURIComponent(String(value)), and the two native encoders are asserted against the real encodeURIComponent by extracting them VERBATIM from the shipped runtime source, compiling with the real toolchains and comparing byte for byte — reading the code is not evidence, and a re-typed expectation table drifts. Watch the three plausible-but-wrong primitives, each of which compiles and diverges: Kotlin URLEncoder.encode is form-encoding (space → +), Swift .urlPathAllowed permits /, &, +, =, and CharacterSet.alphanumerics passes non-ASCII letters through. Reference: packages/native/compiler/src/parse.ts:resolveEndpointParts (allowRuntimeParams) + PyreonURL in both runtimes; locks native-runtime-path-params.test.ts + native-url-encoder-parity.test.ts, both bisect-verified.
A coverage EXCLUSION that says "covered in the other environment" is a claim, and a claim nobody measures rots — measure it where it lives
(the charts host files, 2026-09). The node vitest config excluded 21 canvas hosts with the rationale "fully exercised in real Chromium", which was true for each host's OWN geometry and click and false for everything the hosts share: measuring the browser suite with v8 coverage found hosts at 50–65% statements, one platform file at 0% (dead code, deleted) and four hosts with no keyboard pick at all — Enter announced an item and selected nothing, and no spec noticed because each family spec asserted its own thing and none drove the shared paths on every host. Two rules. (1) An exclusion list needs a twin gate in the environment it points at: the browser config now collects coverage over EXACTLY the node config's exclusion list, thresholds at the measured values, ratcheting up — a host excluded from node coverage and absent from the browser include list is measured NOWHERE, so keep the two lists in sync (both carry the reminder). (2) When N components share a host, write ONE parameterised spec that drives every one through the host's whole surface (host-sweep.browser.test.tsx: paint, a11y surface, tooltip hit/miss/leave, click + keyboard select through both callbacks, export) — a per-family spec proves the family, the sweep proves the CONTRACT, and a host that forgets to wire a hook fails by name. Detection lesson worth its own line: a real-app e2e hover FAILED where the sweep's synthetic events passed, because the chart sat below the fold — Playwright's pointer reaches only what is on screen; scrollIntoViewIfNeeded() before a real-pointer interaction, always. Reference: packages/fundamentals/charts/vitest.browser.config.ts + src/engine/host-sweep.browser.test.tsx.
Four engine-subset slips the chart drift compile catches that tsc and 1,000 green web specs cannot — read them as the rules they are
(the charts scales batch, 2026-09). Every one compiled, typechecked and passed the whole web suite; only native-chart-engine-generated.test.ts, which runs the regenerated engine through real swiftc + kotlinc, refused. (1) A NAMED string-literal union alias (type AxisLabelMode = 'auto' | 'rotate' | …) lowers to an ENUM, so cfg.xLabels ?? 'auto' is AxisLabelMode? ?? String and does not compile; every engine field that carries a literal union writes it INLINE (align: 'start' | 'middle' | 'end' → String), and the named alias belongs to the web props layer only. (2) Math.ceil / Math.floor yield a Double on both targets, so every = Math.ceil(a / b) into an Int local is a type error on Kotlin and an Int index off a floor is one on Swift — walk the ratio or the bin edges with a bounded count loop (ceilRatio, the bin-edge walk), the same idiom countToDouble and the marker's floor-and-clamp scan already use. (3) A typed empty-array let (let xs: Double[][] = []) lowers to a val — a later whole-array reassignment fails on Kotlin; initialise in one expression. (4) A Swift subscript is never optional, so arr[i] ?? d is a "right side never used" warning and a dead branch; bounds-check (i < arr.length ? arr[i]! : d) — and a (v: Double | undefined) => … helper that COMPARES its optional (v > 0.0) is a hard error, so coalesce first and decide presence separately. Rule: any engine file in ENGINE_FILES is Swift and Kotlin source with a TypeScript spelling — run the drift compile locally before the suite is green in your head, and fix at the idiom level, never with a // native: skip. Reference: packages/fundamentals/charts/src/engine/{layout,render,stack,bin}.ts + packages/native/compiler/src/tests/native-chart-engine-generated.test.ts.
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 what is transient and only what is transient: E422 provenance verification, 5xx, dropped sockets — never a 404 (no Trusted Publisher: the same PUT fails forever), a 403, or the cannot-publish-over conflict (that is success). (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. 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.
A cross-platform hook whose WEB default diverges from native must normalize UP — and the normalization needs a platform EVENT to hang on, or it is documented but dead
(useWakeLock, 2026-08). A WakeLockSentinel is released by the browser whenever the document hides and is NOT reacquired; isIdleTimerDisabled / FLAG_KEEP_SCREEN_ON survive backgrounding. Left alone, one call leaves the screen sleeping on web and lit on native — mirrored, not 1:1 — so the web arm re-acquires on visibilitychange unless the caller explicitly released (which means tracking the caller's INTENT separately from whether a lock is currently held; a browser release and a caller release must not look alike). The half that is easy to miss: the re-acquire is UNREACHABLE unless the hook also learns that the browser released it. The first cut checked sentinel === null in the visibility handler, but nothing ever nulled it — the browser announces its own release ONLY through the sentinel's release event. The doc comment described behaviour the code could not perform, and the test that would have caught it could not even be WRITTEN until the hook observed the event, which is what exposed it. Rule: when normalizing a platform difference, name the exact signal the platform gives you for the thing you are compensating for; if there is no signal, the normalization is a wish. Bisect-verified (useWakeLock.test.ts: drop the release listener → expected true to be false). Sub-lesson from the same PR — a gate that validates a hand-maintained list against a directory must check BOTH directions. check-native-cosource failed on a DECLARED Kotlin file that did not exist but never on a file that exists and is declared nowhere, so such a file was silently never verified (PyreonWebView.kt had been in that state). A deliberate omission and a forgotten one must be distinguishable: SDK-dependent files are now declared in pyreon.native.kotlinSdkOnly, and anything in neither list fails.
Re-implementing a peer's serialization instead of RUNNING it makes one source file produce two different results
(the PMTC ( ( — the device-found-runtime class that compile-only validation ( every Cloudflare workerd passes the import-graph cousin of a self-accepting module with NO callback tells Vite "I handled this update" — Vite re-evaluates the module but the mounted DOM still references the OLD component closure, so NOTHING re-renders. Worse: the self-accept ALSO suppresses Vite's full-reload fallback, so the user gets neither an in-place update NOR an automatic reload — a silently-stale UI until a manual refresh. This shipped in middlewares registered directly via the (the If an example build (or when a framework instrumentation layer (devtools registry, perf counters, tracing hooks) is structurally invisible for the "open panel AFTER mount" user workflow because the common "only write a generated file when its content changed" pattern, written as exists-check → read → compare → write, trips CodeQL's high-severity (the PMTC static-route dispatch, 2026-08). The emitted router dispatch resolved DYNAMIC routes through the runtime's Escaping The compiled template path does not call @pyreon/http endpoint URL, 2026-08). The web builds an endpoint's URL at request time (applyPathParams → encodeURIComponent, buildQuery → URLSearchParams); PMTC bakes it at COMPILE time, which was sound WHEN the native path refused anything but literal params (it no longer does — a runtime :param lowers through useQuery, and the encoding then happens at runtime via PyreonURL.encodePathParam, which mirrors the same encodeURIComponent this entry is about). But the native side did a RAW path.replace and joined query pairs by hand, so getUser({ params: { id: 'a b' } }) requested /users/a%20b on the web and /users/a b on iOS/Android — silently, on every awkward literal: # truncated the URL at the fragment, ?/& injected query structure into a path segment. The fix is not "add encoding" — it is to call the SAME primitives the peer calls, so equality is by construction rather than by a table someone maintains: encodeURIComponent for a path segment, a real URLSearchParams for the query. Those are genuinely DIFFERENT encoders (space → %20 vs +; ' → literal vs %27), so one hand-rolled encoder is wrong in both positions — the reason to derive rather than approximate. Two sub-traps. (a) String.replace interprets amp; /
#39; / $ in a STRING replacement, so a value containing them splices the match or its surroundings back in — id: "#39;" emitted /users/ with the id GONE. Use a function replacement (which is why the web's does). (b) The web's own buildUrl is the only honest oracle: assert byte-equality against it rather than a hand-written expectation table, or the two drift again the next time either moves. Sibling class in the same resolver — it read params and query and silently dropped the other five options, so createUser({ json }) emitted a POST with no body and no diagnostic. Lower what can lower (json → body + content-type, headers — both already in the fetch IR, so zero emit change) and WARN BY NAME for the rest; close the class by classifying against the real EndpointArgs type, so a field added later cannot rejoin the dropped set. Reference: packages/native/compiler/src/parse.ts (encodePathParam / buildQueryString / ENDPOINT_LOWERED_ARGS); locks tests/native-http-url-parity.test.ts (differential vs the real buildUrl, bisect-verified: revert → 19 fail, expected '/api/users/a b' to be '/api/users/a%20b') + native-http-endpoint-options.test.ts (revert → 17 fail, warnings were: []).A lowering that RENAMES a binding must emit an alias under the source name — otherwise the declaration is unreachable
defineFeature, 2026-09). const Todo = defineFeature({ name, schema }) emitted enum PyreonFeature_Todo / object PyreonFeature_Todo and nothing called Todo. The only reason to declare a feature is to use it, so every real shared-source app failed on BOTH targets the moment it wrote Todo.name — swiftc cannot find 'Todo' in scope, kotlinc unresolved reference 'Todo', in a generated file the author never wrote. The two sibling lowerings in the same emitter (PyreonFieldMeta, PyreonZodSchema) had always emitted the alias; this one forgot, and PyreonZodSchema skips it ONLY for INLINE schemas, which have no source name. The alias is NOT collision-proof and must not be sold as such: Swift and Kotlin share ONE namespace for types and values (unlike TypeScript, where interface Todo and const Todo coexist), so a same-named user type collides with a typealias AND with a value binding identically — measured both ways; the compiler warns by name for that shape instead. Why five green specs missed it: every one asserts the emitted DECLARATION and none ever writes the binding in a component body. A string-assertion test can only confirm the emitter agrees with itself. The class was wider than the instance — 13 of the 15 tier2-*-emit suites make ZERO swiftc/kotlinc calls, so those emit paths had never been compiled at all. Compiling all 103 shared-source fixtures in those suites (206 compiles) found 4 UNWARNED failures — an indirect zodSchema(base) / arktypeSchema(base) falling through to a verbatim emit of z / type, symbols that exist in neither language — plus 19 correctly declined and 183 clean. Rule: when a recognizer declines, ask what is emitted INSTEAD; a fallthrough to verbatim emit turns "we chose not to support this" into "we ship it broken", and the decline must be observable. Reference: packages/native/compiler/src/{emit-swift,emit-kotlin}.ts (feature alias) + parse.ts:warnUnloweredSchemaAdapter; locks tier2-feature-emit.test.ts (real swiftc+kotlinc compiles) + tier2-validation-emit.test.ts, both bisect-verified.A validation stub STRICTER than the real library REJECTS correct code — the mirror image of stub-masking
usePermissions, 2026-07). The documented trap above is a SUPERSET stub hiding real breakage. The inverse is just as real and reads as an emit bug: the real PyreonPermissions init DEFAULTS its parameter on both targets (init(_ granted: Set<String> = []) / PyreonPermissions(granted: Set<String> = emptySet())), but the Swift stub declared init(_ grants: [String]) — no default, and an Array where the real type takes a Set — and the Kotlin stub declared a REQUIRED initial plus a plain Set property where the real one is Compose MutableState. Three divergences, all in the strict direction. The emit's PyreonPermissions() is CORRECT against the real runtime and was rejected by the gate. Latent only because no fixture used the hook — the moment one did, the gate would have failed correct code and sent someone to "fix" a working emit. The rule generalises in BOTH directions: a stub must MIRROR the real surface, not approximate it — a superset masks breakage, a subset manufactures it. Practical consequence: when a stub-gated emit fails, read the REAL runtime source before touching the emit; the stub is as likely to be wrong as the codegen. Sibling gate HOLE found in the same sweep: useStorage lowers SCALARS to SwiftUI's own @AppStorage (structs go to the runtime's Codable @PyreonAppStorage), and only the latter was stubbed — so the COMMON path was outside the type gate entirely while the uncommon one was covered. A stub added for one branch of an emit does not cover its siblings. Reference: packages/native/compiler/src/{swift-stubs,kotlin-stubs}.ts + tests/lowered-hooks-typecheck.test.ts (all four bisect-verified: reverting each stub fails with the exact missing argument / no value passed for parameter 'initial' the wrong stub produced).PMTC Swift
.task attached to a transparent Group wrapping a conditional → cancelled+restarted on every state flip (the fetch never settles)swiftc -typecheck) structurally cannot catch. A fetch-bearing component appends a mount-time .task { begin → resolve|reject }. SwiftUI ties a .task's LIFETIME to its host view's IDENTITY. When the host is a transparent Group { if isPending { fallback } else { content } } (what <Suspense> / <ErrorBoundary> emit), SwiftUI redistributes the modifier onto the if/else BRANCH — so each loading/error flip changes the branch's structural identity and SwiftUI CANCELS + RESTARTS the task. begin() sets isPending=true → flip → task cancelled mid-flight → restart → begin() again → infinite thrash; the URLSession fetch never completes, so the boundary renders NOTHING (not even its fallback — the else-branch shows an empty ForEach). The bug is invisible to the validate-swift loop (the emit TYPECHECKS fine) and to happy-path unit tests; it only surfaces on a real Simulator where the .task actually runs. Fix: emitSwiftComponent wraps any fetch-bearing component's body in a concrete ZStack (if (_hasFetchDecl) { ZStack { <body> } }) so the appended .task attaches to a STABLE-identity host that fires ONCE on appear; the inner conditional's flips no longer touch the ZStack's identity. Kotlin needs no equivalent — its fetch harness is a LaunchedEffect(Unit) SIBLING node keyed by the stable Unit, which runs once and is not cancelled by recomposition (a fundamentally different mechanism from a SwiftUI view modifier). General rule for any SwiftUI .task/.onAppear-style modifier the emit appends: it MUST attach to a view whose identity does NOT depend on the async state it drives. If the component body can be a transparent Group/conditional, wrap it in a concrete container first. Compile-only validation can never catch this — it's a runtime view-lifecycle property; the device gate is the only thing that exercises it. Reference: packages/native/compiler/src/emit-swift.ts:emitSwiftComponent (_hasFetchDecl ZStack branch) + tests/fetch-computed-shapes.test.ts (Swift .task stable-host wrap (device-found), bisect-verified) + the examples/native-tasks-ios lifecycle-page XCUITest (good-fetch lc-quote + failed-fetch lc-error both render).Hardcoding a package's OWN version in a self-registration / diagnostic call
@pyreon/* package called registerSingleton('@pyreon/X', '0.24.6', import.meta.url) with a HARDCODED version literal that nothing in the release process bumped — so changeset version advanced package.json to 0.28.x while all 24 literals stayed frozen at 0.24.6. The version is only diagnostic (the duplicate-instance sentinel keys on module location, not version), but it has real diagnostic VALUE: the error reports each instance's version, surfacing a genuine version skew between two installed copies — which a stale literal silently defeats (every instance reports the same frozen version). Fix: derive name + version from the package's own package.json — import { name as __pkgName, version as __pkgVersion } from '../package.json' with { type: 'json' } then registerSingleton(__pkgName, __pkgVersion, import.meta.url). Single source of truth; the build inlines the literals (rolldown tree-shakes the JSON import to just the name/version strings — verified zero devDependencies/scripts bloat in lib/), dev (bun → src) reads the live package.json, and drift is structurally impossible — no sync script, no CI gate needed. @pyreon/mcp already used this pattern (import packageJson from '../package.json' with { type: 'json' }); it's the proven repo precedent. General rule: a module that needs its OWN package's version (diagnostics, telemetry, a version export) must DERIVE it from package.json, never hardcode a literal that a human has to remember to bump on release. The bump-the-literal approach (sync script + drift gate) was considered and rejected — it adds tooling to paper over a problem the JSON import eliminates outright.Dereferencing
import.meta.url (or any runtime-injected URL) at module-init without guarding for undefinedundefined for import.meta.url. @pyreon/reactivity's registerSingleton(pkg, version, import.meta.url) → normalizeLocation(url) did a bare url.indexOf('?') → Cannot read properties of undefined (reading 'indexOf') at MODULE-EVAL, before any handler ran → every @pyreon-based Cloudflare Worker crashed at startup. A node:fs-only test can never catch this — Node always supplies a real import.meta.url, so the undefined branch is unreachable in vitest/node-invoke; only a real-workerd run (wrangler pages dev) surfaces it. Fix: guard at the top — if (typeof url !== 'string' || url.length === 0) return '<unknown>'. General rule: any module-init deref of a runtime-injected value (import.meta.url, import.meta.env.X, globalThis.process) must tolerate the value being absent on SOME target runtime — workerd hides import.meta.url + process, Deno/edge hide others. The crash is unrecoverable (module-eval, before any try/catch around a handler), so the guard is the only defense. Lock it with a unit test that passes undefined as unknown as string and asserts no-throw, bisect-verified to throw the exact indexOf TypeError when the guard is removed (packages/core/reactivity/src/tests/singleton-sentinel.test.ts "workerd / undefined import.meta.url" block). Reference: packages/core/reactivity/src/singleton-sentinel.ts:normalizeLocation.A filesystem read as the ONLY delivery path for a build artifact breaks no-filesystem runtimes (workerd)
@pyreon/zero's readBuiltTemplate() read the production SSR template via readFileSync(new URL('./template.html', import.meta.url)) — correct for Node-based adapters (node/bun/vercel/netlify all run on Node), but Cloudflare workerd has NO filesystem, so the read threw → caught → undefined → the SSR page silently shipped the dev entry-client.ts (no hashed <script>) and never hydrated in production. The happy-path /posts 200 looked fine; only inspecting the shipped HTML (dev-entry vs hashed-entry) revealed it. Fix: provide a runtime-agnostic delivery path — the cloudflare adapter reads the artifact at BUILD time (in Node) and inlines it into globalThis.__PYREON_SSR_TEMPLATE__ in _worker.js, then dynamic-imports the handler so the global is set BEFORE the consuming module evaluates (createServer → readBuiltTemplate); readBuiltTemplate checks the global FIRST, falling back to readFileSync for Node runtimes. A static import handler from … would be hoisted ABOVE the global assignment → the consuming module evaluates first → the inline is defeated; the dynamic await import(...) after the assignment is load-bearing. JSON.stringify(artifact) is safe to embed in a .js module (no HTML tokenizer sees </script>; the marker comments survive byte-for-byte). General rule: an artifact a SSR/edge handler needs at runtime must NOT be delivered solely by fs — provide a globalThis-inlined (or bundler-define'd) fallback set before the consuming module evaluates, so it works on workerd / Deno / edge runtimes that have no filesystem. The node-invoke smoke is a FALSE-POSITIVE gate for this class (it has fs); lock the build-time contract with a structural assertion on the emitted bundle (global set before the dynamic import + the artifact carries the prod, not dev, marker), bisect-verified (packages/zero/zero/src/tests/adapters.test.ts "inlines the built SSR template into a global BEFORE dynamic-importing the handler"). Reference: packages/zero/zero/src/adapters/cloudflare.ts + entry-server.ts:readBuiltTemplate.Static VALUE-import of a heavy package at module-eval in a module reachable from a cheap entry point
pyreon/no-heavy-import-only-in-handler (bundle weight) — here the cost is module-eval / cold-transform time, not bundle bytes. @pyreon/lint's LSP module (src/lsp/index.ts) added a top-level import { analyzeReactivity } from '@pyreon/compiler'. cli.ts statically imports the LSP module (to expose --lsp), and src/index.ts re-exports it — so any importer of the lint CLI / package root transitively cold-loaded the ENTIRE @pyreon/compiler graph (full JSX transform + oxc-parser + the TS compiler API via pyreon-intercept) at eval. A runner.test.ts beforeAll doing await import('../cli') (it only needed the CLI flag parser) timed out at 10s in CI under cold vitest transform. Local bun run test did NOT reproduce it — warm filesystem + already-transformed modules; only CI's cold cache crossed the hook timeout. Fix: lazy-load + memoize only the runtime VALUE (let _v; async () => (_v ??= (await import('@pyreon/compiler')).analyzeReactivity)); keep the TYPE as import type (fully erased — zero eager-load). The consuming fn (and the LSP message handler / transport) become async; the editor re-requests on the next keystroke so first-call latency is invisible. General rule: a heavy package must not be statically value-imported in any module on the static import path of a cheap entry point (a CLI arg parser, a package-index re-export, a lint runner). import type is free; defer the value behind the flag/path that actually uses it. Test-environment lesson: a 10s hook timeout from a transitive heavy import is invisible to warm-cache local runs — the CI cold transform is the only place it surfaces; treat "passes locally, hook-times-out in CI on an unrelated test's import('../X')" as a heavy-eager-import smell, not flake. Reference: packages/tools/lint/src/lsp/index.ts:loadAnalyze.Bare
import.meta.hot.accept() (no callback) in a render-framework's Vite plugin@pyreon/vite-plugin's injectHmr from inception; every component/JSX edit in a @pyreon/zero dev app required a hand refresh. The reported symptom ("on changes I need to make refresh manually") had no error, no console output — the bare accept actively hid it. Fix: the accept callback must either drive a framework re-render with the FRESH module Vite hands it, OR call import.meta.hot.invalidate() to fall back to an automatic reload. Never emit a bare accept() for a module whose exports drive rendered DOM. Reference: packages/tools/vite-plugin/src/index.ts:injectHmr → globalThis.__pyreon_hmr_swap__(<id>, freshModule) (registered by @pyreon/router._hmrSwap, matched to the active lazy route via _hmrId from @pyreon/zero fs-router's lazy(() => import(…), { hmrId })). Use the namespace Vite passes the accept callback, NOT a re-run of the dynamic-import thunk: the thunk lives in the (non-invalidated) virtual routes module, so re-importing it returns the OLD module — the stale-?t= trap. Bisect-verified at the e2e layer (e2e/zero-hmr.spec.ts): reverting to the bare accept() times out with the route marker stuck at its pre-edit value; only a real dev server + real Chromium + a real file edit exercises this — unit tests can't (synchronous transform output looks identical at the string level until you assert the exact callback shape).Catch-all dev middlewares registered in
configureServer shadowing Vite's server.proxy (the PZ-11 zero-dev proxy swallow)server.middlewares.use() inside configureServer land BEFORE Vite's internal middlewares — proxy included (configureServer runs before Vite installs its internal stack; only the RETURN-a-function form registers after). A framework catch-all that terminates responses (zero's dev SSR middleware + 404 handler — both accept any request whose Accept includes text/html OR */*, which is fetch's DEFAULT) therefore swallows every URL a server.proxy context owns: with a reachable _404.tsx, a proxied GET /api/backend/x from client code returned zero's 404 HTML and the backend never saw the request — SILENTLY (the proxy config just "didn't work"). Fix shape: a catch-all dev middleware must next() every URL owned by other middleware, and "owned" must be computed with the OWNER's exact semantics — capture Object.keys(resolvedConfig.server?.proxy ?? {}) in configResolved and mirror Vite's doesProxyContextMatchUrl (^-prefixed context = RegExp, else prefix match, tested against the FULL req.url including query — a stripped pathname can disagree with the downstream proxy). Announce the honoring once at boot ([Pyreon] zero dev: honoring vite server.proxy for: …) so the behavior is discoverable. Companion lesson: a path-class skip added to ONE catch-all (the W24 /api/* skip on the 404 handler) silently misses its SIBLING catch-all — in mode:'ssr' the SSR middleware ran FIRST and still swallowed /api/*; when two middlewares share a bug class, fix both and keep their guards consistent. Dev precedence: fs api routes > server.proxy > SSR/404 (fs-wins matches production, where server.proxy doesn't exist). One carve-out: isApiRoute only claims .ts/.js files, so an api/*.tsx file IS a page route — the /api/* skip is gated on a page-route match so such a page keeps dev SSR. Reference: packages/zero/zero/src/vite-plugin.ts:matchesProxyContext + the PZ-11 guards; bisect-verified by tests/integration/dev-proxy.test.ts (real Vite dev server + real HTTP backend: guards reverted → 5 specs fail with 404-instead-of-backend; restored → 17/17).A build-time resolver / heavy precompute in a Vite
transform hook gated only by !isSsr (no && isBuild)transform hook runs in BOTH command: 'serve' (dev) and 'build'. A precompute that bakes a value at first transform and CACHES it (rocketstyle-collapse's resolver SSR-renders the real component once, captures the styler class, freezes it into a _tpl template) is correct in build but a double bug in dev: (1) it spins the heavy resolver per dev process — for rocketstyle-collapse a SECOND nested Vite SSR server bound to the same root, leaked because closeBundle is a build-only Rollup hook that never fires in vite dev; (2) the frozen value is computed from the resolver's OWN module graph and will NOT react to the user's HMR edits to the source it depends on (theme files, .theme() callbacks) — the dev UI silently shows the pre-edit styling until a hard restart, strictly worse than the un-collapsed mount, which IS HMR-reactive. The "it only ran in build because nobody tested dev" framing is the trap: nothing gated it to build; it ran (badly) in dev and the contract was implicit + untested. Fix: gate the block if (enabled && isBuild && !isSsr) (the plugin's config(_, env) hook sets isBuild = env.command === 'build', which always runs before transform), and surface the dev no-op ONCE per process via this.info('[Pyreon] … is build-only — vite dev keeps the normal mount so source edits stay HMR-reactive …') (module-scoped let warned = false guard — transform is per-file) so an opted-in vite dev consumer isn't left wondering why nothing happened. General rule: any transform-hook precompute that (a) is expensive to construct, (b) caches a value across the process, or (c) freezes a value derived from user source that the user can HMR-edit, MUST be isBuild-gated and announce its dev no-op. Build-only is the correct behavior for cache-and-freeze precomputes, not a limitation — but it has to be explicit, tested, and visible. Bisect-verify locally with a stubbed resolver (no nested Vite, no workspace lib/ — otherwise removing the gate false-negatives because the real resolver can't boot in a non-clean tree and silently bails to null): vi.mock the resolver loader to return a canned result, then command:'serve' + gate-removed → __rsCollapse( emitted → fail; gate present → skipped → pass; companion command:'build' spec (same source, same stub) still collapses, proving the gate is the only difference. Reference: packages/tools/vite-plugin/src/index.ts (if (collapseEnabled && isBuild && !isSsr) + warnedDevCollapse) + packages/tools/vite-plugin/src/tests/rocketstyle-collapse-dev.test.ts.A nested build that RECONSTRUCTS a plugin instead of replaying the config drops every option the user set — and the option it drops hardest is the one that only matters there
@pyreon/zero inner-SSR pyreon() instance, 2026-08). zero's mode: 'ssg' | 'ssr' | 'isr' runs a nested Vite build over the same source. It cannot forward the outer pyreon plugin INSTANCE (a second configResolved rewrites captured output paths — that is what RE_ADDED_PLUGIN_NAMES exists for), so it constructed a fresh one, as a bare pyreon(). Every transform option therefore applied to the CLIENT graph and silently did not apply to the SSR graph. ssrTemplate is the shape that makes this sharp: it configures only the SSR emit, so the SSR pass is the sole place it does anything, and the sole place it was dropped — pyreon({ ssrTemplate: false }) in an SSG app was a no-op, hit for real by @pyreon/loom's static-site build, which shipped a comment saying so rather than a fix. Three rules. (1) A nested build has two honest strategies — REPLAY the user's config (createServer with no configFile override, which is what the rocketstyle-collapse resolver does and why it has never had this bug) or RECONSTRUCT it explicitly. Reconstruction is a promise to carry the configuration across; a bare factory call breaks that promise silently, because a plugin constructed with no options is indistinguishable at runtime from one the user configured to its defaults. (2) "Forward everything" is not the fix. Some options describe the OUTER build's shape and would mis-steer the inner one: pyreon({ ssr: { entry } }) makes the plugin's config() return build.rollupOptions.input, and a plugin's config() return BEATS the inline build({ … }) argument in Vite's merge order — so forwarding it does not add an entry, it takes over the sub-build and compiles the user's server entry instead of the synthetic one. The set has to be chosen per option, and each choice justified. (3) Type the split as a TOTAL Record, never an allowlist array. An array reproduces the original bug on the next option added (the "gate input list is a silent-hole generator" class): Record<keyof Required<PyreonPluginOptions>, 'forward' | 'drop'> makes a missing key a TS2741 and an unknown key an error too, so the default stops being "silently inherit the wrong thing" and becomes "the build will not compile until you classify it" (verified by deleting one key: error TS2741: Property 'ssr' is missing). Detection lesson: a suite that tests only the pick/read helper passes with the call site still broken — the bug was never in a helper. The load-bearing test stubs vite's build to capture the config the REAL buildSsrBundle hands it and reads the options back off the plugin instance that call site actually constructed, via the same api field the outer plugin publishes (bisect: bare pyreon() → expected {} to match object { ssrTemplate: false }). Reference: packages/zero/zero/src/inner-pyreon-options.ts + tests/ssr-build-forwards-pyreon-options.test.ts; the plugin's api contract is PyreonPluginApi in packages/tools/vite-plugin/src/index.ts.Diagnosing example build failures by editing source instead of rebuilding lib/
bun run verify-modes) fails with MISSING_EXPORT, missing files, or "the source clearly handles this case but the build doesn't" symptoms — the most likely cause is stale lib/ directories for the workspace package being imported, NOT a bug in the package source. Vite resolves vite.config.ts imports via the node condition (= lib/), not the bun condition (= src/). After git pull, branch switching, or hand-editing source mid-session, lib/ may not reflect current source. Run bun scripts/bootstrap.ts (~30ms when clean, ~45s when rebuild needed). Bootstrap detects mtime drift across all packages and rebuilds all that need it. Do NOT edit source thinking you're fixing a logic bug when the actual bug is "lib was built from older source." Concrete trap: a route filter in @pyreon/zero's scanRouteFilesWithExports correctly excluded API routes from the page-routes virtual module, but the published lib/ was built before that filter was added — verify-modes failed with MISSING_EXPORT "default" is not exported by "src/routes/api/posts.ts", which read as a route-handler bug. The actual fix was bun scripts/bootstrap.ts. The bootstrap script now detects this case via mtime drift, but only on bun install — manual edits between installs still need a manual bootstrap run.Flipping opt-in
_active-gated instrumentation to always-on without deferring expensive work to read timeif (!_active) return blocks recording at capture time, the natural fix is to make capture always-on in __DEV__ (gated only by NODE_ENV at the caller — tree-shaken in prod). But ANY expensive work in the capture path — .stack formatting (new Error().stack), source-map resolution, JSON stringify of complex args, anything that touches I/O — must move to READ time, not eager-fire per capture. Why: local cost lies. Bun + plain Node measure new Error() + .stack access in ~0.5µs per call; under happy-dom + 60 concurrent vitest workers (CI parallel-load) the same code is 20-60× slower due to GC pressure, source-map resolution contention, and JIT thrash. Pre-fix PR #913 made _rdRegister always-on but kept eager .stack parsing → 10k-signal local cost 360ms, CI worst 5947ms (blew past the 800ms threshold). Fix shape: capture only the cheapest possible primitive at create time (new Error() allocation without .stack access is ~0.14µs; the stack is captured lazily). Store the raw primitive on the record. Resolve in the snapshot/read API (getReactiveGraph() etc.) and memoize the result on the record so subsequent reads are O(1). Drop the captured primitive after first resolve so it becomes GC-eligible. Most consumers of always-on instrumentation never read most events (LPIH inlay-hints only consume loc for hot lines the user has on screen; devtools panel only inspects the subset of nodes the user opens) — the typical cost per event is just the cheap allocation regardless of count. General rule for any framework instrumentation that goes always-on: (1) at capture, store the smallest primitive that lets you reconstruct the data later; (2) defer expensive resolution to a read-time path; (3) memoize on the record; (4) bench the worst-case capture cost against a 10k-event microbench under CI conditions, NOT just local — primitives like .stack, JSON.stringify, and PerformanceObserver entries can be 20-60× slower under parallel-load contention; (5) keep an opt-out test helper (__resetXForTesting()) so cross-test pollution from the always-on registry growing across it() blocks doesn't compound GC pressure into threshold flakes. Reference: packages/core/reactivity/src/reactive-devtools.ts:_captureCallerLocation returns DeferredLocation { __deferred, err, skipFrames } instead of resolved SourceLocation; _resolveLoc(rec) is called only by getReactiveGraph / getFireSummaries and memoizes onto rec.loc. Post-deferred-parse: 10k signals at 40ms local / <1s CI worst case (9× faster than eager-parse; parity with the OLD _active-gated zero-cost baseline on the vite-injected fast path). Bisect-verified end-to-end against examples/perf-dashboard: 477/477 (100%) pre-existing signals get loc populated when devtools attaches AFTER mount (LPIH coverage uniform across capture timing).existsSync(out) && readFileSync(out) === content then writeFileSync(out, content) as a write-if-changed guard (CodeQL js/file-system-race TOCTOU)js/file-system-race (time-of-check-to-time-of-use) because the existsSync "check" is paired with the later writeFileSync "use". The existsSync is REDUNDANT — a missing file is just a read miss the read's own try/catch already handles. Fix (fundamentally-correct, not suppression): drop existsSync and read directly in a try/catch (ENOENT → treat as absent), compare, write. One fewer syscall, no check-before-use, alert cleared. Don't reach for the codeql-config.yml paths-ignore here (that's for genuine build-time code-construction false positives like compiled-verdicts.ts / client-directives.ts) — this one has a clean structural fix. Real instance: @pyreon/zero's route-types-gen.ts:writeRouteTypes (typed-routes codegen). General rule for any "write a generated file only on change" helper: read-then-compare, never existsSync-then-write.Two matchers for one concept — a hand-rolled comparison beside the real matcher it must stay identical to
matchPath and STATIC ones through path == "/toolkit". A useUrlState write makes the path /toolkit?filter=done, every static branch missed, and the screen rendered nothing — both device gates, both platforms, with Android printing the router's own [Pyreon Router: no route for /toolkit?filter=done]. The trap is the repair, not the bug. Stripping the query before comparing fixes the reported input and leaves the class open: a differential probe against the real runtime showed a stripped comparison STILL disagrees with matchPath on a trailing slash (/toolkit/) and on empty segments (//toolkit) — both documented matchPath behaviour, both locked by the runtime suites on each target, so the emit was violating a contract its own runtime asserts. Every one of those is a normalization rule the comparison has to re-implement, and would have to keep re-implementing for any rule added later. Rule: when a concept already has a canonical implementation, a second one written for the "simple case" is not an optimization — it is a divergence that only ever grows. Call the real one. Static branches now emit exactly what dynamic branches emit. Bisect with THREE legs whenever a shape fix and a class fix both exist, or the two-leg form certifies the shape fix as correct: raw == → fail, strip-then-compare → fail, matchPath → pass. And lock the ABSENCE of the second matcher, not the presence of the right one — both broken forms satisfy "calls matchPath somewhere". Reference: emit-swift.ts/emit-kotlin.ts route dispatch + tests/static-route-uses-matcher.test.ts.Escape the ESCAPE CHARACTER first, or the escape reopens the hole it closes — and do not let the TEST repeat the bug.
| for a Markdown table cell without first escaping \\ turns an input of \| into \\|, which a renderer reads as an escaped BACKSLASH followed by a LIVE pipe: the cell splits and the rest of the value is dropped, exactly what the escape existed to prevent. CodeQL flags this as js/incomplete-sanitization (high) and is right. Correct order is always .replace(/\\/g, '\\\\') THEN the delimiter — the same rule writer.ts:q() follows for string literals, in a different syntax. Two ways the regression test fails to be load-bearing, both hit in one sitting: (1) counting delimiters with a lookbehind (split(/(?<!\\)\|/)) has the IDENTICAL bug — in \\| the pipe IS preceded by a backslash, but that backslash is itself escaped, so the pipe is live; walk the string and consume \X as a pair instead, which is the only reading that agrees with a renderer. (2) A fixture with an EVEN number of backslashes before the delimiter is handled correctly by the broken code, so it proves nothing — the discriminating case is exactly ONE. Reference: lathe/src/emit/docs.ts:mdCell + tests/docs-emit.test.ts:mdCells.[FIXED, 2026-09] Every
_setX the compiler emits must be a SUPERSET of the setStaticProp branches ABOVE its own dispatch point.setStaticProp; the compiler ROUTES each attribute by name straight to _setAttr / _setValue / _setStyle / _setClass / _setHtml. So every branch setStaticProp runs BEFORE it would have reached that helper is a branch the compiled path skips — and the helper is where it has to be re-stated, or the compiled path silently writes what the h() and SSR paths refuse. Three instances shipped together, all invisible to a h()-only test: (a) _setAttr had NO url guard, so a compiled <a href={u}> wrote javascript:alert(1) while h() dropped it and SSR omitted it — a live XSS in every compiled app AND a guaranteed hydration mismatch; verified on href/src/action/formaction/data/poster/cite. (b) _setValue had no nullish branch: HTMLInputElement.value is [LegacyNullToEmptyString], which maps null → '' but NOT undefined, so a compiled <input value={maybe}> displayed the literal text undefined where h() (removeAttribute) and SSR (no attribute) render an empty field. (c) _setStyle cleared an OBJECT style on a nullish flip but never a STRING one, so style={c ? 'color:red' : undefined} kept color: red forever. Rule: extract the shared branch as one predicate both paths call (isBlockedUrl) rather than re-deriving it — and lock the paths against each other with a DIFFERENTIAL test over {attribute} × {payload} × {compiled via the real transformJSX + mount, h() mount, renderToString}, asserting the three AGREE. A hand-written expectation table only encodes the author's assumptions; the three paths disagreeing is the defect. Note the harness trap: a childless element at a module's top level is left as RAW JSX (jsx: preserve — esbuild's automatic runtime finishes it) and never reaches _tpl, so a differential fixture must NEST the element or it silently tests nothing. Bisect-verified per instance in runtime-dom/src/tests/setx-superset-differential.test.tsx (revert (a) → 42 failures, compiled vs h() on a/href: expected 'javascript:alert(1)' to be null; (b) → compiled .value: expected 'undefined' to be ''; (c) → expected 'red' to be '').[FIXED, 2026-09] A NAMESPACED JSX attribute name (
xlink:href, xml:lang) reached every template name-reader as the EMPTY STRING — and the h() path it was first BAILED to was broken too.xlink:href parses as JSXNamespacedName, not JSXIdentifier, and roughly ten name readers in the template emitter were written name?.type === 'JSXIdentifier' ? … : ''. Neither backend errored: the JS backend baked malformed HTML (<use ="/static">) into the _tpl string and emitted _setAttr(el, "", u) for the dynamic form, while the Rust backend dropped the attribute outright — so <use xlink:href="#icon">, the SVG sprite idiom, rendered nothing in every compiled app, differently per backend. The first fix bailed the element to h() on the stated reasoning that "the runtime sets the qualified name correctly". Measured in Chromium, it does not. setAttribute('xlink:href', v) creates a NULL-namespace attribute whose localName is the literal string xlink:href; an SVG <use> ignores it (href.baseVal === '', getBBox().width === 0) where the parsed form gives '#icon' and 100. So the bail moved the bug from the compiled path to the runtime path and cost the element its template on the way. The real split is PARSED vs ASSIGNED. An attribute reaches the DOM two ways, and only one of them namespaces: the HTML parser runs "adjust foreign attributes" (HTML Standard §13.2.6.5), so SSR bytes AND the compiled _tpl bake — which builds through innerHTML — get XLink for free; every setAttribute spelling does not. A static sprite therefore worked and a dynamic one did not, on the same element, and a server-rendered page silently disagreed with its own client mount. The fix is one QUALIFIED-NAME reader per backend (jsxAttrName / jsx_attr_name) so the name reaches the bake, the dynamic setter and the prescan alike, plus one runtime resolver (foreignAttrNamespace) that reproduces the parser for the assigned form. Three details are load-bearing. (1) The table is CLOSED, not a prefix rule — xlink:href namespaces, xlink:custom and xml:base do not, so deriving the namespace from the prefix disagrees with the parser on exactly the names nobody tests. (2) It applies only in FOREIGN content — <p xml:lang> in HTML parses to a null namespace, which setAttribute already produces, so namespacing it there would CREATE a divergence instead of closing one. (3) In Rust the helper must return Cow, because a namespaced name has to be BUILT rather than borrowed out of the AST — which is the structural reason each reader had grown its own _ => return instead. happy-dom MASKS the whole class: measured, it auto-namespaces setAttribute('xlink:href', …) by prefix, so broken and fixed are indistinguishable there and every namespace assertion passes against the bug. Real Chromium is the only gate, and the load-bearing assertion is getBBox().width — a <use> whose href does not resolve still exists, still carries the attribute, and still passes every getAttribute/hasAttribute check. Bisect PER BACKEND and rebuild the .node: transformJSX prefers the native binary, so a stale artifact reports a false pass, and a corpus that exercises one backend proves nothing about the other. Locked in compiler/src/tests/native-equivalence.test.ts (both backends byte-identical over 14 shapes × 3 modes), runtime-dom/src/tests/namespaced-attributes.test.tsx (the emit, and the drift lock for the hand-written browser shapes), namespaced-attributes.browser.test.tsx (namespace + rendering + hydration in Chromium) and runtime-server/src/tests/namespaced-attributes.test.ts (the server bytes).