Skip to content

feat(codemods): add an upgrade CLI backed by a migration catalogue - #2978

Merged
mfal merged 82 commits into
nextfrom
claude/flow-codemod-upgrade-cli-f87427
Aug 31, 2026
Merged

feat(codemods): add an upgrade CLI backed by a migration catalogue#2978
mfal merged 82 commits into
nextfrom
claude/flow-codemod-upgrade-cli-f87427

Conversation

@mfal

@mfal mfal commented Aug 28, 2026

Copy link
Copy Markdown
Member

Publishes @mittwald/flow-codemods as a CLI and turns the migration guide into generated output of a machine-readable catalogue.

What this adds

npx @mittwald/flow-codemods@latest upgrade [revision]
npx @mittwald/flow-codemods@latest <id> [path]
npx @mittwald/flow-codemods@latest list [revision] [--json]

upgrade bumps every Flow dependency to a resolved target, installs, runs the codemods the crossed version range calls for, and ends by listing the migrations no codemod covers.

revision is patch | minor | major | a dist-tag | an exact version, default minor. The keyword bounds the target; the entry set then falls out of an exact-version gate against it (see below). Keyword resolution excludes prereleases, so upgrade minor never drifts onto the next line — only an explicit dist-tag or version reaches a -next.N.

It refuses to run on a dirty working tree unless --allow-dirty, because -y is implied when stdin is not a TTY (CI, agent runs) and an unattended run would otherwise blend its own uncommitted work into the codemod diff. --dry writes nothing and installs nothing.

list changes nothing, and has two forms. Bare, it prints the whole catalogue — offline, with no manifest and no network, so it answers in an empty directory. Given a revision, it reads the current version from package.json and shows exactly the range upgrade <revision> would act on, which makes it a dry run of the upgrade rather than a separate thing to learn. --json for an agent.

It takes a revision rather than --from/--to because --from asks for something the project already knows.

The catalogue

packages/codemods/src/migrations/<id>/entry.md is now the single source for all 23 consumer migrations. packages/components/MIGRATION.md — which ships in the flow-react-components tarball — and src/migrations.generated.ts are generated from it, so the guide and the data cannot drift apart.

Each entry carries the instruction as prose (apply) plus, where the change is mechanically decidable, a codemod. That is 10 of the 23. The other 13 are executable by hand or by an agent from the instruction alone, without reading 18 KB of guide.

Two orthogonal fields decide when an entry applies and what it costs:

field values decides
kind migration · deprecation nothing — descriptive only. It is rendered as a label and tells a reader whether their code still compiles. It used to gate selection; the gate is one rule now
action codemod · manual · none whatnone covers a behaviour change needing no edit, which an agent has to be told or it keeps looking

The deprecation half matters more than it looks: ADR 0005 §4 leaves type-level changes outside the semver guarantee, so they may ship in a patch. Without it, list on the 1.x line would return nothing.

The gate is one rule

An entry is selected when since <= target. That is all of it — current plays no part in selection.

It used to carry a lower bound (current < since) for entries needing a person, on the reasoning that you had already applied what lay behind you. Nothing records which migrations a project has performed, so that was a guess — and a bad one here, because this tool did not exist until now. On the 1.x line every catalogue entry predates 1.0.0, so a lower bound made list and upgrade report almost nothing to precisely the people who had never migrated.

current still decides how an entry is presented: each is marked new in this range or catch-up (shipped at or before your version, so you may already have done it). Re-running a codemod over its own output is a verified no-op — transformCoverage.test.ts requires a transform.test.ts beside every transform, and each of the 10 carries a "running it twice changes nothing" case. That is not the same as being harmless against code from a later era, which is a per-transform property: imports-to-package-root needed an explicit leave-alone set to earn it (see review thread). A manual step marked catch-up needs the reader's own judgement, and the legend says so rather than implying it is done.

upgrade reports how many codemods actually changed something. "10 codemods run, 0 changed anything" is itself the confirmation that nothing was outstanding.

Groundwork for an AI-assisted update

This is a step towards a migration an agent can carry out, not only a human. That intent explains several choices that otherwise look incidental:

  • apply is an instruction, not a description. 13 of the 23 entries have no codemod, and today they are 18 KB of prose a reader has to search. As a field they are actionable per entry, so an agent can work through them without reading the guide.
  • list [revision] --json is read-only. It answers "what does this range require of me" against the consumer's own manifest, with no writes, no install and no network beyond the registry lookup. That gives an agent a way to plan before it changes anything.
  • upgrade ends by naming what no codemod covered. A codemod is not the whole migration, and a command that stayed silent about the remainder would read as "done".
  • -y is implied when stdin is not a TTY, so an agent run needs no flag — and the dirty-tree refusal exists precisely because of that: unattended, it would otherwise blend its own uncommitted work into the codemod diff with no way to separate them afterwards.

Nothing here is agent-specific, and none of it is a finished feature. It is the data and the seams such a thing needs.

One transform is not a migration

to-remote-package (formerly flowRemote) ports an app onto @mittwald/flow-remote-react-components. No version range calls for it, so it has no catalogue entry and appears in neither the guide nor list — it is run deliberately, by id, and documented in the package README. catalog.test.ts names it as the one commented exception to its otherwise exact transform-set invariant.

