* test: replace the hand-typed Maestro fixture with a generated conformance oracle
Closes#1274.
The old harness (scripts/maestro-conformance*) compared 5 hand-authored flows
against a hand-typed transcription of Maestro 2.5.1's command model. It proved
parser self-consistency, not conformance: all four bug classes that cost #1217
days of live debugging slipped past it by construction, and it verified no
upstream SHAs despite parsing them.
Every expected value here is generated from the pinned upstream artifacts.
dev.mobile:maestro-orchestra:2.5.1 is published on Maven Central, so the harness
runs the real parser and reads the real bytecode — no full Maestro source build.
Layer 1 (parser): a Gradle/Kotlin harness drives the pinned YamlCommandReader
over a corpus of 42 vendored maestro-test flows (sha256-recorded) plus authored
bug-class, coverage, and invalid flows, capturing each parse. The verifier parses
each flow with the live engine and classifies it identical / both-reject /
we-reject / mismatch / we-are-lenient. Every non-identical outcome must be a
declared divergence, so the 17 we-reject entries in expected-divergence.ts are
the mechanical parity backlog (assertTrue, clipboard, travel, killApp, and
option-level gaps) rather than silent drift.
Layer 2 (semantics): ASM reads static-final constants straight from the pinned
bytecode without initializing driver classes (MAX_RETRIES_ALLOWED=3,
SCREENSHOT_DIFF_THRESHOLD=0.005, ANIMATION_TIMEOUT_MS=15000, erase cap, and the
iOS pre-tap gate we intentionally omit), plus the parser-observed 400ms swipe
default. Each is cross-checked against MAESTRO_COMPATIBILITY_PRESETS.
Layer 3 (differential): scheduled device scenarios. Cross-engine comparison is
outcome parity only and says so; finer behavior is asserted engine-side via
invariants over replay-timing.ndjson. Bug class 4's detector — a tap must not
consume the whole settle budget, since a full-budget tap means the stability loop
never latched while the flow still passes — is pure and unit-tested against
synthetic traces; only the device run is scheduled-only.
regenerate.mjs verifies the pinned jar SHA-256s before trusting output and is
byte-deterministic across runs. Layers 1-2 verify in normal CI via node --test
with no Java (the job installs deps: unlike the layering guard it copies, the
verifier parses with the live engine, which imports the `yaml` package).
Acceptance: the four bug classes each have a fixture; every command in
SUPPORTED_MAESTRO_COMMAND_NAMES (the parser's own dispatch table, now exported as
the single source of truth) is corpus-covered or listed unverified; the five
documented deviations are expected-divergence entries.
* fix: address review findings on the conformance oracle
P1 — layer-3 scenarios could never run. They pointed at layer-1 corpus flows,
which exist only to be PARSED: they name a fictional com.example.app and elements
that exist on no device. A device run would have failed before exercising any
runtime behavior, making bug class 4's detector silently vacuous. Layer 3 now has
its own flows under differential/flows/ driving the real fixture app
(examples/test-app, com.callstack.agentdevicelab); the workflow builds and
installs it and hard-fails if it is missing. A test enforces the separation so a
scenario can never point back at the parse corpus.
Nothing else in this repo builds or installs the Expo fixture app, so those steps
are new and unproven. The workflow is therefore dispatch-only: the cron is removed
until a supervised first run proves the path. A nightly job that fails at 05:00
every day teaches nothing.
P2 — layer 3 installed whatever version the online installer served. It now pins
MAESTRO_VERSION from pinned-upstream.json, so layer 3 cannot drift from the
version layers 1-2 claim, and asserts `maestro --version` matches.
P2 — fixture content was not bound to regeneration. CI compared only the embedded
upstream metadata, so a hand edit to a captured command or constant passed: the
transcription failure mode this oracle exists to remove. Two-layer fix, because
per-PR CI must stay Java-free and cannot re-derive:
- Each fixture now carries a contentHash seal that the verifier recomputes, so
editing a capture breaks the build. Tamper-evident, and tested by actually
tampering rather than assuming a hash comparison works.
- New scheduled conformance-regenerate job re-runs the harness against the
pinned jars and fails on any byte difference. Forgery cannot survive a real
re-derivation. This is what makes "generated from upstream" enforced.
P3 — boot-ios-test-simulator requires runtime-version; now passed alongside
preferred-device-name, as the other iOS workflows do.
* tmp: trigger layer-3 differential on this branch to prove the device path
workflow_dispatch cannot run pre-merge (it registers from the default branch), so
this temporary push trigger exists only to execute the never-run device path on
the PR head and capture evidence. Removed before merge.
* fix(ci): install the fixture app unfrozen for the layer-3 device run
First live run of the device path failed at the very first step:
ERR_PNPM_LOCKFILE_CONFIG_MISMATCH. CI implies --frozen-lockfile and the fixture
app's lockfile is out of sync with its package.json overrides. No CI job has ever
built examples/test-app, so that drift was never surfaced.
* fix: drop --ignore-workspace from test-app:install (defeats #649 security overrides)
The first live run of the layer-3 device path failed at
ERR_PNPM_LOCKFILE_CONFIG_MISMATCH, and the cause is a real latent bug rather
than a stale lockfile.
#649 moved the fixture app's `overrides` into examples/test-app/pnpm-workspace.yaml
precisely because pnpm only honors overrides from a workspace root — they pin
transitive deps (ws, brace-expansion, xmldom, postcss, uuid, shell-quote) to
versions that clear Dependabot alerts. But `test-app:install` passes
--ignore-workspace, which ignores that very file, so the overrides are dropped
and no longer match the lockfile that has them baked in. It goes unnoticed
locally because interactive installs are not frozen, and no CI job has ever
installed this app.
Dropping --ignore-workspace makes examples/test-app resolve as its own workspace
root (it has its own pnpm-workspace.yaml and is not a member of the repo-root
workspace), so the overrides apply and a frozen install succeeds. Verified both
directions locally: with the flag + --frozen-lockfile reproduces the CI failure;
without it, a frozen install completes and the lockfile's overrides stay intact.
Note the workaround this replaces would have been actively harmful: installing
with --no-frozen-lockfile resolves the mismatch by regenerating the lockfile
WITHOUT the overrides, silently reverting the app to the vulnerable transitive
versions #649 pinned away.
* fix: make layer-3 scenarios prove what they claim, and parse the Maestro version
Run 3 (29497919702) got the whole device path working: Expo build (30m), app
installed, simctl check, pinned Maestro CLI install. Only the version ASSERTION
failed — `maestro --version` prints an analytics banner before the version, and
`tr -d '[:space:]'` mashed banner+version into one string. The CLI was correctly
2.5.1. Match the semver line instead, and set MAESTRO_CLI_NO_ANALYTICS (CI should
not phone home). Verified the parse against the exact CI output: banner and clean
forms both yield 2.5.1, wrong/empty still fail.
tap-retry-if-no-change was vacuous: it tapped a navigating control, so the first
tap always succeeded and retryIfNoChange never ran — it passed while proving
nothing. It now taps the app's non-interactive title so the screen cannot change
and the retry path is forced, and asserts tapRetries >= 1 from the trace
(MaestroRuntimeMetrics already records it per step). A new metricAtLeast invariant
kind carries the assertion; a test reproduces the old vacuity.
percent-swipe no longer claims bug class 1. Truncation vs rounding is a <=1px
delta that no app-observable device outcome can distinguish, so pass/pass could
never back that claim up. The runtime half is instead pinned exactly by a pure
unit test of resolveMaestroCoordinate (it short-circuits on a known viewport, so
no device is needed) — verified to catch the regression by flipping trunc->round,
which turns 3 of 6 tests red. Truncation had no test coverage at all before this.
A test now forbids any device scenario from re-claiming bug class 1.
* fix(ci): pass --maestro and match the fixture app's real UI in layer-3 flows
Run 4 (29500262301) reached the differential itself — build, install, simctl
check and the pinned Maestro 2.5.1 verification all passed — and surfaced two
real bugs, both mine:
1. The runner invoked `agent-device test <flow>` without --maestro, so every
scenario failed with "test does not support this file type". The repo's own
scripts/run-test-app-maestro-suite.mjs passes it; the flag is what routes a
.yaml through the Maestro compat engine.
2. settle-after-tap and percent-swipe assumed home-open-form is on screen at
launch. It is not: real Maestro reported "Element not found: home-open-form",
and the app's own helper flow scrolls it into view first. settle-after-tap now
scrolls before tapping, mirroring that helper; percent-swipe no longer
navigates at all and swipes the scrollable home screen, so it tests the
conversion and nothing else.
The remaining two flows already reported maestro=pass, so only the agent-device
invocation was wrong for those. Note the settle invariant correctly reported
"no-data: no completed tapOn steps" and FAILED rather than passing — a detector
that cannot run is a failure, as intended.
* feat: declare layer-3 divergences and schedule the differential
Layer 3 ran both engines for the first time (29504440599) and immediately found a
real engine bug. Blocking the measurement instrument on repairing what it just
measured inverts the dependency, so layer 3 now gets the contract layer 1 already
had: every divergence is a decision on the record.
Adds `knownDivergence: { reason, tracking }` to the scenario type — the layer-3
twin of FLOW_DIVERGENCES. A declared divergence keeps the run green; only
UNDECLARED ones fail. Two rules stop that from rotting, both enforced
mechanically rather than by prose discipline:
- `tracking` is required and must be a real issue URL (run.test.ts), because a
declaration with nothing behind it is how "temporarily expected" becomes
permanent without anyone deciding to.
- a stale declaration FAILS: if a declared-divergent scenario starts passing,
the run goes red until the declaration is removed. The fix PR must delete it,
and the differential then enforces the gap stays closed — the oracle is the
acceptance test for its own findings.
Declared:
- settle-after-tap -> #1299. Our scrollUntilVisible times out finding
home-open-form where Maestro 2.5.1 scrolls to it and passes. Real engine
correctness bug in an advertised command, found by this differential. Blocks
bug class 4's device detector until fixed.
- tap-retry-if-no-change -> #1300. The invariant caught the scenario being
vacuous: both engines pass but tapRetries was 0, so retryIfNoChange never
ran. Needs an inert fixture control; a scenario defect, not an engine one.
Proven green on both engines and enforced now: percent-swipe,
optional-warned-not-failed — the latter is real device-verified warned-vs-failed
parity.
With declarations in place the differential is green, so the schedule goes in
(cron 05:00) per #1274. A green run still prints what it is not proving.
* fix: park the flaky retry scenario instead of declaring it a divergence
Run 29510020718 fired the stale-declaration guard on its first outing and caught
my own mistake. tap-retry-if-no-change measured tapRetries=0 in run
29504440599 and tapRetries=1 in 29510020718 — same flow, same commit. So it is
not vacuous as #1300 originally claimed: it is NON-DETERMINISTIC. The tap
sometimes holds the hierarchy signature still and sometimes does not, because the
fixture home screen carries live content.
That exposes a real limit of the mechanism added in the previous commit:
knownDivergence assumes the divergence REPRODUCES. A declared-but-flaky scenario
flips between known-divergence (green) and stale-declaration (red) at random — a
coin-flip scheduled job, which is worse than no scenario because it teaches
people to ignore the differential.
So the scenario is parked, not declared. The flow and the tapRetries invariant
stay implemented and unit-tested, so the fix PR only re-adds the scenario once
the fixture has an inert control. retryIfNoChange therefore has NO device
coverage right now — tracked in #1300 and stated plainly rather than disguised by
a green run. A test keeps it out of the active set until then.
#1300 updated with the corrected diagnosis and both runs' evidence.
Active differential: settle-after-tap (declared divergence, #1299), percent-swipe
and optional-warned-not-failed (both enforced, pass/pass on real devices).
* fix: make a knownDivergence waiver cover exactly one failure, not any failure
P1 from re-review, and a real flaw: the code did not do what its own comment
claimed. runScenario() collapsed every unexpected outcome and every invariant
failure into `misbehaved`, then turned ANY of them green if the scenario carried
a declaration. So while the #1299 scrollUntilVisible waiver is open, upstream
Maestro could start failing too — or a different invariant could break — and the
scheduled job would still report known-divergence and pass. A waiver for one bug
was silently amnesty for the next. That is the exact failure this oracle exists
to prevent, committed one commit after building the guard against it.
knownDivergence now requires an `expected` signature: both engines' outcomes plus
each declared invariant's status. The runner matches it exactly —
- matches -> known-divergence (green, tracked)
- misbehaves differently -> failed (red): not the failure the waiver covers
- stops misbehaving -> stale-declaration (red): remove the declaration
#1299's signature pins what runs 29504440599/29510020718 actually observed:
maestro=pass, agent-device=fail, settle invariant no-data.
Tests prove unrelated failures stay red under an open waiver: upstream also
failing, our engine unexpectedly passing, a different invariant status, and a new
invariant appearing are each NOT covered. A signature where both engines pass is
rejected outright as describing no divergence.
Also retains replay-timing.ndjson as a run artifact (review evidence note): the
invariants are computed from that trace, so a report saying "tapRetries was 0"
cannot be audited once the runner is gone without it.
* perf(ci): cache the fixture app build for the layer-3 differential
The differential job took ~30 minutes, of which 1331s (22 min, 79%) was building
the Expo fixture app and only 347s was the differential itself — rebuilt from
scratch on every run for an app that changes almost never.
Cache the built .app, keyed on everything that can change the binary: the app's
sources, native config, dependency graph, the build step itself, the iOS runtime,
and the Xcode version. Mirrors the existing setup-apple-replay prebuilt-runner
cache (same action pin, same Xcode-key + source-hash shape).
On a hit the build is skipped entirely and the bundle is installed straight onto
the booted simulator (~seconds), taking the job to roughly 8 minutes. On a miss
it falls back to exactly the previous behaviour and repopulates, so the worst
case is unchanged. The existing simctl verification still gates both paths, so a
bad cache cannot produce a vacuous green: if the app is not installed, the job
fails loudly rather than running scenarios against nothing.
Note the first run after this lands is necessarily a miss.
* refactor(ci): extract setup-fixture-app so any job can use the cached app
The fixture-app build + cache was inline in the differential workflow, so nothing
else could reach it. Extracted to a composite action mirroring
setup-apple-replay, because the capability is what #320 has been missing: it
wants replay coverage moved off Apple system apps onto a controlled fixture with
stable ids, and that fixture (examples/test-app) already exists — CI just had no
way to build and install it.
The cache is genuinely shared. GitHub caches are per-repository and readable
across workflows, and a run restores from its own branch or the default branch,
so once a run on main populates it every workflow gets the hit and only the first
one pays the ~22 minutes. The key is computed inside the action from a fixed
input list and deliberately contains nothing caller-specific — folding a caller's
workflow path into it would silently unshare the cache.
Also removes a duplication risk: the action reads the bundle id from the built
app's Info.plist rather than hardcoding it, so it cannot drift from what was
actually built, and it fails loudly if the app is not installed. The conformance
workflow keeps its own narrower assertion — that the installed id is the one its
scenarios target — since that is its concern, not the action's.
Usage:
- uses: ./.github/actions/setup-fixture-app
with:
runtime-version: ${{ env.IOS_RUNTIME_VERSION }}
# outputs: app-path, app-id, cache-hit
* chore(ci): remove the temporary branch push trigger
Run 29519848340 on this head executed both engines against the real fixture app
and came back green, so the trigger that existed only to prove the never-run
device path has done its job.
Merged config is now cron (05:00) + workflow_dispatch, as required by #1274.
known-divergence settle-after-tap maestro=pass agent-device=fail (#1299)
ok percent-swipe maestro=pass agent-device=pass
ok optional-warned-not-failed maestro=pass agent-device=pass
This commit will not itself trigger a run: GitHub evaluates triggers at the
pushed commit, and the push trigger is gone in it.
* feat(android): consolidate touch injection and gesture viewport into the persistent snapshot helper (#1275)
One Android automation helper now owns snapshot capture, gesture viewport
resolution, and canonical one-/two-pointer plan injection. A live persistent
helper session executes gesture/viewport commands over its socket protocol;
without a session the same APK runs one-shot via am instrument. The separate
one-shot multitouch helper APK is deleted (atomic replacement, no fallback).
Touch scheduling/injection is extracted into focused Java classes
(TouchPlan, TouchPlanInjector, PointerEventSchedule, GestureViewportReader)
instead of growing SnapshotInstrumentation. ADR 0013 amended.
* fix(android): stop a structurally-failed helper session before the one-shot viewport retry
A structured ok=false viewport response leaves the session process alive, and
Android permits only one instrumentation owner of UiAutomation - running the
one-shot fallback against a still-live helper contends with it and masks the
original structured failure. Stop the session first; regression pins that the
one-shot retry only executes once the session is gone.
* refactor(android): extract helper touch dispatch into focused classes; split session tests; document helper API v2 (PR #1281 review)
Addresses findings 2 and 3 from PR #1281 review (finding 1, viewport
session-stop ordering, was already fixed in 5961b9247).
- Extract SnapshotInstrumentation.java's one-shot/session touch dispatch
into TouchCommandHandler.java (viewport/gesture population, UiAutomation-
parameterized) and SessionResponseWriter.java (session response encoding),
with shared PROTOCOL/HELPER_API_VERSION/OUTPUT_FORMAT constants moved to
a tiny HelperProtocol.java. SnapshotInstrumentation.java shrinks from 908
to 803 lines; wire format (header keys/values, error shapes) is unchanged.
- Split touch-helper.test.ts (~720 lines) into touch-helper.test.ts
(normalize/parse/one-shot gesture+viewport+result envelope) and
touch-helper-session.test.ts (persistent-session transport + fake-session
harness), moving shared device/plan/install-probe fixtures used by both
files into touch-helper.fixtures.ts.
- Update android/snapshot-helper/README.md to document helper API v2: the
one-shot viewport/gesture modes, the android-touch-plan-v1 payload shape,
and the persistent session's socket command/response contract.
* fix(android): invalidate helper session after APK replacement; recycle viewport windows; align ADR 0002 (PR #1281 re-review)
- prepareAndroidTouchHelper now mirrors the snapshot path: when
ensureAndroidSnapshotHelper replaces the APK (install.installed), the
persistent session started against the previous binary is stopped before
any touch command, so gestures run one-shot against the fresh install
instead of a dead/stale session socket. Regression: 'an APK replacement
stops the stale session and the gesture runs one-shot' drives a live fake
session through an outdated-install probe (new outdatedVersionAdb fixture)
and asserts the session socket receives no gesture, the one-shot
instrumentation path executes, and the session is gone.
- GestureViewportReader.read no longer leaks AccessibilityWindowInfo: a
single pass copies the active/focused and first-application bounds into
locals, every window is recycled in a finally, and the existing precedence
(active/focused app bounds, root-in-active-window, fallback app bounds,
IllegalStateException) is applied afterwards, unchanged.
- ADR 0002's touch-synthesis paragraph is amended (2026-07, issue #1275) to
the shared-helper model, consistent with ADR 0013: a live persistent
helper session executes touch commands directly, one-shot otherwise; the
old stop-before-gesture requirement is kept as historical context.
* fix(android): resolve touch helper artifact from the ADB provider like snapshots do (PR #1281 re-review)
- prepareAndroidTouchHelper now uses the same artifact precedence as
snapshot capture: the scoped adbProvider's snapshotHelperArtifact when
present, otherwise the bundled resolver (whose strict unavailable error
is preserved). The provider artifact drives both the install decision
and the instrumentationRunner used for one-shot commands, so an
ADB-backed provider that supplies a helper artifact but no native touch
override runs snapshots and gestures against the same single helper
(issue #1275). Regression: 'a provider-supplied snapshotHelperArtifact
overrides the bundled artifact for touch' pins the provider packageName
on the install probe, the provider apkPath on the install call, the
provider instrumentationRunner on the am instrument args, and that the
bundled resolver is never invoked.
- ADR 0002 now states explicitly that one-shot retry applies only to
idempotent reads (viewport) after the failed session is stopped;
non-idempotent gesture failures surface directly.
- Helper README session transport corrected: a persistent process serving
one short-lived socket connection per request (the server closes each
accepted connection), not a single long-lived connection.
* fix(android): guard touch session reuse on helper identity, stop mismatched sessions (PR #1281 re-review)
Persistent helper sessions are keyed by device, so touch reuse must also
prove the live session runs the helper binary the command selected. The
session record now stores its helper identity (packageName, runner,
helperVersion, helperVersionCode — the same values that feed the snapshot
session identity), and runAndroidSnapshotHelperSessionTouchCommand takes
the requesting helper identity: on mismatch (packageName/runner always;
version/versionCode when both sides define them) it stops the session and
returns undefined, so the touch command runs one-shot against the selected
artifact — gestures never start sessions; the next snapshot restarts one
with the right artifact. Matching identity reuses the session as before.
Snapshot capture identity and behavior are unchanged.
Regression: 'a provider artifact that mismatches the live session helper
stops it and runs one-shot' — a live fake session from the bundled fixture
artifact, then a gesture through an ADB provider supplying an
already-current artifact with a distinct packageName/runner (no install):
the old session socket receives zero gesture commands, the session is
stopped, the one-shot am instrument args end with the provider runner, and
helperTransport is 'instrumentation'.
* fix(android): include artifact sha in helper session identity; evict stale install memo entries (PR #1281 re-review)
Same-version binary replacement changes only the APK sha, so identity
guards keyed on package/runner/version/versionCode could not detect a
crossover between two artifacts that differ only in bytes:
- The artifact sha256 now joins the helper identity end-to-end:
AndroidSnapshotHelperCaptureOptions gains helperSha256 (snapshot.ts
passes artifact.manifest.sha256 alongside version/versionCode), the
session record stores it, createSessionIdentity includes it (making
snapshot session reuse sha-aware, consistent with the install path's
existing sha check), and the touch identity guard compares it via the
same both-defined rule.
- ensureAndroidSnapshotHelper's install memo now evicts every other
cached decision for the same device+package when it records an
install/current decision, so installing B invalidates A's stale
'current' memo and a later command selecting A re-inspects the device
instead of skipping the sha check.
Regressions: 'a same-version artifact with a different sha stops the live
session and runs one-shot' (touch-helper-session.test.ts — B owns the live
session, a gesture selecting same-version different-sha A sends zero
commands to B's socket, stops it, and completes one-shot) and 'installing
a same-version different-sha helper evicts the stale install memo'
(snapshot-helper.test.ts — A:current cached, B installed, selecting A
re-inspects and reinstalls instead of serving the stale memo). Both
verified to fail without their fix.
* chore: baseline-free production-exports cleanup (#1276)
Classify and burn down the 32 baseline-tolerated unused production exports.
- Live seams: annotate with @internal JSDoc visibility tags (test hooks,
introspection helpers, public install-source constant) so fallow no longer
treats them as dead production exports.
- Wrappers: collapse re-export wrappers in commands/index.ts (ref/selector)
and daemon/lease-context.ts (buildLeaseDiagnosticsContext); update all
importers to pull directly from the source module.
- Stale baseline entry: remove the non-existent
resetAndroidMultiTouchHelperInstallCache entry.
- Empty fallow-baselines/production-unused-exports.json so
check:production-exports now fails loudly on any new dead export.
Fixes#1276
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* chore: address review feedback on production-exports cleanup (#1276)
- CONTRIBUTING.md: document that intentional non-production exports should use
JSDoc @internal with a short justification, treated as a reviewed baseline entry.
- isPlatform: fix JSDoc tag to "@internal" and remove conflicting "public" wording.
- ARCHIVE_EXTENSIONS: re-export from src/sdk/install-source.ts so the public
install-source subpath has a real consumer story for the constant.
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* chore: make production-exports check truly baseline-free (#1276)
- Drop --baseline from pnpm check:production-exports and remove the
check:production-exports:baseline generation script.
- Delete fallow-baselines/production-unused-exports.json.
- Update CONTRIBUTING.md to describe the baseline-free behavior and remove
references to reviewed baseline entries for production unused exports.
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---------
Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Move the scattered root-level native projects into per-platform folders and drop
the now-redundant platform prefix:
- android-ime-helper/ -> android/ime-helper/
- android-multitouch-helper/ -> android/multitouch-helper/
- android-snapshot-helper/ -> android/snapshot-helper/
- apple-runner/ -> apple/runner/
- macos-helper/ -> apple/macos-helper/
- src/platforms/linux/atspi-dump.py -> linux/atspi-dump.py
Only repo source paths move. Identity surfaces stay frozen so no user's runner
cache is invalidated on upgrade: the derived-cache key hashes source paths
relative to AgentDeviceRunner and excludes packageVersion, and the
~/.agent-device/{apple-runner,macos-helper} namespaces, the
agent-device-android-*-helper artifact/manifest/protocol names, the
AgentDeviceRunner Xcode project, and the `prepare ios-runner` CLI command are
unchanged. Updates build/package scripts, CI, package.json files+scripts,
ignore/attr/fallow configs, runtime path resolvers, and test fixtures.
Also: re-base repo-root-relative refs inside the moved apple/runner for the
added nesting level (gated XCUITest fixture walk + two doc links), and clean the
legacy dist/apple-runner packaged output so the relocated runner can't
double-ship into the wholesale-included dist (with a regression test).
* chore: close out architecture experiments
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* docs: record unavailable live experiment
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* ci: make Android perf script atomic
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* docs: explain atomic perf workflow
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* chore: simplify back-edge diagnostics
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---------
Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* refactor(command-descriptor): keep owner-file claims tooling-only
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* refactor(daemon): keep daemon-route owner-file claims tooling-only
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* test(daemon): guard against re-adding owner-file paths to the production route chain
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* refactor(command-descriptor): derive owner-file projection from colocated RAW_COMMAND_DESCRIPTORS
- Keep ownerFiles on each RAW_COMMAND_DESCRIPTORS entry as the source of truth.
- Add tooling-only __OWNER_FILES__ build flag so production bundles omit the
ownerFiles properties entirely.
- Derive COMMAND_OWNER_FILES from RAW_COMMAND_DESCRIPTORS instead of a
hand-maintained parallel table.
- Guard command-explain tests against leaking ownerFiles into production
descriptor objects.
- Enable treeshake.propertyReadSideEffects: false in tsdown to help drop the
dead ownerFiles branch from production bundles.
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* chore: apply oxfmt formatting
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* test(build): guard tooling metadata exclusion
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* refactor(command-descriptor): drop global treeshake option and add bundle guard
- Remove treeshake.propertyReadSideEffects from tsdown.config.ts; the
__OWNER_FILES__ define + conditional spread already keeps owner files out
of the bundle, so the global DCE lever is unnecessary and scope-creeping.
- Add a comment on the __OWNER_FILES__ global declaration explaining the
deliberate type-versus-runtime mismatch.
- Add test/output-economy/owner-files-no-leak.test.ts to build dist and
assert that no command or daemon-route owner-file path appears in the
emitted JS.
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(build): remove owner metadata property reads
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(command-descriptor): enforce owner claim totality
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---------
Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* feat: add Android test IME helper for deterministic text entry (#1198)
Ships a headless InputMethodService (android-ime-helper) as a third Android
helper APK, replacing the visible system keyboard during automated sessions.
Renders zero accessibility nodes and accepts Unicode/CJK/emoji text over a
base64-encoded broadcast channel, fixing both the settle-diff IME-chrome
flood and the ASCII-only adb-shell text entry limit in one structural fix.
- android-ime-helper/: InputMethodService + build/package scripts on the
existing helper-APK toolchain (javac+d8+aapt2+zipalign+apksigner).
- src/platforms/android/ime-helper.ts, ime-lifecycle.ts: install/version
lifecycle (shared with the other two helpers via the new
helper-package-install.ts), activation on session open, and on-device
restore-hygiene (previous IME persisted to a device settings key so any
daemon/state-dir can recover it; restored on close, daemon teardown, and
daemon startup for orphans left by a crashed run).
- input-actions.ts: fill/type route through the helper's broadcast channel
when active, unicode-safe; unchanged ASCII-shell fallback otherwise.
- doctor: new android-test-ime check flags a stuck helper IME with a
copy-pasteable `adb shell ime set` remediation command.
- Gating: default-on for emulators, opt-in via `open --test-ime` on real
devices.
- Dead-weight: rewrote the manual ADBKeyBoard workaround doc, dropped the
now-provably-live skillgym non-ASCII eval case, updated the ASCII
fallback's error message to point at the helper instead of dead-ending.
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(#1201 review): permission-gate the IME receiver, fix CI, add opt-out
Addresses the independent review's blockers and should-fixes.
SECURITY (blocker 1): the text-injection receiver was RECEIVER_EXPORTED with
no gate — any co-installed app could inject text into the focused field while
the test IME was active. Fixed by requiring the WRITE_SECURE_SETTINGS sender
permission on the (in-process, dynamically-registered) receiver: adb shell
holds it, third-party apps cannot. The reviewer's suggested exported=false +
explicit-component approach was tried first but empirically breaks delivery on
API 36 (adb shell cannot reach a non-exported receiver there) — documented in
the helper README. Live-verified: a purpose-built rogue APK's broadcasts
(implicit and package-scoped, no permission) are silently dropped, field
unchanged; adb shell's bare broadcast still injects. Added
ime-helper-security.test.ts asserting the permission gate and that no
permissionless exported registration returns.
CI (blocker 2): (a) added `testIme` to integration-progress-model flag buckets
(Integration Tests was red on the unclassified flag). (b) mocked
resolveAndroidImeHelperArtifact in session-doctor-android / ime-lifecycle /
input-actions-test-ime tests so they no longer depend on android-ime-helper/dist
existing on disk (Coverage was red on a fresh checkout); verified by running
them with dist removed.
Should-fixes: added `--no-test-ime` to opt out on emulators (tri-state gating,
parser-tested); PR body's "byte-identical" claim corrected to size/CRC-match.
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
* docs(#1201): pin the API-36 exported-receiver constraint in a comment
The RECEIVER_EXPORTED flag cannot express why it must stay exported. Add a
one-line note so a future hardening pass doesn't switch to RECEIVER_NOT_EXPORTED
and silently break the CLI (adb shell can't deliver explicit broadcasts to
non-exported components on API 36+; WRITE_SECURE_SETTINGS is the actual gate).
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(#1201 review): harden IME restore lifecycle (blockers 1 & 2)
Blocker 1 — a failed restore no longer deletes the recovery value. restore now
reads back default_input_method after `ime set` and only clears the persisted
previous-IME record on a confirmed-successful restore; a failed set keeps the
value so a later retry / startup recovery / doctor remediation can still
un-strand the user off the helper IME.
Blocker 2 — startup orphan-recovery no longer overwrites/races user state.
It only restores when the device's CURRENT default IME is still our helper
(so a user who legitimately switched away is left alone), and skips any device
a live session in this process owns (the fire-and-forget startup vs. concurrent
`open` race — activate now marks the device active BEFORE the `ime set`, so any
recovery pass that could observe the helper active also observes the flag and
skips). Never persists the helper itself as the previous IME. activate also
verifies its own switch via read-back.
Exported ANDROID_IME_HELPER_SERVICE_COMPONENT so restore compares the active IME
without reading the packaged artifact from disk. Tests: failed-restore keeps the
value (+ later recovery succeeds), startup no-op when current != helper, startup
skips a live-owned device.
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
* chore(#1201): delete unused ACTION_ENTER path, baseline test-only export seams
Rebased onto main (#1202 production-unused-exports gate). Two follow-ups:
- Deleted the unused ACTION_ENTER broadcast end-to-end (TS sendAndroidImeHelperEnter
+ its test, Java handler, README): nothing routes through it — `keyboard enter`
uses the keyevent ENTER path — so the new production-exports gate flagged it as
dead production code. Removed rather than grandfathered.
- Added the three legitimate test-only seams (resetAndroidImeHelperInstallCache,
resetAndroidTestImeActivationCacheForTests, setAndroidTestImeActiveForTests) to
fallow-baselines/production-unused-exports.json, matching how the sibling helper
reset functions (resetAndroidMultiTouchHelperInstallCache, ...) are already
grandfathered there.
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(#1201): stop daemon-startup adb spawn on non-Android hosts (macOS Smoke)
Root cause of the red macOS Smoke shard (proven, not hand-waved): the
fire-and-forget restoreOrphanedAndroidTestImeOnDaemonStartup ran `adb devices`
at EVERY daemon startup, on every platform. GitHub macOS runners ship the
Android SDK, so this cold-started the adb server mid-replay and destabilized the
macOS System Settings replay timing — the failed job's cleanup shows
"Terminate orphan process: pid (N) (adb)"; main's green runs spawn no adb.
Fix: gate the startup orphan scan behind a host-side marker written in the
daemon state dir when a session activates the test IME (mirrors the
managed-web-browser orphan-cleanup `installed` gate). A host that never uses the
Android test IME — the macOS CI runner included — never writes the marker and so
never spawns adb at startup. The marker is cleared once nothing is left stuck.
Adds SessionStore.resolveStateDir(); tests: startup recovery does not scan adb
when no marker exists (+ marker cleared after a clean scan).
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
* chore(#1201): suppress fallow class-member false-positive on state-dir accessor
CI's Fallow audit flags SessionStore.resolveDaemonStateDir as an unused class
member, but it is called via sessionStore.resolveDaemonStateDir() in
session-open.ts — fallow's class-member tracer just doesn't resolve a method
call sited inside a call argument. Renamed for clarity (avoids the collision
with config.ts's free resolveStateDir) and added the localized
fallow-ignore-next-line unused-class-member suppression.
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(#1201 review): durable persist before switch + device-scoped recovery markers
Addresses devin-ai-integration's two P1 restore-safety blockers on 19cbce79d.
P1.1 — durably persist the restore target BEFORE the global IME switch.
writePersistedPreviousIme now checks the `settings put` exit code AND reads the
value back, returning a boolean. activate persists first and, if it cannot be
persisted, fails open to the existing input path WITHOUT switching — a rejected
`settings put` can no longer strand the user on the helper with no restore
target. Regression test added.
P1.2 — close the marker crash/offline blind spot. Recovery intent is now
recorded per device, BEFORE the switch (ordering: durable record -> marker ->
ime set), eliminating the post-switch/pre-marker crash window. Markers are
device-scoped and each is retained until that device is actually observed clean:
an offline/disconnected-but-stuck device keeps its marker and is recovered on
reconnect instead of being cleared because the current `adb devices` scan saw no
set-failed. Close-time restore clears only that device's marker (stateDir plumbed
through teardown/close). Tests cover the persist-failure, post-switch/pre-marker
crash, offline-then-reconnect, live-session-owned, and user-switched-away cases.
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
* feat: add derived fail-open check:affected selector
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* refactor: simplify selector for complexity gate; add docs
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix: fail open on ambiguous non-source fixtures; guard catalog against real package.json/vitest.config
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* refactor: use src/utils/exec.ts process helpers in check:affected runner
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(check:affected): SkillGym ownership, honest catalog, working-tree discovery
- Add SkillGym ownership for skills/ and test/skillgym/; stop short-circuiting
their Markdown as docs-only (findings 2 & 4).
- Drop the fabricated GitHub 'SkillGym' job: it is a local-only gate, now
localRunnable with no CI job, guarded by a workflow-existence self-test (3).
- Fold working-tree (staged/unstaged/untracked) state into local discovery and
disable rename detection so both rename paths classify (1).
- Add run.test.ts entrypoint regressions (real diff/status/rename discovery,
--run order/skip/stop-on-failure).
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(check:affected): union staged + unstaged diffs so they cannot cancel
A single `git diff HEAD` nets index against working tree, so a staged add
and an unstaged delete of the same file cancel and hide it. Collect
`--cached` (staged) and unstaged diffs separately and union them; add a
cancellation regression test.
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(check:affected): cover required suite gates
* refactor(check:affected): delegate tests to vitest
---------
Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* ci: ratchet against test-only exports
Three exported-and-unit-tested-but-unreferenced-in-production incidents
this week (#1166 getNearestCommandNames, #1167 buildSettleTail, #1199
clearMetroSessionHints) — the first two were caught by fallow's dead-code
check because they had zero importers anywhere; #1199 was missed because a
test file imports the export, and fallow's default reachability graph
counts a test import as "used".
Adds a second, stricter pass reusing fallow's own --production mode
(entry.exclude test/story/dev files) via scripts/test-only-exports/check.ts:
an export alive in fallow's default graph but dead in its production graph,
with no other reference anywhere in its own file, has no production call
site — exactly the #1199 shape. Ratchets against a checked-in baseline
(scripts/test-only-exports-baseline.json, 77 entries); new findings fail
`pnpm check:test-only-exports` (wired into CI's Fallow job and
check:tooling). A `// test-seam: <reason>` comment above an export is the
escape hatch for intentional test seams.
Also extends .fallowrc.json's ignoreExports for seven daemon route handlers
(src/daemon/handlers/*.ts) that are genuinely production-reachable through
request-handler-chain.ts's `typeof import()` lazy-load pattern, which
fallow's static import graph can't trace as a named-export consumer —
without this they were false positives in the production-mode pass.
* fix: harden test-only-exports ratchet per review
Addresses the two should-fixes and all five minors from the independent
review of #1202:
- Replace the regex own-file occurrence count with an oxc-parser AST walk
(typescript@7 ships no JS scanner API, so the review's fallback tool
suggestion is the primary): identifiers are counted as AST nodes deduped
by source span, so mentions in JSDoc/block comments, strings, and
template-literal text no longer masquerade as call sites (review finding
1, both constructed cases re-verified fixed), and a `//` inside a string
no longer hides real usages (finding 6). Span dedupe keeps barrel
re-exports (`export { x } from`) counting once. The sharper count
surfaced one organic false negative on main: `selector` in
src/commands/index.ts was previously exempted because the regex matched
"selector" inside the './...selector-read.ts' import path string; it is
now baselined alongside its sibling `ref` (same re-export line).
- Make the baseline shrink-only (finding 2): --update-baseline refuses new
findings with the same wire/delete/annotate message, so the `// test-seam:`
annotation in the reviewed source diff is the only acceptance path;
CONTRIBUTING no longer documents baseline regeneration as an acceptance
option and now describes baseline growth as a deliberate manual edit.
- Stale baseline entries now emit a `::warning` CI annotation (finding 3).
- Commit a re-runnable fixture test (finding 4): check.test.ts mirrors
scripts/layering/model.test.ts, builds a synthetic package with a
clearMetroSessionHints-shaped export (JSDoc self-mention included),
asserts it is flagged, and asserts the annotated twin passes; wired
before the check in pnpm check:test-only-exports.
- Mark the unreadable/unparseable-file fallbacks CONSERVATIVE: per
CONTRIBUTING's convention (finding 5).
- Document the dynamic property access (obj[name]) blind spot in the
script header and CONTRIBUTING (finding 7).
* fix: harden test-only export ratchet
* refactor: use native Fallow export gate
* chore: refresh production export baseline
* test: split the Android platform test aggregation and share the scripted adb stub
AGENTS.md names the platform index.test.ts aggregations as offenders to
shrink opportunistically; this splits the 2,735-line Android one along
its (already well-factored) source modules, every test moved verbatim
(92 tests before and after):
- ui-hierarchy.test.ts (22): parseUiHierarchy/androidUiNodes
- app-lifecycle-install.test.ts (13): install/resolve/infer/launch
component parsing
- app-lifecycle-open.test.ts (19): open/close, deep links, launch args,
TV category, fallback resolve-activity
- input-actions.test.ts (11): type/fill/swipe/scroll/rotate
- settings.test.ts (14): appearance/clear-app-state/fingerprint/
permissions
- notifications.test.ts (2), app-parsers.test.ts (1)
- keyboard state/dismiss tests (10) appended to the existing
device-input-state.test.ts
Consistency fix folded in: the file carried a local withMockedAdb fork
because it needs scripted per-subcommand adb responses, which the shared
arg-recorder helper cannot express. The fork now lives in
src/__tests__/test-utils/mocked-binaries.ts as withScriptedAdb next to
withMockedAdb, and hands each call a fresh copy of the shared
ANDROID_EMULATOR fixture.
The copy matters: the Android TV test mutated the callback's device
(device.target = 'tv'), which the old per-call object literal absorbed
silently. With a shared fixture that mutation leaked into the next test
and flipped its launch to LEANBACK. The helper now clones per call and
the TV test builds { ...device, target: 'tv' } instead of mutating.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FqeW8sA2ZnvnftdvpCqFMS
* test: serialize the scripted-adb group and repoint its slow-test pins
Review follow-up for the android index.test.ts split: the monolith
implicitly serialized the env-mutating adb-stub tests (PATH,
AGENT_DEVICE_TEST_ARGS_FILE) in one worker, and the split let vitest
run them across parallel files. Make the contract explicit:
- new android-adb vitest project runs the six scripted-adb test files
in a single fork (singleFork), keeping the pre-split execution
semantics; ui-hierarchy and app-parsers stay in the parallel unit
project (pure parsing, no env mutation)
- test/test:unit scripts run both projects
- the five slow-test ratchet pins that referenced index.test.ts keys
now point at the split file names, so the pinned real-time offenders
keep their exemption instead of failing at 2x budget under load; the
reporter's own pinned-key fixture updated to match
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FqeW8sA2ZnvnftdvpCqFMS
* test: use vitest 4 android adb serialization
* docs: update unit project readiness guidance
---------
Co-authored-by: Claude <noreply@anthropic.com>
Follow-ups from the bundler/CI speed work, re-validated against latest
main. The typescript package is gone from the toolchain:
- pnpm typecheck stays on tsgo; the typecheck:tsc escape hatch is
removed along with the typescript devDependency.
- args.test.ts extracted cli.ts dispatch literals through the
TypeScript compiler API - the only remaining consumer. It now walks
the same AST via oxc-parser (matching the OXC lint/format/build
stack); both implementations extract an identical 14-literal set
from cli.ts, verified side by side before the swap. The
substitution-free template case ts.isStringLiteralLike covered is
preserved.
- dts bundling is unaffected: the tsdown build uses the tsgo backend
and builds green with no typescript package installed.
Test fixes for containerized agent environments:
- The missing-binary doctor-guidance web provider test pins Node 24
via the file's existing withNodeRuntimeVersion helper, so it asserts
the setup hint instead of inheriting the host Node and failing on
Node 22 (the supported engines floor).
- The clean-xcuitest cleanup-failure smoke test skips as root: chmod
0o500 cannot force a removal failure when the process bypasses
directory permissions.
AGENTS.md toolchain notes updated to match.
Claude-Session: https://claude.ai/code/session_01FqeW8sA2ZnvnftdvpCqFMS
Co-authored-by: Claude <noreply@anthropic.com>
Three measured dev-loop/CI cuts, no signal loss:
- typecheck now runs tsgo (already trusted for declaration emit by the
tsdown build): 21.7s -> 5.3s locally, and check:tooling drops to ~18s
total. tsc stays available as typecheck:tsc; verified tsgo fails on
type errors and respects noUnusedLocals.
- remove the Unit Tests CI job: Coverage runs the same unit +
provider-integration suites under coverage thresholds, so the job
reran ~64s of tests every PR for no extra signal.
- Size workflow: skip docs-only paths (same paths-ignore as CI) and
cache the base commit's dist keyed on base SHA, since dist is fully
determined by that commit. Startup medians are still measured fresh
on the same runner so the base/PR startup comparison stays
same-machine; the cache is saved immediately after the base
measurement so the PR build never poisons it.
Claude-Session: https://claude.ai/code/session_01FqeW8sA2ZnvnftdvpCqFMS
Co-authored-by: Claude <noreply@anthropic.com>
* build: migrate the library build from rslib to tsdown (Rolldown)
Replace the Rspack-based rslib build with tsdown, the Rolldown-based
library bundler from the Vite toolchain family, so bundling, testing
(Vitest/Vite), linting (oxlint), and formatting (oxfmt) all run on the
same OXC/Rolldown stack.
Outcome vs the rslib baseline (size-report):
- build time: ~53s -> ~2s
- JS raw +16.2 kB (+1.1%), JS gzip +2.7 kB (+0.6%) - the residual gap
is OXC vs SWC minifier tightness, not chunking
- npm tarball -3.0 kB
- CLI --version startup ~3 ms faster; --help within the +/-5 ms
measurement noise of interleaved A/B runs
Chunk-merging experiments (single shared group, entries-aware groups,
small-module groups) all regressed either total size or --help startup
(a merged shared chunk adds +140 ms), so the default Rolldown split
graph is kept. Custom codeSplitting groups also currently trip a
rolldown-plugin-dts bug that re-emits type-only imports as runtime
imports.
Declarations still bundle per entry via tsgo; dist layout, entry names,
and the internal/ worker/daemon entry resolution contract are unchanged.
@microsoft/api-extractor was only consumed by rslib dts bundling and is
removed together with @rslib/core.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FqeW8sA2ZnvnftdvpCqFMS
* ci: only cache the pnpm store when setup installs dependencies
The layering-guard job uses setup-node-pnpm with install-deps: false, so
it never creates a pnpm store. setup-node's post-job cache save then
fails with a path validation error whenever the lockfile hash misses the
cache - which any lockfile-changing PR does. Gate the cache on
install-deps so no-install jobs skip pnpm store caching entirely.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FqeW8sA2ZnvnftdvpCqFMS
---------
Co-authored-by: Claude <noreply@anthropic.com>
Generalize the inline CI "Layering Guard" grep into a structured
import-direction lint (scripts/layering/check.ts) over the resolved
import graph, per plans/perfect-shape.md §5.5.
The full target DAG (kernel ◄ platforms ◄ core ◄ commands ◄ {cli,
client, daemon/server}; client ◄ daemon/client) is only partly realized
— the client/remote/metro extraction, the daemon/server split, and the
utils dissolution are still pending Phase-5 moves, so the tree still
holds legitimate back-edges (platforms→core, commands→cli, utils→*).
Enforcing the whole DAG today would need a mass import rewrite that
Phase 5 defers. The lint therefore enforces the three invariants the
completed moves (kernel/, daemon/client/) already guarantee and that are
green today:
R1 kernel-sink — nothing under src/kernel/ imports another zone,
except the one type-only kernel→contracts re-export.
R2 commands-floor — nothing below the command surface (kernel,
platforms, core, daemon) imports src/commands/.
Generalizes the former guard (daemon + platforms).
R3 platforms-seam — platforms/ is statically imported only at the
core interactor seam (src/core/interactors/) and by
the daemon server; elsewhere use a dynamic import()
or a type-only import, preserving CLI cold-start.
Dynamic import('../platforms/*') and `import type` stay allowed.
Fixes the three pre-existing R3 violations by converting static
platforms value imports to dynamic imports (all in already-async call
sites, behavior-preserving and cold-start-improving):
- src/client/client.ts debug.symbols → lazy symbolicateCrashArtifact
- src/cli/commands/web.ts setup/doctor → lazy agent-browser-tool
- src/core/dispatch-interactions.ts runner-sequence → lazy (matches the
file's own dynamic-import pattern)
Wire the check into the Layering Guard CI job and add a check:layering
package.json script (also folded into check:tooling). scripts/layering/**
is excluded from fallow (untested CI script, like scripts/perf/**).
Replaces the manual "run with --debug, hand-count the runner phases" check with
an automated, committed assertion so the Phase 3 step (c) runner relocation (and
future runner refactors) can prove byte-identical runner request behavior.
- src/daemon/runner-request-count.ts: pure, unit-testable counter. Parses the
daemon --debug diagnostics ndjson and counts the iOS-runner round-trip phases,
plus baseline parse/compare logic. Owns RUNNER_ROUND_TRIP_PHASES as the single
source of truth, now imported by request-router.ts (was a local const) so the
in-process cost graft and the external counter never drift.
- src/daemon/__tests__/runner-request-count.test.ts: 13 unit tests over synthetic
ndjson fixtures (tolerant parse, counting, baseline parse/compare). Run in the
normal unit suite; no hardware.
- scripts/runner-request-count/: assertion harness (run.ts) + committed baseline
(expected-counts.json). Drives the existing smoke-ios replay scenario with
--debug in an isolated --state-dir, counts runner round-trips from daemon.log,
and asserts against the baseline. --update regenerates the baseline. Infra
hiccups are inconclusive (don't fail); only a real count drift fails.
- .github/workflows/ios.yml: new "Assert iOS runner request count" step in the
smoke-ios job, reusing the booted simulator.
- package.json: `validate:runner-count` script. .fallowrc.json: harness entry.
The baseline ships unarmed (established=false); the harness records observed
counts (printed + uploaded as a test/artifacts artifact) without failing, so the
maintainer arms it once from a real CI run.