@mittwald/flow-codemods 1.3.0-next.9 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -60,56 +60,6 @@ and where to get the right one — rather than installing with the wrong manager
60
60
  The log line names the agent, the pin and the command it actually ran, so a
61
61
  wrong detection is visible instead of silent.
62
62
 
63
- #### Peer requirements
64
-
65
- Before the install, `upgrade` prints what the Flow packages you declare peer on
66
- at the target — react, react-dom, react-hook-form, `@internationalized/date`,
67
- next, i18next — grouped by range and naming every package that asks for it:
68
-
69
- ```
70
- Peer requirements at 1.2.4 — what the Flow packages you declare ask of your
71
- project:
72
-
73
- next ^16.2.3 — optional
74
- @mittwald/flow-react-components
75
- react ^19.2.0 — required
76
- required by @mittwald/flow-react-components
77
- optional for @mittwald/ext-bridge
78
- ```
79
-
80
- Each peer carries its status, because that is the question you have: **must I
81
- install this at all?** One package requiring it settles that, however many
82
- others call it optional — so `react` above reads `required`, and the two sides
83
- are broken out underneath. Where every package agrees, the status alone carries
84
- it.
85
-
86
- **Optionality is per package**, and it comes from `peerDependenciesMeta`: the
87
- same `react ^19.2.0` is a hard requirement of `flow-react-components` and an
88
- optional peer of `ext-bridge`. An optional peer need not be installed at all —
89
- it only has to match the range if it is. Most of what Flow peers on is optional,
90
- so the ranges alone would tell a Vite project it needs Next 16.
91
-
92
- Your package manager reports conflicts too, but only the ones it decides to name
93
- — Yarn's "`@mittwald/ext-bridge` and other dependencies" never enumerates the
94
- others. This list names every one of them, and it is printed **before** the
95
- install, so a failed install still leaves it on screen. `list [revision]` prints
96
- the same thing without touching anything.
97
-
98
- The list covers the Flow packages your `package.json` **declares**. A project
99
- depending only on `flow-react-components` never sees what `flow-remote-core`
100
- peers on, because it never asks for it.
101
-
102
- These are the ranges Flow declares, **not** a check of what you have installed.
103
- Nothing here reads your `node_modules`; whether your project satisfies them is
104
- the package manager's call during the install.
105
-
106
- A Flow-internal peer the target does not satisfy gets its own warning. Flow
107
- publishes those as exact pins and releases only the packages that changed, so
108
- the pin usually names a neighbouring release rather than the version you are
109
- installing — your package manager will report the conflict. Rarely the pinned
110
- version was never published at all; see the publish-hole caveat in the
111
- repository's release docs.
112
-
113
63
  After installing, `upgrade` runs the codemod of every migration whose `since` is
114
64
  at or below the target and prints the ones with no codemod, for you to apply by
115
65
  hand.
@@ -155,11 +105,8 @@ involve.
155
105
  Options:
156
106
 
157
107
  - `--json` — machine-readable output: an object with `range`
158
- (`current`/`target`, or `null` for the offline whole-catalogue form),
159
- `migrations`, each entry carrying `catchUp`, and `peers` — `external` and
160
- `flowPins`, each a list of `{ peer, range, requiredBy, optionalFor }`, or
161
- `null` for the offline whole-catalogue form, which has no target to read them
162
- at
108
+ (`current`/`target`, or `null` for the offline whole-catalogue form) and
109
+ `migrations`, each entry carrying `catchUp`
163
110
 
164
111
  ### `<id> [path]`
165
112
 
@@ -184,5 +131,6 @@ npx @mittwald/flow-codemods@latest to-remote-package src
184
131
 
185
132
  `0` on success, `1` on a refusal — dirty tree, unresolvable revision, failed
186
133
  install, an unknown id, or an id whose migration has no codemod — and `1` when a
187
- codemod reports an error, is declined for every file it looked at, or found
188
- nothing under `path` to process.
134
+ codemod reports an error, found nothing under `path` to process, or declined
135
+ every file it looked at. "Every" means every: a decline among files the
136
+ transform did read is a per-file fact, not a failed run.
@@ -78,14 +78,19 @@ export const runSingleCodemod = async (parsed, { cwd, log, run = runCodemod }) =
78
78
  }
79
79
  // The same trap as `processedNothing`, one field over: a transform that
80
80
  // declines a file by returning nothing counts as `skipped`, not `unmodified`.
81
- // If every file was skipped and none changed, "0 file(s) changed" would read
81
+ // If every file was declined and none changed, "0 file(s) changed" would read
82
82
  // as a clean no-op run when in fact the transform bailed on everything.
