Files
rose-ash/plans/hs-conformance-to-100.md
giles 2b486976a6 HS-plan: mark cluster 29 blocked
sx-tree MCP file ops broken this session (Yojson Type_error "Expected
string, got null" on every file-based call — sx_read_subtree,
sx_find_all, sx_replace_by_pattern, sx_summarise, sx_pretty_print, even
sx_load_check on existing files works but summarise fails). Can't edit
integration.sx to add before:init/after:init dispatch. Additionally 4
of the 6 tests fundamentally require stricter parser error-rejection
(add - to currently parses to (set! nil ...); on click blargh end
accepts blargh as symbol expression) — out of single-cluster budget.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:46:55 +00:00

42 KiB
Raw Blame History

Hyperscript conformance → 100%

Goal: take the hyperscript upstream conformance suite from 1213/1496 (81%) to a clean 100%. Queue-driven — single-agent loop on architecture branch, one cluster per commit.

North star

Baseline: 1213/1496 (81.1%)
Target:   1496/1496
Gap:      283 tests  (130 real fails + 153 SKIPs)

Track after each iteration via mcp__hs-test__hs_test_run on the relevant suite, not the whole thing (full runs take 10+min and include hanging tests — 196/199/200/615/1197/1198 hang under the 200k step limit).

How to run tests

mcp__hs-test__hs_test_run(suite="hs-upstream-<cluster>")        # fastest, one suite
mcp__hs-test__hs_test_run(start=0, end=195)                     # early range
mcp__hs-test__hs_test_run(start=201, end=614)                   # mid range (skip hypertrace hangs)
mcp__hs-test__hs_test_run(start=616, end=1196)                  # late-1, skip repeat-forever hangs
mcp__hs-test__hs_test_run(start=1199)                           # late-2 after hangs

File layout

