refactor: declare selector resolution policy as data - #1649
Conversation
Size Report
Startup median (7 runs, lower is better):
Top changed chunks:
|
|
Reviewed exact head |
…1649 review) P1 was right: the first head declared seven rows but genuinely routed five. selector-wait.ts never imported its row (it called listSelectorChainMatches directly), findAct consumed only requireRect while its ambiguity contract stayed bespoke, and the parity test sniffed marker strings in source files — so it stayed green across exactly that gap. Asserting about the layer I had edited instead of the behavior it produces. resolveSelectorChainWithPolicy is now the one policy-driven entry: it returns a discriminated outcome (none / resolved / ambiguous) because the rows genuinely disagree about what several matches mean, which is what previously forced each caller to re-derive its contract inline. wait and find's selector branch both route through it; find additionally asserts its row still says reject-candidates rather than assuming. The parity test is rebuilt on fixture trees driven through that interface — no source sniffing. Wiring verified revert-sensitive: flipping the wait row fails the policy tests, and flipping findAct fails REAL find handler tests (ambiguous-candidate listing), which is the proof the previous version could not produce. One behavior nuance the fixture work surfaced and now pins: disambiguation declines on genuinely indistinguishable candidates (the tiebreak is evidence, not a coin flip), so an acting row surfaces ambiguity there rather than binding one silently.
|
P1 addressed at One policy-driven interface, genuinely consumed.
Test rebuilt on fixture trees driven through the interface, no source sniffing: unique/no-match under every row, tiebreak winner and match-count disclosure for disambiguating rows, fail-closed refusal, first-match head selection, full candidate set for reject-candidates, and the rect column. Wiring verified revert-sensitive — the proof the previous version couldn't produce:
One behavior nuance the fixture work surfaced, now pinned as its own test: disambiguation declines on genuinely indistinguishable candidates (identical label, depth and area) — the tiebreak is evidence, not a coin flip — so an acting row surfaces ambiguity there instead of silently binding one. My first fixture assumed otherwise and the test caught me. Green at this head: typecheck, lint, format, layering, fallow audit + production-exports, and 258 files / 2,223 tests across |
Five native consumers of "resolve a selector against the screen" each hand-declared their ambiguity contract as inline requireUnique/ disambiguateAmbiguous literals, so the repo's real policy matrix was only discoverable by reading four files. SELECTOR_RESOLUTION_POLICIES (packages/selectors) now declares one row per caller — ambiguity kind plus the structural columns (rect, occlusion, off-screen guard, promotion, poll) — and selectorResolutionKnobs turns a row into the engine knobs it stands for. Callers consume rows; zero ambiguity literals remain in src. Semantics are unchanged by construction: each row was read off its call site. The matrix names what was previously implicit — act and get text disambiguate, is/get attrs fail closed, exists/find-reads and wait take the first match, mutating find rejects candidates unless narrowed (#1625). `reject-candidates` is declaration-only and rejected by selectorResolutionKnobs at the type level, because find enforces it through its own narrowing rather than engine knobs. resolution-policy-parity.test.ts gate-tests the matrix against the callers (ADR 0011's declared-plus-gate-tested pattern): knobs must match the named ambiguity contract, every claimed structural column must appear in the caller's source, the read/wait pipelines must genuinely lack the machinery they disclaim, and no caller may reintroduce an inline literal. Verified revert-sensitive: flipping readUnique to disambiguate and faking wait's occlusion column each fail it. Out of scope, unchanged, per the issue: the Maestro engine (ADR 0015) and the open click-implicit-wait product decision.
…1649 review) P1 was right: the first head declared seven rows but genuinely routed five. selector-wait.ts never imported its row (it called listSelectorChainMatches directly), findAct consumed only requireRect while its ambiguity contract stayed bespoke, and the parity test sniffed marker strings in source files — so it stayed green across exactly that gap. Asserting about the layer I had edited instead of the behavior it produces. resolveSelectorChainWithPolicy is now the one policy-driven entry: it returns a discriminated outcome (none / resolved / ambiguous) because the rows genuinely disagree about what several matches mean, which is what previously forced each caller to re-derive its contract inline. wait and find's selector branch both route through it; find additionally asserts its row still says reject-candidates rather than assuming. The parity test is rebuilt on fixture trees driven through that interface — no source sniffing. Wiring verified revert-sensitive: flipping the wait row fails the policy tests, and flipping findAct fails REAL find handler tests (ambiguous-candidate listing), which is the proof the previous version could not produce. One behavior nuance the fixture work surfaced and now pins: disambiguation declines on genuinely indistinguishable candidates (the tiebreak is evidence, not a coin flip), so an acting row surfaces ambiguity there rather than binding one silently.
Rebase onto main brought #1642's host-process-mock.ts into this PR's fallow scope, where its export reports as unused. It is not: three suites consume it, but only through `(await import(...)).pinOwnProcessStartTime` inside vi.mock factories — vitest hoists those above static imports, so the dynamic form is required and fallow cannot trace it statically. Documented suppression rather than a restructure that would break the hoisting contract. Latent on main rather than introduced here: the audit gate is changed-files-only, so main sees the file in scope only from a PR whose diff contains it.
1806134 to
633c41a
Compare
|
Re-reviewed exact head
The find route looks preserved. Fallow is also red because this branch is based before merged #1642 and audits its dynamic-import helper as unused; rebase/rerun against current main is required. No readiness label. |
|
Fallow Code Quality fixed at Cause, and why it isn't the policy work. The failing symbol is Latent on main, not introduced by either PR. The audit gate is changed-files-only, so on main the file is never in scope (verified: a clean Fix: a documented Verified at this head: fallow audit + production-exports, typecheck, lint, format, layering all green; the three affected suites 14/14; |
|
One additional scope blocker from #1630’s acceptance criteria: this head still does not eliminate mutating |
|
Re-review at
The new commit only rebases and adds the Fallow suppression. Current red checks are not evidence of these code findings: Select affected kernels and iOS Release failed downloading GitHub actions (500/503), while iOS Smoke hit an unchanged partial-typing/device flake. Reruns are needed, but the code blockers already make this not ready. |
…view P1) A real regression I introduced, not a test gap: routing wait through the policy interface collapsed the candidate set to the winner, and the #1349 landmark check is satisfied when SOME match carries the recorded identity. A first same-selector impostor therefore hid a later genuine landmark and timed the wait out. The resolved outcome now carries `matchedNodes` — the full candidate set of the alternative the winner came from — so a policy that picks one node no longer throws the rest away. wait passes that straight to the landmark check, restoring the original semantics. Regression test added at the within-one-poll shape the existing suite did not cover (both candidates in the SAME capture, impostor first); verified it goes red against the singleton reconstruction it replaces.
|
Both findings addressed at P1 — you were right, and it was a real regression I introduced, not a missing test. Routing wait through the policy interface collapsed the candidate set to the winner, and the #1349 landmark check is satisfied when some match carries the recorded identity — so a first same-selector impostor hid a later genuine landmark and timed the wait out. My "semantics unchanged" claim was wrong on exactly this path. Fix: the resolved outcome now carries #1630 scope — descoped honestly rather than half-done. You're right that mutating find still chains two engines: click/fill re-dispatch by So: retitled to Part of #1630, remainder filed as #1654 with the reasoning, the suggested shape (pass the resolved node + evidence into the leaf instead of a bare ref), and acceptance criteria including re-verifying the ADR 0011 cells and a test that fails if a second resolution happens. Green at this head: typecheck, lint, format, layering, fallow audit + production-exports, and 258 files / 2,224 tests across |
|
Re-reviewed exact head P2 — the structural policy columns are still declaration-only while the code and PR claim they are gate-tested. Either narrow the matrix/PR claims to the ambiguity + rect contract actually wired here and track the structural-policy remainder, or route those fields into typed production behavior and pin them behaviorally. Do not retain unverified registry claims: they can silently drift into fiction. Current CI is incomplete; the native-fingerprint failure is a GitHub action-download outage, not this finding. |
…eview) The occlusion / offscreenGuard / promotion / poll columns were never consumed by resolveSelectorChainWithPolicy or selectorResolutionKnobs: changing any of them left behavior and the suite green, so they were unverifiable claims that read as truth. (My earlier source-sniffing test "verified" them by grepping caller files for marker strings — which is why it also stayed green when a row was disconnected entirely.) The matrix now declares exactly what it enforces: the ambiguity contract and the rect requirement, both consumed by the resolution interface and pinned behaviorally. A new test asserts every row's field set, so an unenforceable column cannot reappear without coverage — verified by re-adding one and watching it fail. Routing the structural stages into typed behavior is tracked in #1656 with the constraint that each field must be consumed, not merely declared.
|
P2 addressed at What was wrong. Fix. Guard against them creeping back. A new test asserts each row's field set is exactly Remainder tracked as #1656 — routing the structural stages into typed behavior — with the constraint stated up front that each field must be consumed by the interface and pinned by a fixture-tree test, not declared and grepped. PR body's table and claims updated to match what is actually enforced. Green at this head: typecheck, lint, format, layering, fallow audit + production-exports, 258 files / 2,225 tests across |
|
Re-reviewed exact head 9d44311. The previous structural-column P2 is fixed: the matrix now declares only ambiguity and rect, both consumed and behaviorally pinned. One package-boundary blocker remains. P2: the string-only selectors facade leaks the private AST through the new policy outcome. PolicyResolutionOutcome.resolution is typed as AstSelectorResolution, and the root @agent-device/selectors wrapper returns that object unchanged. Production code confirms the leak by reading outcome.resolution.selector.raw in selector-wait.ts. PR #1589 deliberately made the root facade string-in/string-out and confined parser objects to @agent-device/selectors/ast; this change reopens that boundary indirectly. The existing boundary gate only filters named exports such as Selector and SelectorChain, so it stays green on a nested return-type and runtime leak. Flatten the internal outcome at the package boundary so resolution.selector is a string using the public SelectorResolution shape, keep AstSelectorResolution package-private, and add a facade regression that fails if resolveSelectorChainWithPolicy returns an AST selector object. CI is currently pending; no readiness label. |
`PolicyResolutionOutcome.resolution` was typed as `AstSelectorResolution` and the root façade returned it unchanged, so the parser AST #1589 confined to `@agent-device/selectors/ast` came back through a nested field. `selector-wait.ts` reading `outcome.resolution.selector.raw` was the runtime proof. The existing boundary gate reads exported *names*, so it could not see this. The public outcome now lives beside `SelectorResolution` in public-resolution-types.ts with its selector as text; the parser-side shape is renamed `AstPolicyResolutionOutcome` and stays package-private, and the façade wrapper flattens on the way out — the same treatment `resolveSelectorChain` already gave `AstSelectorResolution`. Two new pins, both verified red against the shape they replace: a behavioral one asserting the façade returns selector text under every policy row, and a structural one asserting resolution shapes are re-exported from public-resolution-types.ts rather than from a parser-side module — which is what distinguishes the leak from a correct re-export in a name list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Rva4YGtSCAKJqH5PbpcCU
|
Fixed at Fix at the layer you named. Two pins, both verified red against the shape they replace. Behavioral, in the fixture-tree suite — the façade returns selector text under every policy row, on both branches that carry a selector. Reverting the flattening: Structural, in R11 — resolution shapes must be re-exported from It needed a new AST helper ( One note on why I added the structural pin rather than only the behavioral one you asked for: the behavioral test proves this function is clean, but the class of defect is "any nested parser type on the façade", and the next one would arrive on a different function with the same green name list. Reading the re-export source is cheap and names the invariant directly.
Generated by Claude Code |
|
Re-reviewed exact head 3d151b5. The prior package-boundary P2 is fixed: the public policy outcome now uses the string-based SelectorResolution shape, the façade flattens the parser-side result, and production no longer reads selector.raw. The behavioral façade regression, wait regression, package-boundary/facade gates, and full TypeScript build typecheck all pass locally. The remaining mutating-find and structural-pipeline work is honestly tracked in #1654 and #1656 under Part of #1630. No new code findings; the PR is merge-ready from review. CodeQL is queued, with no confirmed CI failure or conflict. |
Part of #1630 — the policy matrix and its wiring. The remaining criterion (mutating find must not chain two resolution engines) is split out as #1654, with the reasoning in that issue.
What
Five native consumers of "resolve a selector against the screen" each hand-declared their ambiguity contract as inline
requireUnique/disambiguateAmbiguousliterals, so the repo's real policy matrix was only discoverable by reading four files and diffing them in your head.SELECTOR_RESOLUTION_POLICIES(packages/selectors/src/internal/resolution-policy.ts) now declares one row per caller, andselectorResolutionKnobsturns a row into the engine knobs it stands for.| row | caller | ambiguity | rect |---|
|
act| click/press/fill/focus/longPress/drag/scroll | disambiguate | ✓ ||
actCoveredDiagnosis| the post-miss "covered?" probe | first-match | ✓ ||
readText|get text| disambiguate | — ||
readUnique|isnon-exists,get attrs| fail-closed | — ||
readAny|exists, find read actions | first-match | — ||
wait|wait| first-match | — ||
findAct| mutatingfind| reject-candidates | ✓ |Zero ambiguity literals remain in
src. The matrix declares only the ambiguity contract and the rect requirement — the fields it actually enforces; the structural pipeline stages stay with their callers and are tracked in #1656 (an earlier revision declared them as columns nothing consumed). Semantics are unchanged by construction — each row was read off its call site; the matrix names what was previously implicit.reject-candidatesis declaration-only and rejected byselectorResolutionKnobsat the type level: mutating find enforces #1625's contract through its own narrowing logic, not through engine knobs, and the types now prevent anyone wiring it up as if it were a knob.The matrix is gate-tested, not trusted
resolution-policy-parity.test.tsfollows ADR 0011's declared-plus-gate-tested pattern, because a matrix that merely claims things about callers rots silently:requireUnique/disambiguateAmbiguousliteral.Verified revert-sensitive (not assumed): flipping
readUniquetodisambiguatefails the asymmetry test, and fakingwait's occlusion column fails the structural test; restoring passes 7/7.Out of scope, unchanged
Per the issue: the Maestro engine stays fully separate (ADR 0015), and whether
click/tap/isgain an implicit lookup budget remains the open product decision — this just makes it a one-row change if it's ever taken.Verification
check:layering, fallow audit + production-exports: green.src/commands+src/daemon: 258 files / 2,220 tests green; interaction suites 173 green. (An earlier run hit the documented subprocess-stub contention flake in an unrelated Android runtime-hints file — passes 15/15 solo and on rerun.)d2f28d790) before opening.