What this removes

The raw-GitHub-URL delivery path, and with it the self-contained-transform rule, the composite bundler and src/composites. Transform files are renamed to their dashed catalogue ids.

URLs printed in already-published MIGRATION.md copies now 404. Accepted deliberately: doc URLs are not covered by the semver contract (ADR 0005), and those consumers are better served by npx @mittwald/flow-codemods@latest upgrade, which works from any old version because npx always fetches the current CLI.

Not a breaking change under ADR 0005 — the package was private: true, so no published package API changed.

Supersedes #2942

Its transform suite, test harness, remoteScope.ts and the migration prose it added are adopted here; they were reviewed and green. Its composite bundler is not, because the CLI runs codemods individually and reports per codemod. Close #2942 rather than merging it — merging it after this lands would conflict with every rename.

Worth knowing while reviewing

  • upgrade resolves the target from the intersection of what every declared Flow dependency has published, not from one of them. Lerna publishes packages one at a time, so during a release the versions diverge for the length of the run — around 25 minutes, measured. The window is narrow and self-healing, but it is exactly when someone runs upgrade. Documented for users in Document the publish window: Flow versions diverge for minutes during a release #2974.
  • The codemod runner drives jscodeshift's Runner in process rather than its CLI. Scraping the CLI's text summary is unsafe: --print writes the transformed source before the summary, so a source comment like // 42 ok beat the regex reading the count.
  • Every Flow dependency moves together because flow-react-components declares flow-icons-pro as a peer at an exact version. Bumping one alone is a peer conflict. The package list is generated from the workspace, not hand-picked.

Out of scope

  • packages/ext-bridge/MIGRATION.md keeps its own hand-written guide.
  • Executable detect / verify checks were built and taken back out — not solid enough to ship here, and better delivered on their own.
  • The ~50 lines of import-resolution logic duplicated across the transforms could now be shared, since they no longer have to run standalone. Separate change.

Verification

pnpm build clean, git diff --exit-code clean, pnpm lint clean, 250 tests. upgrade verified end to end against the real npm registry, including the dirty-tree refusal, --dry, and that codemods no longer reach into a freshly installed node_modules.

@mfal
mfal requested a review from a team August 28, 2026 06:53
@github-actions

Copy link
Copy Markdown
Contributor

🚀 Preview Deployment

Preview environments are ready:

Type URL
docs pr-2978.docs.review.flow-components.de
storybook pr-2978.storybook.review.flow-components.de

Images:

  • docs: ghcr.io/mittwald/flow/docs:pr-2978
  • storybook: ghcr.io/mittwald/flow/storybook:pr-2978

@mfal

mfal commented Aug 28, 2026

Copy link
Copy Markdown
Member Author

Blocked on #2980. The version-contract check fails here because it reads adding engines.node to a previously private package as a tightening — and neither remedy it offers is available: a breaking marker would be rejected by the routing guard on next, and dropping the floor would publish a CLI without one while its code needs Node 24.

The one-line fix was on this branch and I have reverted it out: it is CI infrastructure, a reviewer of this CLI should not have to review it, and the squash-merge would land it under a codemods commit message. #2980 carries the diff and the two tests.

@mfal

mfal commented Aug 28, 2026

Copy link
Copy Markdown
Member Author

The guard fix is now #2982, targeting main. Once it merges, the forward-merge cascade carries it to next and this PR's version-contract check goes green — no CI infrastructure needs to ride along in here.

mfal and others added 20 commits August 31, 2026 08:23
Implementation plan for publishing @mittwald/flow-codemods as a CLI with an
`upgrade` command, backed by a machine-readable migration catalogue that
generates MIGRATION.md.

14 tasks, 103 TDD steps. Key decisions recorded in the plan header: the
raw-GitHub-URL delivery path is retired, transform files are renamed to dashed
catalogue ids, the gate is exact-version (`current < since <= target`) with
revision keywords bounding the target, and every catalogue entry carries
detect/apply/verify so a migration without a codemod is still executable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two changes after review of the first draft.

PR #2942 is no longer a prerequisite. Task 0 pulls its useful parts onto this
branch — the four added transforms, the rewritten flowAlphaAlignToCombine
(+155/-38 for alias and namespace resolution), the test harness, remoteScope,
and the migration prose it added to MIGRATION.md — and deliberately leaves the
composite bundler behind. It supersedes #2942, which should be closed rather
than merged: merging it to main afterwards would conflict with every rename.

Task 15 adds the deprecated APIs that have no MIGRATION.md entry today. The
first draft silently delivered only half the agreed "breaking plus
deprecations" scope: all 22 ported entries come from the existing guide and
only two are deprecations, both already documented. Without Task 15, `upgrade`
on the 1.x line still finds almost nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The snippet pinned 1.0.1 while the repo is on 1.0.2, and Lerna owns the field.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulls reviewed, CI-green work from PR #2942 onto this branch: the
runTransform test harness, the remoteScope authority, nine transforms
(four new, three improved), and package configs. Drops the broken
flowAlphaAll composite and everything that exists only to serve it
(the bundler scripts, flow1.ts, and the standalone/bundledComposites/
documented test suites) — a later task replaces the composite
mechanism. remoteScope.test.ts, idempotency.test.ts and
transforms.test.ts had their flow1-specific fixtures, targets and
comments removed accordingly so the hand-maintained lists match what
is actually on disk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Makes @mittwald/flow-codemods publishable (drops private/peerDependencies,
moves jscodeshift to dependencies for npx, adds bin) and adds the
flow-codemods CLI with argument parsing via node:util parseArgs. Only
--help and --version are wired up; every later command errors with
"not implemented yet".