83
- if (result.changed === 0 && result.skipped > 0) {
83
+ //
84
+ // "All" means all: `unmodified > 0` says the transform read those files and
85
+ // handed them back, so a decline among them is a per-file fact, not a failed
86
+ // run.
87
+ if (result.changed === 0 && result.unmodified === 0 && result.skipped > 0) {
84
88
  log(`${id}: the transform declined all ${result.skipped} file(s) it looked at, and changed none.`);
85
89
  return 1;
86
90
  }
87
91
  const skipped = result.skipped > 0 ? `, ${result.skipped} skipped` : "";
88
- const summary = `${id}: ${result.changed} file(s) changed, ${result.unmodified} unchanged${skipped}.`;
92
+ const empty = result.empty > 0 ? `, ${result.empty} empty` : "";
93
+ const summary = `${id}: ${result.changed} file(s) changed, ${result.unmodified} unchanged${skipped}${empty}.`;
89
94
  // Only a catalogued id has a migration guide entry to point at — a transform
90
95
  // like `to-remote-package` with no catalogue entry has no anchor in
91
96
  // `MIGRATION.md` to link, so pointing there would be a dead link.
@@ -1,5 +1,4 @@
1
1
  import { type CatalogEntry } from "../catalog/entries.js";
2
- import type { PeerSummary } from "../resolve/peers.js";
3
2
  import { type RangeDeps } from "../resolve/range.js";
4
3
  import type { ParsedCommand } from "./args.js";
5
4
  export interface RenderListInput {
@@ -14,15 +13,6 @@ export interface RenderListInput {
14
13
  to: string;
15
14
  };
16
15
  json: boolean;
17
- /**
18
- * The peer ranges the Flow packages declare at `range.to`, from
19
- * `resolveRange`.
20
- *
21
- * Only meaningful together with `range`, and omitted by `upgrade`, which
22
- * prints its own peer section before the install rather than inside this list
23
- * — see `runUpgrade`.
24
- */
25
- peers?: PeerSummary;
26
16
  /** Emit ANSI colour. Off by default so a test sees plain text. */
27
17
  color?: boolean;
28
18
  /** Terminal width to wrap prose to. */
@@ -57,6 +47,7 @@ export interface RenderListInput {
57
47
  */
58
48
  frame?: boolean;
59
49
  }
50
+ export declare const stripAnsi: (text: string) => string;
60
51
  /**
61
52
  * The invocation to print when nobody says otherwise.
62
53
  *
@@ -76,7 +67,7 @@ export declare const defaultInvoke = "npx @mittwald/flow-codemods@latest";
76
67
  * so the rendering is deterministic in a test — `painter` builds its palette
77
68
  * from `color` alone, never from the terminal. `cli.ts` supplies them.
78
69
  */
79
- export declare const renderList: ({ entries, range, json, peers, color, width, path, invoke, frame, }: RenderListInput) => string;
70
+ export declare const renderList: ({ entries, range, json, color, width, path, invoke, frame, }: RenderListInput) => string;
80
71
  export interface ListDeps extends RangeDeps {
81
72
  /**
82
73
  * Raw output writer — matches `process.stdout.write`'s own contract: no
package/dist/cli/list.js CHANGED
@@ -1,9 +1,8 @@
1
+ import colors from "picocolors";
1
2
  import { lte } from "semver";
2
3
  import { allEntries } from "../catalog/entries.js";
3
4
  import { selectEntries, sortBySince } from "../catalog/select.js";
4
5
  import { defaultRangeDeps, resolveRange, } from "../resolve/range.js";
5
- import { renderPeers } from "./peers.js";
6
- import { painter, wrap } from "./text.js";
7
6
  /**
8
7
  * What the reader has to do, and the colour that says it at a glance.
9
8
  *
@@ -22,6 +21,39 @@ const actions = {
22
21
  tone: "blue",
23
22
  },
24
23
  };
24
+ /**
25
+ * ANSI escapes have no width; measuring must ignore them.
26
+ *
27
+ * Built from a char code rather than written as a literal, so no control
28
+ * character sits in the source — which is also what `no-control-regex` wants.
29
+ */
30
+ const ansi = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
31
+ export const stripAnsi = (text) => text.replace(ansi, "");
32
+ const visibleWidth = (text) => stripAnsi(text).length;
33
+ /**
34
+ * Wraps to `width`, measuring visible width so already-coloured text still
35
+ * breaks in the right place.
36
+ */
37
+ const wrap = (text, width) => {
38
+ const lines = [];
39
+ let line = "";
40
+ for (const word of text.split(/\s+/).filter((part) => part !== "")) {
41
+ if (line === "") {
42
+ line = word;
43
+ }
44
+ else if (visibleWidth(line) + 1 + visibleWidth(word) <= width) {
45
+ line = `${line} ${word}`;
46
+ }
47
+ else {
48
+ lines.push(line);
49
+ line = word;
50
+ }
51
+ }
52
+ if (line !== "") {
53
+ lines.push(line);
54
+ }
55
+ return lines.length > 0 ? lines : [""];
56
+ };
25
57
  /**
26
58
  * Renders `code` spans as colour instead of literal backticks.
27
59
  *
@@ -30,6 +62,27 @@ const actions = {
30
62
  * scans for — a symbol name or a command.
31
63
  */
32
64
  const inlineCode = (text, paint) => text.replace(/`([^`]+)`/g, (_, code) => paint.code(code));
65
+ /**
66
+ * Builds a palette from `color`, and only from `color`.
67
+ *
68
+ * `colors` (the module-level `picocolors` default export) auto-detects a TTY
69
+ * and disables itself under one — reading `colors.green` etc. directly would
70
+ * make the _environment_, not this argument, the real decider, silently
71
+ * disagreeing with `cli.ts`'s own TTY/`NO_COLOR`/`--json` check. `createColors`
72
+ * returns a palette with colour forced on or off, so `color` is the only input
73
+ * to how this renders.
74
+ */
75
+ const painter = (color) => {
76
+ const palette = colors.createColors(color);
77
+ return {
78
+ bold: palette.bold,
79
+ dim: palette.dim,
80
+ code: palette.cyan,
81
+ green: palette.green,
82
+ yellow: palette.yellow,
83
+ blue: palette.blue,
84
+ };
85
+ };
33
86
  /**
34
87
  * The invocation to print when nobody says otherwise.
35
88
  *
@@ -157,7 +210,7 @@ const renderSummary = (selected, color) => {
157
210
  * so the rendering is deterministic in a test — `painter` builds its palette
158
211
  * from `color` alone, never from the terminal. `cli.ts` supplies them.
159
212
  */
160
- export const renderList = ({ entries, range, json, peers, color = false, width = 80, path = "src", invoke = defaultInvoke, frame = true, }) => {
213
+ export const renderList = ({ entries, range, json, color = false, width = 80, path = "src", invoke = defaultInvoke, frame = true, }) => {
161
214
  // The two paths differ in their bounds, deliberately: unbounded, this is a
162
215
  // plain catalogue browse — every entry, sorted, regardless of whether it
163
216
  // would apply to any given range. Bounded, it answers "what does this
@@ -182,21 +235,10 @@ export const renderList = ({ entries, range, json, peers, color = false, width =
182
235
  ...entry,
183
236
  catchUp: isCatchUp(entry, range?.from),
184
237
  })),
185
- // `null` rather than omitted, so a consumer can tell "no peers at this
186
- // target" from "this form does not carry peers" — a bare `list` has no
187
- // range to read them at.
188
- peers: peers ?? null,
189
238
  }, null, 2)}\n`;
190
239
  }
191
- // A property of the range, not of the entries, so it prints even when no
192
- // migration falls in it: a consumer already sitting on `target` can still be
193
- // missing a peer, and that is exactly the range that selects nothing.
194
- const peerSection = peers === undefined || range === undefined
195
- ? ""
196
- : renderPeers({ summary: peers, target: range.to, color, width });
197
240
  if (selected.length === 0) {
198
- const nothing = "Nothing to migrate in that range.\n";
199
- return peerSection === "" ? nothing : `${nothing}\n${peerSection}`;
241
+ return "Nothing to migrate in that range.\n";
200
242
  }
201
243
  const body = selected
202
244
  .map((entry) => renderEntry(entry, width, color, isCatchUp(entry, range?.from), path, invoke))
@@ -211,7 +253,7 @@ export const renderList = ({ entries, range, json, peers, color = false, width =
211
253
  // gets left dangling above the first entry.
212
254
  const context = renderContext(selected, range, color, width);
213
255
  const summary = renderSummary(selected, color);
214
- return `${context === "" ? "" : `${context}\n\n`}${peerSection === "" ? "" : `${peerSection}\n`}${body}\n\n${summary}\n`;
256
+ return `${context === "" ? "" : `${context}\n\n`}${body}\n\n${summary}\n`;
215
257
  };
216
258
  export const defaultListDeps = (cwd) => ({
217
259
  ...defaultRangeDeps(cwd),
@@ -253,7 +295,6 @@ export const runList = async (parsed, deps) => {
253
295
  entries: allEntries,
254
296
  range: { from: resolved.current, to: resolved.target },
255
297
  json: parsed.json,
256
- peers: resolved.peers,
257
298
  color: deps.color,
258
299
  width: deps.width,
259
300
  path: deps.path,
@@ -12,7 +12,6 @@ import { readInstalledVersion, resolveRange } from "../resolve/range.js";
12
12
  import { runCodemod } from "../run/jscodeshift.js";
13
13
  import { displaySourcePath, resolveSourcePath } from "./codemod.js";
14
14
  import { renderList } from "./list.js";
15
- import { renderPeers } from "./peers.js";
16
15
  export const defaultUpgradeDeps = (cwd) => ({
17
16
  cwd,
18
17
  fetchVersions,
@@ -87,7 +86,7 @@ export const runUpgrade = async (parsed, deps) => {
87
86
  log(range.reason);
88
87
  return 1;
89
88
  }
90
- const { manifestPath, manifestRaw, manifest, dependencies, current, target, peers, } = range;
89
+ const { manifestPath, manifestRaw, manifest, dependencies, current, target } = range;
91
90
  // A stale dist-tag or an exact version at or below `current` resolves
92
91
  // without complaint — `resolveRange` deliberately does not judge that
93
92
  // (`list` treats the same fact as a legitimate answer). There is nothing to
@@ -106,36 +105,15 @@ export const runUpgrade = async (parsed, deps) => {
106
105
  : ` ${dependency.name} ${dependency.range} — left as it is, ${dependency.field} states what your package supports, not what it installs`);
107
106
  }
108
107
  };
109
- // Printed before the install in every branch, never after it: the install
110
- // can fail (and returns early below), yet a peer range the new version needs
111
- // is exactly what the reader wants in hand when it does. It is also the one
112
- // part of this output that stays true when nothing is bumped at all — a
113
- // project sitting on `target` can still be missing a peer.
114
- const reportPeers = () => {
115
- const rendered = renderPeers({
116
- summary: peers,
117
- target,
118
- color: deps.color,
119
- width: deps.width,
120
- });
121
- if (rendered !== "") {
122
- // Blank line on both sides: this sits between the dependency list and
123
- // whatever the install prints next, and without the trailing one the
124
- // package manager's first line reads as part of the summary.
125
- log(`\n${rendered.trimEnd()}\n`);
126
- }
127
- };
128
108
  const bump = gt(target, current);
129
109
  if (!bump) {
130
110
  // Nothing to write or install — fall through to the codemod pass below.
131
111
  log(`Already on ${current}; "${revision}" resolves to ${target}. No dependency bump needed — checking for codemods to catch up on, since nothing records whether this project already ran them.`);
132
- reportPeers();
133
112
  }
134
113
  else if (dry) {
135
114
  log(`Upgrading Flow from ${current} to ${target} (--dry)`);
136
115
  log(`--dry: would write the following to ${manifestPath}:`);
137
116
  reportDependencies();
138
- reportPeers();
139
117
  log("--dry: skipping the install.");
140
118
  }
141
119
  else {
@@ -143,7 +121,6 @@ export const runUpgrade = async (parsed, deps) => {
143
121
  const indent = detectIndent(manifestRaw);
144
122
  writeFileSync(manifestPath, `${JSON.stringify(applyTarget(manifest, target, flowPackages), null, indent)}\n`, "utf8");
145
123
  reportDependencies();
146
- reportPeers();
147
124
  const manager = await detectPackageManagerIn(cwd);
148
125
  try {
149
126
  // Logged after the run, not before: `install` returns what it actually
@@ -191,9 +168,9 @@ export const runUpgrade = async (parsed, deps) => {
191
168
  if (result.changed > 0) {
192
169
  changedCount += 1;
193
170
  }
194
- // Same three-way distinction as the single-codemod command: "0 changed" on
195
- // its own would read as success where nothing was looked at, or where the
196
- // transform declined everything it saw.
171
+ // Same three-way distinction as the single-codemod command, and the same
172
+ // reading of "declined all": `unmodified > 0` proves the transform read
173
+ // files and handed them back, so it did not bail on the tree.
197
174
  if (result.errors > 0) {
198
175
  hadFailure = true;
199
176
  log(` ${entry.id}: ${result.errors} file(s) failed to transform`);
@@ -202,7 +179,9 @@ export const runUpgrade = async (parsed, deps) => {
202
179
  hadFailure = true;
203
180
  log(` ${entry.id}: no files under ${path} were processed`);
204
181
  }
205
- else if (result.changed === 0 && result.skipped > 0) {
182
+ else if (result.changed === 0 &&
183
+ result.unmodified === 0 &&
184
+ result.skipped > 0) {
206
185
  hadFailure = true;
207
186
  log(` ${entry.id}: declined all ${result.skipped} file(s) it looked at`);
208
187
  }
@@ -1,5 +1,4 @@
1
1
  import { type FlowDependency, type Manifest } from "../manifest.js";
2
- import { type PeerSummary } from "./peers.js";
3
2
  import { fetchVersions } from "./registry.js";
4
3
  /**
5
4
  * The installed version of `name`, read from `cwd`'s `node_modules`.
@@ -24,16 +23,6 @@ export interface ResolvedRange {
24
23
  current: string;
25
24
  target: string;
26
25
  versions: string[];
27
- /**
28
- * What the declared Flow dependencies peer on at `target` — the answer to
29
- * "which Flow package needs which peer range", which otherwise takes reading
30
- * every Flow `package.json` by hand (#3059).
31
- *
32
- * Resolved here rather than in either command because both need it and the
33
- * packuments are already fetched above: `list <revision>` can then answer it
34
- * before anything is written, which is where the answer is worth most.
35
- */
36
- peers: PeerSummary;
37
26
  }
38
27
  export interface UnresolvedRange {
39
28
  ok: false;
@@ -3,7 +3,6 @@ import { join } from "node:path";
3
3
  import { rsort } from "semver";
4
4
  import { flowPackages } from "../flowPackages.generated.js";
5
5
  import { detectCurrentVersion, findFlowDependencies, } from "../manifest.js";
6
- import { collectPeers } from "./peers.js";
7
6
  import { fetchAllVersions, fetchVersions, intersectVersions, } from "./registry.js";
8
7
  import { resolveTarget } from "./target.js";
9
8
  /**
@@ -129,14 +128,7 @@ export const resolveRange = async (revision, deps) => {
129
128
  // pick a version some of the others never published. Resolve instead from
130
129
  // the intersection of what every declared dependency has actually
131
130
  // published, so the version this returns is always installable.
132
- // Deduplicated, because `findFlowDependencies` returns one entry per
133
- // manifest *field*: a library that wraps Flow declares it in both
134
- // `peerDependencies` and `devDependencies`, and that package would otherwise
135
- // be fetched twice and counted twice — once per field — in every peer line it
136
- // appears in (#3204 review). `dependencies` itself keeps both entries, on
137
- // purpose: the two fields are reported separately, one rewritten and one left
138
- // alone.
139
- const fetched = await fetchAllVersions([...new Set(dependencies.map(({ name }) => name))], deps.fetchVersions);
131
+ const fetched = await fetchAllVersions(dependencies.map(({ name }) => name), deps.fetchVersions);
140
132
  const versions = intersectVersions(fetched);
141
133
  if (versions.length === 0) {
142
134
  return {
@@ -177,6 +169,5 @@ export const resolveRange = async (revision, deps) => {
177
169
  current,
178
170
  target,
179
171
  versions,
180
- peers: collectPeers(fetched, target, flowPackages),
181
172
  };
182
173
  };
@@ -1,37 +1,12 @@
1
- /** A peer range as a Flow package declares it. */
2
- export interface PeerDeclaration {
3
- range: string;
4
- /**
5
- * Whether the declaring package marks this peer `optional`.
6
- *
7
- * Npm splits one declaration across two fields — the range in
8
- * `peerDependencies`, the flag in `peerDependenciesMeta` — and a reader that
9
- * takes only the first turns every optional peer into a hard requirement. The
10
- * two are joined here, at the one place that knows the packument's encoding,
11
- * so nothing downstream can read a range without its flag (#3059).
12
- */
13
- optional: boolean;
14
- }
15
1
  export interface RegistryVersions {
16
2
  versions: string[];
17
3
  distTags: Record<string, string>;
18
- /**
19
- * What each published version declares as peers, keyed by version.
20
- *
21
- * Kept rather than discarded because the abbreviated packument below carries
22
- * it for free — see `fetchVersions`. A version with no peers maps to an empty
23
- * object, so a lookup never has to distinguish "none" from "not fetched".
24
- */
25
- peerDependencies: Record<string, Record<string, PeerDeclaration>>;
26
4
  }
27
5
  /**
28
6
  * Every published version of a package, plus its dist-tags.
29
7
  *
30
8
  * Uses the abbreviated packument media type: the full document for a package
31
- * with hundreds of releases is megabytes, and none of it is needed here. The
32
- * abbreviated form still carries `peerDependencies` and `peerDependenciesMeta`
33
- * per version, which is why the peer summary costs no request of its own
34
- * (#3059).
9
+ * with hundreds of releases is megabytes, and none of it is needed here.
35
10
  */
36
11
  export declare const fetchVersions: (packageName: string) => Promise<RegistryVersions>;
37
12
  export interface PackageVersions extends RegistryVersions {
@@ -1,12 +1,4 @@
1
1
  import registryUrl from "registry-url";
2
- /** One version's peers, with the optional flag folded into each range. */
3
- const declaredPeers = (manifest) => Object.fromEntries(Object.entries(manifest?.peerDependencies ?? {}).map(([peer, range]) => [
4
- peer,
5
- {
6
- range,
7
- optional: manifest?.peerDependenciesMeta?.[peer]?.optional === true,
8
- },
9
- ]));
10
2
  /**
11
3
  * The registry that actually serves `@mittwald`, from the consumer's npm
12
4
  * config.
@@ -26,10 +18,7 @@ const registryFor = (packageName) => {
26
18
  * Every published version of a package, plus its dist-tags.
27
19
  *
28
20
  * Uses the abbreviated packument media type: the full document for a package
29
- * with hundreds of releases is megabytes, and none of it is needed here. The
30
- * abbreviated form still carries `peerDependencies` and `peerDependenciesMeta`
31
- * per version, which is why the peer summary costs no request of its own
32
- * (#3059).
21
+ * with hundreds of releases is megabytes, and none of it is needed here.
33
22
  */
34
23
  export const fetchVersions = async (packageName) => {
35
24
  // One wrapper for every failure, not just the HTTP one: `fetch` itself rejects
@@ -49,11 +38,9 @@ export const fetchVersions = async (packageName) => {
49
38
  catch (error) {
50
39
  throw new Error(`Could not read ${packageName} from the npm registry: ${error instanceof Error ? error.message : error}`, { cause: error });
51
40
  }
52
- const versions = Object.entries(packument.versions ?? {});
53
41
  return {
54
- versions: versions.map(([version]) => version),
42
+ versions: Object.keys(packument.versions ?? {}),
55
43
  distTags: packument["dist-tags"] ?? {},
56
- peerDependencies: Object.fromEntries(versions.map(([version, manifest]) => [version, declaredPeers(manifest)])),
57
44
  };
58
45
  };
59
46
  /**
@@ -16,8 +16,15 @@ export interface CodemodOptions {
16
16
  export interface CodemodResult {
17
17
  changed: number;
18
18
  unmodified: number;
19
- /** Files the transform declined by returning nothing. */
19
+ /**
20
+ * Files the transform declined by returning nothing — empty ones excluded.
21
+ *
22
+ * Both come back from jscodeshift as `skip`. A caller may report this field
23
+ * as "the transform bailed on these"; it must not report `empty` that way.
24
+ */
20
25
  skipped: number;
26
+ /** Files with no content, which no transform can do anything with. */
27
+ empty: number;
21
28
  errors: number;
22
29
  /** True when jscodeshift accounted for no file at all — see below. */
23
30
  processedNothing: boolean;
@@ -1,4 +1,5 @@
1
- import { existsSync } from "node:fs";
1
+ import { existsSync, readdirSync, statSync } from "node:fs";
2
+ import { extname, join } from "node:path";
2
3
  import { fileURLToPath, pathToFileURL } from "node:url";
3
4
  import { unknownCodemodMessage } from "../catalog/entries.js";
4
5
  // jscodeshift ships no types for its Runner — `allowJs` in this repo's shared
@@ -54,6 +55,61 @@ const transformPath = (id) => findTransform(id) ?? `${packageRoot}src/tools/${id
54
55
  * module's own `existsSync` check below.
55
56
  */
56
57
  export const transformExists = (id) => existsSync(transformPath(id));
58
+ /**
59
+ * The extensions the walk takes, and the directories it stays out of.
60
+ *
61
+ * Both are handed to jscodeshift below — without them its walk takes every file
62
+ * under the path, and since `upgrade` runs codemods after the install,
63
+ * `node_modules` is freshly populated underneath; non-JS files then fail to
64
+ * parse, which shows up as `errors > 0` and hides the real change count.
65
+ *
66
+ * `emptySourceFiles` reads the same two constants so that its walk and
67
+ * jscodeshift's agree by construction.
68
+ */
69
+ const extensions = ["js", "jsx", "ts", "tsx", "cjs", "mjs", "cts", "mts"];
70
+ const ignoredDirectories = ["node_modules", "dist", ".git"];
71
+ const sourceExtensions = new Set(extensions.map((extension) => `.${extension}`));
72
+ const ignoredDirectorySet = new Set(ignoredDirectories);
73
+ /**
74
+ * How many source files under `path` hold nothing at all.
75
+ *
76
+ * Jscodeshift's worker tests a transform's result for truthiness
77
+ * (`updateStatus(out ? 'nochange' : 'skip', file)`), and on an empty file both
78
+ * `root.toSource()` and `fileInfo.source` are `""` — so an empty file lands in
79
+ * `skip`, indistinguishable there from a transform that declined it.
80
+ *
81
+ * Counted with a walk of our own rather than filtered out of jscodeshift's
82
+ * input: this number only ever explains a count. If the two walks disagree, the
83
+ * worst outcome is a slightly wrong explanation, never a file that should have
84
+ * been transformed and was not.
85
+ */
86
+ const emptySourceFiles = (path) => {
87
+ let found = 0;
88
+ const visit = (candidate) => {
89
+ let stats;
90
+ try {
91
+ stats = statSync(candidate);
92
+ }
93
+ catch {
94
+ // A broken symlink or a file removed mid-walk. jscodeshift skips these
95
+ // too ("Skipping path … which does not exist").
96
+ return;
97
+ }
98
+ if (stats.isDirectory()) {
99
+ for (const child of readdirSync(candidate)) {
100
+ if (!ignoredDirectorySet.has(child)) {
101
+ visit(join(candidate, child));
102
+ }
103
+ }
104
+ return;
105
+ }
106
+ if (stats.size === 0 && sourceExtensions.has(extname(candidate))) {
107
+ found += 1;
108
+ }
109
+ };
110
+ visit(path);
111
+ return found;
112
+ };
57
113
  /**
58
114
  * Runs one codemod over a path.
59
115
  *
@@ -105,21 +161,22 @@ export const runCodemod = async ({ id, path, dry = false, print = false, }) => {
105
161
  silent: true,
106
162
  dry,
107
163
  print,
108
- // Without these the walk takes every file under the path. `upgrade` runs
109
- // codemods after the install, so `node_modules` is freshly populated
110
- // underneath — and non-JS files fail to parse, which shows up as
111
- // `errors > 0` and hides the real change count.
112
- extensions: "js,jsx,ts,tsx,cjs,mjs,cts,mts",
113
- ignorePattern: ["**/node_modules/**", "**/dist/**", "**/.git/**"],
164
+ extensions: extensions.join(","),
165
+ ignorePattern: ignoredDirectories.map((name) => `**/${name}/**`),
114
166
  }));
115
167
  }
116
168
  catch (error) {
117
169
  throw new Error(`Running ${id} failed: ${error instanceof Error ? error.message : error}`, { cause: error });
118
170
  }
171
+ // Clamped rather than subtracted outright: the two walks are independent, so
172
+ // a file that appeared or emptied between them must not push `skipped` below
173
+ // zero.
174
+ const empty = Math.min(emptySourceFiles(path), stats.skip);
119
175
  return {
120
176
  changed: stats.ok,
121
177
  unmodified: stats.nochange,
122
- skipped: stats.skip,
178
+ skipped: stats.skip - empty,
179
+ empty,
123
180
  errors: stats.error,
124
181
  processedNothing: stats.ok + stats.nochange + stats.skip + stats.error === 0,
125
182
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mittwald/flow-codemods",
3
- "version": "1.3.0-next.9",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
5
  "description": "Codemods and an upgrade CLI for consumers of Flow, mittwald's design system",
6
6
  "homepage": "https://flow.mittwald.de",
@@ -38,5 +38,5 @@
38
38
  "vitest": "^4.1.11",
39
39
  "yaml": "^2.8.1"
40
40
  },
41
- "gitHead": "559e56fc7e7b9b80de60ce39c179cfaeb4e7f9a1"
41
+ "gitHead": "c10f5dfb91a2f7544de5224b80707d9bcb98669e"
42
42
  }
@@ -1,22 +0,0 @@
1
- import type { PeerSummary } from "../resolve/peers.js";
2
- export interface RenderPeersInput {
3
- summary: PeerSummary;
4
- /** The version the ranges were read at. */
5
- target: string;
6
- /** Emit ANSI colour. Off by default so a test sees plain text. */
7
- color?: boolean;
8
- /** Terminal width to wrap prose to. */
9
- width?: number;
10
- }
11
- /**
12
- * The peer ranges the Flow packages declare at `target`.
13
- *
14
- * Returns `""` when there is nothing to say, so a caller can print it
15
- * unconditionally.
16
- *
17
- * The closing line is not a disclaimer for its own sake: without it the list
18
- * reads as a check that passed. It is not one — nothing here looks at what the
19
- * project has installed (see `collectPeers`), and a reader who assumes
20
- * otherwise would take silence for a green light.
21
- */
22
- export declare const renderPeers: ({ summary, target, color, width, }: RenderPeersInput) => string;
package/dist/cli/peers.js DELETED
@@ -1,73 +0,0 @@
1
- import { hasPeers } from "../resolve/peers.js";
2
- import { painter, wrap } from "./text.js";
3
- const indent = " ";
4
- /**
5
- * One requirement: the peer and its range on top, who asks for it below.
6
- *
7
- * The askers go on their own wrapped line rather than trailing the range — at
8
- * eight Flow packages the list is longer than the range it belongs to, and
9
- * inline it pushes the next range off the scannable left edge.
10
- *
11
- * Both lists are labelled, even when only one is present. An unlabelled list
12
- * with a marker appended somewhere would leave the reader guessing how far the
13
- * marker reaches — and "does this project actually need react?" is the whole
14
- * question this output exists to answer.
15
- */
16
- const renderRequirement = ({ peer, range, requiredBy, optionalFor }, width, paint) => {
17
- const gutter = indent.repeat(2);
18
- const required = requiredBy.length > 0;
19
- // The status sits on the peer line, in colour, because it answers the
20
- // question the reader actually has — "must I install this at all?" — and one
21
- // package requiring it settles that no matter how many others call it
22
- // optional. Underneath everything is dim, so each entry has exactly one
23
- // bright thing to land on when scanning.
24
- const status = required ? paint.yellow("required") : paint.dim("optional");
25
- // Only a disagreement earns the two labelled lines. Where every package
26
- // treats the peer the same way, the labels would repeat what the status
27
- // already said, once per entry, for the whole list.
28
- const rows = required && optionalFor.length > 0
29
- ? [
30
- ["required by ", requiredBy],
31
- ["optional for ", optionalFor],
32
- ]
33
- : [["", required ? requiredBy : optionalFor]];
34
- const askers = rows
35
- .flatMap(([label, names]) => wrap(`${label}${names.join(", ")}`, Math.max(width - gutter.length, 20)).map((line) => `${gutter}${paint.dim(line)}`))
36
- .join("\n");
37
- return `${indent}${paint.bold(peer)} ${paint.code(range)} — ${status}\n${askers}`;
38
- };
39
- /**
40
- * The peer ranges the Flow packages declare at `target`.
41
- *
42
- * Returns `""` when there is nothing to say, so a caller can print it
43
- * unconditionally.
44
- *
45
- * The closing line is not a disclaimer for its own sake: without it the list
46
- * reads as a check that passed. It is not one — nothing here looks at what the
47
- * project has installed (see `collectPeers`), and a reader who assumes
48
- * otherwise would take silence for a green light.
49
- */
50
- export const renderPeers = ({ summary, target, color = false, width = 80, }) => {
51
- if (!hasPeers(summary)) {
52
- return "";
53
- }
54
- const paint = painter(color);
55
- const sections = [];
56
- if (summary.external.length > 0) {
57
- sections.push([
58
- ...wrap(`Peer requirements at ${paint.bold(target)} — what the Flow packages you declare ask of your project:`, width),
59
- "",
60
- ...summary.external.map((requirement) => renderRequirement(requirement, width, paint)),
61
- "",
62
- ...wrap(paint.dim("Ranges as Flow declares them, not a check of what you have installed — your package manager decides that during the install. An optional peer need not be installed at all; it only has to match the range if it is."), width),
63
- ].join("\n"));
64
- }
65
- if (summary.flowPins.length > 0) {
66
- sections.push([
67
- ...wrap(paint.yellow(`${summary.flowPins.length} Flow-internal peer pin(s) do not accept ${target}. Flow publishes these as exact pins and releases only the packages that changed, so a pin usually names a neighbouring release rather than the version you are installing — your package manager will report the conflict. Rarely the pinned version was never published at all (#2887).`), width),
68
- "",
69
- ...summary.flowPins.map((requirement) => renderRequirement(requirement, width, paint)),
70
- ].join("\n"));
71
- }
72
- return `${sections.join("\n\n")}\n`;
73
- };
@@ -1,27 +0,0 @@
1
- /**
2
- * A palette key from `painter()` — see the `tone` field on `actions` in
3
- * `list.ts`.
4
- */
5
- export type Tone = "green" | "yellow" | "blue";
6
- export declare const stripAnsi: (text: string) => string;
7
- /**
8
- * Wraps to `width`, measuring visible width so already-coloured text still
9
- * breaks in the right place.
10
- */
11
- export declare const wrap: (text: string, width: number) => string[];
12
- export interface Painter extends Record<Tone, (text: string) => string> {
13
- bold: (text: string) => string;
14
- dim: (text: string) => string;
15
- code: (text: string) => string;
16
- }
17
- /**
18
- * Builds a palette from `color`, and only from `color`.
19
- *
20
- * `colors` (the module-level `picocolors` default export) auto-detects a TTY
21
- * and disables itself under one — reading `colors.green` etc. directly would
22
- * make the _environment_, not this argument, the real decider, silently
23
- * disagreeing with `cli.ts`'s own TTY/`NO_COLOR`/`--json` check. `createColors`
24
- * returns a palette with colour forced on or off, so `color` is the only input
25
- * to how this renders.
26
- */
27
- export declare const painter: (color: boolean) => Painter;
package/dist/cli/text.js DELETED
@@ -1,62 +0,0 @@
1
- /**
2
- * Terminal text helpers shared by the renderers in this directory.
3
- *
4
- * They live here rather than in `list.ts` because `peers.ts` needs them too,
5
- * and `list.ts` imports `peers.ts` to render its own peer section — keeping
6
- * them there would make that pair circular.
7
- */
8
- import colors from "picocolors";
9
- /**
10
- * ANSI escapes have no width; measuring must ignore them.
11
- *
12
- * Built from a char code rather than written as a literal, so no control
13
- * character sits in the source — which is also what `no-control-regex` wants.
14
- */
15
- const ansi = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
16
- export const stripAnsi = (text) => text.replace(ansi, "");
17
- const visibleWidth = (text) => stripAnsi(text).length;
18
- /**
19
- * Wraps to `width`, measuring visible width so already-coloured text still
20
- * breaks in the right place.
21
- */
22
- export const wrap = (text, width) => {
23
- const lines = [];
24
- let line = "";
25
- for (const word of text.split(/\s+/).filter((part) => part !== "")) {
26
- if (line === "") {
27
- line = word;
28
- }
29
- else if (visibleWidth(line) + 1 + visibleWidth(word) <= width) {
30
- line = `${line} ${word}`;
31
- }
32
- else {
33
- lines.push(line);
34
- line = word;
35
- }
36
- }
37
- if (line !== "") {
38
- lines.push(line);
39
- }
40
- return lines.length > 0 ? lines : [""];
41
- };
42
- /**
43
- * Builds a palette from `color`, and only from `color`.
44
- *
45
- * `colors` (the module-level `picocolors` default export) auto-detects a TTY
46
- * and disables itself under one — reading `colors.green` etc. directly would
47
- * make the _environment_, not this argument, the real decider, silently
48
- * disagreeing with `cli.ts`'s own TTY/`NO_COLOR`/`--json` check. `createColors`
49
- * returns a palette with colour forced on or off, so `color` is the only input
50
- * to how this renders.
51
- */
52
- export const painter = (color) => {
53
- const palette = colors.createColors(color);
54
- return {
55
- bold: palette.bold,
56
- dim: palette.dim,
57
- code: palette.cyan,
58
- green: palette.green,
59
- yellow: palette.yellow,
60
- blue: palette.blue,
61
- };
62
- };
@@ -1,64 +0,0 @@
1
- import type { PackageVersions } from "./registry.js";
2
- /** One peer range, and how each Flow package that states it treats it. */
3
- export interface PeerRequirement {
4
- /** The peer package, e.g. `react`. */
5
- peer: string;
6
- /** The range, exactly as the Flow packages authored it. */
7
- range: string;
8
- /** The declared Flow dependencies that require it, sorted. */
9
- requiredBy: string[];
10
- /**
11
- * The declared Flow dependencies that mark it `optional`, sorted.
12
- *
13
- * Separate from `requiredBy` rather than a flag on the requirement, because
14
- * optionality is **per package**: at 1.2.2 `react ^19.2.0` is required by
15
- * `flow-react-components` and optional for `ext-bridge`. One line carrying
16
- * both lists says that; a single boolean would have to pick a side, and
17
- * either choice is wrong for half the readers.
18
- */
19
- optionalFor: string[];
20
- }
21
- export interface PeerSummary {
22
- /**
23
- * Everything the Flow packages peer on that is not itself a Flow package —
24
- * react, react-hook-form, next, i18next and the rest.
25
- */
26
- external: PeerRequirement[];
27
- /**
28
- * Flow-internal peers the target does **not** satisfy.
29
- *
30
- * Flow's internal peers publish as exact pins (`@mittwald/flow-remote-core`
31
- * peers on `@mittwald/flow-react-components: "1.1.52"`), so the expected case
32
- * — the pin naming the version being installed — is noise. What is not noise
33
- * is a pin pointing somewhere else: upgrading every declared package to one
34
- * target then leaves it unsatisfied, and nothing else in the output says so.
35
- *
36
- * This is not hypothetical. Across the published history of the five remote
37
- * packages, 2253 of 6210 Flow-internal peer entries pin a version other than
38
- * the one they ship with — e.g. `flow-remote-core@0.2.0-alpha.700` peers on
39
- * `ext-bridge@0.2.0-alpha.699`.
40
- *
41
- * What it usually is **not** is a publish hole. Lerna releases only the
42
- * packages that changed, so the pin names a neighbouring release that exists:
43
- * 2207 of those 2253 point at a published version, leaving 46 that are
44
- * genuinely missing (#2887). The rendered warning leads with the common cause
45
- * for that reason (#3204 review).
46
- */
47
- flowPins: PeerRequirement[];
48
- }
49
- /**
50
- * What the declared Flow dependencies require of their peers at `target`.
51
- *
52
- * Grouped by peer **and** range, not by peer alone: two Flow packages asking
53
- * for different ranges of the same peer is precisely what a consumer needs to
54
- * see, and collapsing them would hide it. Optionality does **not** split a
55
- * group — it is recorded per package inside it (see `optionalFor`).
56
- *
57
- * Ranges are reported exactly as authored — this never decides whether the
58
- * project satisfies them. That is the package manager's job, it already does it
59
- * during the install, and a second opinion here could only drift from the one
60
- * that actually gates.
61
- */
62
- export declare const collectPeers: (packages: PackageVersions[], target: string, flowPackages: string[]) => PeerSummary;
63
- /** Whether a summary has anything worth printing. */
64
- export declare const hasPeers: (summary: PeerSummary) => boolean;
@@ -1,72 +0,0 @@
1
- import { satisfies, validRange } from "semver";
2
- /**
3
- * Whether a Flow-internal peer pin is the unremarkable one.
4
- *
5
- * `satisfies` rather than a string compare, so a genuine range (`^1.1.0`) is as
6
- * expected as the exact pin the publish pipeline usually writes. An unparseable
7
- * range — `workspace:*`, from a package resolved straight out of a checkout
8
- * rather than the registry — is not a claim about `target` at all, so it is
9
- * reported rather than silently judged.
10
- *
11
- * `includePrerelease` is load-bearing, not tidiness: without it a prerelease
12
- * satisfies **no** non-prerelease range, `*` included. The historical
13
- * Flow-internal pin _is_ `*` (2328 of 6210 published entries), and `next` and
14
- * `experimental` are prerelease dist-tags — so every prerelease target reported
15
- * a publish hole for a pin that accepts everything.
16
- */
17
- const expectedFlowPin = (range, target) => validRange(range) !== null &&
18
- satisfies(target, range, { includePrerelease: true });
19
- /**
20
- * Ties break on the string, not on the locale.
21
- *
22
- * `localeCompare` depends on the runtime's ICU data, which differs between Node
23
- * builds and would reorder hyphenated names like `react-dom` from one machine
24
- * to the next — including between a developer's run and CI's.
25
- */
26
- const byString = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
27
- /**
28
- * What the declared Flow dependencies require of their peers at `target`.
29
- *
30
- * Grouped by peer **and** range, not by peer alone: two Flow packages asking
31
- * for different ranges of the same peer is precisely what a consumer needs to
32
- * see, and collapsing them would hide it. Optionality does **not** split a
33
- * group — it is recorded per package inside it (see `optionalFor`).
34
- *
35
- * Ranges are reported exactly as authored — this never decides whether the
36
- * project satisfies them. That is the package manager's job, it already does it
37
- * during the install, and a second opinion here could only drift from the one
38
- * that actually gates.
39
- */
40
- export const collectPeers = (packages, target, flowPackages) => {
41
- const grouped = new Map();
42
- for (const pkg of packages) {
43
- const peers = pkg.peerDependencies[target] ?? {};
44
- for (const [peer, { range, optional }] of Object.entries(peers)) {
45
- // Keyed on both halves, so the two sides of a disagreement survive as
46
- // two entries. JSON rather than a joined string: no separator to pick
47
- // that a package name or a range could contain.
48
- const key = JSON.stringify([peer, range]);
49
- const existing = grouped.get(key) ?? {
50
- peer,
51
- range,
52
- requiredBy: [],
53
- optionalFor: [],
54
- };
55
- (optional ? existing.optionalFor : existing.requiredBy).push(pkg.name);
56
- grouped.set(key, existing);
57
- }
58
- }
59
- const sorted = [...grouped.values()]
60
- .map((requirement) => ({
61
- ...requirement,
62
- requiredBy: [...requirement.requiredBy].sort(byString),
63
- optionalFor: [...requirement.optionalFor].sort(byString),
64
- }))
65
- .sort((a, b) => byString(a.peer, b.peer) || byString(a.range, b.range));
66
- return {
67
- external: sorted.filter(({ peer }) => !flowPackages.includes(peer)),
68
- flowPins: sorted.filter(({ peer, range }) => flowPackages.includes(peer) && !expectedFlowPin(range, target)),
69
- };
70
- };
71
- /** Whether a summary has anything worth printing. */
72
- export const hasPeers = (summary) => summary.external.length > 0 || summary.flowPins.length > 0;