Skip to content

Commit 338aa2a

Browse files
thymikeeclaude
andauthored
refactor: route every native selector resolution through the policy interface (#1715)
* refactor: route every native selector resolution through the policy interface #1649 declared the per-caller ambiguity matrix; four native call sites still bypassed it, spreading `selectorResolutionKnobs(row)` into a raw `resolveSelectorChain` instead of naming the row. That left the "one interface" claim aspirational: a caller could restate its contract as engine knobs and nothing would notice. - `is` non-exists, `get text`/`get attrs`, find's read actions, and the covered-selector diagnosis probe now call `resolveSelectorChainWithPolicy` with their existing row. Semantics are byte-identical: the knob-backed branch of that interface forwards to the same engine call the call sites built by hand. - The façade drops `resolveSelectorChain` and `selectorResolutionKnobs`, so no knob-taking resolver is reachable from outside the package and a call site cannot re-acquire the knobs even by accident. `requireUnique`/`disambiguateAmbiguous` are now named in exactly one function, which `resolve-with-policy.ts` and the replay resolver both derive through. - `get` names the two rows it may consume as a type, so pointing it at any other ambiguity contract is a compile error. Tests: selector-read-policy.test.ts pins which row each read command consumes, end to end, on one ambiguous fixture — the only tree the rows disagree on. Each assertion was proven red by re-pointing its caller at a neighbouring row. The knob-consistency check moves into the package beside the now-private helper. Test call sites that used the raw resolver move to `resolveRecordedTarget`, the same knobs and the path that actually replays a recorded chain. Extracting the failure branch drops `resolveSelectorInteractionTarget` below the complexity threshold; its `fallow-ignore` waiver is removed (verified load-bearing before the extraction, unnecessary after). Closes #1630. Structural stages (occlusion, off-screen, promotion, poll budget) stay per-caller pipeline code, tracked in #1656. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HuKzQWn6WQcMYaAZVvJzdD * test: observe which node find's row selected, not just that one existed #1715 review, P2: the find row assertion was only half a pin. `find exists` returns `found: true` for any resolved node, and the `list` call it leaned on goes through listFindMatches — a path that consumes no policy row at all. So repointing findFirstLocatorMatch at `readText` left both assertions green while selection silently moved from the document-order head to the tiebreak winner. Assert through `find get_attrs`, which returns the ref of the node the row actually selected. Both neighbouring rows are now red: `readText` fails '@e3' !== '@e2' (the move the old test missed), `readUnique` fails by refusing the ambiguous screen. `exists` stays as a second, weaker assertion on the same resolution. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HuKzQWn6WQcMYaAZVvJzdD * refactor: route is exists through the matrix, collapse the double match pass Follow-up tightening on the same seam. `is exists` reached findSelectorChainMatch directly while the `readAny` row's own doc claimed to serve "`exists` and find's read-only actions" — true of the docs, not of the code, which is the unverifiable-claim shape #1656's review called out. It now names `readAny`, the row it always described. Equivalent by construction: both take the first alternative with any match under requireRect: false, and disclose that alternative's count. That leaves the root façade with no consumer for findSelectorChainMatch, so it goes the way of resolveSelectorChain — dropped from the string-only façade, kept on the published ./ast surface. Its façade-twin type SelectorChainMatch dies with it (fallow caught it). resolveSelectorChainWithPolicy matched twice on the uniqueness path: once via resolveSelectorChain, then again to fill matchedNodes. Hoisting the single list call above the row switch removes that second pass, collapses two duplicated ambiguous literals into one helper, and drops a `?? [resolution.node]` fallback that was unreachable — a resolution implies its alternative matched, so the list is never null there. While hoisting: the resolved arm's matchedNodes can describe a different alternative than resolution.selector, because uniqueness skips an ambiguous alternative to try the next one. Unreachable today (only first-match callers read it, where both come from one list), and left as-is rather than silently changed — but the doc claimed "the alternative it came from", so it now says what is actually true. Tests: is exists gets a caller-level pin on the shared ambiguous fixture — passes with matches: 2 where its fail-closed siblings refuse — proven red by pointing it at readUnique. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HuKzQWn6WQcMYaAZVvJzdD * test: discriminate is exists's row by alternative, guard the façade structurally #1715 review, second regression-validity gap. The `is exists` pin observed only `pass: true` and `matches: 2` on a fixture whose first alternative was merely TIEBREAKABLE — so disambiguation succeeded there and reported the same count first-match would. `readAny`, `readText`, and the pre-migration raw lookup all produced that, and only the readUnique swap I had checked went red. One mutation proven is not the same as the row being pinned. `exists` exposes no node ref, so the row has to be read off WHICH alternative answered. New fixture: alternative one matches two nodes that are genuinely indistinguishable (same depth, same area, both on screen) so the tiebreak declines; alternative two matches exactly one. First-match answers from alternative one; every uniqueness row skips the undecidable alternative and answers from alternative two. Asserting the selector now separates them — readText and readUnique both fail with `id="save-unique"` where `label="Save"` is expected. Restoring the raw lookup stays behaviourally invisible, though: findSelectorChainMatch is equivalent to the readAny row it migrated to, which is precisely why that migration preserved semantics. No fixture assertion can catch that revert, so the guard is structural — the façade's export list must not carry resolveSelectorChain, findSelectorChainMatch, or selectorResolutionKnobs. Follows the packages/maestro index.test.ts absence-assertion precedent. Verified red by re-exporting the lookup. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HuKzQWn6WQcMYaAZVvJzdD * fix: cover selector routes in device replays * test: simplify selector replay regression --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3cbdb0a commit 338aa2a

25 files changed

Lines changed: 706 additions & 231 deletions

‎examples/test-app/app.config.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ module.exports = {
3737
android: {
3838
package: 'com.callstack.agentdevicelab',
3939
predictiveBackGestureEnabled: false,
40+
softwareKeyboardLayoutMode: 'pan',
4041
},
4142
},
4243
};

