Skip to content

Commit ab7f050

Browse files
committed
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.
1 parent d0de2f7 commit ab7f050

16 files changed

Lines changed: 453 additions & 214 deletions

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@
130130
"check:affected:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/check-affected/model.test.ts scripts/check-affected/platform-packages.test.ts scripts/check-affected/run.test.ts",
131131
"check:coverage-changed": "node --experimental-strip-types scripts/coverage-changed/run.ts",
132132
"check:coverage-changed:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/coverage-changed/model.test.ts scripts/coverage-changed/run.test.ts",
133-
"check:layering": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/layering/model.test.ts scripts/layering/zone-policy.test.ts scripts/layering/daemon-modularity.test.ts scripts/layering/package-boundaries.test.ts scripts/layering/platform-package-policy.test.ts scripts/layering/platform-package-repository.test.ts scripts/layering/platform-package-source-policy.test.ts scripts/layering/device-inventory-cutover-policy.test.ts scripts/layering/logs-runtime-cutover-policy.test.ts scripts/layering/network-runtime-cutover-policy.test.ts scripts/layering/record-runtime-cutover-policy.test.ts scripts/layering/contracts-implementation-policy.test.ts scripts/layering/selector-pipeline-ownership.test.ts scripts/layering/facade-exports.test.ts scripts/layering/bin-alias-fast-path.test.ts && node --experimental-strip-types scripts/layering/check.ts",
133+
"check:layering": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/layering/model.test.ts scripts/layering/zone-policy.test.ts scripts/layering/daemon-modularity.test.ts scripts/layering/package-boundaries.test.ts scripts/layering/platform-package-policy.test.ts scripts/layering/platform-package-repository.test.ts scripts/layering/platform-package-source-policy.test.ts scripts/layering/device-inventory-cutover-policy.test.ts scripts/layering/logs-runtime-cutover-policy.test.ts scripts/layering/network-runtime-cutover-policy.test.ts scripts/layering/record-runtime-cutover-policy.test.ts scripts/layering/contracts-implementation-policy.test.ts scripts/layering/selector-pipeline-ownership.test.ts scripts/layering/rule-ids.test.ts scripts/layering/facade-exports.test.ts scripts/layering/bin-alias-fast-path.test.ts && node --experimental-strip-types scripts/layering/check.ts",
134134
"depgraph": "node --experimental-strip-types scripts/depgraph/build.ts",
135135
"depgraph:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/depgraph/model.test.ts scripts/depgraph/affected.test.ts",
136136
"check:production-exports": "fallow dead-code --config fallow-production-exports.json --production --unused-exports --fail-on-issues",

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: 1 addition & 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,
@@ -81,7 +70,6 @@ export {
8170
isRoleHintWord,
8271
isSelectorToken,
8372
isValidSelectorExpression,
84-
listSelectorChainMatches,
8573
normalizeIsPositionals,
8674
normalizeSelectorText,
8775
parseFindArgs,
@@ -226,52 +214,5 @@ function validateSelectorExpression(expression: string): void {
226214
parseSelectorChain(expression);
227215
}
228216

229-
/** Public façade wrapper that accepts/returns selector text, never an AST. */
230-
function listSelectorChainMatches(
231-
nodes: SnapshotState['nodes'],
232-
expression: string,
233-
options: SelectorMatchOptions,
234-
): SelectorChainMatchList | null {
235-
const result = listSelectorChainMatchesAst(nodes, parseSelectorChain(expression), options);
236-
return result ? { ...result, selector: result.selector.raw } : null;
237-
}
238-
239217
export { SELECTOR_RESOLUTION_POLICIES } from './internal/resolution-policy.ts';
240218
export type { SelectorResolutionPolicy } from './internal/resolution-policy.ts';
241-
import type { SelectorResolutionPolicy } from './internal/resolution-policy.ts';
242-
243-
/**
244-
* The façade's ONLY selector-resolution entry (#1630): every native consumer
245-
* of "resolve a selector against the screen" states its contract by naming a
246-
* `SELECTOR_RESOLUTION_POLICIES` row, because there is no knob-taking resolver
247-
* here to state it inline with instead. Accepts selector text and returns
248-
* selector text — never an AST, in either direction.
249-
*
250-
* The return leg is the half that is easy to miss: the parser-side outcome
251-
* carries the winning `Selector` node inside `resolution`, and returning it
252-
* unchanged would put a package-private parser object back in every caller's
253-
* hands through a nested field. The façade's own boundary gate reads exported
254-
* *names*, so it cannot see that; `selector-wait.ts` reading
255-
* `outcome.resolution.selector.raw` was the runtime proof it had happened.
256-
* Flattening here is the same treatment `listSelectorChainMatches` above gives
257-
* its own selector node (#1589).
258-
*/
259-
function resolveSelectorChainWithPolicy(
260-
nodes: SnapshotState['nodes'],
261-
expression: string,
262-
policy: SelectorResolutionPolicy,
263-
options: SelectorMatchOptions,
264-
): PolicyResolutionOutcome {
265-
const outcome = resolveSelectorChainWithPolicyAst(
266-
nodes,
267-
parseSelectorChain(expression),
268-
policy,
269-
options,
270-
);
271-
if (outcome.kind !== 'resolved') return outcome;
272-
return {
273-
...outcome,
274-
resolution: { ...outcome.resolution, selector: outcome.resolution.selector.raw },
275-
};
276-
}
277-
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+
}

