Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions packages/selectors/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import type { SnapshotState } from '@agent-device/kernel/snapshot';
import type { Selector } from './internal/parse.ts';
import type {
PolicyResolutionOutcome,
SelectorChainMatch,
SelectorChainMatchList,
SelectorMatchOptions,
SelectorResolution,
SelectorResolutionOptions,
} from './internal/public-resolution-types.ts';
import { resolveSelectorChainWithPolicy as resolveSelectorChainWithPolicyAst } from './internal/resolve-with-policy.ts';
import {
checkElementTargetArgs,
checkGetFormat,
Expand Down Expand Up @@ -60,6 +62,7 @@ import {
export type { FindAction, FindLocator } from './internal/find.ts';
export type { IsPredicate } from './internal/predicates.ts';
export type {
PolicyResolutionOutcome,
SelectorChainMatchList,
SelectorChainMatch,
SelectorResolution,
Expand Down Expand Up @@ -259,3 +262,45 @@ function resolveSelectorChain(
const result = resolveSelectorChainAst(nodes, parseSelectorChain(expression), options);
return result ? { ...result, selector: result.selector.raw } : null;
}
export {
SELECTOR_RESOLUTION_POLICIES,
selectorResolutionKnobs,
} from './internal/resolution-policy.ts';
export type {
KnobBackedSelectorAmbiguity,
SelectorResolutionPolicy,
} from './internal/resolution-policy.ts';
import type { SelectorResolutionPolicy } from './internal/resolution-policy.ts';

/**
* Public façade wrapper that accepts selector text and returns selector text —
* never an AST, in either direction.
*
* The return leg is the half that is easy to miss: the parser-side outcome
* carries the winning `Selector` node inside `resolution`, and returning it
* unchanged would put a package-private parser object back in every caller's
* hands through a nested field. The façade's own boundary gate reads exported
* *names*, so it cannot see that; `selector-wait.ts` reading
* `outcome.resolution.selector.raw` was the runtime proof it had happened.
* Flattening here is the same treatment `resolveSelectorChain` above gives
* `AstSelectorResolution` (#1589).
*/
function resolveSelectorChainWithPolicy(
nodes: SnapshotState['nodes'],
expression: string,
policy: SelectorResolutionPolicy,
options: SelectorMatchOptions,
): PolicyResolutionOutcome {
const outcome = resolveSelectorChainWithPolicyAst(
nodes,
parseSelectorChain(expression),
policy,
options,
);
if (outcome.kind !== 'resolved') return outcome;
return {
...outcome,
resolution: { ...outcome.resolution, selector: outcome.resolution.selector.raw },
};
}
export { resolveSelectorChainWithPolicy };
30 changes: 30 additions & 0 deletions packages/selectors/src/internal/public-resolution-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,36 @@ export type SelectorResolution = {
disambiguation?: SelectorDisambiguationDisclosure;
};

/**
* The façade twin of the parser-side `AstPolicyResolutionOutcome`: identical
* except that the winning alternative is its raw selector text rather than the
* `Selector` node, the same flattening `SelectorResolution` applies to
* `AstSelectorResolution`.
*
* It exists as a separate declaration for the same reason that pair does
* (#1589): the parser representation is package-private, and a nested return
* type is a leak the façade's named-export gate cannot see — it filters export
* *names*, so an `AstSelectorResolution` reached indirectly through
* `outcome.resolution` would reopen the boundary silently.
*/
export type PolicyResolutionOutcome =
/** No selector alternative matched anything. */
| { kind: 'none' }
/**
* The node this policy authorizes acting on, plus the full candidate set of
* the alternative it came from. Callers that verify identity across
* candidates (wait's #1349 landmark check) need the whole set — a policy
* that picks one winner must not throw the rest away, or a first impostor
* would hide a later genuine match.
*/
| { kind: 'resolved'; resolution: SelectorResolution; matchedNodes: SnapshotNode[] }
/**
* Several matches and the policy refuses to choose. `fail-closed` returns
* this instead of guessing; `reject-candidates` returns it so the caller can
* narrow explicitly or surface the candidate list.
*/
| { kind: 'ambiguous'; selector: string; selectorIndex: number; matchedNodes: SnapshotNode[] };

/** The first matching selector alternative and its complete matched-node domain. */
export type SelectorChainMatchList = {
selector: string;
Expand Down
101 changes: 101 additions & 0 deletions packages/selectors/src/internal/resolution-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import type { SelectorResolutionOptions } from './public-resolution-types.ts';

/**
* The per-caller selector-resolution policy matrix (#1630): every native
* consumer of "resolve a selector against the screen" declares its ambiguity
* contract here instead of passing `requireUnique`/`disambiguateAmbiguous`
* literals at the call site. The engine stays policy-neutral; which row a
* caller consumes IS the caller's documented contract, and changing a row is
* a reviewable one-line policy change instead of a multi-file literal hunt.
*
* Ambiguity kinds:
* - `disambiguate` — unique match required, but the engine's visible→deepest→
* smallest-area tiebreak may pick a winner from an ambiguous set (acting
* commands, `get text`).
* - `fail-closed` — unique match required, ties reject (by design: `is`
* predicates and `get attrs` must never guess).
* - `first-match` — any match count accepted, first wins (existence reads and
* the wait loop, where presence is the question).
* - `reject-candidates` — multiple matches reject with the candidate list
* unless the caller explicitly narrows (#1625's mutating-find contract).
* Declaration-only: enforced by find's own narrowing logic, not by engine
* knobs, so `selectorResolutionKnobs` rejects it at the type level.
*
* Scope, deliberately narrow: this matrix declares the **ambiguity contract
* and the rect requirement**, and nothing else. Both are consumed by
* `resolveSelectorChainWithPolicy` and pinned behaviorally in
* resolution-policy-parity.test.ts, so a row that stops matching its
* documented semantics fails a test.
*
* The surrounding pipeline stages — occlusion, the off-screen guard,
* hittable-ancestor promotion, and the wait poll budget — still live in the
* callers and are NOT declared here. An earlier revision listed them as
* columns; nothing consumed them, so they were unverifiable claims that read
* as truth while being free to drift (#1649 review). Routing them into typed
* behavior is tracked in #1656.
*/

export type KnobBackedSelectorAmbiguity = 'disambiguate' | 'fail-closed' | 'first-match';
export type SelectorAmbiguityPolicy = KnobBackedSelectorAmbiguity | 'reject-candidates';

export type SelectorResolutionPolicy = {
ambiguity: SelectorAmbiguityPolicy;
/** Only nodes carrying a rect participate (acting paths need a tap point). */
requireRect: boolean;
};

export const SELECTOR_RESOLUTION_POLICIES = {
/** click/press/fill/focus/longPress/drag/scroll targets (resolution.ts). */
act: {
ambiguity: 'disambiguate',
requireRect: true,
},
/** The post-miss diagnosis probe deciding "no match" vs "matched but covered". */
actCoveredDiagnosis: {
ambiguity: 'first-match',
requireRect: true,
},
/** `get text` — reads through the same tiebreak acting uses. */
readText: {
ambiguity: 'disambiguate',
requireRect: false,
},
/** `is` non-exists predicates and `get attrs` — ties reject, never guess. */
readUnique: {
ambiguity: 'fail-closed',
requireRect: false,
},
/** `exists` and find's read-only actions — presence is the question. */
readAny: {
ambiguity: 'first-match',
requireRect: false,
},
/** `wait` — first match per poll, under the wait budget. */
wait: {
ambiguity: 'first-match',
requireRect: false,
},
/** Mutating `find` (#1625): candidates reject unless explicitly narrowed. */
findAct: {
ambiguity: 'reject-candidates',
requireRect: true,
},
} as const satisfies Record<string, SelectorResolutionPolicy>;

/**
* The engine knobs a knob-backed policy row stands for. `reject-candidates`
* rows are rejected at the type level — that contract is enforced by the
* caller's narrowing logic, not by these knobs.
*/
export function selectorResolutionKnobs(
policy: SelectorResolutionPolicy & { ambiguity: KnobBackedSelectorAmbiguity },
): Pick<SelectorResolutionOptions, 'requireRect' | 'requireUnique' | 'disambiguateAmbiguous'> {
if (policy.ambiguity === 'first-match') {
return { requireRect: policy.requireRect, requireUnique: false };
}
return {
requireRect: policy.requireRect,
requireUnique: true,
disambiguateAmbiguous: policy.ambiguity === 'disambiguate',
};
}
123 changes: 123 additions & 0 deletions packages/selectors/src/internal/resolve-with-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import type { SnapshotState } from '@agent-device/kernel/snapshot';
import type { SelectorChain } from './parse.ts';
import type { SelectorMatchOptions } from './public-resolution-types.ts';
import {
listSelectorChainMatches,
resolveSelectorChain,
type AstSelectorResolution,
} from './resolve.ts';
import type { SelectorResolutionPolicy } from './resolution-policy.ts';

/**
* The one policy-driven resolution entry every native caller routes through
* (#1630). A caller passes the policy row that IS its documented contract;
* this decides what "resolved" means for that row, so ambiguity semantics
* live in the matrix rather than in each caller's local branching.
*
* The outcome is a discriminated union rather than a nullable node, because
* the rows genuinely disagree about what to do with several matches:
* `disambiguate` and `fail-closed` want one winner or nothing, `first-match`
* wants the head of the list, and `reject-candidates` needs the whole
* candidate set to refuse with (or to narrow, when the caller was given an
* explicit index). Collapsing those into "node | null" is what previously
* forced every caller to re-derive its own contract inline.
*/

export type AstPolicyResolutionOutcome =
/** No selector alternative matched anything. */
| { kind: 'none' }
/**
* The node this policy authorizes acting on, plus the full candidate set
* of the alternative it came from. Callers that verify identity across
* candidates (wait's #1349 landmark check) need the whole set — a policy
* that picks one winner must not throw the rest away, or a first impostor
* would hide a later genuine match.
*/
| {
kind: 'resolved';
resolution: AstSelectorResolution;
matchedNodes: SnapshotState['nodes'];
}
/**
* Several matches and the policy refuses to choose. `fail-closed` returns
* this instead of guessing; `reject-candidates` returns it so the caller
* can narrow explicitly or surface the candidate list.
*/
| {
kind: 'ambiguous';
selector: string;
selectorIndex: number;
matchedNodes: SnapshotState['nodes'];
};

export function resolveSelectorChainWithPolicy(
nodes: SnapshotState['nodes'],
chain: SelectorChain,
policy: SelectorResolutionPolicy,
options: SelectorMatchOptions,
): AstPolicyResolutionOutcome {
const matchOptions = { ...options, requireRect: policy.requireRect };

if (policy.ambiguity === 'reject-candidates') {
const list = listSelectorChainMatches(nodes, chain, matchOptions);
if (!list || list.matchedNodes.length === 0) return { kind: 'none' };
if (list.matchedNodes.length > 1) {
return {
kind: 'ambiguous',
selector: list.selector.raw,
selectorIndex: list.selectorIndex,
matchedNodes: list.matchedNodes,
};
}
return resolvedFromList(list);
}

if (policy.ambiguity === 'first-match') {
const list = listSelectorChainMatches(nodes, chain, matchOptions);
if (!list || list.matchedNodes.length === 0) return { kind: 'none' };
return resolvedFromList(list);
}

const resolution = resolveSelectorChain(nodes, chain, {
...matchOptions,
requireUnique: true,
disambiguateAmbiguous: policy.ambiguity === 'disambiguate',
});
if (resolution) {
const list = listSelectorChainMatches(nodes, chain, matchOptions);
return {
kind: 'resolved',
resolution,
matchedNodes: list?.matchedNodes ?? [resolution.node],
};
}

// Distinguish "nothing matched" from "matched but this policy will not
// choose" — a fail-closed caller must report ambiguity, not absence.
const list = listSelectorChainMatches(nodes, chain, matchOptions);
if (!list || list.matchedNodes.length === 0) return { kind: 'none' };
return {
kind: 'ambiguous',
selector: list.selector.raw,
selectorIndex: list.selectorIndex,
matchedNodes: list.matchedNodes,
};
}

function resolvedFromList(
list: NonNullable<ReturnType<typeof listSelectorChainMatches>>,
): AstPolicyResolutionOutcome {
const node = list.matchedNodes[0];
if (!node) return { kind: 'none' };
return {
kind: 'resolved',
matchedNodes: list.matchedNodes,
resolution: {
node,
selector: list.selector,
selectorIndex: list.selectorIndex,
matches: list.matchedNodes.length,
diagnostics: [{ selector: list.selector.raw, matches: list.matchedNodes.length }],
},
};
}
24 changes: 24 additions & 0 deletions scripts/layering/facade-exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,27 @@ export function readDirectNamedExports(source: string): string[] {
}
return [...names].sort();
}

/**
* Which module each name in `source` is re-exported FROM, for names that come
* from a re-export rather than a local declaration.
*
* A façade's export *names* are only half its boundary: a type re-exported
* from the right module and one re-exported from a package-private module read
* identically in the name list, while only the second leaks. #1649 shipped
* exactly that — a policy outcome re-exported from the parser-side module, so
* its nested `resolution` field handed callers the private AST — and the
* name-list gate stayed green throughout.
*/
export function readReExportSources(source: string): Map<string, string> {
const parsed = parseSync('facade-reexport-source-scan.ts', source);
const sources = new Map<string, string>();
for (const staticExport of parsed.module.staticExports) {
for (const entry of staticExport.entries) {
if (entry.exportName.kind !== 'Name' || !entry.exportName.name) continue;
if (!entry.moduleRequest) continue;
sources.set(entry.exportName.name, entry.moduleRequest.value);
}
}
return sources;
}
21 changes: 20 additions & 1 deletion scripts/layering/package-boundaries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { test } from 'node:test';
import { listSourceFiles } from './check.ts';
import { readDirectNamedExports, readNamedExports } from './facade-exports.ts';
import { readDirectNamedExports, readNamedExports, readReExportSources } from './facade-exports.ts';
import {
checkPackageBoundaries,
checkPackageInternalSites,
Expand Down Expand Up @@ -398,6 +398,25 @@ test('the real tree parses, declares, and passes R11', () => {
[],
'selectors façade keeps AST and grammar internals private',
);
// Named exports are not the whole boundary. A parser-side type reached
// through a NESTED field — `PolicyResolutionOutcome.resolution` typed as
// `AstSelectorResolution` — leaks the same objects while exporting none of
// their names, and the assertion above stays green on it (#1649). What
// separates the two is which module the type is re-exported FROM:
// `public-resolution-types.ts` holds the string-flattened shapes,
// `resolve-with-policy.ts` and `resolve.ts` hold the parser-side ones. A
// resolution type re-exported from either of the latter means a flattening
// step at the façade was skipped.
const selectorsReExports = readReExportSources(
fs.readFileSync(path.join(repoRoot, 'packages/selectors/src/index.ts'), 'utf8'),
);
assert.deepEqual(
['PolicyResolutionOutcome', 'SelectorResolution', 'SelectorChainMatchList'].filter(
(name) => selectorsReExports.get(name) !== './internal/public-resolution-types.ts',
),
[],
'selectors façade must publish resolution shapes from public-resolution-types.ts, not from the parser-side modules',
);
// The AST subpath's one in-repo consumer is the published SDK re-export.
// Anything else importing it means the string-only façade was bypassed.
assert.deepEqual(
Expand Down
Loading