Implementation-discovery details that are not specification. The phase specs live in the approved plan and reference only the DDR + Amendment A1. This file captures parser/tooling/runtime realities discovered during implementation.
- parse5 lowercases all attribute names. Both the property segment and the command segments arrive lowercased. So source
innerHTML.rawBind.to-viewreaches the parser asinnerhtml.rawbind.to-view. The camelCase legibility ofrawBind/rawSetis a source-only affordance; the compiler never sees the casing.- Normalize the property segment through
PROPERTY_NAME_MAP(already used fortextcontent→textContent, etc.). - Command matching is lowercase-keyed.
- Normalize the property segment through
- Three-segment attribute grammar (DDR §8/§10). Attribute names may now have 2 or 3 dot-segments. Parse by splitting the attr name on
.:- 2-segment:
property.command— e.g.value.set,value.bind,value.to-view,value.from-view,value.two-way,value.rawSet,click.calls,panel.capture. - 3-segment:
property.command.qualifier— the raw directional escape hatch:innerHTML.rawBind.to-view,innerHTML.rawBind.from-view,innerHTML.rawBind.two-way(and the §10 directionalbind.to-view/bind.from-viewif authored property-prefixed).
- 2-segment:
- Internal representation:
BindingInfocarries{ type, raw }wheretypeis the operation enum andrawis a boolean. This is NOT a flattened token — the source surface stays three-segment (rawBind.to-view). The flattened tokensrawTo-view/rawFrom-view/rawTwo-waymust never appear (spec correction #1). Mapping:set→{set,false},rawSet→{set,true}bind→{bind,false},rawBind(.dir)→{,true}to-view/from-view/two-way→{same,false}calls→{calls,false},capture→{capture,false}
- Kill the silent
|| 'bind'fallback inparseBindingCommand. Unknown/retired commands (one-time,trigger,delegate, typos) become harderrordiagnostics, not a silent two-way bind (fail-open is unacceptable in a security release).
- New compiler module houses
SAFE_SINKS+ the pure gate decision + diagnostic types. SAFE_SINKS contents are the §11.2 "load-bearing unknown" — starter set below, refine empirically. - Gate runs for the four OUTBOUND sink ops (
set, bind, to-view, two-way). NOT forcalls/capture(addEventListener, no sink),if/else-if/repeat.for(no sink, §6.2), orfrom-view(inbound — see correction below). CORRECTED (Phase 2 review): this was a bug. See "from-view one-way fix" below.core.ts bind()always runs the view-update effect, sofrom-viewis also an outbound sink write → gated.
The Phase 1 generator wired from-view as DiamondCore.bind(el, prop, () => expr, (v) => expr = v) — i.e. two-way, because bind() unconditionally ran the to-view getter effect. That let the model push into the sink, contradicting the construct's name and silently bypassing a developer's intentional inbound-only security boundary (e.g. innerHTML.rawBind.from-view for a validated inbound path — a websocket/sibling write to the model would reach innerHTML unvalidated).
- Fix:
bind()getter is now(() => unknown) | undefined; the to-view effect runs ONLY when a getter is passed.from-viewemitsDiamondCore.bind(el, prop, undefined, setter)— DOM→model only, model never reaches the sink. (Matches Aurelia from-view: view not initialized from the model either.) - Consequence:
from-viewis now genuinely inbound → removed from the outboundSinkOpset and NOT outbound-gated (§3.3 row 1). Its inbound risk is the runtime smell check (§3.3 row 3, Phase 3). Therawflag onfrom-view(rawBind.from-view, valid per §4.2 "+ raw counterparts") is preserved as the inbound-escape hatch Phase 3 will consume. - Principle: a one-way-named flow must never permit the opposite flow. Tests: runtime
core.test.ts(model write does not reach the sink) + generator (undefined getter, from-view not outbound-gated). - Centralize all sink emission through one helper so no future code path can emit a property write without passing the gate (this is what permanently closes the one-time bypass).
- Gate decision table (normalize property via PROPERTY_NAME_MAP, then
safe = SAFE_SINKS.has(prop)):raw=false, safe=true→ clean, no diagnostic.raw=false, safe=false→ emit treated-as-raw bytes +stink:warn(hard gate).raw=true, safe=false→ raw write +stink:declared(baselined, no block).raw=true, safe=true→ clean +info(redundant raw).
- Insight: for a non-safe sink, emitted bytes are identical declared vs. undeclared — you can't make
innerHTMLsafe with code, only with a declaration. The gate forces the declaration into the reviewed baseline.
text: textContent, innerText; value/state: value, valueAsNumber, valueAsDate, checked, selected, selectedIndex; class: className; boolean UI: disabled, readOnly, required, hidden, multiple, open; numeric scalars: tabIndex, maxLength, minLength, rowSpan, colSpan, scrollTop, scrollLeft; plain-text descriptors: placeholder, title, alt, label, htmlFor; constrained tokens: type, name, accept, autocomplete, inputMode, step, min, max, pattern, id.
Off-list (require raw): innerHTML, outerHTML, srcdoc, href, src, srcset, action/formAction, style/cssText, all on*, everything unenumerated (fail closed).
Coupling: any safe sink whose camelCase ≠ lowercase must also be in PROPERTY_NAME_MAP or it fails closed as a false-positive warn. Add missing entries: minLength, scrollTop, scrollLeft, valueAsNumber, valueAsDate, inputMode, selectedIndex, autocomplete. Ship a unit test asserting SAFE_SINKS ⊆ keys(PROPERTY_NAME_MAP) ∪ lowercase-identical.
- Add
diagnostics: Diagnostic[]toCompileResult(additive; existing{outputCode, result}consumers unaffected). Parser emits retired/unknown-command errors; generator emits gate diagnostics (only it knows property+op+raw together);DiamondCompiler.compile()merges. - Parcel transformer: throw on
severity:'error'(retired/unknown = broken source); passwarn/declared/infothrough silently. Enforcement is the merge gate (tools script), not local dev.
- Baseline file at repo root (checked in), sorted for clean diffs. Record per declared raw:
{ id: "file:line:property:op", file, line, property, op, expression }.expressionis the §8 human-readable audit record ("raw innerHTML at SearchBar:8, via sanitizeHtml"). - Check script mirrors
tools/check-loc-budget.ts(tsx). Modes:--check(fail on anyerrororstink:warncount>0; declared baseline drift surfaces via git diff, does NOT hard-fail per §3.4) and--update(rewrite baseline). Wirestink:check/stink:updatenpm scripts; add toprepublishOnlybesidecheck-loc.
Must add new tokens AND retain retired ones: detect calls|set|rawset|rawbind|capture|bind|to-view|from-view|two-way plus retired trigger|delegate|one-time — otherwise a .trigger file is never detected, never compiled, and the helpful "renamed to .calls" diagnostic never fires (silently served as raw HTML).
examples/hello-world/src/Counter.diamond.htmlusesclick.trigger→click.calls.- Tests referencing retired tokens:
parser.test.ts,generator.test.ts,compiler.test.ts,transformer.test.ts(one-time/trigger/delegate assertions).
- New
ElementInfo.structural?: { type: 'if' | 'else-if' | 'repeat'; expression; location; itemName?; itemsExpression? }. At most one structural per element (diagnostic if two). - Single-segment attrs:
if→structural if;else-if→structural else-if;else→error (bare else removed → suggestelse-if="!cond"or switch v2.1);with→error (removed → VM getter);rawif→error (if has no sink/raw). repeat.forspecial-cased BEFORE binding-command parsing (segments[0]==='repeat'): parse"item of items"via/^\s*(\w+)\s+of\s+(.+)$/.- Misuse:
if.<anything>/else-if.<x>→ error ("takes no command; use bare");with.bind→ error (with removed);repeat.<not-for>→ error.
prefixExpressionrewritten to a token-aware identifier prefixer (handles conditions with operators:!a && !b,nodes.length > 0,progress > 0.5). Regex matches string-literals |.tail| identifier-root; only prefixes identifier-roots that are NOT keywords, NOT in-scope loop vars. Keywords set includesthis,true,false,null,undefined,typeof,instanceof,in,of,new,void,NaN,Infinity.scopeVars: Set<string>— loop variables in scope; pushed around repeat bodies so${user.name}andselect(user)reference the closure param, notthis.generateNodesgroups consecutiveif+else-ifsiblings (skipping whitespace) into ONE conditional; orphanelse-if→ error.generateElement(element, ignoreStructural)— when building a branch/item body, ignore the element's own structural attr (already consumed by the enclosing construct).- Conditional →
const a = document.createComment('if'); DiamondCore.if(a, [{when:()=>cond, make:()=>{...; return el}}, ...]). - Repeat →
const a = document.createComment('repeat'); DiamondCore.repeat(a, ()=>items, (item)=>{...; return el}). Index param omitted from generated closure (not in DDR surface).
- Cleanup scope:
currentScope: CleanupFn[] | null.bind/on/if/repeatregister teardown viatrack()when a scope is active. Top-level (component root) scope is null → matches existing no-cleanup-at-root behavior. captureScope(fn)→{ value, cleanup }: collects all teardown registered duringfn. Structural directives dispose a removed branch/item with it. [Corrected v2.1.1, D-1] In v2.1 this claim was false for theif/switchtoggle path (branches were cached and detached without cleanup); as of v2.1.1 the branch cache is removed and detached means disposed across all three structural directives. Note also: a bare structural directive outside anycaptureScoperegisters no cleanup at all (track()is a no-op whencurrentScopeis null).DiamondCore.if(anchor, branches): first truthywhen()wins; branches built lazily; a toggled-off branch is disposed eagerly (node removed + captured cleanup invoked, same shape as repeat'sgone.cleanup()) and rebuilt fresh on re-activation — there is no branch cache (removed in v2.1.1, D-1). Reactive reads (thewhen()s) happen BEFOREmake()so the master effect tracks condition deps (the engine nulls activeEffect after a nested effect, so reads must precede builds).DiamondCore.repeat(anchor, itemsGetter, makeItem): keyed by item identity (default). Reuses/reorders nodes viainsertBefore(node, anchor)in document order; disposes gone items. Known limit: duplicate primitive items collide on identity (acceptable; most repeats are over objects).itemsGetter()read before builds for correct dep tracking.
Top-level compiled bindings still don't auto-clean on unmount (Phase 0/1 behavior). Structural directives DO clean their branch subtrees (via captureScope) — as of v2.1.1 (D-1) this holds uniformly, including the if/switch toggle path, which is stricter than the root. (In v2.1 the toggle path detached without disposing; the "stricter than the root" claim was false for that path.) A holistic root-cleanup pass is deferred.
DDR-consistent but unstated convention: PascalCase head (CurrencyConverter) = converter class → directional .format/.parse. camelCase head (parseRaw, clamp, formatPercent) = plain transform function → direct call. §5.3's formatPercent(clamp(parseRaw(this.value))) = camelCase direct calls; §5.4's CurrencyConverter.format(this.amount,'USD') = PascalCase static methods. Args in parens thread to BOTH legs.
- Pipes live ONLY in interpolations
${...}and value-binding expressions.if/else-ifconditions are a different code path (generateConditionalcallsprefixExpressiondirectly) — never pipe-split, soif="a || b"is structurally safe. splitTopLevel(src, delim): char scanner tracking string state ('"\`` +` escape) and bracket depth()[]{}; splits only at depth 0, outside strings. For|: skip||(logical OR — check prev/next char). Reused for pipe-split (|) and arg-split (,).- Precedence: pipe is LOWEST (
cond ? a : b | f→f(cond ? a : b)). Lone|is always a pipe (bitwise-OR unsupported in template exprs). - Heads are bare imports, emitted verbatim (NEVER
this.-prefixed). Only the data leaf (segments[0]) and each arg are run throughprefixExpression. This is the critical fix: passing a whole pipe expr toprefixExpressionwould mangleCurrencyConverter→this.CurrencyConverter.
- interpolation / to-view / set (display, outbound): full multi-segment FORMAT chain (left-to-right composition).
- from-view (inbound): at most ONE transform. Converter →
.parse(ParseResult, validated); plain fn → direct call (unvalidated); none → passthrough(v)=>this.target=v. Multiple → error. - two-way: exactly ONE segment, must be a PascalCase converter. format=
Conv.format(data,args), parse=Conv.parse(v,args). camelCase on two-way → error (non-invertible — this is the §5.1 hole the capitalization convention opens). Multi-segment two-way → error.
// from-view: value.from-view="amount | CurrencyConverter('USD')"
DiamondCore.bind(input0, 'value', undefined, (v) => { const r = CurrencyConverter.parse(v, 'USD'); if (r.valid) this.amount = r.value; });
// two-way: value.two-way="amount | CurrencyConverter('USD')"
DiamondCore.bind(input0, 'value', () => CurrencyConverter.format(this.amount, 'USD'), (v) => { const r = CurrencyConverter.parse(v, 'USD'); if (r.valid) this.amount = r.value; });"Keep raw on invalid" (§5.7) is FREE: from-view's undefined getter means the model never writes the DOM, so not-writing-the-model on invalid leaves the user's text intact. The error rendering surface is deferred (§5.7) — mark the seam, don't build it.
§5.5 says "the compiler follows that import." Resolution lives IN DiamondCompiler, NOT a separate tool / Parcel:
compile()(direction): emitsCompileResult.converterObligations(name + 'parse' + direction + location) for converters on inbound legs; emits camelCase-on-two-way + multi-segment-two-way errors directly (non-invertibility is visible by inspection — no module access needed).compileAndInject()(resolution): for each obligation, regex-scanscomponentSourcefor the converter'simport ... from '<path>'(NOT a TS parse — accepted string-compiler ceiling), resolves<path>relative tooptions.filePath(tries.ts/.js//index.*), reads the module, checks/static\s+parse\b/. Emitserror(converter-missing-parse) if absent; softinfo(converter-unresolved) for package specifiers / re-exports / missing files ("verify manually" — fail-soft, not a crash). Uses the same diagnostic pipeline;fs/pathadded to the compiler.- Standalone path guard:
CompileResult.pipeTransformslists all named pipe heads. The Parcel transformer (utils.compileTemplate, standalone.diamond.html→ module) errors (pipe-transform-standalone) if any are present — they'd be undefined symbols in a module that only imports DiamondCore. Converters require the component-inject context (where the author's imports are in scope). Fail-closed on the API surface > runtime ReferenceError.
- Property-scoped
property.commandattr:value.update-on="<event>". Pairs with thevaluetwo-way/from-view binding on the same element; sets which DOM event samples the model (replaces the default input/change). - Parser:
<prop>.update-on→ collected into a per-element map keyed by canonical property; applied to the matching binding asBindingInfo.updateOn. Bareupdate-on(single segment) → error (ambiguous on multi-binding elements, §4.3).update-onon a property with no inbound (two-way/from-view) binding → diagnostic. - Runtime:
DiamondCore.bind(el, prop, getter, setter?, eventName?)— wheneventNamegiven, the from-view listener uses it instead ofgetInputEventName(el). - Generator: from-view/two-way emit the 5th arg
'<event>'whenbinding.updateOnis set.
protectedComponent methods returning a wrapped fn. Self-register theircancelviaregisterCleanupat creation time → the class-field one-liner (handleInput = this.debounce(v => this.query = v, 500)) is leak-safe with no visible timer-cancel burden. Field-init order: basecleanups = []runs during super(), subclasshandleInput = this.debounce(...)runs after →registerCleanupis ready.- Cancel-on-unmount: Component.unmount runs all cleanups → pending timers cleared.
- A lone
&(not&&) in a binding/interpolation expression → hard error. Detection:/(?<!&)&(?!&)/(excludes&&/||). - Bitwise lone
&is intentionally flagged too — this is NOT a false positive. A template is a declarative binding surface, not a computation surface (§2.8): there is no legitimate bitwise&in a binding (same reason there's none in a SQL WHERE clause or CSS selector). The redirect is a view-model getter (get result() { return this.a & this.b }). Syntactically bitwise-&and behavior-&are indistinguishable (both lone&), so no regex separates them — and that's fine, because BOTH should leave the template. Softening to a warning was rejected (softening a security-adjacent error is how holes open; rare legit uses are trivially fixed with a getter, rare illegit uses sneaking past a warning are the real risk). The message redirects both cases (bitwise → getter; behavior → update-on / debounce / reactive / set).
Currency (Intl.NumberFormat; en-US-shaped parse + documented seam), Date (Intl.DateTimeFormat; ISO→UTC-midnight parse + calendar-validity check — rejects Feb 30), Phone (NA-only; canonical 10-digit model + "implement your own" seam). Depends on @diamondjs/runtime for ParseResult. Opt-in via bootstrapper / one npm install. Budget entry added to check-loc-budget.ts (500 LOC).
- NOT
stink:warn(that's a compile-time gated code; reusing it is a category error). Useconsole.warn('[Diamond] inbound corruption: ...'). - Heuristic:
oldValuenumber,newValuestring,Number.isNaN(Number(newValue))(catches "$1,250.00" over 1234.56). Covers only 1 of §5.1's 3 rows (phone string→string and date are invisible) — it's a THIN BACKSTOP; the real defense is §5.6 compile-time. - Hot-path: guard dev-only (
NODE_ENV !== 'production'), warn-once-per-property (the reactivesettrap is the §11.2 perf-sensitive path).
ParseResult stays in @diamondjs/runtime (the validation contract; batteries + user converters import the same one so it can't drift). Batteries (Currency/Date/Phone) → new @diamondjs/converters (deps: runtime) — tree-shakeable, opt-in, honors "import graph is the registry." Needs a check-loc-budget.ts entry.
- Standalone
.diamond.htmlcan't import a named transform → emit a diagnostic (named pipe transforms require the component-inject path). Inject path leaves the author's converter imports intact (no auto-import). - Interpolation extraction regex
/\$\{([^}]+)\}/gbreaks on}inside pipe args (${x | Conv('}')}) — pre-existing; document, fix later with a brace scanner if needed. - Compiler checks
parseEXISTS, not its signature (§5.4 — TS checks arity when the module type-checks).
Ratified design decisions live in docs/spec/v2.1.0/DiamondJS_v2.1_Amendment_A2_Design_Record.md; these are the parser/tooling/runtime realities discovered while implementing them.
<switch>/<case>/<default>are consumed WHOLE byprocessSwitchinprocessChildren—<case if>never reachestryStructural, so it cannot collide with the structuralif. Acase/defaultreached via the normal path has, by construction, no<switch>parent →*-outside-switcherror.- Spread detection must run BEFORE the attr-name dot-split (
'...attrs.bind'.split('.')shreds into['','','attrs','bind']and would fall into unknown-command). - The @import directive scan runs on the RAW template text in
DiamondCompiler.compile()— parse5 comments never reachprocessChildren, so the node tree can't carry them. A secondIMPORT_LIKE_REpass makes malformed@import-shaped comments fail loudly instead of silently no-op'ing. emitBindCalldecides single- vs multi-line: block-body setters are ALWAYS multi-line (theif (r.valid)prominence is the point, not line length); concise setters split only past 100 chars.combineRootsis where the erased-wrapper semantics live (multi-root case bodies → DocumentFragment).generate()keeps its fixedrootname (test-compat); only switch bodies use the hint-named variant.- Static-switch equality uses strict
===between the decodedon=literal and the decoded case literal — a numeric caseif="3"matcheson="3"(both decode to number) but noton="'3'".
DiamondCore.switchslot layout:built[cases.length]is the default branch.onGetter()runs once per effect; expression-case predicates still read reactive state inside the master effect, so their deps track (same reads-before-builds reasoning asif()).- Spread's property-restore reconciliation snapshots the PRE-first-write property value (
prior), not the previous spread value. ITERATE_KEYis a module-level symbol; the set trap triggers it only for NEW keys (!Reflect.hasbefore the write) — plain value updates don't wake key-set iterators.- Collection's version signal is a one-field micro-proxy (
createProxy({n:0})) — reads/bumps ride the existing engine + scheduler;revis a plain untracked mirror used only for sortBy cache invalidation (bumps also increment it insidemutate, which slightly over-invalidates the cache — harmless). repeatregisters node→item in a WeakMap at row BUILD; reused rows keep their mapping; disposed rows are deleted eagerly (WeakMap would GC them anyway, but a still-referenced removed node must not resolve).- Compiler tests import the runtime through its built dist (workspace resolution) — rebuild the runtime before compiler tests whenever security.ts/core.ts change (same stale-dist footgun as parcel-plugin ↔ compiler).
- BSD sed has no
\b; the rename sweeps used perl. - stink-check now ignores
reference_files/(prior-project material was tripping 183 errors / 57 warns). - npm does NOT topologically order
--workspacesscripts; the root build explicitly builds runtime + primafacie first. - parcel-plugin src is at exactly 300/300 LOC (tests count toward the budget) — the §2.2 deliberate-increase decision is now due before ANY further growth.
The first application built on the published 2.2.2 constellation (a hosted single-page map/reduce text processor) surfaced five defects, filed as issues #7–#11. Implementation realities discovered while fixing them:
- Structural call deferral (#7).
generateNodesis unchanged; each container (element children ingenerateElement, the root / switch-case fragments incombineRoots) pushes an attach frame, andgenerateConditional/generateSwitch/generateRepeatemit only the anchor declaration inline — theDiamondCore.if/switch/repeatcall is queued on the innermost frame and emitted bycloseFrame()right after the container'sappendChildblock. Consequence for readers of compiled output: the global var counter now numbers a structural's branch bodies AFTER the siblings that follow it in source (ifAnchor_3,el_select_4, …, thenel_div_9inside the branch). Tests that need exact var names must locate them by regex, not assume source order. combineRootsnow takes NODES (it opens the frame itself);generate()passes the fixed namerootthrough its third argument (test-compat preserved).- A structural that IS the template root has no frame — its call is emitted in place, before
return, and first render relies on the runtime guard (one microtask). Every nested structural renders synchronously on mount. - Scope vars survive deferral because deferred emitters run inside the enclosing
generateElementcall — the loop variable is still inscopeVarswhen a nested repeat/if body is generated. <select>wiring (#9) is the ONE element whose bindings + handlers are emitted after its children (generateWiring), behind a[Diamond]hint. Every other element keeps bindings → handlers → children. Composes with #7: arepeat.for-generated option list is wired by the attach frame before the select'svaluebinding runs.- Static
<a href>(#8) is a literal-only exception in the static-attr loop (isInertStaticHref), not an allowlist change:hrefremains offSAFE_SINKS; boundhref.*still warns. Scope isa/areaonly (<link href>/<base href>still warn). Whitespace/control characters are stripped before the scheme test to mirror the HTML URL parser (java\nscript:must still warn; stripping can only make a literal look MORE like a scheme).INERT_URL_SCHEMES = http, https, mailto, tel— a fail-closed set; extend deliberately.
DiamondCore.placeBefore(#7): synchronous insert whenanchor.parentNodeexists, else ONE microtask retry that re-reads the directive's live nodes (activefor if/switch, thecurrentmap for repeat) and inserts them in current order (re-inserting a placed node before its anchor is a no-op move). No retry loop: an anchor still detached after the microtask stays unrendered until the next change —Component.mount()attaches synchronously, so components are always covered.isInertMetadataKey(#8) =isDataOrAriaKey ∪ role; both gates and spread's branch decision use it.isDataOrAriaKeyis unchanged and still exported. Spec §6.3's "37 entries" stays accurate —rolerides the attribute branch, not the allowlist. The A2 inert-metadata sentence (data-*/aria-*) is now one token short; amend when the spec is next touched.adoptDefinedReactiveFields(#11) runs at the top ofComponent.mount()— the base constructor is too early (subclass fields are defined aftersuper()returns). Legacy path: keys recorded per prototype; TC39 field path: per instance viacontext.addInitializer. Repair = capture own value →delete→ define an instance accessor only when no prototype setter exists → reassign. The dev report (__DIAMOND_DEV__) fires once per constructor (WeakSet) and lists every repaired field.- vitest/esbuild lower class fields to [[Set]] in this repo, so the tests simulate [[Define]] explicitly with
Object.definePropertyin the constructor; they are independent of the test toolchain's own field lowering.
- route-check template stubs (#10):
installTemplateStubs()registers ESM hooks from adata:URL (no extra file to ship indist/bin) AND wrapsModule._extensions['.js']. Why the wrapper: tsx'stsImporttags CommonJS filenames with?namespace=…, so Node'sfindLongestRegisteredExtensionnever matches a.htmlkey — but tsx's transformer delegates every non-TS file to the.jshandler it captured at registration, which is ours when installed first. Consumers without"type": "module"(the Quick Start'snpm init -ydefault) hit the CJS path; a"type": "module"consumer hits the ESM path. Both are tested. - The loader test spawns the real bin from source via
tsx/cli(resolved through tsx's exports map —tsx/dist/cli.mjsis not an exported subpath) against two on-disk fixtures. Fixtures must live inside the repo tree so@diamondjs/runtimeresolves; copying them to a temp dir would break resolution.
-
Link interceptor pass-throughs (#14).
Router.onClicknow returns beforepreventDefaultfor anchors withdownload, atargetother than_self, areltokenexternal(both compared case-insensitively), and any resolved URL whose protocol is nothttp:/https:. The protocol test is what catchesblob:— ablob:URL'soriginis the creating document's origin, so the same-origin test alone claims it;data:/mailto:/tel:already failed that test (originis'null') and are now pinned by tests rather than incidental. The attribute checks live inRouter.optsOutOfRoutingto keeponClicknear its previous lint complexity. -
_top/_parentare passed through like_blankeven though they equal_selfin a top-level document: the browser then does an ordinary full-page load of a same-origin URL, which is correct, just not SPA. -
Spec touch-point for Joe: Router Specification §9 (and the Work Order's link bullet) enumerate the pass-throughs as "middle-click, modifier clicks, and external hrefs". The #14 set extends that list; the spec text is unchanged here — amend when it is next touched.
-
happy-dom performs the default action of an unprevented anchor click — it navigates the test document. A pass-through test that clicks a
blob:link moveslocationto the blob URL and every laterhistory.replaceStatein the file throwsSecurityError. The #14 tests add awindowclick listener (runs after the router'sdocumentlistener) that recordsdefaultPreventedand then cancels the click itself. -
Query and hash through the pipeline (#20).
run()takes aUrlTail({ search, hash }) in place of the oldqueryargument and writeshref(path) + search + hash. Sources: the anchor's URL on a link click (request(), the private entry behindnavigate()),locationon initial load and popstate, a route-* Destination'squery(serialized inrunTo).currentTailsits besidecurrentPathso a failed commit restores the whole previous URL. Before this, initial load and popstate rewrote the entry to the bare path — the query and hash were lost on load, not only on link clicks. -
recognize/match/parseQuerytake the TARGET's search.parseQueryused to readlocation.search, which on a push is still the page being left — a link's?tab=2never reached the declared converter. -
A same-document fragment link (
hashpresent, pathname and search equal to the location's) is not claimed.onPopstatelikewise returns early when only the hash differs fromcurrentTail— Back / Forward between two hashes of one page fires popstate (and a browser may fire it for the fragment navigation itself; not verified in a real browser here), and running the pipeline there would re-run guards on every in-page anchor. A popstate with an identical URL (a duplicate entry) still runs, so "guards on every vector" holds for real traversals. -
URLs behave as in HTML (#28).
navigate(url)parses its argument withnew URL(url, 'http://app.invalid/')(the base only lends a parser) and handsrequest()the path plus{ search, hash }; the interceptor now callsnavigate()with the stripped path, query and hash, so a link and the equivalent call are literally one code path. Side effect worth knowing: the URL parser percent-encodes the pathname (navigate('/café')writes/caf%C3%A9), which is what a link click already produced, so the two now agree. -
href="#"resolves to a URL whosehashis''but whosehrefends in#;isSameDocumentFragmenttestsurl.href.endsWith('#')as well, so the empty fragment passes through (the browser scrolls to the top). -
Scroll.
run()ends withsettleScroll(vector, hash)after the commit: a popstate with a recorded position restores it (window.scrollTo); else a hash whose element exists getsscrollIntoView()(#topwith no such element scrolls to the top, as HTML); else a push scrolls to the top and an initial load is left to the browser. Positions are recorded per history entry, keyed by thediamondNavIdthe entry's state carries:rememberScroll()runs before a push'spushStateand at the top ofonPopstate(the page being left is still in the DOM there), andonPopstatereads the saved position forhistory.state.diamondNavIdBEFORErun()stamps the entry with a new id.currentNavIdis set wherever the entry's state is written, including the failed-commitreplaceState. -
history.scrollRestorationis NOT set tomanual. The browser's own restoration runs on the page being left (before the commit, so it lands on the old DOM); ours runs after the commit and wins. Leaving it onautokeeps a reload's position and the native behaviour of hash-only traversals, which the router still ignores. 'Where possible' therefore means: a position is restored when the router recorded one for that entry (every entry it wrote and left); clamping to the mounted page's height is the browser's. -
happy-dom does no layout: the tests stub
scrollX/scrollY/scrollToonwindowandscrollIntoViewonElement.prototypeper test and restore the descriptors after. Forward is simulated as before (re-push the entry WITH the state object the browser would have kept, then a popstate), which is what makes the restore-on-Forward test meaningful. -
happy-dom:
history.back()/forward()work and fire popstate asynchronously, butreplaceStateon a back entry DISCARDS forward history (a browser keeps it) — so after the router's popstatereplaceState, Forward has to be simulated (re-push the entry + dispatch popstate). A hash-only anchor click sets the hash and fires hashchange but no popstate. -
Mounted range (#17).
DiamondCore.trackRange(body, stop?)→{ node, remove }replaces the three.remove()-on-the-returned-node sites (if(),switch(),Component.unmount()). It is public for the same reasoncaptureScopeis:Componentneeds it. The range is a first/last node pair taken BEFORE the insert (a fragment is empty afterwards);remove()walksnextSiblingfrom first and stops atlast, atstop(the owning structural's anchor — never removed) or at the end of the parent.Componenthas no owning anchor, so there the walk is bounded bylastor the end of the host only. -
Why the start needs care, the end does not. Every structural inserts BEFORE its own anchor and never removes that anchor, so a body's last root is stable whether it is static or an anchor. The first root is not: if the body begins with a structural, its output lands in front of the range. Worse, compiled bodies append the anchors to the fragment and THEN wire the structurals, so a truthy
if/ non-emptyrepeathas already rendered into the fragment by the timemake()returns — the fragment's first child is then that structural's output, which is replaced on the next change (a first/last pair anchored on it loses the whole range).trackRangetherefore prepends an empty comment when the first node is "unstable": a registered anchor, the first node of a nested mounted range (both in theunstableWeakSet) or a repeat row (itemRegistry). CheckingnodeType === 8instead would also mark theemptyand dead-switch placeholder comments, which need no marker. -
A sole-root structural (
<case><p if></case>, a template that is only<div if>) reachestrackRangeas the bare anchor, still detached and not yet rendered; it is wrapped in a fragment[start, anchor]so the existing detached-anchor microtask placement lands inside the range. Twofirst-mount-orderassertions changed accordingly (<!---->now precedes the root-level branch) and both now assert the host is empty afterunmount(). -
Cleanup order is unchanged:
if()/switch()remove the range and then dispose its scope;unmount()disposes and then removes.repeat()is unchanged — its rows are single elements. -
In-place array mutation re-runs iterating effects (#26).
pushon a reactive array goes through thesettrap twice: once for the new index (a new key, soITERATE_KEYfired — which onlyownKeysreaders track) and once forlength— but the array exotic object has already bumpedlengthwhen the index was defined, so that second write sawoldValue === valueand triggered nothing.Array.from,for…ofand.lengthreaders tracklengthand the existing indices, never the index being added. The trap now also triggerslengthwhen a new key lands on an array target: a new index IS a length change.pop,shiftandspliceremovals already worked (theirlengthwrite changes the value);unshiftandspliceinsertions had the same gap aspushand are covered by the same line. Effects stay batched, so N mutations in one tick are one re-run; the test pins that count. -
Reassignment keeps working and nothing warns on either idiom;
Collectionremains the tool for large lists mutated in place. The hello-world example'sadd()now pushes. Spec wording for §7.1 is proposed in the PR.
- Literal
${(#29): syntax is read from the RAW source.TemplateParserkeeps the template string and, for each text node and each plain attribute value, takes the raw span (parse5sourceCodeLocation), scans THAT with the sharedscanInterpolations, and only then entity-decodes each piece. Fast path: a raw span with no${is not scanned at all — the parser's own decoded value is used, so a template without interpolation syntax cannot change. - Decoding is delegated back to parse5 (
raw-source.ts), not reimplemented: a text piece is re-parsed with the real parent element as fragment context (so RCDATA<textarea>/<title>, raw-text<style>, legacy entities without;, CRLF → LF all match by construction); an attribute piece is re-parsed as an attribute value (©=2stays literal there). No new dependency. - Trust check. Before the raw span is used,
decode(whole raw span) === parse5's valuemust hold; otherwise the decoded value is scanned as before #29 (scanRaw). This guards against location quirks rather than assuming there are none. One is known and handled inrawTextSpan: for the first text child of<pre>/<textarea>/<listing>, parse5 reports a start offset AFTER the entity when the text begins with one following the dropped leading newline (<pre>\r\n&x→ span;x); the span is taken from the start tag's end, less that one newline. scanInterpolationsnow returns{ spans, statics, escapes }. A backslash run directly before${reads by the JS rule (pairs collapse, an odd one escapes);staticsare already unescaped.interpolationParts()lays a scan out asTextPart[]; the parser and the generator's hand-built-node fallback share it.TextInfo.parts(new, optional) is what the generator emits from;contentstays parse5's decoded value (escapes still in place) and is used for whitespace tests and the hint comment only. Hand-built nodes withoutpartsbehave as before: nointerpolations→ plain text; otherwisecontentis scanned.- Interpolation locations are unchanged (still the text node's start). The
escaped-interpolationnotice carries the exact line/column of the backslash in text, and the attribute's location in attributes. - Encoded syntax inside an expression follows the same rule: an encoded quote or
}in the RAW expression is not seen by the scanner's string/brace tracking (${a } b}closes at the real}; the body then decodes toa } b). - D-3's
attr.value.includes('${')is gone; a real raw${in a plain attribute is still the error. Binding / structural attribute values are expressions and are never scanned.
- route-check on Node 22 (#27) — two separate failures, one per loader path. ESM consumers: tsx's namespaced loader (
tsImport) appends?tsx-namespace=<id>to every URL itsresolvechain returns, including thedata:URL the template stub resolves to; in adata:URL everything after the comma is module source, so the stub ended inexport default {}\n?tsx-namespace=…and failed to parse. The hooks module now also exports aloadhook that serves the stub source for any URL that starts with the stub URL, and the stub source itself ends in a//line so an appended tail is a comment either way. The unit test imports the stub with a tail attached in a child node (vitest cannot import adata:module through vite-node). - CommonJS consumers (no
"type": "module"): with tsx 4.21 under plain Node 22,tsImportresolved the routes module's own extensionless imports (./pages/home) through the ESM resolver and failed withERR_MODULE_NOT_FOUND. The test spawns the bin through the tsx CLI, whose own loader masked this, which is why the issue reported only the ESM symptom; the compiled bin runs under plain node and hit it. Bisected: 4.21.1 … 4.23.13 fail, 4.23.14 fails withERR_UNKNOWN_FILE_EXTENSION, 4.23.15 passes — sotsxmoves to^4.23.15in@diamondjs/devand the root. The lockfile grows by tsx's nested esbuild 0.28 platform packages; the hoisted esbuild stays on the version tsup and vitest use. - Also under Node 22 with tsx 4.21,
tsImportof a CommonJS routes module surfaceddefault=module.exportswith no named exports beside it (Node 20 listed them), somod.routes ?? mod.defaultpicked the{ routes }wrapper andflattenreaddef.pathof undefined.pickRouteMapunwraps that wrapper (and an__esModuledefault) and tells a route literally namedroutesapart by itspath. tsx 4.23.15 restores the named export, so this is a backstop, kept because it is what the compiled bin sees with any tsx the range admits. - Verified with the built bin under plain Node 20.18.1 and 22.23.3 on both fixtures, and with the whole suite under both versions.
- Phase 0 recon against
main@ 82bb8bc. P-1 (component.ts:76setmounted = truebeforecreateTemplate()), P-2 (core.ts:43captureScopelost its scope on throw), P-3 (core.ts:298microtask placement; confirmed with a router scratch test, now #38), P-4 (component.ts:38flatcleanups;unmount()emptied it at 116), P-5 (router.ts:680constructed all incoming before anything), P-6 all verified. Divergences from the record's §1: the repo'sexamples/never overridemount()(only Turbine does, four sites, twounmount()overrides); the test environment is happy-dom, not jsdom; and the default vitest run already compiles the runtime tests with TC39 decorators +[[Define]]fields (roottsconfig.base.jsontargets ES2022 withoutuseDefineForClassFields), while the example tests compile with legacy decorators +[[Set]]. The "second tsconfig run" is thereforevitest.legacy.config.ts(legacy +[[Set]]) overpackages/runtime/tests/lifecycle/;npm run test:lifecycleruns both. Production LOC before: 1,746. - Scope (
core.ts).Scope { list, children, scopes, pending, nodes, prev };dispose()is LIFO, each cleanup in its owntry, failures returned as{ index, error }, every list emptied after.currentScopeis aScope;openScope()recordsprev,closeScope()restores it;withScope()for callbacks.captureScope()keeps its{ value, cleanup }shape for the structurals and the tests, addsscope, and disposes on throw (P-2).effect()now tracks its cleanup (LC-7) — the one defect the new tests found; the structurals' master effects andbind/spread's inner effects are created throughreactivityEngine.createEffectdirectly so each acquisition is in the inventory once. - Placement. A structural captures
owner = currentScopewhen wired.placeBefore(anchor, nodes, live, scopes, owner): anchor in a tree → insert now; else push the placement intoowner.pending(no microtask — the record's "instead of, not in addition to"), orqueueMicrotaskwhen there is no owner (hand-written templates, L-37). After every placementsettle(): anchor in the document →deliverBrancheach placed scope; else →owner.scopes.push(...)so the owner's drain reaches them. Branch and row removal moved INTO the branch/row scope (scope.add(range.remove)as the last entry, so LIFO removes first): a disposed branch is a detached one, which is what makesdeliverBranch's "the failed branch is gone" one call, and whyif()/switch()/repeat()no longer callremove()separately. - Consequence to know: a component mounted into a host that is NOT in the document never drains, so a root-level structural does not render there any more (it used to on the next microtask). Tests that did this (
first-mount-order×2,mounted-range×2,switch.test) now append the host todocument.body;switch.testand the hello-worldtaskboard.testalso pinned "nodes stay after the scope is disposed", which is no longer true (the rows/branch leave). Dev builds warn once per class when a root is mounted detached. - Drain (
DiamondCore.drain). Order:children(D8 instances) →scopes(branches placed while detached) →pending(root-level placements). The record's §5.3 lists children then pending;scopesis the extra list the nested case needs (a child inside a branch registers into the BRANCH scope, which the parent's mount scope cannot see without it).deliverBranchcontains a child'smounted()throw (branch disposed, parent stays mounted, L-23/A-7); a direct child's throw propagates throughComponent.deliver()into the parent's rollback. - Component.
lc = DiamondCore.reactive({ phase, generation })is the snapshot (§5.9, no decorator);transition()is the single writer (ring of 32,Printper LC-14,fold()= last record).mount()allowsconstructingas well asconstructed/unmounted(a hand-constructed component's firstmount()runsensureConstructed()itself — the record's pseudo-code checked the phase before that call).rollbackMountis idempotent (mountScopenull → no-op) becausedeliver()'s catch andmount()'s catch both reach it.unmount()on a non-mounted instance throws (it used to be a silent no-op).dispose()is idempotent and never transitions out offaulted.domPing:mounted⇔ every range node connected,mounting⇔ not connected, detached phases ⇔ no element and no mount scope.trackRangegainednodes()for it. - Timers (P-4).
debounce/throttlecancels register in the instance scope (dispose) AND in atimersset thatunmount()runs — so a pending timer is cancelled at unmount as before (the timing tests pin that) and the cancel survives for the remount.registerCleanupgoes to the mount scope while one is open, else the instance scope. - Additions the record did not name, flagged for Joe:
whileMounted(fn)(protected) is the generation-bound wrapper LC-9 implies and L-31/A-8 need — nothing else in the runtime had a stale-callback path.Component.adopt(scope)(@internal) folds the router's construction scope into the instance scope so a constructor that did acquire something is not silently disposed (§3 says constructors acquire nothing; adopting is the forgiving reading).DiamondCore.inScope()is howmount()knows it is nested (for the detached-root warning). - Router.
construct()per incoming component: own scope,adopt,ensureConstructed(); a throw disposes the attempt (P-5).unmountOutlet(name, occ, final): step (b) unmounts (final = false) so a rollback can remount the previous occupants — the record's "unmountOutlet calls dispose()" cannot hold during the commit, because a disposed instance is terminal and rollback remounts it; departed occupants are disposed once the commit stands, andstop()/rollback dispose outright. The hand-written rollback stays (the record expected ~25 LOC removed; the bookkeeping it does is still needed). - A faulted outgoing occupant cannot be remounted by a rollback (
mount()throws,run()narrates "commit failed … previous route preserved" — but that occupant is gone). A-10 as written ("navigation refused with the faulted instance named; previous route intact") assumed the instance is reused; the router constructs a fresh instance on the return navigation, so A-10 asserts the fault is recorded and named (cleanup #0 Error: cleanup boom, oneCRITICAL) and that the fresh instance mounts. - LOC: 2,027 production (+281 of the +300 ceiling; the record's target was ~170). Where it went:
Scope+ open/close/with + drain/deliverBranch/child/settle (~95 incore.ts), the FSM/ring/transition/domPing/whileMounted(~150 incomponent.ts), routerconstruct+ disposal (~25), the dev counter (+6). Unions and error strings are single-line to stay under the ceiling. - Tests: 64 lifecycle tests in
packages/runtime/tests/lifecycle/(hooks L-1..L-15, failures L-20..L-28, invariants L-30..L-34 + L-38, router L-26/L-36, scope + L-37, fixtures), green under both configs; L-35 runs in the harnessafterEach. The harness wraps the prototypes that actually ownaddEventListenerin happy-dom (elements,document,windoweach have their own; Node's globalEventTargetsees nothing). The fixtures undertests/lifecycle/fixtures/ARE the spec §4.2/§4.4 and README code blocks —fixtures.test.tsreads the markdown and fails if they drift. L-34 ran over three in-file components; theexamples/and Turbine sweeps did not run (Turbine is a separate repo on the published runtime). - Acceptance (
tests/acceptance/, Playwright + Chromium): a static server (app/server.mjs, port 4173, SPA fallback,/slowdelays 300ms) serves the repo root;app/index.htmlimport-maps@diamondjs/runtimeand@diamondjs/primafacieto the builtdist/*.mjs;app/app.jsholds one hand-written component per scenario, mirroring Turbine's shapes. A-1..A-10 green (A-6 also as a direct load). A-9 tolerance: after a 5-cycle warm-up, 50 cycles of three pages (50repeatrows with a D8 child each, bindings, listeners, anif, a debounce, an effect inmounted()): DOM nodes 163 → 163, listeners 66 → 66, live page instances (WeakRef census afterHeapProfiler.collectGarbage) 1, JS heap +5.0% (1,774,596 → 1,863,896 bytes; three isolated reruns all read +5.0%). The assertion allows 20%: a run that overlapped a fullnpm run build && npm teston the same machine failed a 10% bound, and a real leak of the 150 departed pages would be several times the baseline, so the census (alive ≤ 2) and the exact node/listener equality are the sharp detectors and the heap bound is the coarse one. Without the warm-up the first measurement sits before the JIT settles and the growth reads +36%, which is not retention. Playwright output dirs are gitignored. - Turbine migration is prepared as a patch (not applied — separate repo on the published runtime):
override mount(host) { super.mount(host); … }→override mounted() { … }(four sites), constructors'ui.activeTab = …→mounting(), the twooverride unmount()→unmounting()with hand-mounted children unmounted whenphase === 'mounted'. Hand child mounting stays until D8.
- What was lost before.
processTextNodereturnednullfor a whitespace-only node and the generator emitted static text throughjsString(content.trim()), sopress <em>Start job</em> onrendered "pressStart jobon" and an NBSP-only node vanished. Interpolated text went through untrimmed, so static and interpolated text disagreed. The${work (#29) did not touch either site, which is why #15 stayed open after it. - Parser. A node is skipped only when it is empty (
content === '', which parse5 does not produce). Adjacent text runs merge (mergeText): the only way two become adjacent is a dropped HTML comment, and the merge is whatNode.normalize()would do once the comment is gone — without it the output held'\n ', '\n 'as two string arguments.partsmerge when both sides carry them;locationis the first run's. Root edges (rule (c)) are trimmed at the NODE level intrimRootEdges: a whitespace-only first or last root goes, a text root with content keeps its own edges, trailing line break included. A character-level trim of edge text roots was proposed and Joe rejected it ("if the user puts a new line somewhere we can't go arbitrarily removing them"). ASCII whitespace only, the same class as theelse-ifrule (#18); an NBSP-only root is content. - Generator. Static text returns a string literal from
generateTextand takes no variable;emitAppend(parent, children)writes oneappend(...)per parent in DOM order, one argument per line past the §13.4 width unless there is a single argument (a string cannot break, so wrapping it only adds two lines).combineRoots: a lone string child becomescreateTextNode(a text-only template or case body is its own root); the empty-comment and fragment cases are unchanged.isStringLiteralis the quote test — every static text literal is single-quoted byjsString.jsTemplatePartescapes LF so an interpolated run with line breaks stays on one line for the line-level source map. - Known interaction. An element with a
textContentbinding and indentation-only children: the bind's first pass runs before the append (bindings precede children except for<select>), so until the first update the element shows the bound text followed by the indentation. The lossless oracle excludes bound elements. A template that does this is asking for two writers of one node; not changed in 2.3.0. - Old tests. Fifteen assertions pinned the old shape (
appendChildper child,createTextNodefor static text,text_Nnumbering, "skips whitespace-only text nodes"); each was rewritten to the new shape, not deleted. The<select>test's option variables renumbered (el_option_3→el_option_2) because static text no longer takes a counter. The zero-diff oracle inliteral-interpolation.test.tsnow mirrors the parser's two steps (merge across comments, trim root edges) instead of filtering whitespace-only nodes. - Tests added (
preserve-text.test.ts, 33): the two #15 repros, Prettier shapes, NBSP / thin-space / tab /<pre>/<textarea>cases, comments between runs, the root-edge rule, static/interpolated parity,\${, structurals (if toggles, else-if chain, repeat separators, multi-root case bodies, the<!---->marker, which only guards a body — a switch at the template root gets the component-level marker instead), the emitted shape, and a round-trip ofTasks.diamond.html. The lossless property also ran as a scratch script over the hello-world example and Turbine's five templates: 207 of 207 text nodes equal to plain HTML. - Turbine audit (not changed; separate repo). Of 83 whitespace-only runs between inline siblings, 80 sit in flex or grid parents (
.actions,.grid-fields,.panel-head,.tabs,label, …) where the gap is not rendered. Three are in plain parents:prompt.diamond.html:6and:14are a typed space between two<span>s in an<h2>— the old compiler dropped it ("Instructions(system prompt)"), so these are the fix itself;output.diamond.html:37is a line break between two<span>s in a<div>whose class is bound (clearBannerClass), a word space if that class is not.banner(flex). Nowhite-space: precontext holds a newly kept node. - Compiled size:
Tasks.diamond.html116 → 119 lines; Turbine'sprompt1052 → 1120 (24 wrappedappendcalls, 103–352 characters on one line).
- The v2.3.0 specification was audited section by section against the code at
07cf171. Spec-side corrections landed in the docs PR; three new defects were recorded (D-26 console.error paths outsidePrint, D-27 undocumentedproperty.bind.to-view/.bind.from-view/.bind.two-waysynonyms inCOMMAND_MAP, D-28<switch>whitespace tested withtrim()instead of the ASCII class). D-26 and D-28 are one-line fixes; D-27 needs Joe's ruling (remove the map entries, or admit the synonyms in §5.1). - Comment and message rot found on the way:
component-composition-unsupported's message says composition "lands in v2.3" (compiler.ts) — it is v2.3.1; parser/compiler diagnostics cite "DDR §7.1 / §4.3 / §6.1 / §5.6" (parser.ts, compiler.ts), section numbers of the superseded design record that do not match the spec's numbering (§5.6 is now attribute spread; handler timing is §5.7);reactivity.tsandcore.tsstill describe the inbound smell warning and the spread unsafe-key warning as "dev-only" while both print in every build; the double-mount error (component.ts) still cites "(D-6)", which §4.4's phase rule superseded;tools/check-loc-budget.ts's header says "Parcel Plugin: ~200 LOC" against a 300 budget; runtime source comments cite "Router Spec §3.1 / §3.3 / §3.5 / §3.6" (router.ts, guard.ts, pending.ts) — the Router Specification's own headings are §2, §4, §7, §8, i.e. spec §17.2, §17.4, §17.7, §17.8. - Untested claims worth a test:
navigate('/café')writes/caf%C3%A9(holds bynew URL, no test pins it); the #38 hash scroll after a root-level structural placement is pinned only throughmounted()seeing the branch, not through the scroll itself. GuardClass(guard.ts) is not exported from the runtime index; the spec spells the fieldtypeof Guard | Array<typeof Guard>. Export it or leave it internal — either way the spec is consistent now.
- D-26. The last two
console.errorsites in the runtime now go throughPrint: an effect throwing during a scheduler flush isEXCEPTION(the flush continues with the next effect), a cleanup throwing in a captured scope or structural branch isFAILUREwith the cleanup's index.scheduler.tsgains the primafacie import (the runtime already depends on it). Joe's reason: centralization through primafacie is what the later flight-data-recorder work needs; a strayconsole.errornever reaches a sink. Tests sit inlogging-consolidation.test.tswith aconsole.errorspy asserting no direct write. - D-27. Three
COMMAND_MAPentries deleted (bind.to-view,bind.from-view,bind.two-way). They dated from the v2.0 Phase-1 commit, were documented nowhere, used nowhere (repo, examples, Turbine), and parsed to exactly the two-segment command — thebindsegment was ignored.unknown-commandnow carries a hint for that shape naming the two-segment form and therawBindform. The one parser test that pinned the old shape asserts the rejection instead. - D-28.
processSwitchtests whitespace with the ASCII class instead oftrim(); a non-breaking space directly inside<switch>isswitch-bad-child(the message says an NBSP counts as text, since it is invisible in an editor). Joe's reading: an NBSP that silently disappears is a hole; the error is the loud alternative. Two error cases added toswitch.test.ts.