scripts/layering/check.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -210,12 +210,6 @@ function checkContractsImplementationAuthority(
210210
);
211211
}
212212

213-
function checkSelectorPipelineOwnership(sources: ReadonlyMap<string, string>): LayeringViolation[] {
214-
return selectorPipelineOwnershipViolations(
215-
[...sources].map(([path, source]) => ({ path, source })),
216-
);
217-
}
218-
219213
function checkNetworkRuntimeCutover(sources: ReadonlyMap<string, string>): LayeringViolation[] {
220214
const production = [...sources].map(([file, source]) => ({ path: file, source }));
221215
return [
@@ -666,7 +660,7 @@ export function main(): number {
666660
...checkCycles(edges),
667661
...checkLogsRuntimeCutover(sources),
668662
...checkContractsImplementationAuthority(sources),
669-
...checkSelectorPipelineOwnership(sources),
663+
...selectorPipelineOwnershipViolations(edges, workspaceSpecifierTargets(repoRoot)),
670664
...checkNetworkRuntimeCutover(sources),
671665
...checkRecordRuntimeCutover(sources),
672666
...checkContractsImplementationAuthority(sources),

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',

scripts/layering/rule-ids.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import assert from 'node:assert/strict';
2+
import path from 'node:path';
3+
import { fileURLToPath } from 'node:url';
4+
import { test } from 'node:test';
5+
import {
6+
collectRuleDeclarations,
7+
duplicateRuleIds,
8+
KNOWN_RULE_ID_COLLISIONS,
9+
readLayeringSources,
10+
} from './rule-ids.ts';
11+
12+
const layeringDirectory = path.dirname(fileURLToPath(import.meta.url));
13+
14+
test('no rule id names two rules beyond the collisions #1750 is renaming away', () => {
15+
const declarations = collectRuleDeclarations(readLayeringSources(layeringDirectory));
16+
assert.ok(declarations.length > 10, 'expected the layering rules to be discovered');
17+
const unexpected = duplicateRuleIds(declarations).filter(
18+
(collision) => !KNOWN_RULE_ID_COLLISIONS.includes(collision),
19+
);
20+
assert.deepEqual(unexpected, [], 'two rules answer to one id; allocate the next free number');
21+
});
22+
23+
test('a collision is reported with both names, whichever order it lands in', () => {
24+
// The failure this gate exists for: two branches both take a free number.
25+
// Synthetic ids: a fixture naming real rules would read as an allocation to
26+
// anyone grepping for the next free number, which is how the collision this
27+
// gate exists for happened.
28+
const collision = [
29+
{ path: 'a.ts', source: "rule: 'R99 first-claimant'" },
30+
{ path: 'b.ts', source: "const RULE = 'R99 second-claimant';" },
31+
];
32+
assert.deepEqual(duplicateRuleIds(collectRuleDeclarations(collision)), [
33+
'R99 names first-claimant and second-claimant',
34+
]);
35+
assert.deepEqual(
36+
duplicateRuleIds(collectRuleDeclarations([...collision].reverse())),
37+
['R99 names first-claimant and second-claimant'],
38+
'order of arrival must not change the verdict',
39+
);
40+
});
41+
42+
test('one rule declaring its id from several call sites is not a collision', () => {
43+
// R7 and R12 legitimately emit from many places.
44+
assert.deepEqual(
45+
duplicateRuleIds(
46+
collectRuleDeclarations([
47+
{ path: 'a.ts', source: "rule: 'R98 many-emitters'" },
48+
{ path: 'a.ts', source: "rule: 'R98 many-emitters'" },
49+
]),
50+
),
51+
[],
52+
);
53+
});

0 commit comments

Comments
 (0)