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.
86 lines
3.4 KiB
TypeScript
86 lines
3.4 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { test } from 'node:test';
|
|
import {
|
|
collectRuleDeclarations,
|
|
duplicateRuleIds,
|
|
KNOWN_RULE_ID_COLLISIONS,
|
|
readLayeringSources,
|
|
ruleIdCollisionFailures,
|
|
} from './rule-ids.ts';
|
|
|
|
const layeringDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
|
|
/** The exact collision #1750 is renaming away — the string the allowance names. */
|
|
const TRANSITIONAL_COLLISION =
|
|
'R11 names contracts-implementation-authority and package-boundaries';
|
|
|
|
function failuresFor(sources: string[], allowed: readonly string[] = []): string[] {
|
|
const declarations = collectRuleDeclarations(
|
|
sources.map((source, index) => ({ path: `${index}.ts`, source })),
|
|
);
|
|
return ruleIdCollisionFailures({ declarations, allowed });
|
|
}
|
|
|
|
test('the layering rules currently in tree carry no unallowed collision', () => {
|
|
const declarations = collectRuleDeclarations(readLayeringSources(layeringDirectory));
|
|
assert.ok(declarations.length > 10, 'expected the layering rules to be discovered');
|
|
assert.deepEqual(
|
|
ruleIdCollisionFailures({ declarations, allowed: KNOWN_RULE_ID_COLLISIONS }),
|
|
[],
|
|
);
|
|
});
|
|
|
|
test('a collision nobody allowed fails, whichever order it lands in', () => {
|
|
// Synthetic ids: a fixture naming real rules would read as an allocation to
|
|
// anyone grepping for the next free number, which is how the collision this
|
|
// gate exists for happened.
|
|
const collision = ["rule: 'R99 first-claimant'", "const RULE = 'R99 second-claimant';"];
|
|
const expected = [
|
|
'two rules answer to one id: R99 names first-claimant and second-claimant. Allocate the next free number.',
|
|
];
|
|
assert.deepEqual(failuresFor(collision), expected);
|
|
assert.deepEqual(failuresFor([...collision].reverse()), expected, 'order must not matter');
|
|
});
|
|
|
|
test('an allowance expires with the collision it allows', () => {
|
|
// The failure mode this replaces: after #1750 renames R11 apart, an allowance
|
|
// still naming that collision would wave it back through if it returned.
|
|
const cleanTree = [
|
|
"rule: 'R11 package-boundaries'",
|
|
"rule: 'R18 contracts-implementation-authority'",
|
|
];
|
|
assert.deepEqual(failuresFor(cleanTree, [TRANSITIONAL_COLLISION]), [
|
|
`stale allowance: "${TRANSITIONAL_COLLISION}" no longer occurs, so the entry admits a collision ` +
|
|
'nobody is fixing. Delete it from KNOWN_RULE_ID_COLLISIONS.',
|
|
]);
|
|
});
|
|
|
|
test('reintroducing the transitional collision is rejected once the allowance is gone', () => {
|
|
// The post-#1750 world: the list is empty, and the exact string it used to
|
|
// carry buys nothing.
|
|
const reintroduced = [
|
|
"rule: 'R11 contracts-implementation-authority'",
|
|
"rule: 'R11 package-boundaries'",
|
|
];
|
|
assert.deepEqual(failuresFor(reintroduced, []), [
|
|
`two rules answer to one id: ${TRANSITIONAL_COLLISION}. Allocate the next free number.`,
|
|
]);
|
|
// And while the transition is live, the same collision is allowed exactly
|
|
// because it is present — the allowance is never a blanket exemption.
|
|
assert.deepEqual(failuresFor(reintroduced, [TRANSITIONAL_COLLISION]), []);
|
|
});
|
|
|
|
test('one rule declaring its id from several call sites is not a collision', () => {
|
|
assert.deepEqual(
|
|
duplicateRuleIds(
|
|
collectRuleDeclarations([
|
|
{ path: 'a.ts', source: "rule: 'R98 many-emitters'" },
|
|
{ path: 'a.ts', source: "rule: 'R98 many-emitters'" },
|
|
]),
|
|
),
|
|
[],
|
|
);
|
|
});
|