fix: demote non-unique ids from writer identity/selector chain (#1269)#1272
Conversation
Size Report
Startup median (7 runs, lower is better):
Top changed chunks:
|
Live post-fix validation (the record/replay proof requested in review)Built a daemon from this branch (head PROOF 1 — the generated
PROOF 2 — it replays cleanly; the identity class is eliminated.
Honest nuance: one earlier replay hit a Scope reminder: the Android list-press class (label-less |
|
Review of Conformance verified: capture-time uniqueness (not a namespace blocklist) at both edit sites; resolver untouched per ADR-0012's disclosed-not-changed posture; all four production Finding 1 — the uniqueness predicate exists twice with non-identical semantics, and the fix's correctness depends on the two agreeing.
Either divergence yields the bad half-state this fix exists to prevent: chain demotes while identity keeps the id (or vice versa) → resolution and verification key on different identities → the mismatch class persists. Fix: extract ONE shared Finding 2 — the load-bearing replay-time test is missing. The new tests prove record-time demotion + record-time self-check. The scenario that motivated the whole issue — record, then the tree REORDERS (a row inserted before the target), then replay-time verification — is untested. One test in the replay verification suite: id-demoted evidence (role+label identity), mutated tree with the target at a different position → path 4 Finding 3 — clarify the "measured stripped-id 20/20 clean" comment in Nit: the synthetic Settings fixture is fine, but the archived real capture from the E1 run ( Back-compat note for the record: |
…es (#1269 review) Address the maintainer review on #1272: 1. ONE shared uniqueness predicate. The two demotion sites were counting id matches with DIFFERENT semantics — `demoteNonUniqueId` via `filterIdentitySet` (NFC + 256-byte cap, and a broken-parent-walk exclusion), `selectableId` via a raw `normalizeSelectorText` scan (trim, no NFC/cap, no exclusion) — so the identity tuple and the selector chain could disagree and half-demote (id gone from one, kept in the other). Extract `idMatchCountInTree(nodes, id)` in target-identity-node.ts, counting over the canonical `readNodeLocalIdentity` id the replay verifier keys on, with no ancestry/parent-walk exclusion. Both `demoteNonUniqueId` and `selectableId` now call it. Corrects the inaccurate "vacuously-true / plain id scan" comment. Cross-invariant test (build.test.ts): for the same node+tree, evidence.id === undefined iff the built chain has no id= clause — across demoted, unique, and a non-NFC (decomposed vs precomposed) edge case. Verified it fails under the old raw-scan and passes under the unified predicate. 2. End-to-end reorder proof (session-replay-target-classification.test.ts): record against a tree whose rows share android:id/title, then classify against a DIFFERENT replay tree where the shared-id rows reorder — the demoted role+label identity rebinds the correct row (verified, matchCount 1) while `id="android:id/title"` resolves ambiguously (null). This pins the FDR 1.0 -> 0 mechanism, not just record-time demotion. 3. Removed the conflated "20/20 clean" live-number comment from the unit test; it now states the mechanism (role+label selectivity) instead. Behavior for the already-clean unique-id path is unchanged: for ordinary ascii ids the canonical count equals the old raw count. The only outcomes that change are the edge cases the old split mishandled (non-NFC, broken parent walk) — where demotion is the correct result. The kept clause still emits the chain's own normalizeSelectorText id string, so unique ids lead the chain exactly as before.
|
Thanks — all three addressed in Ask 1 — one shared uniqueness predicate (the correctness fix)Confirmed the two predicates diverged, as you said. Fix: extracted one predicate Cross-invariant test ( Ask 2 — the load-bearing reorder test (FDR 1.0 → 0)Added an end-to-end mechanism test ( Ask 3 — removed the conflated live numberDeleted the Behavior preservation (your STOP-and-report gate)Not triggered. For ordinary ascii ids the canonical count equals the old raw count, so the already-clean unique-id path is unchanged — unique ids still lead the chain, and the kept clause still emits the chain's own Layering: Gates: |
#1269) Android list-row GET replays bind the wrong row because the recorder uses the non-unique framework resource id `android:id/title` (matchCount 11 on Settings root) as primary identity; positional drift then makes the identity verifier correctly refuse with `identity-mismatch`. Demote an id from identity whenever it matches more than one node in the record-time tree (capture-time uniqueness, not an `android:id/*` namespace check — a reused RN FlatList testID hits the same class on iOS). Applied in both places a recorded id feeds identity: - `computeTargetEvidence` (session-target-evidence.ts): the `target-v1` identity tuple falls back to role+label when the id's own capture-time match count exceeds one, reusing the existing `filterIdentitySet` domain machinery (an empty ancestry degrades it to a plain id scan). - `buildSelectorChainForNode` (selectors/build.ts): the recorded selector chain omits a non-unique id rather than leading with it. Every writer call site (get/press/fill recording, plus the divergence-suggestion path) now passes the record-time tree so the check has something to count against; omitting it preserves prior behavior for isolated-node callers (tests). Resolver-side `resolveSelectorChain` and live press/fill resolution are untouched per ADR 0012 (disclosed-not-changed disambiguation) — this is writer/replay-scoped only. Amends ADR 0012 decision 3: an id may serve as identity (and lead the selector chain) only when it uniquely denotes the target in the record-time tree. Adds fixtures: an Android duplicated-`android:id/title` list (the measured repro) and an iOS/RN duplicated-testID FlatList shape, both demoted and still verifying via the now-selective label; a regression case confirming an already-unique id is unaffected. Out of scope: the Android list-*press* class (matchCount 12, label-less `role="linearlayout"` container with no id at all to demote) needs a separate design decision — deriving identity from the labeled descendant. Tracked as a follow-up, not attempted here.
…es (#1269 review) Address the maintainer review on #1272: 1. ONE shared uniqueness predicate. The two demotion sites were counting id matches with DIFFERENT semantics — `demoteNonUniqueId` via `filterIdentitySet` (NFC + 256-byte cap, and a broken-parent-walk exclusion), `selectableId` via a raw `normalizeSelectorText` scan (trim, no NFC/cap, no exclusion) — so the identity tuple and the selector chain could disagree and half-demote (id gone from one, kept in the other). Extract `idMatchCountInTree(nodes, id)` in target-identity-node.ts, counting over the canonical `readNodeLocalIdentity` id the replay verifier keys on, with no ancestry/parent-walk exclusion. Both `demoteNonUniqueId` and `selectableId` now call it. Corrects the inaccurate "vacuously-true / plain id scan" comment. Cross-invariant test (build.test.ts): for the same node+tree, evidence.id === undefined iff the built chain has no id= clause — across demoted, unique, and a non-NFC (decomposed vs precomposed) edge case. Verified it fails under the old raw-scan and passes under the unified predicate. 2. End-to-end reorder proof (session-replay-target-classification.test.ts): record against a tree whose rows share android:id/title, then classify against a DIFFERENT replay tree where the shared-id rows reorder — the demoted role+label identity rebinds the correct row (verified, matchCount 1) while `id="android:id/title"` resolves ambiguously (null). This pins the FDR 1.0 -> 0 mechanism, not just record-time demotion. 3. Removed the conflated "20/20 clean" live-number comment from the unit test; it now states the mechanism (role+label selectivity) instead. Behavior for the already-clean unique-id path is unchanged: for ordinary ascii ids the canonical count equals the old raw count. The only outcomes that change are the edge cases the old split mishandled (non-NFC, broken parent walk) — where demotion is the correct result. The kept clause still emits the chain's own normalizeSelectorText id string, so unique ids lead the chain exactly as before.
… helper Rebase-conflict resolution against origin/main. #1217 (typed direct Maestro engine) extracted `buildReplayDivergenceSuggestionForNode` out of `resolveSuggestionCandidate` and added a second caller in `session-replay-maestro-failure.ts`. My #1269 change had added `nodes` to the `buildSelectorChainForNode` call that #1217 moved into the extracted helper, so after rebase the helper referenced an out-of-scope `nodes`. Thread the record-time tree as a required `nodes` param on the helper and pass it from BOTH callers (each already has it in scope). This keeps the non-unique-id demotion applied wherever a divergence/repair suggestion chain is built — now including the typed-Maestro suggestion path — with no behavior change for the already-unique-id case.
35ff81f to
fcc9b60
Compare
|
Rebased onto latest One heads-up: the rebase was not fully conflict-free. Resolved in a clearly-labeled third commit ( Gates on the rebased head: |
|
Summary
Fixes #1269 — Android list-row
getreplays bind the wrong row because the recorder uses the non-unique framework resource idandroid:id/title(matchCount 11 on Settings root) as the primary identity; positional drift on replay then makes the identity verifier correctly refuse withidentity-mismatch(measured FDR 1.0, see issue).The rule: capture-time uniqueness, not a namespace blocklist. Demote an id from identity whenever it matches more than one node in the record-time tree. This is not Android-specific in principle — a fixture below reproduces the same class on iOS via an RN
FlatListreusing onetestIDacross rows.The two edit sites (both required — either alone is insufficient)
computeTargetEvidence(src/daemon/session-target-evidence.ts): thetarget-v1identity tuple now falls back to role+label when the recorded id's own capture-time match count exceeds one. Reuses the existingfilterIdentitySetdomain machinery already computed there — passing an emptyancestrydegrades its ancestry-prefix check to vacuously-true, turning it into a plain "how many nodes in the record-time tree carry this id" scan.buildSelectorChainForNode(src/selectors/build.ts): the recorded selector chain now omits a non-unique id rather than leading with it. Every writer call site (get/press/fill recording inresolution.ts/selector-read.ts, plus the replay divergence-suggestion path insession-replay-divergence.ts) now passes the record-time tree so there's something to count against. Omittingnodes(e.g. an isolated test node) preserves prior behavior — no regression for call sites that can't supply a tree.What's explicitly untouched
resolveSelectorChainand live press/fill resolution are unchanged, per ADR 0012's deliberately-disclosed-not-changed disambiguation. This fix is writer-side / replay-scoped only — it changes what gets recorded, never how a live selector is resolved.ADR 0012 amendment
Decision 3 gets one added sentence (plus a status-bullet entry): "An id may serve as identity (and lead the selector chain) only when it uniquely denotes the target in the record-time tree."
Fixtures
android:id/titlelist (the measured repro shape: label-lessLinearLayout/RecyclerView/FrameLayoutancestry, 3 rows sharing the id) — demoted, and still verifies via the now-selective label (src/daemon/__tests__/session-target-evidence.test.ts,src/selectors/build.test.ts).testIDFlatList shape (one shared accessibility identifier acrossCellrows) — same demotion, proving the rule isn't anandroid:id/*namespace check.Explicitly out of scope
The Android list-press class is a separate, unfixed defect: the clickable row is a label-less
role="linearlayout"container with no id at all to demote (matchCount: 12, still diverges). Id-demotion doesn't apply since there's no id in the first place — it needs a different design (deriving identity from the labeled descendant). Not attempted here; needs its own issue per the maintainer scope decision on #1269.Test plan
pnpm typecheckpnpm exec oxlint --deny-warnings(repo-wide)pnpm format:check(project's oxfmt scope:src test skills ...)pnpm check:fallow --base origin/main(no dead-code/unused-export issues)pnpm check:affected --base origin/main --run— 166 test files / 1745 tests, all greensrc/daemon/__tests__/session-target-evidence.test.tsandsrc/selectors/build.test.ts