@mittwald/flow-codemods 1.3.5 → 1.4.0-next.1

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,6 +60,56 @@ 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
+
63
113
  After installing, `upgrade` runs the codemod of every migration whose `since` is
64
114
  at or below the target and prints the ones with no codemod, for you to apply by
65
115
  hand.
@@ -105,8 +155,11 @@ involve.
105
155
  Options:
106
156
 
107
157
  - `--json` — machine-readable output: an object with `range`
108
- (`current`/`target`, or `null` for the offline whole-catalogue form) and
109
- `migrations`, each entry carrying `catchUp`
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
110
163
 
111
164
  ### `<id> [path]`
112
165
 
@@ -1,4 +1,5 @@
1
1
  import { type CatalogEntry } from "../catalog/entries.js";
2
+ import type { PeerSummary } from "../resolve/peers.js";
2
3
  import { type RangeDeps } from "../resolve/range.js";
3
4
  import type { ParsedCommand } from "./args.js";
4
5
  export interface RenderListInput {
@@ -13,6 +14,15 @@ export interface RenderListInput {
13
14
  to: string;
14
15
  };
15
16
  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;
16
26
  /** Emit ANSI colour. Off by default so a test sees plain text. */
17
27
  color?: boolean;
18
28
  /** Terminal width to wrap prose to. */
@@ -47,7 +57,6 @@ export interface RenderListInput {
47
57
  */
48
58
  frame?: boolean;
49
59
  }
50
- export declare const stripAnsi: (text: string) => string;
51
60
  /**
52
61
  * The invocation to print when nobody says otherwise.
53
62
  *
@@ -67,7 +76,7 @@ export declare const defaultInvoke = "npx @mittwald/flow-codemods@latest";
67
76
  * so the rendering is deterministic in a test — `painter` builds its palette
68
77
  * from `color` alone, never from the terminal. `cli.ts` supplies them.
69
78
  */
70
- export declare const renderList: ({ entries, range, json, color, width, path, invoke, frame, }: RenderListInput) => string;
79
+ export declare const renderList: ({ entries, range, json, peers, color, width, path, invoke, frame, }: RenderListInput) => string;
71
80
  export interface ListDeps extends RangeDeps {
72
81
  /**
73
82
  * Raw output writer — matches `process.stdout.write`'s own contract: no
package/dist/cli/list.js CHANGED
@@ -1,8 +1,9 @@
1
- import colors from "picocolors";
2
1
  import { lte } from "semver";
3
2
  import { allEntries } from "../catalog/entries.js";
4
3
  import { selectEntries, sortBySince } from "../catalog/select.js";
5
4
  import { defaultRangeDeps, resolveRange, } from "../resolve/range.js";
5
+ import { renderPeers } from "./peers.js";
6
+ import { painter, wrap } from "./text.js";
6
7
  /**
7
8
  * What the reader has to do, and the colour that says it at a glance.
8
9
  *
@@ -21,39 +22,6 @@ const actions = {
21
22
  tone: "blue",
22
23
  },
23
24
  };
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
- };
57
25
  /**
58
26
  * Renders `code` spans as colour instead of literal backticks.
59
27
  *
@@ -62,27 +30,6 @@ const wrap = (text, width) => {
62
30
  * scans for — a symbol name or a command.
63
31
  */
64
32
  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
- };
86
33
  /**
87
34
  * The invocation to print when nobody says otherwise.
88
35
  *
@@ -210,7 +157,7 @@ const renderSummary = (selected, color) => {
210
157
  * so the rendering is deterministic in a test — `painter` builds its palette
211
158
  * from `color` alone, never from the terminal. `cli.ts` supplies them.
212
159
  */
213
- export const renderList = ({ entries, range, json, color = false, width = 80, path = "src", invoke = defaultInvoke, frame = true, }) => {
160
+ export const renderList = ({ entries, range, json, peers, color = false, width = 80, path = "src", invoke = defaultInvoke, frame = true, }) => {
214
161
  // The two paths differ in their bounds, deliberately: unbounded, this is a
215
162
  // plain catalogue browse — every entry, sorted, regardless of whether it
216
163
  // would apply to any given range. Bounded, it answers "what does this
@@ -235,10 +182,21 @@ export const renderList = ({ entries, range, json, color = false, width = 80, pa
235
182
  ...entry,
236
183
  catchUp: isCatchUp(entry, range?.from),
237
184
  })),
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,
238
189
  }, null, 2)}\n`;
239
190
  }
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 });
240
197
  if (selected.length === 0) {
241
- return "Nothing to migrate in that range.\n";
198
+ const nothing = "Nothing to migrate in that range.\n";
199
+ return peerSection === "" ? nothing : `${nothing}\n${peerSection}`;
242
200
  }
243
201
  const body = selected
244
202
  .map((entry) => renderEntry(entry, width, color, isCatchUp(entry, range?.from), path, invoke))
@@ -253,7 +211,7 @@ export const renderList = ({ entries, range, json, color = false, width = 80, pa
253
211
  // gets left dangling above the first entry.
254
212
  const context = renderContext(selected, range, color, width);
255
213
  const summary = renderSummary(selected, color);
256
- return `${context === "" ? "" : `${context}\n\n`}${body}\n\n${summary}\n`;
214
+ return `${context === "" ? "" : `${context}\n\n`}${peerSection === "" ? "" : `${peerSection}\n`}${body}\n\n${summary}\n`;
257
215
  };
258
216
  export const defaultListDeps = (cwd) => ({
259
217
  ...defaultRangeDeps(cwd),
@@ -295,6 +253,7 @@ export const runList = async (parsed, deps) => {
295
253
  entries: allEntries,
296
254
  range: { from: resolved.current, to: resolved.target },
297
255
  json: parsed.json,
256
+ peers: resolved.peers,
298
257
  color: deps.color,
299
258
  width: deps.width,
300
259
  path: deps.path,
@@ -0,0 +1,22 @@
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;
@@ -0,0 +1,73 @@
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
+ };
@@ -0,0 +1,27 @@
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;
@@ -0,0 +1,62 @@
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
+ };
@@ -12,6 +12,7 @@ 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";
15
16
  export const defaultUpgradeDeps = (cwd) => ({
16
17
  cwd,
17
18
  fetchVersions,
@@ -86,7 +87,7 @@ export const runUpgrade = async (parsed, deps) => {
86
87
  log(range.reason);
87
88
  return 1;
88
89
  }
89
- const { manifestPath, manifestRaw, manifest, dependencies, current, target } = range;
90
+ const { manifestPath, manifestRaw, manifest, dependencies, current, target, peers, } = range;
90
91
  // A stale dist-tag or an exact version at or below `current` resolves
91
92
  // without complaint — `resolveRange` deliberately does not judge that
92
93
  // (`list` treats the same fact as a legitimate answer). There is nothing to
@@ -105,15 +106,36 @@ export const runUpgrade = async (parsed, deps) => {
105
106
  : ` ${dependency.name} ${dependency.range} — left as it is, ${dependency.field} states what your package supports, not what it installs`);
106
107
  }
107
108
  };
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
+ };
108
128
  const bump = gt(target, current);
109
129
  if (!bump) {
110
130
  // Nothing to write or install — fall through to the codemod pass below.
111
131
  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();
112
133
  }
113
134
  else if (dry) {
114
135
  log(`Upgrading Flow from ${current} to ${target} (--dry)`);
115
136
  log(`--dry: would write the following to ${manifestPath}:`);
116
137
  reportDependencies();
138
+ reportPeers();
117
139
  log("--dry: skipping the install.");
118
140
  }
119
141
  else {
@@ -121,6 +143,7 @@ export const runUpgrade = async (parsed, deps) => {
121
143
  const indent = detectIndent(manifestRaw);
122
144
  writeFileSync(manifestPath, `${JSON.stringify(applyTarget(manifest, target, flowPackages), null, indent)}\n`, "utf8");
123
145
  reportDependencies();
146
+ reportPeers();
124
147
  const manager = await detectPackageManagerIn(cwd);
125
148
  try {
126
149
  // Logged after the run, not before: `install` returns what it actually
@@ -3,6 +3,15 @@
3
3
  // Source: packages/codemods/src/migrations/<id>/entry.md
4
4
  /** Every migration, newest first. Bodies live in `src/migrations`. */
5
5
  export const migrations = [
6
+ {
7
+ id: "image-rounded-corners-default",
8
+ since: "1.3.0",
9
+ title: "Image: rounded corners by default, withBorder draws only the border",
10
+ kind: "migration",
11
+ action: "none",
12
+ remotePackage: true,
13
+ apply: "No code change required. To keep square corners on an `Image` without `withBorder`, pass `withRoundedCorners={false}`.",
14
+ },
6
15
  {
7
16
  id: "popover-open-state-props",
8
17
  since: "1.1.47",
@@ -207,7 +216,7 @@ export const migrations = [
207
216
  title: "Form: resets itself after the surrounding modal closes",
208
217
  kind: "migration",
209
218
  action: "none",
210
- remotePackage: true,
219
+ remotePackage: false,
211
220
  apply: "No code change required. To keep the previous behaviour (the form keeping what the user entered), pass `autoReset={false}` (or `autoReset={{ onAfterModalClose: false }}`) to `Form`.",
212
221
  },
213
222
  {
@@ -0,0 +1,64 @@
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;
@@ -0,0 +1,72 @@
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;
@@ -1,4 +1,5 @@
1
1
  import { type FlowDependency, type Manifest } from "../manifest.js";
2
+ import { type PeerSummary } from "./peers.js";
2
3
  import { fetchVersions } from "./registry.js";
3
4
  /**
4
5
  * The installed version of `name`, read from `cwd`'s `node_modules`.
@@ -23,6 +24,16 @@ export interface ResolvedRange {
23
24
  current: string;
24
25
  target: string;
25
26
  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;
26
37
  }
27
38
  export interface UnresolvedRange {
28
39
  ok: false;
@@ -3,6 +3,7 @@ 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";
6
7
  import { fetchAllVersions, fetchVersions, intersectVersions, } from "./registry.js";
7
8
  import { resolveTarget } from "./target.js";
8
9
  /**
@@ -128,7 +129,14 @@ export const resolveRange = async (revision, deps) => {
128
129
  // pick a version some of the others never published. Resolve instead from
129
130
  // the intersection of what every declared dependency has actually
130
131
  // published, so the version this returns is always installable.
131
- const fetched = await fetchAllVersions(dependencies.map(({ name }) => name), deps.fetchVersions);
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);
132
140
  const versions = intersectVersions(fetched);
133
141
  if (versions.length === 0) {
134
142
  return {
@@ -169,5 +177,6 @@ export const resolveRange = async (revision, deps) => {
169
177
  current,
170
178
  target,
171
179
  versions,
180
+ peers: collectPeers(fetched, target, flowPackages),
172
181
  };
173
182
  };
@@ -1,12 +1,37 @@
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
+ }
1
15
  export interface RegistryVersions {
2
16
  versions: string[];
3
17
  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>>;
4
26
  }
5
27
  /**
6
28
  * Every published version of a package, plus its dist-tags.
7
29
  *
8
30
  * Uses the abbreviated packument media type: the full document for a package
9
- * with hundreds of releases is megabytes, and none of it is needed here.
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).
10
35
  */
11
36
  export declare const fetchVersions: (packageName: string) => Promise<RegistryVersions>;
12
37
  export interface PackageVersions extends RegistryVersions {
@@ -1,4 +1,12 @@
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
+ ]));
2
10
  /**
3
11
  * The registry that actually serves `@mittwald`, from the consumer's npm
4
12
  * config.
@@ -18,7 +26,10 @@ const registryFor = (packageName) => {
18
26
  * Every published version of a package, plus its dist-tags.
19
27
  *
20
28
  * Uses the abbreviated packument media type: the full document for a package
21
- * with hundreds of releases is megabytes, and none of it is needed here.
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).
22
33
  */
23
34
  export const fetchVersions = async (packageName) => {
24
35
  // One wrapper for every failure, not just the HTTP one: `fetch` itself rejects
@@ -38,9 +49,11 @@ export const fetchVersions = async (packageName) => {
38
49
  catch (error) {
39
50
  throw new Error(`Could not read ${packageName} from the npm registry: ${error instanceof Error ? error.message : error}`, { cause: error });
40
51
  }
52
+ const versions = Object.entries(packument.versions ?? {});
41
53
  return {
42
- versions: Object.keys(packument.versions ?? {}),
54
+ versions: versions.map(([version]) => version),
43
55
  distTags: packument["dist-tags"] ?? {},
56
+ peerDependencies: Object.fromEntries(versions.map(([version, manifest]) => [version, declaredPeers(manifest)])),
44
57
  };
45
58
  };
46
59
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mittwald/flow-codemods",
3
- "version": "1.3.5",
3
+ "version": "1.4.0-next.1",
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": "f480c1dd2738050ce09ec534ff2c08f84ee66f5d"
41
+ "gitHead": "31ec95af38164122f89af7755bd2c05a9f5ca807"
42
42
  }