Skip to content

Commit 74eab2a

Browse files
authored
refactor: route selector-resolution structural stages into typed policy (#1744)
* refactor: route selector structural stages into typed policy #1649 landed the per-caller ambiguity matrix and deliberately left four structural columns out: occlusion, off-screen, hittable-ancestor promotion, and the poll budget were per-caller pipeline code, so declaring them would have been an unverifiable claim (nothing consumed them; flipping one left the suite green). This adds the missing half as a table with runners. `SELECTOR_PIPELINE_POLICIES` (src/core/selector-pipeline-policy.ts) gives each caller ONE row naming its ambiguity contract plus its four stages, and every stage is reached only through a runner that reads the row: - occlusion -> selectorPipelineCandidates (candidacy) and resolveSelectorPipelineTarget (refusal). Acting rows exclude covered nodes and refuse covered targets; `find` and the diagnosis probe keep them as candidates and refuse at the target; reads and `wait` ignore them. - promotion -> resolveSelectorPipelineTarget. The per-call-site `promoteToHittableAncestor: boolean` is gone: click/press/longpress name `promotedTarget`, fill/focus/scroll/drag endpoints and the native-ref preflight name `resolvedTarget`. `find`'s below-the-root variant is a declared value rather than a second local helper. - off-screen -> throwIfOffscreenInteractionTarget, which now takes the row and returns the node untouched (no iOS rescue round trip) for observation rows. - poll -> selectorPollBudget, which createWaitPolling derives its deadline and inter-poll delay from; the two wait loops carry a budget, every other row carries none and cannot be polled. Behavior is byte-identical. The acting refusal keeps its exact node, label and details in every branch (promotion declines to retarget away from a covered node, so the "both covered" case names the same node it always did), and `find` carries the occlusion verdict to the focus/type seam rather than raising it early, because find click/fill still delegate that refusal to the interaction leaf's own error shape. selector-pipeline-policy.test.ts drives EVERY row through EVERY runner, including the rows whose answer is "skip" — the half that used to be an absence of code, and an absence cannot fail. Each stage was proven red by flipping its cell (occlusion, promotion, off-screen, poll, plus the declare-only-what-is-enforced guard). The ADR 0011 occlusion/nonHittable `via` pointers for the runtime tree paths now name the runner that makes the decision, not the predicate it applies. Closes #1656; prework for #1739 (waves 4-5). * docs: state constraints instead of narrating the refactor Comment pass over #1656: drop the "used to be per-caller code" / "not module constants" / "rather than an omission" narration — a comment should say what a future edit must respect, not what the previous shape was — and compress the find occlusion-verdict and poll-budget notes to the constraint they actually carry. * refactor: make the selector pipeline the only door to the engine Review of #1744: the structural rows were declared but bypassable. Read and wait routes composed `selectorPipelineCandidates(row, nodes)` with the raw `resolveSelectorChainWithPolicy(..., row.resolution)` and never entered the promotion or off-screen stages, so flipping a read row's `promotion` or `offscreen` changed only the policy unit tests — production `get`/`is`/`wait` were unaffected, which is the unverifiable-column failure #1656 exists to remove. Callers could also pair one row's candidate set with another row's ambiguity contract, and `find list` reached the engine directly. The owning interface (src/core/selector-pipeline.ts) now runs every stage a row declares, skips included, and the stage functions are private to it: - `resolveSelectorPipeline` — single-target rows: candidacy, ambiguity, the replay-guard hook, promotion, occlusion, off-screen. - `listSelectorPipelineMatches` — `reject-candidates` rows, returning the candidate set AND the tree the row sees, so ranking and equivalence classification judge the same nodes candidacy produced. - `runNodePipelineStages` — the node stages for a target from a non-chain matcher (`@ref`, find's fuzzy locator) or a narrowed candidate set. A row whose off-screen stage refuses must supply a refusal shape, so flipping an observation row to `refuse` fails on its real route instead of silently observing. `find list` now names a `readList` row (the new `reject-candidates`/no-rect ambiguity row) instead of calling the engine. R17 selector-pipeline-ownership (scripts/layering/) makes the bypass structurally inexpressible: only the owner may import the engine entry points. Proven against a planted import in selector-read.ts, which the repo-wide scan rejects with the entry points that replace it. Flips now fail through REAL command routes, verified one at a time: readUnique.occlusion/offscreen/promotion and wait.occlusion via get attrs / is / wait; readAny.offscreen via is exists and find; readList.occlusion via find list; promotedTarget.promotion via runtime click. The wait route test needed an advancing clock first — with the frozen one a refused wait spun instead of failing, so the flip hung rather than asserting. * refactor: drop find's dead candidate binding The selector branch bound the row's candidate set and never read it: only the acting classification needs that tree, and find's locator branch brings its own matcher. Names what actually governs the locator target — the shared node stages below, not a candidate set it never had. * refactor: reserve the selector engine behind the pipeline owner Review of #1744 (three blockers). **Listing rows no longer claim stages they cannot run.** `find <q> list` resolves to a candidate SET, so promotion, the off-screen guard and a poll budget have nothing to apply to — a listing has no single element to retarget, keep on screen, or wait for. `readList` now declares only the two stages a listing executes (`SelectorListPolicy`: resolution + occlusion), and the narrower shape is load-bearing: `runNodePipelineStages` and `selectorPollBudget` take the full row, so handing them a listing row is a compile error rather than a silently skipped stage. Pinned with `@ts-expect-error` — widening `readList` makes the directives unused and fails the typecheck. **The engine door is a specifier, not a symbol.** R17's regex could not see a namespace import, a re-export, or a deferred `import()`, none of which mention the symbol it matched. The two engine entries moved to `@agent-device/selectors/engine`, and R19 enforces over the resolved import graph, where every one of those forms is the same edge. Proven on the repo-wide scan by planting each form into a shipped route: namespace import, dynamic import, and `export *` laundering all come back red. `resolveImportEdges` drops an edge whose specifier resolves to nothing, so a specifier rule goes quiet — not red — if the subpath is ever retired. The gate now says that out loud instead of scanning clean. **R19, not R17.** #1750 allocates R17/R18. Verified free against origin/main and that PR's diff, then validated by real merges in both directions: the uniqueness gate passes either way and the three ids stay distinct. The gate itself is new (`scripts/layering/rule-ids.ts`): two branches taking one free number do not conflict in git, so nothing caught R17 twice. Matching whole string literals is what separates a declaration from prose that names a rule, and it is what let the gate see #1750's `const RULE = '…'` shape — the first version missed it and would have been vacuous. `main`'s two pre-existing collisions (R11, R13) are listed as known, not pinned by equality, so #1750 lands in either order without breaking this. Also: the root façade now exposes no resolver at all, and its surface test pins both doors. * fix(layering): make each rule-id allowance expire with its collision Review of #1744: `KNOWN_RULE_ID_COLLISIONS` filtered the exact R11/R13 collision strings, so once #1750 renames those rules apart the entries would keep waving those very collisions through if anyone reintroduced them. "Inert" was wrong — a stale allowance fails open, permanently. `ruleIdCollisionFailures` now checks the transition from both sides: a collision nobody allowed fails, AND an allowance whose collision is absent from the scan fails as a stale allowance. The entry therefore has to be deleted in the same change that removes the collision, and the list burns down to empty, which admits nothing. #1750 is still open, so the transitional entries stay for now (option (b)). Verified against a scratch tree carrying that PR's rename: leaving the list untouched reports both entries as stale; deleting them is clean; and reintroducing `R11 names contracts-implementation-authority and package-boundaries` afterwards is rejected. The last of those is also a unit regression, so the post-transition guarantee is pinned rather than argued.
1 parent 057ab1c commit 74eab2a

30 files changed

Lines changed: 1741 additions & 339 deletions

packages/contracts/src/interaction-guarantees.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -150,9 +150,12 @@ const SHARED_RESPONSE_CONSTRUCTION: GuaranteeEnforcement = {
150150
// guard/observation implementations; only how the target is found
151151
// (disambiguation) and how failures are described (errorTaxonomy) differ.
152152
const RUNTIME_TREE_SHARED_GUARANTEES = {
153+
// #1656: the decision point is the pipeline runner, which reads the acting
154+
// row's occlusion stage; isSnapshotNodeInteractionBlocked stays the
155+
// predicate it applies (and the annotation contract it reads).
153156
occlusion: {
154157
kind: 'runtime',
155-
via: 'src/snapshot/snapshot-occlusion.ts#isSnapshotNodeInteractionBlocked',
158+
via: 'src/core/selector-pipeline.ts#runNodePipelineStages',
156159
},
157160
parentOwnedTouchPoint: {
158161
kind: 'runtime',
@@ -171,9 +174,11 @@ const RUNTIME_TREE_SHARED_GUARANTEES = {
171174
kind: 'runtime',
172175
via: 'src/commands/interaction/runtime/resolution.ts#throwIfOffscreenInteractionTarget',
173176
},
177+
// Promotion runs only for rows that declare it (#1656); the retarget itself
178+
// is still resolveActionableTouchResolution.
174179
nonHittable: {
175180
kind: 'runtime',
176-
via: 'src/core/interaction-targeting.ts#resolveActionableTouchResolution',
181+
via: 'src/core/selector-pipeline.ts#runNodePipelineStages',
177182
},
178183
responseConstruction: SHARED_RESPONSE_CONSTRUCTION,
179184
responseIdentity: {
@@ -249,7 +254,7 @@ export const INTERACTION_DISPATCH_PATHS: Record<InteractionPathId, InteractionPa
249254
},
250255
occlusion: {
251256
kind: 'runtime',
252-
via: 'src/snapshot/snapshot-occlusion.ts#isSnapshotNodeInteractionBlocked',
257+
via: 'src/core/selector-pipeline.ts#runNodePipelineStages',
253258
},
254259
parentOwnedTouchPoint: {
255260
kind: 'runtime',
@@ -370,7 +375,7 @@ export const INTERACTION_DISPATCH_PATHS: Record<InteractionPathId, InteractionPa
370375
},
371376
occlusion: {
372377
kind: 'runtime',
373-
via: 'src/snapshot/snapshot-occlusion.ts#isSnapshotNodeInteractionBlocked',
378+
via: 'src/core/selector-pipeline.ts#runNodePipelineStages',
374379
},
375380
parentOwnedTouchPoint: {
376381
kind: 'inapplicable',

packages/selectors/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"private": true,
55
"sideEffects": false,
66
"type": "module",
7-
"description": "Shared selector matching, argument, and replay semantics for agent-device. `.` is string-only; `./ast` is the published parser surface behind `agent-device/selectors`.",
7+
"description": "Shared selector matching, argument, and replay semantics for agent-device. `.` is string-only; `./ast` is the published parser surface behind `agent-device/selectors`; `./engine` is the resolve/list surface reserved for the selector-pipeline owner (R19).",
88
"dependencies": {
99
"@agent-device/ad-script": "workspace:*",
1010
"@agent-device/contracts": "workspace:*",
@@ -18,6 +18,10 @@
1818
"./ast": {
1919
"types": "./src/ast.ts",
2020
"default": "./src/ast.ts"
21+
},
22+
"./engine": {
23+
"types": "./src/engine.ts",
24+
"default": "./src/engine.ts"
2125
}
2226
},
2327
"devDependencies": {

packages/selectors/src/engine.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* The matching engine's own door (#1656).
3+
*
4+
* "Resolve a selector against a screen" and "list what it matched" are the two
5+
* decisions a pipeline policy row exists to qualify: which nodes may be
6+
* candidates, whether several matches refuse or collapse, and which structural
7+
* stages run around the answer. A caller that reaches these directly gets the
8+
* ambiguity contract and silently skips every structural stage — the failure
9+
* #1649 caught in the first policy matrix and #1656's review caught in the
10+
* second.
11+
*
12+
* So they live behind a subpath of their own rather than on the root façade,
13+
* and R19 selector-pipeline-ownership admits exactly one importer:
14+
* `src/core/selector-pipeline.ts`. A specifier is what the import graph
15+
* resolves, so namespace imports, dynamic imports, and re-exports are all the
16+
* same edge and all equally refused — which a name-shaped check could not say.
17+
*
18+
* Everything else selectors publishes (parsing, matching, formatting, replay)
19+
* stays on the root façade: none of it decides what a row decides.
20+
*/
21+
export {
22+
listSelectorChainMatches,
23+
resolveSelectorChainWithPolicy,
24+
} from './internal/facade-engine.ts';

packages/selectors/src/index.test.ts

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import assert from 'node:assert/strict';
2+
import { resolveSelectorChainWithPolicy } from './engine.ts';
23
import { test } from 'vitest';
34
import type { SnapshotNode } from '@agent-device/kernel/snapshot';
45
import * as selectorsFacade from './index.ts';
6+
import * as selectorsEngine from './engine.ts';
57
import {
68
buildSelectorCandidates,
79
readReplaySelectorDisplayValue,
810
readSelectorExpression,
911
resolveRecordedTarget,
1012
resolveReplaySuggestionCandidate,
11-
resolveSelectorChainWithPolicy,
1213
SELECTOR_RESOLUTION_POLICIES,
1314
} from './index.ts';
1415

@@ -201,16 +202,36 @@ test('replay suggestion resolution and display values stay string-only at the fa
201202
* preserved semantics — so no fixture-tree assertion can catch a revert
202203
* (#1715 review). The absence of the symbol is the only observable.
203204
*/
204-
test('the façade exposes no resolver that bypasses the policy matrix', () => {
205+
test('the façade exposes no resolver at all, and the engine door exposes only the two entries', () => {
205206
const exported = Object.keys(selectorsFacade);
206-
assert.ok(exported.includes('resolveSelectorChainWithPolicy'));
207-
assert.ok(!exported.includes('resolveSelectorChain'), 'knob-taking resolver must stay private');
207+
// #1656 moved both engine entries behind `./engine`, which R19 reserves for
208+
// the selector-pipeline owner: a route that could reach a resolver from the
209+
// root façade would get an ambiguity contract while skipping every
210+
// structural stage its policy row declares.
208211
assert.ok(
209-
!exported.includes('findSelectorChainMatch'),
210-
'count-only existence lookup must stay private; `is exists` names the readAny row',
212+
!exported.includes('resolveSelectorChainWithPolicy'),
213+
'resolution belongs to the engine subpath, behind the pipeline owner',
211214
);
212215
assert.ok(
213-
!exported.includes('selectorResolutionKnobs'),
214-
'knob derivation must stay private so a call site cannot rebuild a contract from knobs',
216+
!exported.includes('listSelectorChainMatches'),
217+
'enumeration belongs to the engine subpath, behind the pipeline owner',
215218
);
219+
assert.deepEqual(Object.keys(selectorsEngine).sort(), [
220+
'listSelectorChainMatches',
221+
'resolveSelectorChainWithPolicy',
222+
]);
223+
224+
// Unchanged since #1630, on both surfaces: neither door hands out a knob
225+
// resolver a call site could rebuild its contract from.
226+
for (const [surface, names] of [
227+
['facade', exported],
228+
['engine', Object.keys(selectorsEngine)],
229+
] as const) {
230+
assert.ok(!names.includes('resolveSelectorChain'), `${surface}: knob-taking resolver`);
231+
assert.ok(
232+
!names.includes('findSelectorChainMatch'),
233+
`${surface}: count-only existence lookup; \`is exists\` names the readAny row`,
234+
);
235+
assert.ok(!names.includes('selectorResolutionKnobs'), `${surface}: knob derivation`);
236+
}
216237
});

packages/selectors/src/index.ts

Lines changed: 2 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,4 @@
1-
import type { SnapshotState } from '@agent-device/kernel/snapshot';
21
import type { Selector } from './internal/parse.ts';
3-
import type {
4-
PolicyResolutionOutcome,
5-
SelectorChainMatchList,
6-
SelectorMatchOptions,
7-
} from './internal/public-resolution-types.ts';
8-
import { resolveSelectorChainWithPolicy as resolveSelectorChainWithPolicyAst } from './internal/resolve-with-policy.ts';
92
import {
103
checkElementTargetArgs,
114
checkGetFormat,
@@ -30,11 +23,7 @@ import {
3023
IS_PREDICATE_USAGE_HINT,
3124
normalizeIsPositionals,
3225
} from './internal/predicates.ts';
33-
import {
34-
listSelectorChainMatches as listSelectorChainMatchesAst,
35-
selectorFailureHint,
36-
STALE_REF_HINT,
37-
} from './internal/resolve.ts';
26+
import { selectorFailureHint, STALE_REF_HINT } from './internal/resolve.ts';
3827
import {
3928
findBestMatchesByLocator,
4029
checkFindArgs,
@@ -59,6 +48,7 @@ export type { IsPredicate } from './internal/predicates.ts';
5948
export type {
6049
PolicyResolutionOutcome,
6150
SelectorChainMatchList,
51+
SelectorMatchOptions,
6252
SelectorResolution,
6353
} from './internal/public-resolution-types.ts';
6454
export { formatSelectorFailure } from './internal/resolve.ts';
@@ -80,7 +70,6 @@ export {
8070
isRoleHintWord,
8171
isSelectorToken,
8272
isValidSelectorExpression,
83-
listSelectorChainMatches,
8473
normalizeIsPositionals,
8574
normalizeSelectorText,
8675
parseFindArgs,
@@ -225,52 +214,5 @@ function validateSelectorExpression(expression: string): void {
225214
parseSelectorChain(expression);
226215
}
227216

228-
/** Public façade wrapper that accepts/returns selector text, never an AST. */
229-
function listSelectorChainMatches(
230-
nodes: SnapshotState['nodes'],
231-
expression: string,
232-
options: SelectorMatchOptions,
233-
): SelectorChainMatchList | null {
234-
const result = listSelectorChainMatchesAst(nodes, parseSelectorChain(expression), options);
235-
return result ? { ...result, selector: result.selector.raw } : null;
236-
}
237-
238217
export { SELECTOR_RESOLUTION_POLICIES } from './internal/resolution-policy.ts';
239218
export type { SelectorResolutionPolicy } from './internal/resolution-policy.ts';
240-
import type { SelectorResolutionPolicy } from './internal/resolution-policy.ts';
241-
242-
/**
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.
248-
*
249-
* The return leg is the half that is easy to miss: the parser-side outcome
250-
* carries the winning `Selector` node inside `resolution`, and returning it
251-
* unchanged would put a package-private parser object back in every caller's
252-
* hands through a nested field. The façade's own boundary gate reads exported
253-
* *names*, so it cannot see that; `selector-wait.ts` reading
254-
* `outcome.resolution.selector.raw` was the runtime proof it had happened.
255-
* Flattening here is the same treatment `listSelectorChainMatches` above gives
256-
* its own selector node (#1589).
257-
*/
258-
function resolveSelectorChainWithPolicy(
259-
nodes: SnapshotState['nodes'],
260-
expression: string,
261-
policy: SelectorResolutionPolicy,
262-
options: SelectorMatchOptions,
263-
): PolicyResolutionOutcome {
264-
const outcome = resolveSelectorChainWithPolicyAst(
265-
nodes,
266-
parseSelectorChain(expression),
267-
policy,
268-
options,
269-
);
270-
if (outcome.kind !== 'resolved') return outcome;
271-
return {
272-
...outcome,
273-
resolution: { ...outcome.resolution, selector: outcome.resolution.selector.raw },
274-
};
275-
}
276-
export { resolveSelectorChainWithPolicy };
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import type { SnapshotState } from '@agent-device/kernel/snapshot';
2+
import { parseSelectorChain } from './parse.ts';
3+
import { listSelectorChainMatches as listSelectorChainMatchesAst } from './resolve.ts';
4+
import { resolveSelectorChainWithPolicy as resolveSelectorChainWithPolicyAst } from './resolve-with-policy.ts';
5+
import type { SelectorResolutionPolicy } from './resolution-policy.ts';
6+
import type {
7+
PolicyResolutionOutcome,
8+
SelectorChainMatchList,
9+
SelectorMatchOptions,
10+
} from './public-resolution-types.ts';
11+
12+
/**
13+
* The two engine entries, behind `@agent-device/selectors/engine` (#1656).
14+
* Both are string-in/string-out façade wrappers (#1589): a nested parser node
15+
* would reopen the AST boundary invisibly, since the package-boundary gate
16+
* reads exported *names* and cannot see into a returned shape.
17+
*/
18+
19+
/** Public façade wrapper that accepts/returns selector text, never an AST. */
20+
export function listSelectorChainMatches(
21+
nodes: SnapshotState['nodes'],
22+
expression: string,
23+
options: SelectorMatchOptions,
24+
): SelectorChainMatchList | null {
25+
const result = listSelectorChainMatchesAst(nodes, parseSelectorChain(expression), options);
26+
return result ? { ...result, selector: result.selector.raw } : null;
27+
}
28+
29+
/**
30+
* The façade's ONLY selector-resolution entry (#1630): every native consumer
31+
* of "resolve a selector against the screen" states its contract by naming a
32+
* `SELECTOR_RESOLUTION_POLICIES` row, because there is no knob-taking resolver
33+
* here to state it inline with instead. Accepts selector text and returns
34+
* selector text — never an AST, in either direction.
35+
*
36+
* The return leg is the half that is easy to miss: the parser-side outcome
37+
* carries the winning `Selector` node inside `resolution`, and returning it
38+
* unchanged would put a package-private parser object back in every caller's
39+
* hands through a nested field. The façade's own boundary gate reads exported
40+
* *names*, so it cannot see that; `selector-wait.ts` reading
41+
* `outcome.resolution.selector.raw` was the runtime proof it had happened.
42+
* Flattening here is the same treatment `listSelectorChainMatches` above gives
43+
* its own selector node (#1589).
44+
*/
45+
export function resolveSelectorChainWithPolicy(
46+
nodes: SnapshotState['nodes'],
47+
expression: string,
48+
policy: SelectorResolutionPolicy,
49+
options: SelectorMatchOptions,
50+
): PolicyResolutionOutcome {
51+
const outcome = resolveSelectorChainWithPolicyAst(
52+
nodes,
53+
parseSelectorChain(expression),
54+
policy,
55+
options,
56+
);
57+
if (outcome.kind !== 'resolved') return outcome;
58+
return {
59+
...outcome,
60+
resolution: { ...outcome.resolution, selector: outcome.resolution.selector.raw },
61+
};
62+
}

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

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,12 @@ import type { SelectorResolutionOptions } from './public-resolution-types.ts';
2828
* documented semantics fails a test.
2929
*
3030
* The surrounding pipeline stages — occlusion, the off-screen guard,
31-
* hittable-ancestor promotion, and the wait poll budget — still live in the
32-
* callers and are NOT declared here. An earlier revision listed them as
33-
* columns; nothing consumed them, so they were unverifiable claims that read
34-
* as truth while being free to drift (#1649 review). Routing them into typed
35-
* behavior is tracked in #1656.
31+
* hittable-ancestor promotion, and the wait poll budget — are declared in the
32+
* companion structural table, `src/core/selector-pipeline-policy.ts` (#1656),
33+
* whose rows each name one row of this matrix. They live there rather than
34+
* here because this package is deliberately blind to snapshot occlusion
35+
* annotations, backend visibility probes, and the wait clock: a column here
36+
* would be a claim nothing in this package could enforce (#1649 review).
3637
*/
3738

3839
export type KnobBackedSelectorAmbiguity = 'disambiguate' | 'fail-closed' | 'first-match';
@@ -80,6 +81,14 @@ export const SELECTOR_RESOLUTION_POLICIES = {
8081
ambiguity: 'reject-candidates',
8182
requireRect: true,
8283
},
84+
/**
85+
* `find <q> list` — the inspection surface: every match is the ANSWER, so the
86+
* candidate set stays whole and nothing needs a tap point.
87+
*/
88+
readList: {
89+
ambiguity: 'reject-candidates',
90+
requireRect: false,
91+
},
8392
} as const satisfies Record<string, SelectorResolutionPolicy>;
8493

8594
/**

scripts/layering/check.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ import {
102102
sourceExecutedUsingDeclarationViolations,
103103
} from './logs-runtime-cutover-policy.ts';
104104
import { contractsImplementationAuthorityViolations } from './contracts-implementation-policy.ts';
105+
import { selectorPipelineOwnershipViolations } from './selector-pipeline-ownership.ts';
105106
import {
106107
networkLegacyRouteViolations,
107108
networkRuntimeNarrowingViolations,
@@ -670,6 +671,7 @@ export const LAYERING_RULE_IDS = [
670671
'value-import-cycles',
671672
'logs-runtime-cutover',
672673
'contracts-implementation-authority',
674+
'selector-pipeline-ownership',
673675
'network-runtime-cutover',
674676
'record-runtime-cutover',
675677
'back-edges',
@@ -691,6 +693,8 @@ export const LAYERING_RULES: Readonly<Record<LayeringRuleId, LayeringRule>> = {
691693
'logs-runtime-cutover': (context) => checkLogsRuntimeCutover(context.sources),
692694
'contracts-implementation-authority': (context) =>
693695
checkContractsImplementationAuthority(context.sources),
696+
'selector-pipeline-ownership': (context) =>
697+
selectorPipelineOwnershipViolations(context.edges, workspaceSpecifierTargets(repoRoot)),
694698
'network-runtime-cutover': (context) => checkNetworkRuntimeCutover(context.sources),
695699
'record-runtime-cutover': (context) => checkRecordRuntimeCutover(context.sources),
696700
'back-edges': (context) => checkBackEdges(context.edges),

scripts/layering/package-boundaries.test.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -387,13 +387,17 @@ test('the real tree parses, declares, and passes R11', () => {
387387
);
388388
const selectorsPackage = packages.find((pkg) => pkg.name === '@agent-device/selectors');
389389
assert.ok(selectorsPackage, 'selectors package must exist');
390-
// Two subpaths, and the split is the point: `.` is the string-only façade
390+
// Three subpaths, and each split is the point: `.` is the string-only façade
391391
// every in-repo consumer uses, `./ast` is the published parser surface that
392392
// `agent-device/selectors` has shipped since before the engine moved into
393-
// this package. A third subpath, or the AST leaking into `.`, fails here.
393+
// this package, and `./engine` is the resolve/list surface reserved for the
394+
// selector-pipeline owner (R19, #1656) — a route reaching it skips the
395+
// structural stages its policy row declares. A fourth subpath, or the AST
396+
// leaking into `.`, fails here.
394397
assert.deepEqual([...selectorsPackage.exportTargets.keys()].sort(), [
395398
'@agent-device/selectors',
396399
'@agent-device/selectors/ast',
400+
'@agent-device/selectors/engine',
397401
]);
398402
assert.deepEqual([...selectorsPackage.workspaceDependencies].sort(), [
399403
'@agent-device/ad-script',

0 commit comments

Comments
 (0)