feat(ios): decide the presented surface from a captured tree

Merged
TH
7h ago
+963-9

Summary

Adds the rule that decides whether an iOS capture read a modal presentation over the app's own content
(#3565), the golden table pinning its answers, and the publication that carries them. Layer 2 of 3. 8
files, +951/−2.

The rule and the publication are one layer on purpose: check:production-exports refuses an exported
rule with no production consumer, and it is right to. A layer that computes a fact nobody can read is
not a shippable unit, so the first consumer is here rather than one layer up.

The TypeScript rule lives in ios-snapshot-engine/engine.ts, not its own module. A new module the
engine imports is a new eager edge that ADR 0027's no-growth rule refuses — thirteen carried entries
would have grown by one module and 70 eager statements, and the rule is pre-existing code, so no split
explains it. The engine is also the module that publishes this fact and already derives the
completeness statement the rule reads, so the placement is not a workaround for a count.

absent is never the default, because #1831 arms a dismissal confirmation on it. It requires that the
producer stated the capture was not capped, that every node carries the UIKit class name the rule
reads, that each node is numbered once so the parent links the walk follows name the node they mean,
and that the one content window under the app root holds exactly one UITransitionView. Positive
evidence — a UIDimmingView, an _UIAlertController* root class, or a Sheet/Drawer/Alert/
Dialog element type under its name or its raw Element(<n>) spelling — answers presented from one
node, so a capped capture still reports what it read. Producers that publish no class names (every
XCTest runner tier) answer presented or unknown, never absent. Completeness arrives as
'complete' | 'capped' | 'unstated' from the producer's truncation statement, never inferred from the
tree. The rule reads a dimming view without its enablement, unlike the covering-presentation cut in
geometry-policy.ts that shares the container class — no parallel classifier, the same vocabulary
extended at that owner.

Evidence is matched on the node the walk stood on, not on the number it carries. Excluding a
keyboard host's subtree therefore removes the occurrences that walk reached, and an app window's own
dimming view that happens to share a number with one of them keeps its evidence. Numbering is only
required for the strong answer: absent claims something about every window the producer read, and
parent links cannot tell two nodes of the same number apart, so a producer that numbered a node twice
cannot support that claim. The most a collision costs is the answer the rule declines to make.

Two choices worth a reviewer's eye: system windows are a denylist (an unlisted host carrying a
dimming view of its own would read presented; none appears in the captures taken here) so an app
hosting its scene in a UIWindow subclass stays readable; and Element(<n>) must be bare digits,
because Int("+7") == 7 would otherwise let each language's parser decide the answer.

The Swift twin, its parity test, and the runner's own reading of its acquisition are in layer 3, where
the runner acquires trees. Both languages read the one fixture, which is here, so the layer-3 parity
test cannot drift from it.

Evidence coverage (iPhone 17 simulator, RN test app)

Captured live at the stack top 857adacc0 (this layer's capture-kit, contracts and fixtures are identical there; the rebased head b4a119f96 differs only by a layer-1 field no backend wrote). Rows marked live were read off the device;
the rest are golden cases, said so plainly. Every row here is produced by this layer's TypeScript engine,
which is the producer the CLI talks to on a simulator — the runner half lands in layer 3.

PresentationAnswerBasis
Home, Catalog, automation lab — one content window, one containerabsentlive, snapshot and snapshot -i
The same lab after the sheet is dismissedabsentlive
RN pageSheet sheet — window holds 2 containers, dimming views at 4 and 134presentedlive
That sheet through the projected cut (6 nodes) and the interactive cut (4 nodes), neither of which publishes a dimming viewpresentedlive — the fact is read off the acquisition, not the projection
Home with the Expo dev-menu floating button on screen (a second content window)unknownlive — a window this rule cannot name
Any screen through the private-AX backend (--actions, 0 class names published)unknownlive — the class-blind gap
Sheet resting at an undimmed detentpresentedgolden case, not device-captured
RN Alert.alert (own window, no dimming view)presentedlive, and a golden case read from a captured tree
Alert whose producer names only Element(7)presentedgolden case, both readings agree
Full-screen / custom modal, no dimming viewunknowngolden case — nothing positive, no guessing
SwiftUI .sheetpresented expectedsame UIKit container as a page sheet; not device-captured
SpringBoard system alertout of scoperesolveBlockingSystemModal stamps .presented (layer 1)
Capped, unstated, malformed numberingunknown, or presented on evidence it did readgolden cases

Two things the live run is worth stating out loud. The dev-menu floating button puts a real
EXDevMenu.DevMenuFABWindow beside the app window, and the honest answer for that screen is unknown —
the four absent rows above were taken with it disabled
(defaults write com.callstack.agentdevicelab EXDevMenuShowFloatingActionButton -bool NO), which is the
single-content-window shape the rule reads absence from. And the interactive cut of the open sheet
publishes four nodes, none of them a dimming view, while the fact still answers presented: that is the
failure mode this fact replaces, since a consumer reading the published tree would have had to answer
absent on a screen with a sheet over it.

Validation

b4a119f96, rebased onto main: pnpm typecheck clean, with the serialized pnpm check:affected --run result posted on the PR. Before the rebase, at 777d9b73a: check:production-exports passes (the
head before this re-split failed it, reporting collectPresentedSurface as an export with no production
consumer), swift build is clean, pnpm build and pnpm typecheck are clean, and vitest over
packages/capture-kit, packages/kernel and packages/platform-apple is 2,963 green, eager-closure
budgets included. A repository-wide local gate is deliberately not claimed for this head: four other
worktrees on this host are running subprocess-heavy suites at the same time, and the failures that would
produce earlier in this stack — an ADB connect failure, a daemon startup timeout, one abort-timing case —
were contention from exactly that, each passing in isolation while failing under load. What the previous
head genuinely failed was check:production-exports, which is the check above.

The 18-case contracts/fixtures/ios-presented-surface.json gives each case a class-aware and a
class-blind answer, and both parity tests read that one file, so drift in either language or vocabulary
fails. Claims outside the vocabulary are pinned by cases and confirmed by mutation: the element-type
numbering is held at its owner (tree.test.ts decodes each raw value through the bridge decoder and
compares to the table); completeness is pinned on a class-aware tree, the only shape that reaches the
absence branch; a cycle case pins the visited guard (without it, classification spins forever); a
partially class-blind tree — the shape the bridge emits when it reads no class — pins the class-name
requirement (delete it and that case answers absent); and three malformed-numbering cases pin the
boundary just described — an app-side dimming view sharing a number with a node the keyboard-host walk
reached keeps its evidence and answers presented (it had been asserting unknown, which was asserting
the bug), one hanging under the shared number answers unknown because that one genuinely is
ambiguous, and neither can earn absent. Publication is pinned by engine.test.ts, whose depth-1 raw
case now carries structural depths and asserts the cut really dropped both container and dimmer while
the fact still answers presented, and by ios-snapshot-runtime-presented-surface.test.ts, which fails
when the runtime seam drops the field.