refactor(landmarks): one shared enumeration for both capture sites (#74) - #78
Merged
Merged
Conversation
ADR-0007 made `visible_landmarks` visibility-filtered in the recorder and in `page-state` using the same predicate. That closed half the gap: the two sites shared the predicate but still disagreed about which elements to test. The recorder walked the tree with implicit roles against an 8-role set; `page-state` checked 6 `[role=]` selectors with tag fallbacks for only main/nav/form. On semantic markup with no redundant `role=`, `page-state` silently missed banner, complementary, and contentinfo. That output becomes `RepairContext.page_state`, so once #27 wires a real repair model it would be handed a landmark list the recorder never produced — the same harm ADR-0007 set out to prevent, reached by a different mechanism. Extract one enumeration into `src/shared/landmarks.ts`: role vocabulary, implicit-role map, visibility predicate, and tree walk. Both sites run it and own no copy. The browser-side role list is generated from the exported TS constants with JSON.stringify, so the two cannot drift. It is a JS source string, not a function. Both sites hand their evaluate body to the browser as text because esbuild's keepNames wraps named function expressions in `__name(...)`, which does not exist there; a shared function would reintroduce the PR #73 crash that CI cannot see. `src/shared/` rather than either capture site: `src/runner/` importing from `src/recorder/` inverts the pipeline dependency and the reverse is no better, and `contracts/` holds language-neutral JSON outside tsconfig's include. It is documented as a leaf — in-page snippets only, never a utility drawer. BEHAVIOUR CHANGE, not a pure refactor: `capturePageState` now reports complementary, contentinfo and region, which its old selector list could not produce, and orders by DOM position rather than by its own fixed role array. The recorder is unchanged — `npm run recorder -- --fixture` re-records with an identical dom_digest, and the compiled bundle regenerates byte-identical, so no artifact is committed. tests/unit/landmarks.test.ts asserts the two sites agree on semantic markup with no redundant `role=` — the case the in-tree fixture cannot exercise, since it puts an explicit `role=` on every landmark. Verified the guard bites: reverting page-state to its old enumeration fails 6 of 8 (the fixture-based one still passes, exactly as #74 predicted). It also fails if a second copy of the predicate appears anywhere under `src/`. Closes #74 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Jul 28, 2026
OM152002
reviewed
Jul 28, 2026
OM152002
left a comment
Collaborator
There was a problem hiding this comment.
Approve. Verified by running it, not by reading it.
Confirmed
- The divergence is gone. On the markup that used to split them, both sites now return
["banner","navigation","main","complementary","region","search","form","contentinfo"]— identical, order included.page-statepreviously missedcomplementary,contentinfo,region. - Visibility agrees: a page whose only banner is
visibility:hiddenplus adisplay:noneaside gives["main"]from both. - Independent grep for
checkVisibilityundersrc/finds exactly one implementation. - Both sites still pass a string to the browser, so the #73
__namecrash cannot return. - Full suite green; no conflict with #77;
architecture.mdgained thesrc/shared/row.
Injecting the role vocabulary via JSON.stringify from the exported constants is the right call — the TS copy and the browser copy now cannot drift. keeps the predicate in exactly one place under src/ is a real structural guard.
Non-blocking
- Branch is
BEHINDmain and protection isstrict: true— needs a main merge before it can land. - The recorder now walks the DOM twice (counts pass + landmark pass) where it was one pass. Irrelevant at recording speed, but it is a change.
- Shared blind spot, not a divergence:
<search>and<section aria-label>map tosearch/regionin ARIA but have no implicit-tag entry, so neither site reports them — verified, both return["main"]. #74 stays closed since they agree, but it under-reports on semantic markup and #24 records against real Grafana next. Already an ADR-0007 open question; worth keeping visible. LANDMARK_ENUMERATION_JSdeclaresconsts — injecting it twice into one scope would be a redeclaration error. One docstring line would prevent that.
OM152002
approved these changes
Jul 28, 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.
Closes #74.
What changed
ADR-0007 made
visible_landmarksgenuinely visibility-filtered in both capture sites usingthe same predicate. That closed half the gap — the two sites still disagreed about which
elements to test:
Both now run one enumeration from
src/shared/landmarks.ts— role vocabulary,implicit-role map, visibility predicate, and tree walk — and neither owns a copy. The
browser-side role list is generated from the exported TypeScript constants with
JSON.stringify, so the TS copy and the copy that actually runs cannot drift.The recorder still does its own DOM-wide counting pass for
role_counts, but through theshared
paragentWalk/paragentRoleOf, so role resolution exists once. Counts stayDOM-wide per ADR-0007.
RepairContext.page_state, not a pure refactorcapturePageStatenow reports:complementary,contentinfo,region— its old 6-selector list could not producethem at all, at any markup.
banner/navigation/form/mainfrom semantic tags without arole=attribute.Expected and desirable — it is the point of the issue — but it means anything reading
page_state.visible_landmarkssees a longer, differently-ordered list. Today the only readeris
StubRepairModelClient, which ignores it; when #27 wires a real model this is the list itgets, and it is now the list the recorder actually produced.
The recorder is unchanged. Same 8-role set, same implicit map, same predicate, same DOM
order.
Where it lives, and why
The issue asked for this to be weighed rather than assumed.
src/runner/importssrc/recorder/src/recorder/importssrc/runner/contracts/contracts/is four language-neutral JSON Schemas and is outsidetsconfig.json'sinclude, so a.tsfile there would ship untypechecked. It would also makecontracts/a mixed JSON/TS directory to save one import.src/shared/(chosen)src/. Both capture sites depend on it; it depends on neither.The real risk with a package called
sharedis that it becomes a utility drawer and later adependency cycle. Constrained explicitly in
src/shared/index.tsand indocs/architecture.md: something belongs here only if it runs insidethe browser and two packages must run the identical copy. Anything else goes in the package
that owns it.
The string-body technique is preserved
Both sites still hand their evaluate body to the browser as text — the recorder via
new Function,page-stateviapage.evaluate("...")— because esbuild'skeepNameswrapsnamed function expressions in
__name(...), which does not exist in the browser (PR #73). Sothe shared unit is a JS source string, not a TS function. A shared function would have
reintroduced a
ReferenceErrorthat CI cannot see, sincecapturePageState's only caller isthe repair loop that the
gate:matrixexit-2 guard keeps unreached.tests/unit/page-state.test.tsis unchanged and still green.The guard, and proof it bites
tests/unit/landmarks.test.ts(8 tests) asserts the two sites agree on semantic markup withno redundant
role=— the case the in-tree fixture cannot exercise, becausegrafana-gate-login.htmlputs an explicitrole=on every landmark and both paths thereforeagree by accident. Expected lists are named in full, so two sites both returning
[]cannotpass. One test also fails if a second copy of the predicate appears anywhere under
src/.Reverted
page-stateto its old enumeration and re-ran:The fixture-based test passing under the revert is the issue's own claim, reproduced.
Artifacts
npm run recorder -- --fixturere-records with an identicaldom_digestand identicalvisible_landmarks; the only diff wasrecorded_at/started_offset_ms/duration_mswall-clock noise, so that churn was reverted rather than committed.
npm run compileoncontracts/examples/trajectory.example.jsonregenerates the bundle byte-identical. No artifactchanges in this PR.
Docs
page_statebehaviour change, and adds an open question about<search>/ named<section>(both are in the role set but have no implicit tag mapping; adding them would change what the
recorder writes, which is out of scope here).
docs/gate/recorder.md,docs/gate/runner.md,docs/gate/compiler.md— the"predicate shared, enumeration not" wording replaced with what is now true.
docs/architecture.md—src/shared/added to the package table with a section on whyit exists and why it must stay a leaf, plus a sixth invariant covering the string-body
technique and the one-copy rule.
Tests
🤖 Generated with Claude Code