Skip to content

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

Description

@thymikee

Status: closed. A and B shipped; C is withdrawn because this issue's premise was wrong.

src/cli.ts eager closure on 527a56a: 295 modules, from 365. The prediction for A+B was 294; the extra module is main moving underneath, not a modelling error.

Measured startup effect: none. Details below. A side-finding — a stale APPROVED_OVER_CEILING row for command-registry/src/planned-operations.ts — was real and landed separately as #2373.

The premise was wrong

This issue opened on the sentence "every agent-device invocation — --version included — eagerly evaluates 365 modules through src/cli.ts." That is true of the source graph and false of the shipped artifact, and the difference is the whole point.

tsdown rolls the ~1,670 source modules into ~371 dist/src/*.js chunks. dist/src/cli.js statically imports 19 of them. Building the base and the branch and diffing those two chunk sets:

chunks removed:  (none)
chunks added:    (none)

The 70 source modules A and B took off the eager path were already merged into chunks the CLI imports regardless. Nothing stopped loading. The size report agreed across two independent runs:

run 1 run 2 (rebased, smaller diff)
--version 20.6 → 20.6 ms 27.3 → 27.5 ms
--help 55.9 → 58.3 ms (+2.4) 76.8 → 75.4 ms (−1.4)

The --help delta flipped sign on a smaller diff, and the baseline moved 55.9 → 76.8 ms on unchanged code — 37% runner-to-runner. Both readings are noise; there is no effect to measure in either direction.

So the eager-closure gate measures the source graph, not what the shipped CLI evaluates. It remains a real invariant — layering, test-suite import cost, its own no-growth rule — but it does not predict CLI startup, and this issue presented it as though it did.

Why C is withdrawn rather than deferred

C proposed splitting commands/family/registry.ts (dominates 82 modules on the current head) so twelve command families stop loading their runtime for a metadata read. Two things kill it as written:

  1. The payoff is source-only. Those 82 modules would have to correspond to whole chunks dist/src/cli.js stops importing. Nothing suggests they do, and the A+B result is direct evidence that source-module reductions of this shape do not survive bundling.
  2. The cost is a redesign. Only 2 of the 12 families expose metadata separately; the other 10 declare metadata and run together per command, exactly as family/types.ts:31 describes. Separating them changes how commands are authored across ten families.

A ten-family authoring change for an unmeasurable win is a bad trade. Anyone revisiting this should diff the chunk graph firstgrep -o 'from"\./[^"]*"' dist/src/cli.js | sort -u, before and after a prototype — and re-scope from what actually loads.

What A and B were actually worth

Not performance. Both are layering corrections that stand on the repo's own rules: a metadata module should not reach into runtime for a constant contracts owns (gestures.ts only re-exports it, and says so in a comment), and a string-membership check should not load a runtime barrel when the leaf has zero imports and the subpath-per-file rule exists. Cost: +111 B of download for providers.ts becoming its own chunk.

The follow-up this exposed

The genuinely open question is a measurement, not a refactor: of the 19 chunks dist/src/cli.js imports, how many does --version actually need? cli.js already lazy-imports for dispatch (./connection.js, ./cli-help.js, ./generic.js), so the seam exists; what is missing is evidence about which of the 19 are avoidable. That is worth its own issue if anyone wants the startup win this one failed to deliver.


Original analysis, preserved — accurate about the source graph, wrong about what it implies

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

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; 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 saying the vocabulary lives there "so the public API can declare ScrollOptions without depending on this command runtime".

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

src/cli/connection/provider-policy.ts imports a constant map, a pure predicate over it and one type from @agent-device/provider-webdriver — whose only export was ".", so the whole WebDriver runtime loaded to answer "is this string a known provider name". packages/provider-webdriver/src/providers.ts has zero imports.

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

src/commands/family/registry.ts value-imports all twelve command families to build one array. Its seven non-test consumers each want a different facet and each pays for all twelve families' runtime.

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. It still 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.
  • A lint rule banning vocabulary→runtime edges: only one instance of the smell existed, so a gate would be enforcement ahead of evidence.

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