Runtime/compiler/parser live in lib/hyperscript/*.sx. The test runner at tests/hs-run-filtered.js loads shared/static/wasm/sx/hs-*.sxafter every .sx edit you must cp lib/hyperscript/<file>.sx shared/static/wasm/sx/hs-<file>.sx.

The test fixtures live in spec/tests/test-hyperscript-behavioral.sx, generated from tests/playwright/generate-sx-tests.py. Never edit the behavioral.sx fixture directly — fix the generator or the runtime.

Cluster queue

Each cluster below is one commit. Order is rough — a loop agent may skip ahead if a predecessor is blocked. Status: pending / in-progress / done (+N) / blocked (<reason>).

Parallel-worktree mode

When fanning out multiple clusters at once (Agent with isolation: "worktree"), each worktree agent:

  1. Works on a fresh copy of the repo — no contention on the mutable tree.
  2. Picks one cluster, runs the full loop (read, baseline, fix, sync WASM, verify smoke 0-195 + target suite, commit on the worktree's branch).
  3. Leaves its branch + commit SHA for the orchestrator. Does not push, does not update plans/hs-conformance-to-100.md or the scoreboard — those updates happen in the orchestrator's cherry-pick commit so the ledger stays linear.
  4. Scope inside the worktree is unchanged (lib/hyperscript/**, shared/static/wasm/sx/hs-*, tests/hs-run-filtered.js, tests/playwright/generate-sx-tests.py + its regen output spec/tests/test-hyperscript-behavioral.sx). Do not edit the plan or the scoreboard inside the worktree — that's the orchestrator's job.

Orchestrator cherry-picks worktree commits onto architecture one at a time; resolves conflicts as they arrive (most will be trivial since each cluster lives in its own parser/compiler branch or in a different mock).

Good candidates to parallelise: clusters that touch disjoint surfaces — e.g. 26 (resize observer) and 27 (intersection observer) edit the same mock file but different class stubs; 25 (parenthesised commands) is parser-only; 30 (logAll config) is bootstrap/integration-only. Avoid fanning out clusters that all rewrite the same dispatch spot (emit-set, parse-expr) in the same commit.

Cherry-pick footgun (observed 2026-04-24): sx-tree's pretty-printer reformats large regions when an edit lands in the middle of a big let/fn body. Two worktree commits whose logical diffs touch different defines in the same .sx file will still conflict textually because the pretty-print shuffles comments and indentation. Because .sx files can't be Edit-ed (hook blocks Edit/Write), conflict markers left by git are unrepairable. Workaround: when you see a conflict, abort the cherry-pick and re-apply the worktree commit surgically via sx_replace_node/sx_insert_near on the specific paths that changed. The logical diff is usually small (510 nodes); read it with git show SHA file.sx and apply it as a series of tree edits on top of current HEAD.

Bucket A: runtime fixes, single-file (low risk, high yield)

  1. [done (+4)] fetch JSON unwraphs-upstream-fetch 4 tests (can do a simple fetch w/ json + 3 variants) got {:__host_handle N}. Root: hs-fetch in runtime.sx returns raw host Response object instead of parsing JSON body. Fix: when format is "json", unwrap via host-get "_json" and json-parse. Expected: +4.

  2. [done (+1)] element → HTML via outerHTMLasExpression / converts an element into HTML (1 test) + unlocks response fetches. Mock DOM El class in tests/hs-run-filtered.js has no outerHTML getter. Add a getter computed from tagName + attributes + children (recurse). Expected: +1 direct, + knock-on in fetch.

  3. [done (+2)] Values dict insertion orderasExpression / Values | FormEncoded + | JSONString (2 tests) — form fields come out lastName, phone, firstName, areaCode. Root: hs-values-absorb in runtime.sx uses dict-set! but keys iterate in non-insertion order. Investigate hs-gather-form-nodes walk — the recursive kids traversal silently fails when children is a JS Array (not sx-list), so nested inputs arrive via a different path. Fix: either coerce children to sx-list at the gather boundary OR rewrite gather to explicitly use sx-level iteration helpers. Expected: +2.

  4. [done (+3)] not precedence over orexpressions/not 3 tests (not has higher precedence than or, not with numeric truthy/falsy, not with string truthy/falsy). Check parser precedence — not should bind tighter than or. Fix in parser.sx expression-level precedence. Expected: +3.

  5. [done (+1)] some selector for nonempty matchexpressions/some / some returns true for nonempty selector (1 test). some .class probably returns the list, not a boolean. Runtime fix. Expected: +1.

  6. [done (+2)] string template ${x}expressions/strings / string templates work w/ props + w/ braces (2 tests). Template interpolation isn't substituting property accesses. Check hs-template runtime. Expected: +2.

  7. [done (+1) — partial, 3 tests remain: inserted-button handler doesn't fire for afterbegin/innerHTML paths; might need targeted trace of hs-boot-subtree! or _setInnerHTML timing] put hyperscript reprocessingput / properly processes hyperscript at end/start/content/symbol (4 tests, all Expected 42, got 40). After a put operation, newly inserted HS scripts aren't being activated. Fix: hs-put-at! should hs-boot-subtree! on the target after DOM insertion. Expected: +4.

  8. [done (+1)] select returns selected text (1 test, hs-upstream-select). Runtime hs-get-selection helper reads window.__test_selection stash (or falls back to real window.getSelection().toString()). Compiler rewrites (ref "selection") to (hs-get-selection). Generator detects the createRange / setStart / setEnd / addRange block and emits a single (host-set! ... __test_selection ...) op with the resolved text slice of the target element. Expected: +1.

  9. [done (+4)] wait on event basicswait / can wait on event, on another element, waiting ... sets it to the event, destructure properties in a wait (4 tests). Event-waiter suspension issue. Expected: +3-4.

  10. [done (+1)] swap variable ↔ propertyswap / can swap a variable with a property (1 test). Swap command doesn't handle mixed var/prop targets. Expected: +1.

  11. [done (+3) — partial, hide element then show element retains original display remains; needs on click N count-filtered event handlers, out of scope for this cluster] hide strategyhide / can configure hidden as default, can hide with custom strategy, can set default to custom strategy, hide element then show element retains original display (4 tests). Strategy config plumbing. Expected: +3-4.

  12. [done (+2)] show multi-element + display retentionshow / can show multiple elements with inline-block, can filter over a set of elements using the its symbol (2 tests). Expected: +2.

  13. [done (+2) — partial, can toggle for a fixed amount of time needs an async mock scheduler (sync io-sleep collapses the toggle/un-toggle into one click frame)] toggle multi-class + timed + until-eventtoggle (3 assertion-fail tests). Expected: +3.

  14. [done (+1)] unless modifierunlessModifier / unless can conditionally execute (1 test). Parser/compiler addition. Expected: +1.

  15. [done (+2) — partial, can use initial to transition to original value needs on click N count-filtered events (same sync-mock block as clusters 11/13)] transition query-ref + multi-prop + initialtransition 3 tests. Expected: +2-3.

  16. [done (+1)] send can reference sender — 1 assertion fail. Expected: +1.

  17. [blocked: tell semantics are subtle — me should stay as the original element for explicit to me writes but the implicit default for bare add .bar inside tell X should be X. Attempted just leaving you/yourself scoped (dropping the me shadow) regressed 4 passing tests (restores proper implicit me, works with an array, etc.) which rely on bare commands using me as told-target. Proper fix requires a beingTold symbol distinct from me, with bare commands compiling to beingTold-or-me and explicit me always the original — more than a 30-min cluster budget.] tell semanticstell / attributes refer to the thing being told, does not overwrite me symbol, your symbol represents thing being told (3 tests). Expected: +3.

  18. [done (+2)] throw respond async/syncthrow / can respond to async/sync exceptions in event handler (2 tests). Expected: +2.

Bucket B: parser/compiler additions (medium risk, shared files)

  1. [done (+13)] pick regex + indicespick 13 tests. Regex match, flags, of syntax, start/end, negative indices. Big enough that a single commit might fail — break into pick-regex and pick-indices if needed. Expected: +10-13.

  2. [done (+3)] repeat property for-loops + whererepeat / basic property for loop, can nest loops, where clause can use the for loop variable name (3 tests). Expected: +3.

  3. [done (+1)] possessiveExpression property access via itspossessive / can access its properties (1 test, Expected foo got ``). Expected: +1.

  4. [blocked: tried three compile-time emits — (1) guard (can't catch Undefined symbol since it's a host-level error, not an SX raise), (2) env-has? (primitive not loaded in HS kernel — Unhandled exception: "env-has?"), and (3) hs-win-call runtime helper (works when reached but SX can't CALL a host-handle function directly — Not callable: {:__host_handle N} because NativeFn is not callable here). Needs either a host-call-fn primitive with arity-agnostic dispatch OR a symbol-bound? predicate in the HS kernel.] window global fn fallbackregressions / can invoke functions w/ numbers in name + unlocks several others. When calling foo() where foo isn't SX-defined, fall back to (host-global "foo"). Design decision: either compile-time emit (or foo (host-global "foo")) via a helper, or add runtime lookup in the dispatch path. Expected: +2-4.

  5. [done (+1)] me symbol works in from expressionsregressions (1 test, Expected Foo). Check from expression compilation. Expected: +1.

  6. [done (+1)] properly interpolates values 2 — URL interpolation regression (1 test). Likely template string + property access. Expected: +1.

  7. [done (+1)] can support parenthesized commands and featuresparser (1 test, Expected clicked). Parser needs to accept (cmd...) grouping in more contexts. Expected: +1.

Bucket C: feature stubs (DOM observer mocks)

  1. [done (+3)] resize observer mock + on resize — 3 tests. Add a minimal ResizeObserver mock to hs-run-filtered.js, plus parse/compile on resize. Expected: +3.

  2. [done (+3)] intersection observer mock + on intersection — 3 tests. Mock IntersectionObserver; compile on intersection with margin/threshold modifiers. Expected: +3.

  3. [done (+4)] ask/answer + prompt/confirm mockaskAnswer 4 tests. Requires test-name-keyed mock: first test wants confirm → true, second confirm → false, third prompt → "Alice", fourth prompt → null. Keyed via _current-test-name in the runner. Expected: +4.

  4. [blocked: sx-tree MCP tools returning Yojson Type_error on every file op. Can't edit integration.sx to add before:init/after:init dispatch. Also 4 of the 6 tests fundamentally require stricter parser error-rejection (add - to currently succeeds as SX expression; on click blargh end accepts blargh as symbol), which is larger than a single cluster budget.] hyperscript:before:init / :after:init / :parse-error events — 6 tests in bootstrap + parser. Fire DOM events at activation boundaries. Expected: +4-6.

  5. [done (+1)] logAll config — 1 test. Global config that console.log's each command. Expected: +1.

Bucket D: medium features (bigger commits, plan-first)

  1. [pending] runtime null-safety error reporting — 18 tests in runtimeErrors. When accessing .foo on nil, emit a structured error with position info. One coordinated fix in the compiler emit paths for property access, function calls, set/put. Expected: +15-18.

  2. [pending] MutationObserver mock + on mutation dispatch — 15 tests in on. Add MO mock to runner. Compile on mutation [of attribute/childList/attribute-specific]. Expected: +10-15.

  3. [pending] cookie API — 5 tests in expressions/cookies. document.cookie mock in runner + the cookies + set the xxx cookie keywords. Expected: +5.

  4. [pending] event modifier DSL — 8 tests in on. elsewhere, every, first click, count filters (once / twice / 3 times, ranges), from elsewhere. Expected: +6-8.

  5. [pending] namespaced def — 3 tests. def ns.foo() ... creates ns.foo. Expected: +3.

Bucket E: subsystems (DO NOT LOOP — human-driven)

All five have design docs on their own worktree branches pending review + merge. After merge, status flips to design-ready and they become eligible for the loop.

  1. [design-done, pending review — plans/designs/e36-websocket.md on worktree-agent-a9daf73703f520257] WebSocket + socket — 16 tests. Upstream shape is socket NAME URL [with timeout N] [on message [as JSON] …] end with an implicit .rpc Proxy (ES6 Proxy lives in JS, not SX), not with proxy { send, receive } as this row previously claimed. Design doc has 8-commit checklist, +1216 delta estimate. Ship only with intentional design review.

  2. [design-done, pending review — plans/designs/e37-tokenizer-api.md on worktree-agent-a6bb61d59cc0be8b4] Tokenizer-as-API — 17 tests. Expose tokens as inspectable SX data via hs-tokens-of / hs-stream-token / hs-token-type etc; type-map current hs-tokenize output to upstream SCREAMING_SNAKE_CASE. 8-step checklist, +1617 delta.

  3. [design-done, pending review — plans/designs/e38-sourceinfo.md on agent-e38-sourceinfo] SourceInfo API — 4 tests. Inline span-wrapper strategy (not side-channel dict) with compiler-entry unwrap. 4-commit plan.

  4. [design-done, pending review — plans/designs/e39-webworker.md on hs-design-e39-webworker] WebWorker plugin — 1 test. Parser-only stub that errors with a link to upstream docs; no runtime, no mock Worker class. Hand-write the test (don't patch the generator). Single commit.

  5. [design-done, pending review — plans/designs/e40-real-fetch.md on worktree-agent-a94612a4283eaa5e0] Fetch non-2xx / before-fetch event / real response object — 7 tests. SX-dict Response wrapper {:_hs-response :ok :status :url :_body :_json :_html}; restructured hs-fetch that always fetches wrapper then converts by format; test-name-keyed _fetchScripts. 11-step checklist. Watch for regression on cluster-1 JSON unwrap.

Bucket F: generator translation gaps (after bucket A-D)

Many tests are SKIP (untranslated) because tests/playwright/generate-sx-tests.py bailed with return None. These need patches to the generator to recognize more JS test patterns. Estimated ~25 recoverable tests. Defer to a dedicated generator-repair cluster once the queue above drains.


Ground rules for the loop agent

  1. One cluster per commit. Don't batch. Short commit message: HS: <cluster name> (+N tests).
  2. Baseline first, verify at the end. Before starting: record the current pass count for the target suite AND for one smoke range (0-195). After fixing: rerun both. Abort and mark blocked if:
    • Target suite didn't improve by at least +1.
    • Smoke range regressed (any test flipped pass → fail).
  3. Never edit .sx files with Edit/Read/Write. Use sx-tree MCP (sx_read_subtree, sx_replace_node, sx_insert_child, sx_insert_near, sx_replace_by_pattern, sx_rename_symbol, sx_validate, sx_write_file).
  4. Sync WASM staging. After every edit to lib/hyperscript/<f>.sx, run cp lib/hyperscript/<f>.sx shared/static/wasm/sx/hs-<f>.sx.
  5. Never edit spec/tests/test-hyperscript-behavioral.sx directly. Fix the generator or the runtime.
  6. Scope: lib/hyperscript/**, shared/static/wasm/sx/hs-*, tests/hs-run-filtered.js, tests/playwright/generate-sx-tests.py, plans/hs-conformance-to-100.md. Do not touch spec/evaluator.sx, the broader SX kernel, or unrelated files.
  7. Commit even partial fixes. If you get +N where N is less than expected, commit what you have and mark the cluster done (+N) — partial, <what's left>.
  8. If stuck >30min on a cluster, mark it blocked (<reason>) in the plan and move to the next pending cluster.
  9. Branch: architecture. Commit locally. Never push. Never touch main.
  10. Log every iteration in the Progress log below: one paragraph, what you touched, delta, commit SHA.
  11. Update the scoreboard at plans/hs-conformance-scoreboard.md in the SAME plan-update commit: bump the Merged: line, update the row's Status / Δ / Commit, and adjust the buckets roll-up counts.
  12. Also expand scope to include plans/hs-conformance-scoreboard.md (for rule 6 purposes).

Known gotchas

  • env-bind! creates bindings; env-set! mutates existing ones.
  • SX do is R7RS iteration — use begin for multi-expr sequences.
  • cond / when / let clause bodies evaluate only the last expr — wrap in begin.
  • list? in SX checks for {_type:'list'} — it returns false on raw JS Arrays. host-get node "children" returns a JS Array in the mock, so recursion via (list? kids) silently drops nested elements.
  • append! on a list-valued scoped var (:s) requires emit-set in the compiler — done, see commit 1613f551.
  • When symbol target is the-result, also sync it (done, see emit-set).
  • Hypertrace tests (196, 199, 200) and query-template test (615) hang under 200k step limit — always filter around them.
  • repeat forever tests (1197, 1198) also hang.

Progress log

(Reverse chronological — newest at top.)

2026-04-24 — cluster 19 pick regex + indices

  • 4be90bf2HS: pick regex + indices (+13 tests). Parser: pick items/item EXPR to EXPR accepts start/end keywords; pick match/pick matches accept | <flag> after regex; pick item N without to still works. Runtime: hs-pick-items/hs-pick-first/hs-pick-last polymorphic for strings (slice) in addition to lists; hs-pick-items resolves start/end sentinel strings and negative indices at runtime; new hs-pick-matches wrapping regex-find-all; hs-pick-regex-pattern accepts (list pat flags) shape with i flag. Suite hs-upstream-pick: 11/24 → 24/24. Smoke 0-195 unchanged.

2026-04-24 — cluster 28 ask/answer + prompt/confirm mock

  • 6c1da921HS: ask/answer + prompt/confirm mock (+4 tests). Five-part change: (a) tokenizer.sx registers ask and answer as hs-keywords. (b) parser.sxcmd-kw? gains both, parse-cmd gains cond branches dispatching to new parse-ask-cmd (emits (ask MSG)) and parse-answer-cmd which reads answer MSG [with YES or NO] with parse-atom for the choices (using parse-expr there collapsed "Yes" or "No" into (or "Yes" "No") before match-kw "or" could fire — parse-atom skips the logical layer). No-with form emits (answer-alert MSG). (c) compiler.sx — three new cond branches (ask, answer, answer-alert) compile to (let ((__hs-a (hs-XXX ...))) (begin (set! the-result __hs-a) (set! it __hs-a) __hs-a)) so then put it into … works. (d) runtime.sxhs-ask, hs-answer, hs-answer-alert call window.prompt/confirm/alert via host-call (host-global "window") …. (e) tests/hs-run-filtered.js — test-name-keyed stubs for globalThis.{alert,confirm,prompt}, __currentHsTestName updated before each test. One extra tweak: host-set! innerHTML/textContent now coerces JS null → the string "null" (matching browser behaviour) so prompt returning null → put it into #out renders literal "null" text — the fourth test depends on exactly this. Suite hs-upstream-askAnswer: 1/5 → 5/5. Smoke 0-195: 166/195 → 170/195.

2026-04-24 — cluster 25 parenthesized commands and features

  • d7a88d85HS: parenthesized commands and features (+1 test). Parser-only fix in lib/hyperscript/parser.sx. Three additions: (a) parse-feat gets a new cond branch — on paren-open, advance, recurse parse-feat, consume paren-close; lets features like (on click ...) be grouped. (b) parse-cmd gets two new cond branches — on paren-close return nil (so cl-collect terminates when the outer paren group ends), and on paren-open advance+recurse+close (parenthesized single commands like (log me)). (c) The key missing piece: cl-collect previously only recursed when the next token was a recognised command keyword (cmd-kw?), so after the first (log me) the next (trigger foo) would end the body. Extended the recursion predicate to also fire when the next token is paren-open. Result: on click (log me) (trigger foo) now emits both commands inside the handler body, not the second as a sibling top-level feature. Suite hs-upstream-core/parser: 9/14 → 10/14. Smoke 0-195: 165/195 → 166/195.

2026-04-24 — cluster 20 repeat property for-loops + where (worktree re-apply)

  • c932ad59HS: repeat property for-loops + where (+3 tests). Worktree agent a7c6dca2… produced c4241d57; straight cherry-pick conflicted on runtime.sx with cluster 30's log-all block and cluster 27's intersection-attach helper, so logical diff was replayed surgically via sx-tree. Parser: obj-collect now appends pairs (not cons), preserving source order. Compiler: emit-for detects coll-where wrapping, binds the filter lambda to the for-loop variable name (not default it), and wraps symbol collections with cek-try instead of the broken hs-safe-call (uninitialised CEK call-ref in WASM). array-index emits (hs-index …) not (nth …). Runtime: new polymorphic hs-index (dict/list/string/host dispatch); hs-put-at! default branch delegates to hs-put! when target is a DOM element; hs-make-object tracks insertion order in a hidden _order list; hs-for-each and hs-coerce (Keys/Entries/Map branches) prefer _order when present. Suite hs-upstream-repeat: 25/30 → 28/30 (remaining 2 are the repeat forever hangs we knowingly skip). Smoke 0-195 unchanged at 165.

2026-04-24 — cluster 27 intersection observer (worktree re-apply)

  • 0c31dd27HS: intersection observer mock + on intersection (+3 tests). Worktree agent ad6e17cb… produced the logical change but its commit's reformatted compiler/runtime bodies conflicted with cluster 26's landing of resize-observer state in tests/hs-run-filtered.js and runtime.sx; straight cherry-pick hit markers on .sx files that can't be Edit'd. Reapplied surgically via sx-tree: (a) parser parse-on-feat now collects having margin X threshold Y clauses between from and body, packaging them in :having {"margin" M "threshold" T}; (b) compiler scan-on threads a new having-info param and, when event-name is "intersection", wraps (hs-on target "intersection" fn) with (do on-call (hs-on-intersection-attach! target margin threshold)); (c) runtime adds hs-on-intersection-attach! which constructs an IntersectionObserver with {rootMargin, threshold} opts and a callback that dispatches an intersection DOM event carrying {intersecting, entry} detail; (d) runner adds HsIntersectionObserver stub that fires the callback synchronously on observe() with isIntersecting=true. Suite hs-upstream-on: 33/70 → 36/70. Smoke 0-195 unchanged at 165.

2026-04-24 — cluster 26 resize observer (worktree agent cherry-pick)

  • 304a52d2 (from worktree worktree-agent-a8983e935d0d7a870 / aea5f7d2) — HS: resize observer mock + on resize (+3 tests). tests/hs-run-filtered.js: El.style becomes a Proxy that fires a synthetic resize DOM event (detail carries {width, height} parsed from the current inline style) whenever width/height is written via direct property assignment OR setProperty. ResizeObserver no-op stub replaced by HsResizeObserver maintaining a per-element callback registry in globalThis.__hsResizeRegistry; added ResizeObserverEntry stub. on resize needs no parser/compiler work — parse-compound-event-name already accepts it and hs-on binds via dom-listen. Generator: new pattern translates (page.)?evaluate(() => { document.(getElementById|querySelector)(...).style.PROP = 'VAL'; }) into (host-set! (host-get TARGET "style") "PROP" "VAL"). Suite hs-upstream-resize: 0/3 → 3/3. Smoke 0-195 unchanged at 165/195.

2026-04-24 — cluster 30 logAll config (worktree agent cherry-pick)

  • 64bcefff (from worktree worktree-agent-a2bf303fd00e2fd4b / e50c3e6e) — HS: logAll config (+1 test). Runtime additions in runtime.sx: _hs-config-log-all flag + _hs-log-captured list + setters hs-set-log-all!, hs-clear-log-captured!, reader hs-get-log-captured, emitter hs-log-event! which both appends and forwards to (host-call (host-global "console") "log" msg). integration.sx hs-activate! now emits (hs-log-event! "hyperscript:init") as the first action inside its when-block. Generator tests/playwright/generate-sx-tests.py detects the upstream body pattern (contains both logAll and _hyperscript.config.logAll) and emits a hand-rolled deftest: reset captured list → toggle log-all on → build detached _="on click add .foo" div → hs-boot-subtree! → assert (some (fn (l) (string-contains? l "hyperscript:")) captured). Suite hs-upstream-core/bootstrap: 19/26 → 20/26. Smoke 0-195: 164 → 165.

2026-04-24 — cluster 24 properly interpolates values 2

  • cb37259dHS-gen: string-aware line-comment stripping (+1 test). process_hs_val in tests/playwright/generate-sx-tests.py stripped //… line comments with a naïve regex, which devoured https://yyy.xxxxxx.com/… inside a backtick template — test 2074 was landing with the HS source truncated at https:. New helper _strip_hs_line_comments walks char-by-char and only strips // / leading-whitespace -- when not inside '…', "…", or `…`; also respects \\-escapes inside strings. Regen produced full template intact. Suite hs-upstream-core/regressions: 11/16 → 12/16. Smoke 0-195: 163/195 → 164/195.

2026-04-24 — cluster 23 me symbol works in from expressions

  • 0d38a75bHS: closest parent <sel> traversal (+1 test). parse-trav now recognises parent as an ident modifier after the closest keyword: consumes it and re-invokes itself with kind closest-parent, so closest parent <div/> produces AST (closest-parent "div" (me)) instead of (string-postfix (closest "*" (me)) "parent") — the latter was the generic trailing-ident-as-unit rule swallowing parent. Compiler translates (closest-parent sel target) to (dom-closest (host-get target "parentElement") sel) so me (the element with the _ attribute) is skipped and only strict ancestors match. Also added closest-parent to the put X into <trav> inner-html shortcut alongside next/previous/closest. Suite hs-upstream-core/regressions: 10/16 → 11/16. Smoke 0-195: 162/195 → 163/195.

2026-04-24 — cluster 8 select returns selected text (cherry-picked from worktree)

  • 0b9bbc7bHS: select returns selected text (+1 test). Runtime hs-get-selection prefers window.__test_selection stash and falls back to getSelection().toString(). Compiler rewrites (ref "selection") to (hs-get-selection). Generator detects document.createRange() + getElementById(ID).firstChild + setStart/setEnd and emits a single host-set! on window.__test_selection with the resolved substring, sidestepping a propagating DOM range/text-node mock. Runner resets __test_selection between tests. Suite hs-upstream-select: 3/4 → 4/4.

2026-04-24 — cluster 22 window global fn fallback (blocked, reverted)

  • Attempted three compile-time emits for select2()→window fallback: (1) (guard (_e (true ((host-global "select2")))) (select2)) — guard didn't catch "Undefined symbol" because that's a host-level eval error, not an SX raise. (2) (if (env-has? "select2") (select2) ((host-global "select2")))env-has? primitive isn't loaded in the HS kernel (Unhandled exception: "env-has?"). (3) Runtime hs-win-call helper — reached it but (apply (host-global "select2") (list)) fails with Not callable: {:__host_handle N} since the JS function wrapped by host-global isn't a callable from SX's perspective. Reverted all changes per abort rule. Proper fix: either expose env-has? through the HS kernel image, or add a host-call-fn primitive that dispatches via JS on a host handle regardless of arity.

2026-04-24 — cluster 21 possessive expression via its

  • f0c41278HS: possessive expression via its (+1 test). Two generator changes: (a) parse_run_locals (Pattern 2 var R = await run(...)) now recognises result: <literal> in the opts dict and binds it to it so run("its foo", {result: {foo: "foo"}}) produces (eval-hs-locals "its foo" (list (list (quote it) {:foo "foo"}))). Same extraction added to Pattern 1. (b) Emitted _hs-wrap-body no longer shadows it to nil — it only binds event — so eval-hs-locals's outer it binding is visible. eval-hs still binds it nil at its own fn wrapper. Suite hs-upstream-expressions/possessiveExpression: 22/23 → 23/23. Smoke 0-195: 162/195 unchanged.

2026-04-24 — cluster 18 throw respond via exception event

  • dda3becbHS: throw respond via exception event (+2 tests). hs-on wraps each event handler in a guard that catches thrown exceptions and re-dispatches them as an exception DOM event on the same target with {error: e} as detail. on exception(error) handlers (also registered via hs-on) receive the event and destructure error from detail. Wrapping skips exception/error event handlers to avoid infinite loops. Suite hs-upstream-throw: 5/7 → 7/7. Smoke 0-195: 162/195 unchanged.

2026-04-24 — cluster 17 tell semantics (blocked, reverted)

  • Attempted: drop the me shadow from tell X compile so add .bar to me preserves original and put your innerText into me writes to original. Fixed tests 2 and 3 but regressed 4 others (restores a proper implicit me, works with an array, establishes a proper beingTold symbol, ignores null) which require bare commands like add .bar (no explicit target) to use the told as default. Reverted per abort rule. Proper fix needs a distinct beingTold symbol with compiler rewriting bare commands to target beingTold-or-me while leaving explicit me alone — >30min cluster budget.

2026-04-24 — cluster 15 transition query-ref + multi-prop (partial +2)

  • 3d352055HS: transition query-ref + multi-prop (+2 tests). Three parts: (a) parser collect-transitions recognises style tokens (*prop) as a continuation, so transition *width from A to B *height from A to B chains both transitions instead of dropping the second. (b) Mock El class gets nextSibling/previousSibling (+ *ElementSibling aliases) so transition *W of the next <span/> can resolve the next-sibling target via host-get. (c) Generator pattern for const X = await evaluate(() => { const el = document.querySelector(SEL); el.dispatchEvent(new Event(NAME, ...)); return ... }); optionally prefixed by destructuring and allowing trailing expect(...).toBe(...) junk because _body_statements only splits on ; at depth 0. Remaining initial test needs on click N count-filtered events. Suite hs-upstream-transition: 13/17 → 15/17. Smoke 0-195: 162/195 unchanged.

2026-04-24 — cluster 13 toggle multi-class + until (partial +2)

  • bd821c04HS: toggle multi-class + until event (+2 tests). Parser parse-toggle-cmd: after the leading class ref collect any additional class refs and treat toggle .foo .bar as toggle-between (pair-only). Recognise until EVENT [from SOURCE] modifier and emit a new toggle-class-until AST. Compiler handles the new node by emitting (begin (hs-toggle-class! tgt cls) (hs-wait-for src ev) (hs-toggle-class! tgt cls)) — reuses the cluster-9 event waiter so the class flips back when the event fires. can toggle for a fixed amount of time remains — sync mock io-sleep collapses the two toggles into one click frame; needs async scheduler. Suite hs-upstream-toggle: 22/25 → 24/25. Smoke 0-195: 162/195 unchanged.

2026-04-24 — cluster 12 show multi-element + display retention

  • 98c957b3HS: show multi-element + display retention (+2 tests). Two fixes in tests/hs-run-filtered.js: (a) mt (matches-selector) now splits comma-separated selector lists and matches if any clause matches, so qsa("#d1, #d2") returns both elements. (b) host-get on an El for innerText returns textContent (DOM-level alias) so when its innerText contains "foo" predicates can see the mock's stored text. Suite hs-upstream-show: 16/18 → 18/18. Smoke 0-195: 162/195 unchanged.

2026-04-24 — cluster 11 hide strategy (partial +3)

  • beb120baHS: hide strategy config (+3 tests). Three parts: (a) runtime.sx hs-hide-one!/hs-show-one! consult a new _hs-hide-strategies dict (and _hs-default-hide-strategy override) before falling through to the built-in display/opacity/hidden cases. Strategy fn is called directly with (op, el, arg). New setters hs-set-hide-strategies! and hs-set-default-hide-strategy!. (b) Generator _hs_config_setup_ops recognises _hyperscript.config.defaultHideShowStrategy = "X", delete …default…, and hideShowStrategies = { NAME: function (op, el, arg) { if … classList.add/remove } } with brace-matched function body extraction. (c) Pre-setup emitter handles __hs_config__ pseudo-name by emitting the SX expression as-is. Suite hs-upstream-hide: 12/16 → 15/16. Remaining test (hide element then show element retains original display) needs on click N count-filtered event handlers — separate feature. Smoke 0-195: 162/195 unchanged.

2026-04-24 — cluster 10 swap variable with property

  • 30f33341HS: swap variable with property (+1 test). Mock El class in tests/hs-run-filtered.js: dataset is now a Proxy that forwards property writes to attributes["data-*"], and setAttribute("data-*",...) populates the backing dataset with camelCase key. That way #target.dataset.val = "new" updates the data-val attribute so the swap command can read+write the property correctly. Suite hs-upstream-swap: 3/4 → 4/4. Smoke 0-195: 162/195 unchanged.

2026-04-24 — cluster 9 wait on event basics

  • f79f96c1HS: wait on event basics (+4 tests). Five parts: (a) tests/hs-run-filtered.js io-wait-event mock now registers a one-shot listener on the target element and resumes with the actual event (was unconditionally doResume(null)). (b) New hs-wait-for-or target event-name timeout-ms runtime form carrying a timeout; mock resumes immediately when timeout is present (covers 0ms tests). (c) parser.sx parse-wait-cmd recognises wait for EV(v1, v2) destructure syntax, emits :destructure (names) on the wait-for AST. (d) compiler.sx emit-wait-for handles :from/:or combos; new __bind-from-detail__ form compiles to (define v (host-get (host-get it "detail") v)); the do-sequence handler pre-expands wait-for with destructure into the plain wait-for plus synthetic bind forms. (e) generator extracts detail: ... from CustomEvent option blocks. Suite hs-upstream-wait: 3/7 → 7/7. Smoke 0-195: 162/195 unchanged.

2026-04-23 — cluster 16 send can reference sender

  • ed8d71c9HS: send can reference sender (+1 test). Three parts: (a) emit-send builds {:sender me} detail instead of nil for send NAME target and send NAME. (b) Parser parse-atom recognises sender keyword (previously swallowed as noise) and emits (sender). (c) Compiler translates bare sender symbol and (sender) list head to (hs-sender event), a new runtime helper that reads detail.sender. Suite hs-upstream-send: 7/8 → 8/8. Smoke 0-195: 162/195 unchanged.

2026-04-23 — cluster 14 unless modifier (blocked, reverted)

  • Attempted: parser cl-collect handles cmd unless cond by emitting (hs-unless-wrap cond cmd), compiler adds a hs-unless-wrap case that translates to (if (hs-falsy? cond) cmd nil). Compile output correct. But test fails with Undefined symbol: _test-result suggesting the test-harness thunk eval throws somehow. Also added a generator pattern for classList.add/remove/toggle but that alone didn't fix the test. Reverted per abort rule; compile-shape fix looks sound and should be revisited after clusters that don't depend on harness internals.

2026-04-23 — cluster 8 select returns selected text (blocked, reverted)

  • Attempted: added hs-get-selection runtime, compiler branch to rewrite bare selection to (hs-get-selection), generator pattern to translate evaluate(() => { var range = document.createRange(); ...; window.getSelection().addRange(range); }), and mock support in hs-run-filtered.js for document.createRange / window.getSelection / firstChild text node. Tests still returned empty — range.toString() wasn't picking up the text. Reverted per the abort rule. Would need a more faithful mock of DOM text nodes with data propagation.

2026-04-23 — cluster 7 put hyperscript reprocessing (partial)

  • f21eb008HS: put hyperscript reprocessing — generator fix (+1 test). Generator was swallowing non-window-setup evaluate(() => { ... }) blocks. Fixed to only continue when a window-setup actually parsed, else fall through. Added a new pattern for evaluate(() => { const e = new Event(...); SEL.dispatchEvent(e); }). Suite hs-upstream-put: 33/38 → 34/38. "at end of" now passes; "at start of" / "in a element target" / "in a symbol write" still fail because the inserted-button handler doesn't activate on the afterbegin/innerHTML code paths. Smoke 0-195: 162/195 unchanged.

2026-04-23 — cluster 6 string template ${x}

  • 108e25d4HS: string template ${x} (+2 tests). Two-part fix: (a) compiler.sx now emits (host-global "window") (plus other well-known globals) for bare dot-chain base identifiers that would otherwise be unbound symbols. (b) generate-sx-tests.py now has eval-hs-locals ALSO call host-set! on window.<name> for each binding, so tests whose window.X = Y setup was translated as a local pair can still see window.X. Suite hs-upstream-expressions/strings: 5/8 → 7/8. Smoke 0-195: 162/195 unchanged.

2026-04-23 — cluster 5 some selector for nonempty match

  • e7b86264HS: some selector for nonempty match (+1 test). some <html/>(not (hs-falsy? (hs-query-first "html")))document.querySelector('html'). Mock's querySelector searched from _body, missing the _html element. Fixed the mock to short-circuit for html/body and walk documentElement. Suite hs-upstream-expressions/some: 5/6 → 6/6. Smoke 0-195: 162/195 unchanged.

2026-04-23 — cluster 4 not precedence over or

  • 4fe0b649HS: not precedence over or + truthy/falsy coercion (+3 tests). parse-atom's not branch emitted (not (parse-expr)), which let or/and capture the whole RHS, and also used SX's not which treats only nil/false as falsy. Fixed to emit (hs-falsy? (parse-atom)) — tight binding + hyperscript truthiness (0, "", nil, false, []). Suite hs-upstream-expressions/not: 6/9 → 9/9. Smoke 0-195: 162/195 unchanged.

2026-04-23 — cluster 3 Values dict insertion order

  • e59c0b8eHS: Values dict insertion order (+2 tests). Root cause was the OCaml kernel's dict implementation iterating keys in scrambled (non-insertion) order. Added _order hidden list tracked by hs-values-absorb, and taught hs-coerce FormEncoded/JSONString branches to iterate via _order when present (filtering the _order marker out). Suite hs-upstream-expressions/asExpression: 28/42 → 30/42. Smoke 0-195: 162/195 unchanged.

2026-04-23 — cluster 2 element→HTML via outerHTML

  • e195b5bdHS: element → HTML via outerHTML (+1 test). Added an outerHTML getter on the mock El class in tests/hs-run-filtered.js. Merges .id/.className (host-set! targets) with .attributes, falls back to innerText/textContent. Suite hs-upstream-expressions/asExpression: 27/42 → 28/42. Smoke 0-195: 162/195 unchanged.

2026-04-23 — cluster 1 fetch JSON unwrap

  • 39a597e9HS: fetch JSON unwrap (+4 tests). Added hs-host-to-sx helper in runtime.sx that converts raw host-handle JS objects/arrays to proper SX dicts/lists via Object.keys/Array walks. hs-fetch now calls it on the result when format is "json". Detects host-handle dicts by checking (host-get v "_type") == "dict" — genuine SX dicts have the marker, host handles don't. Suite hs-upstream-fetch: 11/23 → 15/23. Smoke 0-195: 162/195 unchanged.

2026-04-23 — cluster fixes session baseline

  • 6b0334afHS: remove bare @attr, set X @attr, JSON clean, FormEncoded, HTML join (+3)
  • 1613f551HS add/append: Set dedup, @attr support, when-clause result tracking (+6)
  • Pre-loop baseline: 1213/1496 (81.1%).