Files
callstack__agent-device/scripts/layering/selector-pipeline-ownership.test.ts
Michał Pierzchała 74eab2a554 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.
2026-08-12 07:57:08 +02:00

89 lines
3.7 KiB
TypeScript

import assert from 'node:assert/strict';
import { test } from 'node:test';
import { parseImports, type ResolvedImportEdge } from './model.ts';
import {
SELECTOR_ENGINE_OWNER,
SELECTOR_ENGINE_SPECIFIER,
selectorPipelineOwnershipViolations,
} from './selector-pipeline-ownership.ts';
/**
* The rule reads resolved edges, so these drive real source through the same
* `parseImports` the gate uses. That is the point of the shape: a bypass is
* only refused in every import FORM if the check never looks at symbol names.
*/
function violations(source: string, file = 'src/daemon/handlers/planted.ts'): string[] {
const edges: ResolvedImportEdge[] = parseImports(source).map((edge) => ({
...edge,
file,
target: edge.spec,
fromZone: 'daemon-server',
toZone: 'selectors',
}));
return selectorPipelineOwnershipViolations(edges).map((violation) => violation.message);
}
test('every import form that reaches the engine is refused', () => {
for (const source of [
// Named — the shape a symbol-matching check would also have caught.
`import { resolveSelectorChainWithPolicy } from '${SELECTOR_ENGINE_SPECIFIER}';`,
// Namespace: no symbol name appears anywhere in the statement.
`import * as engine from '${SELECTOR_ENGINE_SPECIFIER}';`,
// Deferred: the call site is a promise, and the specifier is the only trace.
`const engine = await import('${SELECTOR_ENGINE_SPECIFIER}');`,
// Re-export: launders the engine through a module the routes may import.
`export { listSelectorChainMatches } from '${SELECTOR_ENGINE_SPECIFIER}';`,
`export * from '${SELECTOR_ENGINE_SPECIFIER}';`,
// Side-effect import, for completeness of the edge kinds the model emits.
`import '${SELECTOR_ENGINE_SPECIFIER}';`,
// Type position still names the module the row exists to qualify.
`import type { SelectorChainMatchList } from '${SELECTOR_ENGINE_SPECIFIER}';`,
]) {
assert.equal(violations(source).length, 1, source);
}
});
test('the owner holds the engine, and the root façade stays open to everyone', () => {
assert.deepEqual(
violations(
`import { resolveSelectorChainWithPolicy } from '${SELECTOR_ENGINE_SPECIFIER}';`,
SELECTOR_ENGINE_OWNER,
),
[],
);
assert.deepEqual(
violations(
[
"import { buildSelectorChainForNode, formatSelectorFailure } from '@agent-device/selectors';",
"import type { SelectorChainMatchList } from '@agent-device/selectors';",
"import { resolveSelectorPipeline } from '../../core/selector-pipeline.ts';",
].join('\n'),
),
[],
);
// A neighbouring subpath is a different door; only the engine is reserved.
assert.deepEqual(
violations("import { parseSelectorChain } from '@agent-device/selectors/ast';"),
[],
);
});
test('a missing engine door fails the gate instead of silencing it', () => {
// The specifier is the whole enforcement, and `resolveImportEdges` drops an
// edge that resolves to nothing: without this, retiring the subpath would
// turn the rule green forever.
const [message] = selectorPipelineOwnershipViolations([], new Map()).map((v) => v.message);
assert.ok(message?.includes('not a workspace export'), message);
assert.deepEqual(
selectorPipelineOwnershipViolations([], new Map([[SELECTOR_ENGINE_SPECIFIER, 'x.ts']])),
[],
);
});
test('the refusal names the owner and the entries that replace the bypass', () => {
const [message] = violations(`import * as engine from '${SELECTOR_ENGINE_SPECIFIER}';`);
assert.ok(message?.includes(SELECTOR_ENGINE_OWNER), message);
assert.ok(message?.includes('resolveSelectorPipeline'), message);
assert.ok(message?.includes('SELECTOR_PIPELINE_POLICIES'), message);
});