Files
Michał Pierzchała eb3fc5b28d chore: scan packages/** with fallow instead of ignoring it (#1591)
`ignorePatterns: ["packages/**"]` landed in #1494 W0 with the recorded
reason "its resolver cannot follow workspace specifiers". That was either
wrong at the time or never re-checked: the fallow version has not moved
(^2.95.0 then and now) and it resolves @agent-device/* through each
package's exports map today. packages/kernel alone exposes 8 subpaths and
~110 exports reachable only via workspace specifiers, and scanning it
reports zero findings — a resolver that could not follow the specifier
would report all of them.

The cost of the ignore is that every package extraction silently removes
its code from dead-code analysis. #1589 moved the selector engine into
packages/selectors/ and shipped a façade with 15 zero-consumer exports,
including `selectorUsesKey`, written in that PR and never called. A
follow-up commit removed them by hand; nothing would have caught them.

Removing the pattern surfaced 43 findings, driven to zero by deleting the
dead code rather than by baselining or excluding it (fallow-baselines/*.json
are empty on purpose — the posture is fix-or-document-the-exemption, so a
first baseline entry would be a policy change):

- 38 are deleted. 24 façade type re-exports whose only claim was that a
  consumer might one day want to name them — typecheck is green without
  every one, so the claim was theoretical; 5 façade value re-exports; 9
  `export` keywords on symbols used only inside their own file. Every
  deleted façade symbol comes off scripts/layering/facade-symbols.ts (and
  ad-replay's inline pin in package-boundaries.test.ts) in the same change,
  so R11 is narrowed with the façade, never weakened around it.
- 4 stale suppressions in src/provider-limrun-runtime.ts existed only
  because packages/ was invisible.
- 5 have consumers analysis genuinely cannot see, and get an
  `ignoreExports` entry naming the consumer per the existing `comment`
  convention: four test-tree importers that --production does not walk, and
  `LimrunIosCommandExecution`, which src/sdk/limrun.ts republishes as
  agent-device/limrun — its only importer compiles in a temp checkout, so
  no static edge reaches it. test/integration/limrun-public-types.test.ts
  is the standing proof that one is real API.

Three doc comments named types their façade no longer exports and are
corrected rather than left asserting something false — including #1555's
claim in session-replay-target-verification.ts that the daemon imports
`AdReplayVerifiedTargetGuard` directly. It does not; it reaches that shape
through `AdReplayTargetClassification`/`AdReplayDispatchGuard`, which is
why the name read as dead.

`scripts/maestro-conformance/**` was ignored wholesale to cover its corpus
data. Narrowed to `corpus/**`, which un-hides the tooling beside it and
turned up one more file-local export (`buildManifest`); regenerate.mjs's
importer of `fixtureContentHash` becomes visible, so that needs no
exemption at all.

scripts/check-affected/model.ts deliberately did not select the `fallow`
check for packages/*/src/**, carrying the same stale rationale as a
comment. Without that selection the new scope would never run in the
affected-driven lane, so the ignore removal would have bought nothing.
model.test.ts now pins the selection.

Verified: check:fallow and check:production-exports green with packages in
scope; full-repo `fallow dead-code` back to its one pre-existing finding;
typecheck, layering (R11), lint, format, build, check:package, and the
limrun published-types integration test all pass. Probed by adding a fresh
zero-consumer export to the xml façade — check:production-exports reports
it, so the #1589 case now fails the gate.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 18:26:03 +02:00
..

Maestro conformance oracle

A three-layer oracle that proves the private agent-device Maestro package (packages/maestro) stays faithful to a version-pinned upstream Maestro. It replaces the original hand-typed parser fixture, whose transcribed expectations let four bug classes slip through during #1217. Every expected value here is generated from the pinned upstream artifacts — hand transcription is the failure mode this replaces (issue #1274).

Pinned upstream: dev.mobile:*:2.5.1 (v2.5.1 / a4c7c95f), see pinned-upstream.json.

Layers

Layer What it proves Generated by Runs in
1 — parser Our parser accepts/rejects/normalizes each flow like upstream maestro-orchestra's YamlCommandReader over the corpus node --test, per-PR
2 — semantics Our geometry/retry/timing constants match upstream ASM-read bytecode constants + parser-observed model defaults node --test, per-PR
3 — differential Outcome parity on a device, plus engine-side timing invariants Real Maestro vs agent-device test dispatch-only (see below)

Layers 1–2 are checked-in generated fixtures (fixtures/) verified deterministically with no Java. Layer 3 needs a device and the maestro CLI.

How "generated from upstream" is enforced

Per-PR CI cannot re-derive the fixtures — that needs Java, and staying Java-free on the normal path is a design constraint. So enforcement is two-layered:

  1. Per-PR: every fixture carries a contentHash seal over its generated content, recomputed on each verify run. Editing a captured command or constant by hand breaks the seal. This is tamper-evident — it makes casual or accidental hand-editing impossible, but a determined editor could recompute it.
  2. Scheduled (conformance-regenerate): actually re-runs the JVM harness against the pinned jars and fails if the checked-in fixtures differ by a byte. Forgery cannot survive a real re-derivation.

Without (2), "generated from upstream" would be documentation. Do not weaken either half: together they are what stops this oracle from decaying back into the hand-typed fixture it replaced.

What layer 3 actually proves

Read scenarios.ts literally. Cross-engine comparison is outcome parity — it only catches a divergence severe enough to fail the flow. It cannot see settle latching, retap counts, or a 1px truncation difference. Where finer behavior matters we assert it engine-side via engineInvariants over agent-device's own replay-timing.ndjson.

Layer-3 flows live in differential/flows/ and drive the real fixture app (examples/test-app, com.callstack.agentdevicelab), which the workflow builds and installs. They are deliberately not the layer-1 corpus: those flows exist only to be parsed — they name a fictional com.example.app and elements that exist on no device — so a device run against them would fail before exercising any runtime behavior, making the settle detector silently vacuous. A test enforces the separation, and the workflow hard-fails if the app is not installed.

Declared divergences (knownDivergence)

Layer 3 has the same contract as layer 1: every divergence is a decision on the record. When the differential catches a real engine bug, the instrument does not block on repairing what it just measured — the scenario declares it with a knownDivergence: { reason, tracking }, the scheduled run stays green on that known gap, and only undeclared divergences fail.

Two rules keep that from rotting, both enforced by run.test.ts / the runner rather than by good intentions:

  • tracking is required and must be a real issue URL. A declared divergence with nothing behind it is how "temporarily expected" silently becomes permanent.
  • A stale declaration fails. If a declared-divergent scenario starts passing, the run goes red until the declaration is removed — so the fix PR must delete it, and the oracle then enforces that the gap stays closed. The differential is the acceptance test for its own findings.

A green run still prints what it is not proving.

This matters most for bug class 4 (settle ordering), whose 200ms × 10 loop has no reflectable upstream constant, so layer 3 is its only home. Its detector is the invariant "a tap must not consume the entire settle budget" — a full-budget tap (~2093ms against 2000ms) means the stability loop never latched, yet the flow still passes, so outcome parity would miss it. The evaluator is pure and unit-tested against synthetic traces (invariants.test.ts); only the device run that produces a real trace is scheduled-only. A scenario that declares an invariant fails if the trace is missing — a detector that cannot run is a failure, not a pass.

Files

  • jvm-harness/ — Gradle/Kotlin generator for layers 1–2. Depends on the published Maestro jars; reads the parser and constants directly (never transcribed). Requires JDK 17+.
  • corpus/ — flows driven through the upstream parser. manifest.json (generated by build-manifest.mjs, do not hand-edit) records provenance: upstream flows are vendored verbatim with their sha256; authored flows fill coverage gaps (authored/), encode the bug classes (bug-classes/), and give the never-accept-what-upstream-rejects guard teeth (invalid/). To add a flow: drop the .yaml in, add a note to NOTES in build-manifest.mjs, and regenerate.
  • fixtures/ — the generated, checked-in layer-1/layer-2 captures.
  • packages/maestro/src/internal/conformance-normalize.ts — package-private canonical projection.
  • packages/maestro/test/conformance/ — the deterministic verifier, declared divergences, package-private harness, shared fixture seal, and layer-3 differential scenarios. Keeping this code under package tests prevents parser/canonicalization tooling from widening the production facade; regeneration imports the same package-owned seal implementation.
  • regenerate.mjs — SHA-verifies the jars, rebuilds the corpus manifest and fixtures, and seals them.

Verify (per-PR, no Java)

pnpm maestro:conformance

The verifier fails on any undeclared divergence: a flow our engine parses that upstream rejects (a conformance regression), an undeclared parse mismatch, or an undeclared we-reject. Every we-reject must list the unsupported command/option in packages/maestro/test/conformance/expected-divergence.ts — that list is the mechanical parity record. A focused issue is attached only when implementation work is planned.

Regenerate (only on an upstream-pin bump)

Heavy, manual, needs JDK 17+ (Gradle is provided by the committed wrapper):

pnpm maestro:conformance:regenerate

This resolves the pinned jars, verifies their SHA-256 against pinned-upstream.json, runs the harness over the corpus, and rewrites fixtures/. Review the diff, then run the verifier. To bump the pin: update pinned-upstream.json (version/tag/commit + the jar SHA-256s from Maven Central), refresh the vendored corpus flows and their manifest.json sha256s, regenerate, and reconcile any new divergences.

Layer 3 (device)

pnpm maestro:conformance:differential -- --platform ios --out-dir .tmp/diff

Runs scheduled on the conformance-differential workflow, and on demand via workflow_dispatch. --dry-run validates the scenario registry without a device.

The workflow builds and installs the fixture app, verifies its bundle id, pins the Maestro CLI to the same version as layers 1-2, and passes --maestro so the flow routes through the compat engine.

The built .app is cached (keyed on the app's sources, its dependency graph, the iOS runtime, and the Xcode version), because building it costs ~22 minutes versus ~6 minutes for the differential itself — 79% of the job, for an app that changes almost never. A cache hit installs the bundle directly; a miss falls back to the full build and repopulates. If you change anything under examples/test-app, expect the next run to rebuild.

Investigate locally, not through CI. A device iteration in CI is ~40 minutes; --only plus a local simulator is minutes:

pnpm test-app:install && pnpm --dir examples/test-app exec expo run:ios --configuration Release
pnpm maestro:conformance:differential -- --platform ios --only settle-after-tap --trace-root .agent-device