mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
74eab2a554
* 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.
89 lines
3.7 KiB
TypeScript
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);
|
|
});
|