‎packages/ad-script/src/internal/__tests__/script.test.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,33 @@ test('formatPortableActionLine preserves inline open runtime hints', () => {
5050
);
5151
});
5252

53+
test('open replay script round-trips explicit Android test IME selection', () => {
54+
const actions: SessionAction[] = [
55+
{
56+
ts: Date.now(),
57+
command: 'open',
58+
positionals: ['Demo'],
59+
flags: { testIme: false },
60+
},
61+
{
62+
ts: Date.now(),
63+
command: 'open',
64+
positionals: ['Demo'],
65+
flags: { testIme: true },
66+
},
67+
];
68+
69+
const script = formatReplayScriptForTest(actions);
70+
assert.match(script, /open "Demo" --no-test-ime/);
71+
assert.match(script, /open "Demo" --test-ime/);
72+
73+
const parsed = parseReplayScriptDetailed(script).actions;
74+
assert.equal(parsed[0]?.flags.testIme, false);
75+
assert.equal(parsed[1]?.flags.testIme, true);
76+
assert.deepEqual(parsed[0]?.positionals, ['Demo']);
77+
assert.deepEqual(parsed[1]?.positionals, ['Demo']);
78+
});
79+
5380
test('record replay script parses fps, quality, and hide-touches flags', () => {
5481
const script = 'record start "./capture.mp4" --fps 24 --quality high --hide-touches\n';
5582
const parsed = parseReplayScriptDetailed(script).actions;

‎packages/ad-script/src/internal/open-script.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ export function appendOpenActionScriptArgs(
4646
if (action.flags?.relaunch) {
4747
parts.push('--relaunch');
4848
}
49+
if (action.flags?.testIme === true) {
50+
parts.push('--test-ime');
51+
} else if (action.flags?.testIme === false) {
52+
parts.push('--no-test-ime');
53+
}
4954
appendRuntimeHintFlags(parts, action.runtime);
5055
}
5156

@@ -61,6 +66,14 @@ export function parseReplayOpenFlags(args: string[]): {
6166
flags.relaunch = true;
6267
continue;
6368
}
69+
if (token === '--test-ime') {
70+
flags.testIme = true;
71+
continue;
72+
}
73+
if (token === '--no-test-ime') {
74+
flags.testIme = false;
75+
continue;
76+
}
6477
argsWithoutRelaunch.push(token);
6578
}
6679
const parsedRuntime = parseReplayRuntimeFlags(argsWithoutRelaunch);

‎packages/selectors/src/index.test.ts‎

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import assert from 'node:assert/strict';
22
import { test } from 'vitest';
33
import type { SnapshotNode } from '@agent-device/kernel/snapshot';
4+
import * as selectorsFacade from './index.ts';
45
import {
56
buildSelectorCandidates,
67
readReplaySelectorDisplayValue,
78
readSelectorExpression,
89
resolveRecordedTarget,
910
resolveReplaySuggestionCandidate,
10-
resolveSelectorChain,
11+
resolveSelectorChainWithPolicy,
12+
SELECTOR_RESOLUTION_POLICIES,
1113
} from './index.ts';
1214

1315
const saveNode: SnapshotNode = {
@@ -177,11 +179,38 @@ test('replay suggestion resolution and display values stay string-only at the fa
177179
assert.equal(readReplaySelectorDisplayValue('label="Save"'), 'Save');
178180
assert.equal(readReplaySelectorDisplayValue('label="Save" || label="Draft"'), undefined);
179181

180-
const resolved = resolveSelectorChain([saveNode], 'id="save"', {
181-
platform: 'ios',
182-
requireRect: true,
183-
requireUnique: true,
184-
});
185-
assert.equal(resolved?.selector, 'id="save"');
186-
assert.equal(typeof resolved?.selector, 'string');
182+
const resolved = resolveSelectorChainWithPolicy(
183+
[saveNode],
184+
'id="save"',
185+
SELECTOR_RESOLUTION_POLICIES.act,
186+
{ platform: 'ios' },
187+
);
188+
assert.equal(resolved.kind, 'resolved');
189+
assert.equal(resolved.kind === 'resolved' ? resolved.resolution.selector : null, 'id="save"');
190+
});
191+
192+
/**
193+
* #1630 made `resolveSelectorChainWithPolicy` the façade's ONLY resolution
194+
* entry: a native caller states its ambiguity contract by naming a policy row
195+
* because there is no knob-taking or count-only resolver here to state it
196+
* inline with instead.
197+
*
198+
* This guard is structural on purpose. Restoring either removed lookup is
199+
* BEHAVIOURALLY invisible — `findSelectorChainMatch` is equivalent to the
200+
* `readAny` row it was migrated to, which is exactly why that migration
201+
* preserved semantics — so no fixture-tree assertion can catch a revert
202+
* (#1715 review). The absence of the symbol is the only observable.
203+
*/
204+
test('the façade exposes no resolver that bypasses the policy matrix', () => {
205+
const exported = Object.keys(selectorsFacade);
206+
assert.ok(exported.includes('resolveSelectorChainWithPolicy'));
207+
assert.ok(!exported.includes('resolveSelectorChain'), 'knob-taking resolver must stay private');
208+
assert.ok(
209+
!exported.includes('findSelectorChainMatch'),
210+
'count-only existence lookup must stay private; `is exists` names the readAny row',
211+
);
212+
assert.ok(
213+
!exported.includes('selectorResolutionKnobs'),
214+
'knob derivation must stay private so a call site cannot rebuild a contract from knobs',
215+
);
187216
});

‎packages/selectors/src/index.ts‎

Lines changed: 9 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,8 @@ import type { SnapshotState } from '@agent-device/kernel/snapshot';
22
import type { Selector } from './internal/parse.ts';
33
import type {
44
PolicyResolutionOutcome,
5-
SelectorChainMatch,
65
SelectorChainMatchList,
76
SelectorMatchOptions,
8-
SelectorResolution,
9-
SelectorResolutionOptions,
107
} from './internal/public-resolution-types.ts';
118
import { resolveSelectorChainWithPolicy as resolveSelectorChainWithPolicyAst } from './internal/resolve-with-policy.ts';
129
import {
@@ -34,9 +31,7 @@ import {
3431
normalizeIsPositionals,
3532
} from './internal/predicates.ts';
3633
import {
37-
findSelectorChainMatch as findSelectorChainMatchAst,
3834
listSelectorChainMatches as listSelectorChainMatchesAst,
39-
resolveSelectorChain as resolveSelectorChainAst,
4035
selectorFailureHint,
4136
STALE_REF_HINT,
4237
} from './internal/resolve.ts';
@@ -64,7 +59,6 @@ export type { IsPredicate } from './internal/predicates.ts';
6459
export type {
6560
PolicyResolutionOutcome,
6661
SelectorChainMatchList,
67-
SelectorChainMatch,
6862
SelectorResolution,
6963
} from './internal/public-resolution-types.ts';
7064
export { formatSelectorFailure } from './internal/resolve.ts';
@@ -81,7 +75,6 @@ export {
8175
detectUnknownSelectorKeyToken,
8276
evaluateIsPredicate,
8377
findBestMatchesByLocator,
84-
findSelectorChainMatch,
8578
isReadOnlyFindAction,
8679
normalizeFindActionToken,
8780
isRoleHintWord,
@@ -99,7 +92,6 @@ export {
9992
readSelectorExpression,
10093
resolveRecordedTarget,
10194
resolveReplaySuggestionCandidate,
102-
resolveSelectorChain,
10395
selectorFailureHint,
10496
selectorContainsValue,
10597
splitSelectorFromArgs,
@@ -233,16 +225,6 @@ function validateSelectorExpression(expression: string): void {
233225
parseSelectorChain(expression);
234226
}
235227

236-
/** Public façade wrapper that accepts/returns selector text, never an AST. */
237-
function findSelectorChainMatch(
238-
nodes: SnapshotState['nodes'],
239-
expression: string,
240-
options: SelectorMatchOptions,
241-
): SelectorChainMatch | null {
242-
const result = findSelectorChainMatchAst(nodes, parseSelectorChain(expression), options);
243-
return result ? { ...result, selector: result.selector.raw } : null;
244-
}
245-
246228
/** Public façade wrapper that accepts/returns selector text, never an AST. */
247229
function listSelectorChainMatches(
248230
nodes: SnapshotState['nodes'],
@@ -253,37 +235,25 @@ function listSelectorChainMatches(
253235
return result ? { ...result, selector: result.selector.raw } : null;
254236
}
255237

256-
/** Public façade wrapper that accepts/returns selector text, never an AST. */
257-
function resolveSelectorChain(
258-
nodes: SnapshotState['nodes'],
259-
expression: string,
260-
options: SelectorResolutionOptions,
261-
): SelectorResolution | null {
262-
const result = resolveSelectorChainAst(nodes, parseSelectorChain(expression), options);
263-
return result ? { ...result, selector: result.selector.raw } : null;
264-
}
265-
export {
266-
SELECTOR_RESOLUTION_POLICIES,
267-
selectorResolutionKnobs,
268-
} from './internal/resolution-policy.ts';
269-
export type {
270-
KnobBackedSelectorAmbiguity,
271-
SelectorResolutionPolicy,
272-
} from './internal/resolution-policy.ts';
238+
export { SELECTOR_RESOLUTION_POLICIES } from './internal/resolution-policy.ts';
239+
export type { SelectorResolutionPolicy } from './internal/resolution-policy.ts';
273240
import type { SelectorResolutionPolicy } from './internal/resolution-policy.ts';
274241

275242
/**
276-
* Public façade wrapper that accepts selector text and returns selector text —
277-
* never an AST, in either direction.
243+
* The façade's ONLY selector-resolution entry (#1630): every native consumer
244+
* of "resolve a selector against the screen" states its contract by naming a
245+
* `SELECTOR_RESOLUTION_POLICIES` row, because there is no knob-taking resolver
246+
* here to state it inline with instead. Accepts selector text and returns
247+
* selector text — never an AST, in either direction.
278248
*
279249
* The return leg is the half that is easy to miss: the parser-side outcome
280250
* carries the winning `Selector` node inside `resolution`, and returning it
281251
* unchanged would put a package-private parser object back in every caller's
282252
* hands through a nested field. The façade's own boundary gate reads exported
283253
* *names*, so it cannot see that; `selector-wait.ts` reading
284254
* `outcome.resolution.selector.raw` was the runtime proof it had happened.
285-
* Flattening here is the same treatment `resolveSelectorChain` above gives
286-
* `AstSelectorResolution` (#1589).
255+
* Flattening here is the same treatment `listSelectorChainMatches` above gives
256+
* its own selector node (#1589).
287257
*/
288258
function resolveSelectorChainWithPolicy(
289259
nodes: SnapshotState['nodes'],

‎packages/selectors/src/internal/public-resolution-types.ts‎

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,14 @@ export type PolicyResolutionOutcome =
4545
/** No selector alternative matched anything. */
4646
| { kind: 'none' }
4747
/**
48-
* The node this policy authorizes acting on, plus the full candidate set of
49-
* the alternative it came from. Callers that verify identity across
50-
* candidates (wait's #1349 landmark check) need the whole set — a policy
51-
* that picks one winner must not throw the rest away, or a first impostor
52-
* would hide a later genuine match.
48+
* The node this policy authorizes acting on, plus the candidate set of the
49+
* first matching alternative. Callers that verify identity across candidates
50+
* (wait's #1349 landmark check) need the whole set — a policy that picks one
51+
* winner must not throw the rest away, or a first impostor would hide a
52+
* later genuine match. Those callers use `first-match` rows, where the two
53+
* fields describe the same alternative by construction; a uniqueness row can
54+
* resolve from a LATER alternative, so pair `matchedNodes` with
55+
* `resolution.selector` before reading them as one set.
5356
*/
5457
| { kind: 'resolved'; resolution: SelectorResolution; matchedNodes: SnapshotNode[] }
5558
/**
@@ -66,14 +69,6 @@ export type SelectorChainMatchList = {
6669
matchedNodes: SnapshotNode[];
6770
};
6871

69-
/** A first-match lookup used by existence checks. */
70-
export type SelectorChainMatch = {
71-
selectorIndex: number;
72-
selector: string;
73-
matches: number;
74-
diagnostics: SelectorDiagnostics[];
75-
};
76-
7772
/**
7873
* The options every selector lookup takes. Stated once here rather than inline
7974
* per function so a façade wrapper and the parser-side function it forwards to

‎packages/selectors/src/internal/replay.ts‎

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { splitIsSelectorArgs, splitSelectorFromArgs } from './arguments.ts';
66
import { buildSelectorChainForNode } from './build.ts';
77
import { matchesSelector } from './match.ts';
88
import { tryParseSelectorChain } from './parse.ts';
9+
import { selectorResolutionKnobs } from './resolution-policy.ts';
910
import { listSelectorChainMatches, resolveSelectorChain } from './resolve.ts';
1011

1112
/**
@@ -78,6 +79,23 @@ export function readSelectorExpression(
7879
return { kind: 'expression', expression: split.selectorExpression, rest: split.rest };
7980
}
8081

82+
/**
83+
* A replay policy states its ambiguity contract per request rather than per
84+
* caller, so it names an ambiguity KIND from the same vocabulary the static
85+
* matrix uses and derives its engine knobs through the same function (#1630) —
86+
* `selectorResolutionKnobs` stays the only place `requireUnique` and
87+
* `disambiguateAmbiguous` are named.
88+
*/
89+
function recordedTargetResolutionOptions(policy: ReplayRecordedTargetPolicy) {
90+
return {
91+
platform: policy.platform,
92+
...selectorResolutionKnobs({
93+
ambiguity: policy.allowDisambiguation ? 'disambiguate' : 'fail-closed',
94+
requireRect: policy.requireRect,
95+
}),
96+
};
97+
}
98+
8199
/** Resolve a recorded target and return the winning node plus its same-alternative domain. */
82100
export function resolveRecordedTarget(
83101
expression: string,
@@ -86,12 +104,7 @@ export function resolveRecordedTarget(
86104
): ReplayRecordedTargetResolution {
87105
const chain = tryParseSelectorChain(expression);
88106
if (!chain) return { kind: 'unresolved', reason: 'parse-invalid', matchedNodes: [] };
89-
const resolved = resolveSelectorChain(nodes, chain, {
90-
platform: policy.platform,
91-
requireRect: policy.requireRect,
92-
requireUnique: true,
93-
disambiguateAmbiguous: policy.allowDisambiguation,
94-
});
107+
const resolved = resolveSelectorChain(nodes, chain, recordedTargetResolutionOptions(policy));
95108
if (resolved) {
96109
const matchedNodes = nodes.filter((node) => {
97110
if (policy.requireRect && !node.rect) return false;
@@ -147,12 +160,7 @@ export function resolveReplaySuggestionCandidate(
147160
): ReplaySuggestionCandidateMatch | undefined {
148161
const chain = tryParseSelectorChain(candidate);
149162
if (!chain) return undefined;
150-
const resolved = resolveSelectorChain(nodes, chain, {
151-
platform: policy.platform,
152-
requireRect: policy.requireRect,
153-
requireUnique: true,
154-
disambiguateAmbiguous: policy.allowDisambiguation,
155-
});
163+
const resolved = resolveSelectorChain(nodes, chain, recordedTargetResolutionOptions(policy));
156164
if (!resolved) return undefined;
157165
return { node: resolved.node, basis: classifySuggestionBasis(resolved.selector) };
158166
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'vitest';
3+
import { SELECTOR_RESOLUTION_POLICIES, selectorResolutionKnobs } from './resolution-policy.ts';
4+
5+
/**
6+
* `selectorResolutionKnobs` is package-private (#1630): the façade exports no
7+
* resolver that accepts `requireUnique`/`disambiguateAmbiguous`, so this is the
8+
* only place in the repo that names them and the only place a knob-vs-row
9+
* mismatch can be introduced. The row-level BEHAVIOR each mapping produces is
10+
* pinned separately, through the public interface, in
11+
* src/commands/interaction/runtime/__tests__/resolution-policy-parity.test.ts.
12+
*/
13+
test('knobs stay consistent with the ambiguity each knob-backed row names', () => {
14+
for (const [name, policy] of Object.entries(SELECTOR_RESOLUTION_POLICIES)) {
15+
if (policy.ambiguity === 'reject-candidates') continue;
16+
const knobs = selectorResolutionKnobs(policy);
17+
assert.equal(knobs.requireRect, policy.requireRect, name);
18+
if (policy.ambiguity === 'first-match') {
19+
assert.equal(knobs.requireUnique, false, name);
20+
} else {
21+
assert.equal(knobs.requireUnique, true, name);
22+
assert.equal(knobs.disambiguateAmbiguous, policy.ambiguity === 'disambiguate', name);
23+
}
24+
}
25+
});

‎packages/selectors/src/internal/resolution-policy.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@ import type { SelectorResolutionOptions } from './public-resolution-types.ts';
2222
*
2323
* Scope, deliberately narrow: this matrix declares the **ambiguity contract
2424
* and the rect requirement**, and nothing else. Both are consumed by
25-
* `resolveSelectorChainWithPolicy` and pinned behaviorally in
26-
* resolution-policy-parity.test.ts, so a row that stops matching its
25+
* `resolveSelectorChainWithPolicy` — the one door native callers have, since
26+
* the package façade exports no knob-taking resolver — and pinned behaviorally
27+
* in resolution-policy-parity.test.ts, so a row that stops matching its
2728
* documented semantics fails a test.
2829
*
2930
* The surrounding pipeline stages — occlusion, the off-screen guard,
@@ -82,8 +83,11 @@ export const SELECTOR_RESOLUTION_POLICIES = {
8283
} as const satisfies Record<string, SelectorResolutionPolicy>;
8384

8485
/**
85-
* The engine knobs a knob-backed policy row stands for. `reject-candidates`
86-
* rows are rejected at the type level — that contract is enforced by caller
86+
* The engine knobs a knob-backed policy row stands for, and the only place in
87+
* the repo that names `requireUnique`/`disambiguateAmbiguous` (#1630): the
88+
* façade exports no resolver that accepts them, so a caller cannot restate an
89+
* ambiguity contract as knobs even by accident. `reject-candidates` rows are
90+
* rejected at the type level — that contract is enforced by caller
8791
* classification, not by these knobs.
8892
*/
8993
export function selectorResolutionKnobs(

0 commit comments

Comments
 (0)