Two deviations from the task brief, both needed to make `tsc` actually
build and run this package in this repo:

- tsconfig.build.json declares `"types": ["node"]`. Excluding
  src/tests/**/*.ts and src/transforms/**/*.ts shrinks its program to 2
  files (cli.ts, cli/args.ts). The native `tsc` binary this repo resolves
  bare `tsc` to (TypeScript 7, aliased `@typescript/native` per
  pnpm-workspace.yaml) does not run its usual automatic @types/*
  discovery for a program this small under `composite`, so `process` and
  `node:util` resolved to nothing. tsconfig.json's own tsc --noEmit gate
  is unaffected because its program is much larger.
- tsconfig.json's `include` gained "package.json", and cli.ts imports
  "./cli/args.js" (not "./cli/args", as the brief's block had it).
  Without the extension, tsc's `module: "preserve"` emits the import
  as-is and Node's ESM loader can't resolve it (ERR_MODULE_NOT_FOUND) —
  ".js" is required so tsc maps it back to the sibling .ts file. Without
  the package.json include, `tsc --noEmit` failed with TS6307 because
  the composite project's file list didn't cover the JSON file cli.ts
  imports for --version.

@inquirer/prompts, @types/semver and yaml resolved within the brief's
declared ranges (^7.9.0 -> 7.10.1, ^7.7.3 -> 7.8.0, ^2.8.1 -> 2.9.0); no
minimumReleaseAge rejection.

59 tests pass (47 pre-existing + 10 args + 2 bin).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Relative imports in emitted modules need a .js extension: the shared config
sets module=preserve, so an extensionless import type-checks and is emitted
unchanged, which Node ESM rejects. Every other package here is consumed by a
bundler, so this is the first one to hit it.

Also keeps catalog/read.ts out of dist — it imports yaml, a devDependency.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
process.exit() can end the process before a pending stdout write has flushed,
which on POSIX is the common case when stdout is a pipe. Every command exits
through this path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Introduces the migration catalogue: MigrationEntry/Kind/Action types, a
build-time reader that parses YAML-frontmatter Markdown files under
src/migrations, and a generator that emits them as a typed
src/migrations.generated.ts the CLI imports at run time via
catalog/entries.ts. Two proof entries author the schema (one codemod, one
manual); the other 19 follow in Task 3.

src/catalog/read.ts imports yaml (a devDependency) and is excluded from
tsconfig.build.json so dist never ships an unresolvable import; it stays
covered by the tsc --noEmit gate.

Renames flowAlphaAlignToCombine.ts to align-to-combine.ts so the catalogue's
action:codemod <-> transform-file-exists invariant holds, and updates every
place that named it, including remoteScope.test.ts's target map (missed by
the task brief but broken by the same rename).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Task briefs, per-task reports, review diffs and the progress ledger are
session-local bookkeeping. They were neither gitignored nor prettier-ignored,
so one got committed and two turned format:check red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ect runnable

Three review findings:

- The generator sorted only on `since`, so the two entries sharing a release
  ordered by directory listing — not guaranteed across platforms, and enough to
  fail CI's generated-code check on another machine.
- `align-to-combine`'s detect command passed `-t tsx`, which ripgrep rejects;
  it errored instead of matching, the one failure mode the field must not have.
- `fail` was an arrow, which does not narrow control flow, so its call sites
  carried a redundant `throw`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Author the remaining 20 catalogue entries (segmented-control-deprecated
through renamed-css-export's two pre-existing siblings now sit alongside them)
and add a guide generator that renders packages/components/MIGRATION.md from
the catalogue, wired into `pnpm nx build codemods`. MIGRATION.md is now
generated, not hand-maintained.

Fixed the two Task 2 "proof of schema" entries (align-to-combine,
renamed-css-export) to carry their body verbatim from the previous guide,
since the generated file must not silently drop migration content.

Rewrote remoteScope.test.ts to check every catalogue entry's `remotePackage`
against the remote package's real export surface, replacing the old
transform-name-keyed target list.

This lands with a known-red catalog.test.ts: 8 `action: codemod` entries still
point at a `flowAlpha*`/`flow020`-named transform file, because renaming
transforms is Task 4's job. See the task-3 report for the full failure
breakdown, including one additional ordering issue in guide.test.ts unrelated
to the transform renames (`since: 0.2.0` sorts above `0.2.0-alpha.*` under
strict semver, even though those alphas shipped later in this project's real
history) — left unresolved pending a maintainer decision, not silently
patched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…igration

The entry claimed `0.2.0`, taken from the guide's "From version 0.1.0 to
version 0.2.0" heading. Neither version was ever published — the headings name
release lines, and the first stable release is 1.0.0. Semver ranks 0.2.0 above
every 0.2.0-alpha.*, so the oldest migration sorted as the newest and the
version gate would have offered a consumer on 0.2.0 none of the alpha
migrations.

The export flattening shipped in 0.2.0-alpha.28: alpha.27 still published one
entry per component, alpha.28 published the flat set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review found several fields that were written but never tested against
realistic input.

Four detect commands under-matched: three anchored a render-prop to the opening
tag on one line, so Prettier-wrapped JSX matched nothing, and the package-root
import entry restricted itself to TypeScript while its own examples are
JavaScript. A detect that matches nothing is the field's worst failure — an
agent concludes the migration does not apply.

Two apply fields were factually wrong. The OverlayController handler type was
widened, not narrowed, so no consumer change is needed; what matters is that a
handler returning false now vetoes the close, which the entry did not mention.
The TooltipTrigger entry invented a numeric mapping that mapped 500ms onto the
1500ms preset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… path

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mfal and others added 9 commits August 31, 2026 08:23
… manual migrations

The lower bound (current < since) on selectEntries encoded a guess: that a
consumer already applied every codemod behind their version. Nothing records
that — bumping a dependency and running this tool are two separate acts, and
a project can sit on a version for months without ever running upgrade.

For a codemod that guess buys nothing: idempotency.test.ts proves re-running
an applied codemod is a no-op. So the gate now drops the lower bound for
action: "codemod" (since <= target only), matching how detectCurrentVersion
already leans on the same idempotence when it falls back to the lowest
version a range allows. The lower bound stays for manual/none migrations,
which have no no-op — showing those again would just be noise a person has
to re-read and re-judge.

upgrade's early "already on X, nothing to do" return no longer skips the
codemod pass: under the old gate that was equivalent to "nothing to run"
too, but a codemod's lower bound is gone, so a project already on target can
still have codemods nothing ever ran. It still skips the manifest write and
the install (those genuinely have nothing to do), and its closing summary
now reports how many codemods ran and how many changed something, so "N run,
0 changed" reads as confirmation rather than a to-do list.

list marks a catch-up codemod (since <= current) apart from a new one, and
names how many manual migrations the window hides without listing them
(list with no revision still shows the whole catalogue).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nd duplicate header

Four presentation-only fixes, no behaviour change:

- A zero-width range ("from 1.0.9 to 1.0.9") now reads as "nothing newer
  than 1.0.9", with a note that the codemods below are catch-up when any
  are.
- The catch-up legend is two short sentences (what the mark means, why
  re-running is safe) instead of one sentence doing three jobs; the trailing
  "(N of M shown here)" is dropped since the counts line above it already
  gives the codemod count.
- The hidden-count line no longer reads as contradicting "N by hand" above
  it: it now says deprecations aren't windowed by version and stay listed,
  unlike the manual migrations it hides.
- `renderList` gets a `header` option (default true); `upgrade`'s by-hand
  section passes `header: false` so it no longer prints its own heading
  followed by renderList's duplicate one for the same entries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fold the hidden manual migration count into the counts line as a third
bullet-separated item (using "manual" to distinguish from "by hand").
Reduce the catch-up legend to one clause naming the version and the
reassurance; drop the internal explanation. When nothing is hidden or
catch-up, omit those items entirely. Presentation only—no behaviour
change, --json stays byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
src/migrations, src/transforms and the pooled fixtures in
src/tests/transforms.test.ts and src/tests/idempotency.test.ts held one
migration's material together only by a shared id spread across three
directories. Move it under one directory per id instead:

  src/migrations/<id>/entry.md            frontmatter + guide prose
  src/migrations/<id>/transform.ts        the codemod (9 of 22 ids)
  src/migrations/<id>/transform.test.ts   its fixtures + idempotency case

to-remote-package has no catalogue entry (a port, not a migration), so it
moved to src/tools/to-remote-package.ts with its test alongside it.

The idempotency suite used to assert coverage (every transform has a fixture)
and run the proof (twice, compare output) in one file. Splitting fixtures per
transform meant splitting that too: the proof now lives in each transform's
own transform.test.ts, and src/tests/transformCoverage.test.ts (renamed from
idempotency.test.ts) is a directory scan that fails when a transform.ts has
no transform.test.ts beside it — independent of what any test file claims to
do, so a forgotten test file can't hide. catalog.test.ts and
remoteScope.test.ts re-expressed their bidirectional entry/transform checks
over the new paths; both got simpler since entry and transform are now
siblings.

Updated alongside: readCatalog, runCodemod's transform resolution,
runTransform's test helper, package.json's files (ships only transform.ts
files, not entry.md or tests — verified with npm pack --dry-run),
tsconfig.build.json's exclude list, vitest.config.ts's include globs, and
AGENTS.md.

No behaviour change: migrations.generated.ts and MIGRATION.md rebuild
byte-identical. Test count is unchanged at 237 (across 28 files, up from 20,
as the two pooled fixture files split one-per-transform).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…erministic

The counts sat above the entries, where a 22-entry list scrolls them out of
sight before the reader reaches the end. Split renderList's header into a
top context (range, catch-up legend) and a bottom summary (counts, hidden
count) that now follows the last entry. `header` is renamed to `frame`
since it gates both halves; upgrade passes `frame: false` to suppress both,
since it already prints its own heading and its own aggregate.

Also fixes a determinism bug found while checking the above: renderList
claimed colour was driven only by its `color` parameter, but `actions` and
`painter` read colour functions off picocolors' module-level default
export, which auto-detects a TTY and silently disables itself off one --
the decision was made twice, once by `color` and once by picocolors, and
they could disagree. `src/tests/list.test.ts` > "colour only when asked
for" passed under the nx target (something sets FORCE_COLOR) and failed
under a bare `vitest run`. Fixed by building the palette per call from
`colors.createColors(color)`, which forces colour on/off instead of
sniffing it -- `color` is now the only input. `env -u FORCE_COLOR
corepack pnpm vitest run` now passes (240/240).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…where

selectEntries gated a manual/no-code-change entry on current < since,
on the assumption a consumer already applied everything behind their
version. That was a guess about work already done, not a fact —
nothing records which migrations a project has performed. Dropping
the same guess for codemods (idempotent, verified by
transformCoverage.test.ts) but keeping it for manual entries was
inconsistent, and it hid exactly the migrations a consumer who never
ran this tool needed to see: for them, the manual steps are as
undone as the codemods.

The gate is now one rule for every entry: since <= target. current no
longer takes part in selection at all — it still flows into list and
upgrade for the catch-up mark, extended from codemods to every entry.
The mark replaces the hiding: a reader sees what's new and what's
catch-up ("may already be done", never "already done") instead of a
hidden count. The hidden-count helper and its summary line are gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Unwrapped it ran to 150 characters and broke hard in any terminal narrower
than that — the one line in the output that ignored the width.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The co-location commit invalidated the path `src/migrations/*.md`, which four
places still named — two of them generators, so the wrong path was reaching
MIGRATION.md and migrations.generated.ts, where an agent looks to find the
source.

CONTRIBUTE.md still told contributors to ship "a MIGRATION.md entry". That file
is generated; the entry goes into the catalogue.

The consumer front doors (root README, the components README/USAGE, the remote
package's USAGE) named the guide but not the command that applies the codemods
in it, and the Align docs page said "use the codemod from MIGRATION.md" where
the id now gives an exact invocation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The page got you installed and stopped there — it named neither the upgrade
command nor the versioning guarantees, so the one guide that explains what a
version bump does to your code was reachable only through the navigation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mfal
mfal marked this pull request as draft August 31, 2026 06:24
@mfal
mfal force-pushed the claude/flow-codemod-upgrade-cli-f87427 branch from 9e1473e to 081cb1f Compare August 31, 2026 06:25
mfal and others added 4 commits August 31, 2026 08:36
…subpath

Two findings from a real consumer upgrade.

A `color` value hidden in a ternary was left behind. The three transforms that
rewrite a literal prop value all skipped anything dynamic, which their
docblocks called out but which a real codebase hits immediately —
`color={hasApiToken ? "secondary" : "accent"}` needed hand work twice in one
upgrade. They now rewrite every position whose value can reach the prop: the
expression itself, both ternary branches, the operands of `??`/`||`, and the
right operand of `&&` (its left operand only surfaces when falsy, and a
non-empty string never is). A lookup key stays, and so does a value the
transform cannot see into.

AccentBox needs a stricter rule than the other two, because it decides per
attribute rather than per literal: it renames only when every value position is
a literal and they all fall on the same side of the background/content split.
A mix has no single right answer and keeps `color`.

The renamed password-tools subpath was missing from the catalogue entirely.
`0.2.0-alpha.913` renamed the export to `/password-tools` and
`0.2.0-alpha.1000` reverted it, so code written inside that window fails to
compile with TS2307 — no deprecation, no fallback. Its transform is guarded by
a test asserting the path it writes is one the package really exports and the
path it rewrites away from is not.

The helpers are duplicated per transform on purpose: the CLI loads
src/migrations/<id>/transform.ts straight out of the published package, which
ships nothing else, so a shared module would pass every local test and crash
for consumers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `unit` job failed on my new path guard with an ENOENT on
`packages/remote-react-components/src/auto-generated/index.ts` — a file that is
committed and present at checkout.

The cause is a race, not a missing file. `generateRemoteComponents.ts` calls
`jetpack.remove()` on the whole `auto-generated` directory and writes
`index.ts` back only after generating every component file, so the file is gone
for as long as that loop runs. nx schedules this package's `test:unit`
concurrently with `components:build`, and `tests/remoteScope.ts` read that file
at module scope — putting the window in front of every test file that imports
the module. The pre-existing importer got lucky; mine doubled the exposure and
hit it.

Two changes. The guard now reads `packages/components/package.json` directly:
its subject is that package's `exports` map, the manifest is committed, and no
generator touches it. And `remoteExports` is read on demand instead of at
import time, so only the one test that needs the remote surface is exposed.

Verified by removing the generated file and re-running: the guard passes, and
the failure is confined to `remoteScope.test.ts` as test failures rather than
an import-time crash that takes a whole file down.

The generator's non-atomic regeneration is untouched — it is a repo-wide race
that belongs in its own change, not in a codemods PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mfal
mfal marked this pull request as ready for review August 31, 2026 07:10
mfal and others added 5 commits August 31, 2026 09:14
Four claims in ADR 0006 described the draft, not the result.

The entry fields: detect and verify were built and then cut, so "every entry
carries detect, apply and verify" was false, and so was the consequence that
rested on it. Only apply remains, plus a codemod where the change is
mechanically decidable. The cut is now recorded as a non-goal with its two
reasons, so nobody re-proposes it without solving the part that failed.

The command surface: list takes a revision, not --from/--to. And upgrade runs
every codemod up to the target, not "the crossed range" — selectEntries filters
on `lte(entry.since, target)` alone, deliberately, because nothing records what
a project has already done.

AGENTS.md carried the same detect/apply/verify claim and is corrected with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dropping the gate's lower bound left the wording behind in nine places. "Runs
the codemods the crossed version range calls for" describes a range with two
ends; selectEntries has one — `since <= target`, deliberately, because nothing
records which migrations a project already performed. A reader following the
old wording would not expect the older entries the command actually offers, and
would read them as a bug.

The same sentence had spread through the root README, the ADR's consequences,
the codemods README twice, the generated guide's intro, and both German docs
pages. The guide and the versioning page now also say why older entries appear,
since that is where a consumer meets them.

Two more claims were wrong rather than vague. The source-path default is `./src`
when that directory exists and only otherwise the project root — worth stating,
because an unrelated `src/` beside the real sources silently wins. And the exit
codes omitted two refusals the CLI has: an unknown id, and an id whose migration
has no codemod.

Finally the "adding a migration" checklist: it said to add `targets` in
remoteScope.test.ts only when the migration applies to the remote package. Every
catalogue id has to be listed there. Following the old step is what broke the
unit job two commits ago, so the step now says so, including the `[]` +
notNameScoped case for a layout entry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two claims in the repo's guides described a state that no longer exists.

CONTRIBUTE.md told a contributor to create four files per component doc page —
index.mdx plus overview.mdx, guidelines.mdx and develop.mdx. #2730 deleted 256
of those files and consolidated everything onto index.mdx; none of the three
exist anywhere in the repo any more. Following the instruction produced three
files the docs app does not render. apps/docs/README.md and apps/docs/AGENTS.md
already described the consolidated layout, so CONTRIBUTE.md was the only one
left behind — which is the worst place for it, being what a new contributor
reads first.

The cross-version claim was inverted by #3006. Both this package's CONTRIBUTE.md
and the root AGENTS.md footgun row said the harnesses run on a schedule and
"not automatically on pull requests". The iframe harness now runs on every PR as
the `cross-version` job in test.yml, gated on nx affected and installing the
published versions from the registry. Only the in-process harness and the full
per-version matrix are schedule- or label-only. The label advice survives, but
its premise had to change: it is no longer "nothing runs on the PR".

Also: `pnpm test` runs test:links too, which its comment did not mention.

Verified mechanically alongside this: every backticked path in all 32
AGENTS/README/CONTRIBUTE/USAGE files resolves, every documented `pnpm nx` target
exists against nx's own project graph, and every identifier they name appears in
the source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Lisa18289

Copy link
Copy Markdown
Member

Reviewed by checking out the branch: build clean, git diff --exit-code clean, eslint clean, 246 tests green. Ran the built binary against temp projects and the real registry.

The architecture is right — catalogue as single source, generated MIGRATION.md, resolveRange shared between list and upgrade, driving jscodeshift's Runner instead of scraping its CLI, resolving from the intersection of published versions. Three things I'd want resolved before merge.

1. upgrade breaks correct, current code.

Selection has no lower bound, so imports-to-package-root (since: 0.2.0-alpha.28) runs on every 1.x project. The transform's else branch is a catch-all and it knows only four subpaths, so every subpath that exists in today's exports map is flattened onto the package root:

import "@mittwald/flow-react-components/all-layered.css";   // → import "@mittwald/flow-react-components";  (stylesheet silently dropped)
import { Rule } from ".../mittwald-password-tools-js";       // → from "@mittwald/flow-react-components";
import { Button } from ".../flr-universal";                  // → from "@mittwald/flow-react-components";
import { something } from ".../internal";                    // → from "@mittwald/flow-react-components";

Default imports also become named specifiers. On a clean 1.0.0 project whose only Flow line is a correct mittwald-password-tools-js import, upgrade --dry reports imports-to-package-root: 1 file(s) changed / 10 codemods run, 1 changed something.

Idempotency doesn't cover this. It proves "running the same transform twice changes nothing", not "an era-specific transform is harmless against code from a later era". Either era-scoped entries need an upper bound (until), or this transform needs an allowlist of the subpaths that existed at alpha.28 instead of the catch-all else.

2. The run order lets one migration cancel another.

selectEntries sorts ascending by since, so imports-to-package-root (alpha.28) runs before password-tools-subpath-renamed (alpha.1000). The first flattens …/password-tools onto the root; the second then finds nothing:

password-tools-subpath-renamed: 0 file(s) changed, 1 unchanged.
→ import { Rule } from "@mittwald/flow-react-components";

The root doesn't export Rule. The migration meant to fix exactly this reports success, and the aggregate reads "10 codemods run, 1 changed something".

3. First publish of a new package name under trusted publishing.

@mittwald/flow-codemods was private: true and does not exist on npm. publish.yml publishes via npm Trusted Publisher OIDC with provenance, which is configured per package — the comment at the top of that workflow describes this failure class. lerna publish from-package --concurrency 1 would abort mid-release, with the packages ahead of it already published. This needs an npm-side bootstrap before merge.

Should still go in

  • hasUncommittedChanges fails open too widely: exit 128 is not only "not a repository" — detected dubious ownership is 128 too, and that's exactly the minimal CI container the guard is written for. Check git rev-parse --is-inside-work-tree first, or read stderr instead of ignoreing it.
  • nx wiring: codemods:build writes packages/components/MIGRATION.md, which sits inside components-src's default glob, with no dependency edge between the two projects. Under nx run-many --targets=build nothing decides whether components:build is hashed before or after that rewrite. Exclude MIGRATION.md from components-src, or add the edge.
  • engines: ">=24.0.0" on a consumer-facing CLI. The repo needs 24, consumers don't — the whole suite runs on Node 22. Anyone on 20/22 gets EBADENGINE from npx.
  • peerDependencies are rewritten too. A library declaring peerDependencies: { "@mittwald/flow-react-components": "^1.0.0" } gets ^1.0.14, narrowing what its consumers may install. Intended?

Smaller

  • kind has no effect on anything. Its only read is the label in list.ts:187; since the lower bound is gone, migration and deprecation behave identically. The table in the description ("decides when") describes code that isn't there.
  • Stale numbers in the description and in ADR 0006: 23 entries not 22, 10 codemods not 9, 246 tests not 222. The description also cites idempotency.test.ts, which no longer exists — transformCoverage.test.ts only asserts a transform.test.ts file is present, not that it contains an idempotency case (all 10 do today).
  • Out of scope for this PR: .superpowers in .gitignore/.prettierignore, the rewritten cross-version row in AGENTS.md, the CONTRIBUTE.md docs-authoring section (feat(docs): consolidate component tabs into a single page #2730).
  • Comment density: list.ts carries ~140 comment lines over 280 lines of code, cli.ts opens with 12 lines on stdout-vs-stderr. Much of it is decision rationale that belongs in the ADR or the commit body.
  • Ctrl+C at the codemod checkbox rejects out of runUpgrade after package.json is written and installed — the user sees an inquirer error rather than "bumped and installed, no codemods run". @inquirer/prompts is also imported eagerly, so list pays for it.
  • list --json carries no range metadata (current/target), so the agent-facing form can't tell which range it got.
  • On the 1.x line list and list minor return the same 23 entries, all marked catch-up, so "exactly the range upgrade would act on" is currently indistinguishable from browsing the catalogue.

The error messages are genuinely good — "minor" looks for the highest published stable release in 0.x; none is published. Use "major" (→ 1.0.14) is the kind of message one hopes for.

Review found that dropping the gate's lower bound turned an era-specific
transform into a corrupter of correct code, and I reproduced all of it.

`imports-to-package-root` (since alpha.28) collapsed 94 subpath exports onto the
package root, and it did that with a catch-all `else`. With no lower bound it
reaches every 1.x project, where five of today's nine subpaths were collateral:
`all-layered.css` became a JS import with the stylesheet silently gone, and
`Rule` from `mittwald-password-tools-js` moved onto a root that does not export
it. A default import became a named specifier along the way.

It also cancelled another migration. Sorted by `since` it runs first, so it
flattened `…/password-tools` before `password-tools-subpath-renamed` — the entry
whose whole job is that path — could see it. Both then reported success while
the file no longer compiled.

Both have one cause, so one fix: an explicit leave-alone set. It holds every
subpath the package still exports, plus `password-tools`, which another entry
owns. Era code still migrates — verified both directions. Two independent tests
pin it, one comparing the set against the real `exports` map so a new subpath
cannot quietly become collateral again; both fail if a single entry is removed.

Idempotency could never have caught this, and a comment in manifest.ts leaned on
it as if it could. It proves a second pass over a transform's own output changes
nothing — not that an era-specific transform is harmless against later code.

Also from the review:

- The dirty-tree guard treated every git exit 128 as "not a repository", so
  `detected dubious ownership` — the minimal CI container the guard exists for —
  read as a clean tree. It now matches the message, with `LC_ALL=C` pinning it.
- `peerDependencies` were rewritten. A peer range states what a package
  supports, not what it installs; narrowing `^1.0.0` to `^1.0.14` is a breaking
  change to someone else's package made by a command they ran on their own. They
  are now reported and left, through one reporter `--dry` shares.
- `engines.node` was `>=24.0.0` on a CLI consumers reach through `npx`. The only
  thing needing 24 was an import attribute for reading the version; that is a
  `readFileSync` now, and the floor is `>=22.0.0`.
- `components-src` included the `MIGRATION.md` that `codemods:build` writes,
  with no edge between the projects, so its hash depended on task order.
  Excluded.
- Ctrl+C at the codemod prompt rejected out of `runUpgrade` after the bump and
  install, showing an inquirer trace instead of what had happened. It now
  reports that and runs nothing. `@inquirer/prompts` also loads lazily, so
  `list` no longer pays for it.
- `list --json` was a bare array that could not say which range it described —
  on the 1.x line `list` and `list minor` select the same entries. It now
  carries `range` and per-entry `catchUp`. This replaces a test that pinned the
  bare array; that test recorded the old shape, it did not defend it.
- `kind`'s docblock still described the selection it once drove. It is
  descriptive now, and says so.

Not fixed here, because it is not in the repo: `@mittwald/flow-codemods` does
not exist on npm, and npm binds one Trusted Publisher per package. The first
publish needs an npm-side bootstrap or `lerna publish from-package` aborts
mid-release with the packages ahead of it already out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mfal

mfal commented Aug 31, 2026

Copy link
Copy Markdown
Member Author

Reproduced all three blockers before touching anything. 1 and 2 are fixed in 36cbd48c4; 3 is real and not fixable in the repo.

1 + 2 have one cause, so one fix. Both come from imports-to-package-root's catch-all else. It now carries an explicit leave-alone set: every subpath the package still exports, plus password-tools, which password-tools-subpath-renamed owns. Verified both directions — current code is 0 file(s) changed, era code still migrates (/Button → root, globals.cssall.css, react-hook-form/useFooreact-hook-form), and the rename now finds its path instead of the flatten having eaten it.

Two independent tests pin it, one comparing the set against the real exports map; removing a single entry fails both. I chose the intrinsic guard over an until bound because applicability here is a property of the code, not of the target version: a consumer genuinely on alpha.20 still needs this transform, and an upper bound keyed on the target would deny it to them.

You're right that idempotency could never have caught this — and worse, a comment in manifest.ts leaned on it as if it could. Corrected there and in the PR description.

3 stands and is yours. Confirmed: @mittwald/flow-codemods is E404 on the registry, publish.yml runs lerna publish from-package --yes --concurrency 1, and the workflow's own header documents that npm binds one Trusted Publisher per package. Nothing in the repo fixes it — the new name needs an npm-side bootstrap before this merges, or the next release aborts mid-run with the packages ahead of it already published. Worth noting no Flow package declares publishConfig, so access is handled elsewhere and adding one here would be noise.

Fixed from the rest: the git 128 guard now matches the message with LC_ALL=C pinning it, not the exit code. peerDependencies are reported and left alone, through one reporter --dry shares. MIGRATION.md excluded from components-src. Ctrl+C at the prompt reports what already happened instead of throwing an inquirer trace, and @inquirer/prompts loads lazily so list no longer pays for it. list --json now carries range plus per-entry catchUp — this replaced a test that pinned the bare array, which recorded the old shape rather than defending it. kind is descriptive; its docblock and the description's table said otherwise, both corrected, field kept because it tells a reader whether their code compiles today. Numbers and the idempotency.test.ts reference fixed in the description and the ADR.

One correction. CI runs Node 24, not 22 — prepare-workspace and every workflow pin node-version: 24, and there is no .nvmrc. Your point stands regardless: >=24.0.0 on a package reached through npx is a consumer-facing defect. The only thing needing 24 was an import attribute for reading the version, now a readFileSync; the floor is >=22.0.0. Flagging that I could not test it — this machine has only Node 24 — so 22 is reasoned from the feature set (toSorted is the highest bar), not measured. If you want it earned, a Node 22 leg on the codemods unit tests would do it.

On the out-of-scope three: all deliberate, and two exist because of this PR's timeline rather than despite it. The cross-version row and the CONTRIBUTE docs section were both wrong when I found them — #3006 landed the PR-gated iframe harness under this branch's feet and left the "not on pull requests" claim behind, and #2730's tab consolidation left CONTRIBUTE telling contributors to create three files that no longer exist. Happy to split them into their own PR if you'd rather review them separately; .superpowers I'll defend, the branch's tooling writes there.

Comment density: agreed, and list.ts is the worst of it. Say the word and I'll do a pass moving the decision rationale into the ADR; I left it for now because it is the kind of change that is easier to review on its own.

Not addressed, by choice: that list and list minor coincide on the 1.x line. That is the gate having one rule, which is deliberate — every entry predates 1.0.0, so any revision selects all of them. It resolves itself as soon as anything ships with a 1.x since.

🤖 Addressed by Claude Code

mfal and others added 3 commits August 31, 2026 10:59
… read

The `unit` job failed on the cancel case I added with the review fixes. It
passed here and failed on the runner for one reason: `createChoose` read
`process.env.CI` inside itself, underneath the parameters the caller passes. On
a runner `CI` is set, so the prompt never ran, so it never rejected, so the
cancel path the test asserts was unreachable.

That is the second time a hidden environment read under an explicit parameter
has produced a green local suite and a red CI one — picocolors self-disabling
beneath the `color` argument was the first. So `isCI` is an input now, stated by
`cli.ts` where reading the environment is the job, and TypeScript makes every
call site name it.

Two things fell out. The behaviour that read gave — a TTY on an unattended
runner must not prompt — was untested, because no test could reach it; it has a
case now. And the one existing test that wanted the prompt shown had to save,
delete and restore `process.env.CI` around itself, with a comment explaining
the workaround. That scaffolding is gone, and no test in this package touches
`process.env` any more.

Verified both ways: 251 tests with `CI=true` and with `CI` unset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mfal
mfal merged commit 0334cef into next Aug 31, 2026
21 checks passed
@mfal
mfal deleted the claude/flow-codemod-upgrade-cli-f87427 branch August 31, 2026 10:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants