Repository navigation
TDD 0070: UX record library ([UI] grammar, index validate, isolated render, coverage) - #194
Merged
Merged
Conversation
Evals for TDD 0070 obs 1-13 and 9b (ux-record.test.sh: grammar, delta, validate, coverage, internal errors; ux-render.test.sh: render scoping and degrade, capture, real render, index.html). Registered in the implement-gate aggregator so ci-checks runs them. Red: scripts/lib/ux.sh does not exist yet. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NpGG21Y2VGGhTQaFr4oshB
…age (TDD 0070) scripts/lib/ux.sh: tl_ux_* wrappers (ui_reqs, delta, validate, render, capture, index_html, merged_index, coverage) with the [UI] short-circuit, the python3 check (rc 3) and integration-ref resolution via _tl_integration_ref. ux_record.py: [UI] grammar with fence/inline-code skipping, block hashing, delta, schema-v1 structural + PRD-consistency validation, offline-URL / tokens-link / unreferenced-image / unlisted-mock checks, merged-index coverage. ux_render.py: scoped headless-Chrome render with whole-set status and rc-4 degrade, out-of-repo capture, deterministic escaped index.html. Every parse failure exits non-zero with a named ux: message; unexpected exceptions exit 2. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NpGG21Y2VGGhTQaFr4oshB
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NpGG21Y2VGGhTQaFr4oshB
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NpGG21Y2VGGhTQaFr4oshB
…ymlinks Security review of the 0070 build found three gaps in render/validate: - Mocks render from file://, so an <img>/<iframe> pointing at a file:// URL, an absolute path, or a ../ path escaping docs/ux/ would bake a local file into a committed PNG. validate now rejects those references, and render refuses an unsafe mock before the browser runs. - --host-resolver-rules stops DNS names but not IP literals, localhost, or ws://. render now also routes all traffic to a dead proxy with loopback not bypassed, and validate rejects http(s)/ws(s)/ftp/file URLs anywhere in a mock, including script literals. - A symlink under docs/ux/ (the dir itself, a screen dir, or a mock) let render delete or write outside the repo. validate reports every symlink, and render and index-html refuse to run while one exists. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NpGG21Y2VGGhTQaFr4oshB
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NpGG21Y2VGGhTQaFr4oshB
…ll ref scan, heading [UI] - B2: a [UI] on a markdown heading must fail loudly (rc 2, malformed marker). - B1(b): meta refresh targets, every srcset candidate, image-set() strings, entity-encoded paths, legacy background=, data:text/html frames are refused. - B1(a)/(c): the render points the browser at a private copy whose pages carry a script-src 'none' CSP; a real-Chrome eval renders a JS-navigating mock and asserts the outside canary never reaches the PNG (control proves detection). - N1: inline SVG xmlns and plain-text URLs are valid. - N3/N5/N6: proper file URI, no 'rendered' line for a deleted PNG, a mock missing at render time is rc 2 with nothing cleared; no bytecode written. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NpGG21Y2VGGhTQaFr4oshB
… heading [UI] fails loudly B1: tl_ux_render now copies docs/ux to docs/.ux-render.XXXX/ and renders that copy. Every HTML page in it gets a leading CSP meta (script-src 'none', plus no frames, plugins, workers, fetch, <base> or form targets), so a mock cannot build a path or navigate to a local file. --blink-settings=scriptEnabled=false was tried first: with google-chrome 154 it stops --screenshot from writing the PNG (a JS-blocking profile hangs it), so a CSP meta is used instead. The symlink and mock checks run on the copy, which also closes the N4 TOCTOU. Capture is unchanged (scripts on). The scan covers every reference value: src/href/srcset (every candidate)/ action/poster/data/background/…, <meta http-equiv=refresh> targets, and CSS url()/@import/image-set() strings. Values are entity/CSS-unescaped and percent-decoded, and a backslash is read as '/'. B2: a [UI] on a markdown heading is rc 2 "malformed [UI] marker". N1: the scheme check applies only to reference values. xmlns and plain text are no longer flagged. N3: pathlib as_uri. N5: "rendered" lines print only after the call succeeds. N6: a missing mock is rc 2, checked before anything is cleared. Python runs with -I -B, so no __pycache__ is written into the plugin tree. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NpGG21Y2VGGhTQaFr4oshB
…-->, <!--->, BOM, CRLF) Real-Chrome canary checks for six mock prefixes plus a stub-level check that every rendered copy starts with exactly our doctype + CSP meta. Against 508bdc3 the <!--> and <!---> cases leak the canary into the PNG. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NpGG21Y2VGGhTQaFr4oshB
Every HTML file in the private render copy is now written as "<!DOCTYPE html>" + CSP_META + the original bytes (one leading UTF-8 BOM dropped). _DOCTYPE_RE is gone: it did not know HTML's abruptly-closed comments (<!--> / <!--->), so a script before the mock's doctype ran ahead of the CSP and could pull a local file into the committed PNG. Our doctype comes first, so pages still render in standards mode. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NpGG21Y2VGGhTQaFr4oshB
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NpGG21Y2VGGhTQaFr4oshB
… matrix obs 7: the stub fetches its http://127.0.0.1 URL while render's server is live and logs body + headers (doctype + exact CSP meta, CSP header on pages and 404s, text/html; charset=utf-8), no file:// in argv, and the render copy lives in $TMPDIR and is gone afterwards. obs 9b: capture argv has no proxy flags. obs 10: a tokens.css-styled mock and an inline-SVG mock render non-blank. obs 10b: real-Chrome isolation probe matrix with a magenta out-of-repo canary — CSS-file / base64 data:text/css / loopback-port / %2e%2e CSS vectors must pass validate, render rc 0, 0 canary px; script, unquoted-=/backtick/NBSP img, meta refresh, base file:, loopback img and %2e%2e img vectors must not leak; a direct file:// control shows the canary. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NpGG21Y2VGGhTQaFr4oshB
…enforced CSP render no longer opens repo files over file://. It checks docs/ux for symlinks, copies it with copytree(symlinks=True) into a mkdtemp() dir outside the repo, re-checks the copy, prefixes every .html/.htm/.xhtml with <!DOCTYPE html> + CSP_META (one UTF-8 BOM stripped), and serves it from a 127.0.0.1:0 ThreadingHTTPServer (GET only, no listings, silent, CSP header on every response incl. errors, HTML as text/html; charset=utf-8). Chrome shoots http://127.0.0.1:<port>/screens/<sid>/<state>.html behind the resolver rule and a dead proxy bypassed only for the server's port. The policy is rev 2's default-src 'self' data: one, so CSS files, base64 data: CSS, unquoted / backtick / NBSP img values, %2e%2e and other loopback ports can no longer bake an out-of-repo file into a PNG. The server and the copy are torn down in finally. The old docs/.ux-render.* path is gone; capture keeps no proxy flags. Deviation: the resolver rule is "MAP * ~NOTFOUND, EXCLUDE 127.0.0.1" — Chrome 154 maps IP literals too, so the TDD's bare rule makes the page itself fail ERR_NAME_NOT_RESOLVED (rc 0, no PNG). Other hosts stay unresolvable and other loopback ports still hit the dead proxy (probe css-loopback-port). Also: obs 7 compares the Content-Type header case-insensitively (http.server sends "Content-type"), and ux-record's data:/#fragment fixture escapes '#' in its sed (it silently never applied). Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NpGG21Y2VGGhTQaFr4oshB
…or browser; private browser profile/HOME - [C] stub browser that blocks: SIGTERM / SIGHUP to `ux_render.py render` mid-shot must give rc 143 / 129, leave no ux-render.* in $TMPDIR, and leave no stub browser (or its child) running. - [7] / [C] / [9b]: every shot's argv carries one --user-data-dir= inside the private mkdtemp tree (ux-render.* for render, ux-capture.* for capture), and the browser's HOME and TMPDIR are inside it too; all gone after the call. - [C] real Chrome: a mock that meta-refreshes to a downloadable file writes nothing under a fake $HOME (no ~/Downloads), leaves nothing in $TMPDIR and no Chrome running after the render is stopped. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NpGG21Y2VGGhTQaFr4oshB
…rowser profile, HOME, TMPDIR - SIGTERM, SIGHUP and SIGINT (unless inherited as ignored, e.g. nohup) raise SystemExit(128+signum), so every `finally` runs: the browser group is killed, the loopback server shut down, the private mkdtemp tree removed. Further signals are ignored while cleanup runs. A render signalled mid-call also deletes the PNGs it wrote, as on any other failure (N5). - The browser runs in its own session via Popen; on timeout (rc 124) or any exception its whole process group gets TERM then KILL, so Chrome and its helpers are never orphaned. The `timeout` wrapper is no longer used. - Every shot gets --incognito and --user-data-dir=<tmp>/b/p, with HOME, XDG_* and TMPDIR under <tmp>/b: downloads land in the private tree, not ~/Downloads, and no profile files reach ~/.config, ~/.cache or $TMPDIR. Render uses one profile per call inside its ux-render.* tree; capture uses its own ux-capture.* mkdtemp, removed afterwards. (--incognito: a fresh --user-data-dir otherwise stalls headless Chrome on first run.) Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NpGG21Y2VGGhTQaFr4oshB
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NpGG21Y2VGGhTQaFr4oshB
This was referenced Oct 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements TDD 0070 (rev 2): the UX record library behind the
/ux-authorphase (PRD FR-89, FR-90, FR-91, FR-94, FR-99, FR-101, NFR-4; ADR 0017). Plugin 3.51.0 → 3.52.0.What it adds
scripts/lib/ux.shplus stdlib python3 helpers (ux_record.py,ux_render.py). python3 is needed only when the PRD contains[UI]; otherwise the wrappers short-circuit.Parsing and record functions
tl_ux_ui_reqs: the strict**<ID> [UI] <title>**grammar. A malformed marker fails loudly (rc 2 withpath:line), including one on a heading line. Inline code and fenced blocks are skipped.tl_ux_delta: reportsnew/changed/orphanedrequirements, detected by a per-requirement text hash.tl_ux_validate: checksdocs/ux/index.jsonschema v1, the required states, mock references (no network orfile:schemes, absolute paths ordocs/uxescapes), thetokens.csslink, and symlinks. It rejects any unexpected image, so captures can't be committed. Missing screenshots are reported asscreenshots k/total, never stored.tl_ux_index_html: generates the flow page deterministically, with escaped values and a missing-screenshot list.tl_ux_merged_index/tl_ux_coverage: read the merged index from the integration branch (best-effort fetch first) and report covered / stale / uncovered.Rendering and capture
tl_ux_render <ids…>(rev 2 isolation; explicit screen ids only):docs/uxto a privatemkdtempdirectory outside the repo.<!DOCTYPE html>plus a CSP meta at byte 0 of every page.ThreadingHTTPServer, which sends the CSP header on every response.--incognito, and a private profile and HOME.The browser, not a scanner, enforces the boundary. The run handles SIGTERM/SIGHUP/SIGINT and kills the browser process group, so nothing is left behind. rc 4 is a declared degrade.
tl_ux_capture: live-app reference screenshots, which refuse an in-repo output directory.Gate history (run 20261006-220142)
The first review and three runtime-verifies FAILed on the same class: an out-of-repo local file baked into a committed PNG through the
file://render. A regex scanner kept being bypassed (script navigation,<!-->, unquoted / backtick / NBSP attributes, base64 CSS,url()in CSS files). That led to TDD rev 2 (#193), which makes the browser the boundary. The final runtime-verify found a SIGTERM cleanup gap, fixed in retry 4.test(failing)beforefix:)Deviations from the TDD text (accepted in review; note at the next TDD revision)
MAP * ~NOTFOUND, EXCLUDE 127.0.0.1. Chrome 154 otherwise maps the render's own127.0.0.1page.--incognitoplus private HOME/XDG/TMPDIR. These keep downloads and profile files contained and avoid a first-run hang.timeout 60, with a process-group kill.tests/implement-gate.test.shregisters the two new evals (declared in rev 2).ux_render.pyis 439 lines against 420 estimated, and the render test 628 against 600. The total diff is 2143 against 2146.Non-blocking residuals
file://(ux_record.py:439).ux.shwrapper isn't forwarded, and there's a tiny pre-trysignal window.git fetchis skipped whentimeoutis absent.file://passes the scanner; the browser still blocks it.🤖 Generated with Claude Code
https://claude.ai/code/session_01NpGG21Y2VGGhTQaFr4oshB