Skip to content

refactor(cli): cut src/cli.ts eager closure by taking vocabulary off runtime barrels #2374

Description

@thymikee

Purpose

Every agent-device invocation — --version included — eagerly evaluates 365 modules through src/cli.ts. Measured on main at 5ba4ac7 with pnpm depgraph (value edges only, so type-only and await import edges are excluded); the number matches the eager-closure gate's own reading of src/cli.ts exactly.

It is the outlier by a wide margin. Across the 239 entries the gate measures:

category count median max
mechanics-surface 4 47 365 (src/cli.ts)
domain-facade 120 6 177 (platform-android/src/mechanics.ts)
vocabulary-facade 109 1 30
platform-facade 6 1 1

Where those 365 live: commands 92, contracts 65, provider-webdriver 25, host-kit 24, cli 19, core 18, kernel 17, selectors 15, snapshot 14, ad-script 13.

These are not three unrelated wins. They are one defect class: a module that needs vocabulary — a constant list, a predicate over it — reaches it through a runtime barrel and pays for the runtime. Wave 3 (#2335) made this both visible and fixable: the vocabulary now has package homes, and the subpath-per-file rule gives each one a cheap door.

Dominator analysis from src/cli.ts — modules that stop loading if that one module goes lazy:

module dominates
commands/family/registry.ts 118
commands/interaction/index.ts 49
commands/interaction/metadata.ts 43
commands/interaction/runtime/gestures.ts 40
cli/connection/provider-policy.ts 32
packages/provider-webdriver/src/index.ts 31

Steps, in order

Each is independently landable and independently measurable.

A — one import line, −40 modules (365 → 325)

src/commands/interaction/metadata.ts:42 imports SCROLL_INPUT_DIRECTIONS from ./runtime/gestures.ts. Every other import in that file is vocabulary (@agent-device/contracts/*, @agent-device/selectors); this one line is the odd one out, and it drags 40 modules of gesture runtime into every CLI run.

gestures.ts does not even own the constant — it re-exports it from @agent-device/contracts/scroll-gesture, with a comment above saying the vocabulary lives there "so the public API can declare ScrollOptions without depending on this command runtime". metadata.ts already imports from that exact module on line 21.

Fix: fold the specifier into the existing contracts import. One line, no behaviour change.

B — one subpath, −31 modules (325 → 294)

src/cli/connection/provider-policy.ts imports CLOUD_WEBDRIVER_PROVIDERS (a constant map), isCloudWebDriverProviderName (a pure predicate over it) and one type from @agent-device/provider-webdriver — whose only export is ".", so the whole WebDriver runtime loads to answer "is this string a known provider name".

packages/provider-webdriver/src/providers.ts has zero imports: a pure leaf. It predates the subpath-per-file rule the wave established.

Fix: publish ./providers pointing straight at src/providers.ts and point provider-policy.ts at it.

C — family registry facet split, −82 further (294 → 212)

src/commands/family/registry.ts value-imports all twelve command families to build one array, and dominates 118 modules. Its seven non-test consumers each want a different facet — CLI schemas, CLI readers, metadata, definitions, output formatters, daemon writers — and each pays for all twelve families' runtime.

Unlike A and B this is a design task, not a mechanical one, and should not be started assuming otherwise. family/types.ts:31 states the coupling directly: "What a command file authors: metadata plus run; the facet derives the executable from them." Separating them means the registry aggregates metadata eagerly and resolves run at dispatch. Split this out into its own issue if the design grows past one PR.

Expected result

after modules vs today
today 365
A 325 −40
A + B 294 −71
A + B + C 212 −153 (−42%)

The module counts above are measured. The wall-clock effect is not: today's size report reads --version 27 ms and --help ~79 ms, and module count is a proxy for parse-and-evaluate cost, not a linear one. Each step should report the size-report delta it actually produced rather than predicting one.

Validation

The eager-closure gate already enforces no-growth per entry against the merge-base, so each step verifies itself and the improvement shows up in the gate's own numbers. No baseline edits, no APPROVED_OVER_CEILING row, and no new suppressions should be needed by any step — if one appears to be, that is a signal the step is doing something other than what is described here.

Evaluated and not scheduled

  • The daemon code-signature cost (~620 → ~1,461 modules walked, ~2.9 ms → ~7.5 ms cached, landed with refactor(daemon): move four pure leaves to their kits #2347) is a real startup regression but a different mechanism — a workspace source walk in walkDaemonCodeGraph, not an import closure. Nothing here touches it; it wants its own issue.
  • packages/platform-android/src/mechanics.ts (177) and the platform-apple façades (100–120) are the next-largest closures, but they are not on the CLI's hot path; the composition root keeps them behind src/platform-runtime.ts (47).
  • A lint rule banning vocabulary→runtime edges: only one instance of the smell exists today (step A), so a gate would be enforcement ahead of evidence.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Jqfa11D8QsCMuL17SsLvDz

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions