@mittwald/flow-codemods 1.2.0-next.0 → 1.2.0-next.10

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.
Files changed (44) hide show
  1. package/README.md +35 -6
  2. package/dist/cli/codemod.d.ts +16 -0
  3. package/dist/cli/codemod.d.ts.map +1 -1
  4. package/dist/cli/codemod.js +21 -0
  5. package/dist/cli/list.d.ts +30 -1
  6. package/dist/cli/list.d.ts.map +1 -1
  7. package/dist/cli/list.js +15 -4
  8. package/dist/cli/upgrade.d.ts +7 -0
  9. package/dist/cli/upgrade.d.ts.map +1 -1
  10. package/dist/cli/upgrade.js +31 -20
  11. package/dist/cli.js +16 -1
  12. package/dist/install.d.ts +110 -11
  13. package/dist/install.d.ts.map +1 -1
  14. package/dist/install.js +163 -50
  15. package/dist/migrations/accent-box-color-to-background-color/transform.js +175 -0
  16. package/dist/migrations/action-prop-to-on-action/transform.js +82 -0
  17. package/dist/migrations/align-to-combine/transform.js +166 -0
  18. package/dist/migrations/button-color-accent-to-success/transform.js +126 -0
  19. package/dist/migrations/button-props-interfaces/transform.js +178 -0
  20. package/dist/migrations/color-primary-to-default/transform.js +135 -0
  21. package/dist/migrations/imports-to-package-root/transform.js +93 -0
  22. package/dist/migrations/muted-action-error-to-abort-action-error/transform.js +198 -0
  23. package/dist/migrations/package.json +3 -0
  24. package/dist/migrations/password-tools-rule/transform.js +173 -0
  25. package/dist/migrations/password-tools-subpath-renamed/transform.js +51 -0
  26. package/dist/migrations.generated.d.ts.map +1 -1
  27. package/dist/migrations.generated.js +12 -3
  28. package/dist/run/jscodeshift.d.ts +4 -1
  29. package/dist/run/jscodeshift.d.ts.map +1 -1
  30. package/dist/run/jscodeshift.js +60 -22
  31. package/dist/tools/package.json +3 -0
  32. package/dist/tools/to-remote-package.js +29 -0
  33. package/package.json +5 -6
  34. package/src/migrations/accent-box-color-to-background-color/transform.ts +0 -218
  35. package/src/migrations/action-prop-to-on-action/transform.ts +0 -110
  36. package/src/migrations/align-to-combine/transform.ts +0 -212
  37. package/src/migrations/button-color-accent-to-success/transform.ts +0 -164
  38. package/src/migrations/button-props-interfaces/transform.ts +0 -229
  39. package/src/migrations/color-primary-to-default/transform.ts +0 -173
  40. package/src/migrations/imports-to-package-root/transform.ts +0 -107
  41. package/src/migrations/muted-action-error-to-abort-action-error/transform.ts +0 -260
  42. package/src/migrations/password-tools-rule/transform.ts +0 -219
  43. package/src/migrations/password-tools-subpath-renamed/transform.ts +0 -61
  44. package/src/tools/to-remote-package.ts +0 -37
package/dist/install.js CHANGED
@@ -1,38 +1,45 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { existsSync } from "node:fs";
3
- import { join } from "node:path";
4
- /** Lockfile to manager, in precedence order. */
5
- const lockfiles = [
6
- ["pnpm-lock.yaml", "pnpm"],
7
- // Both Bun lockfile names: `bun.lock` (text) is the default only from Bun
8
- // 1.2; before that Bun writes the binary `bun.lockb`, which plenty of
9
- // projects still have. Missing it would fall through to the npm default and
10
- // run `npm install` on a Bun project, leaving a stray package-lock.json.
11
- ["bun.lock", "bun"],
12
- ["bun.lockb", "bun"],
13
- ["yarn.lock", "yarn"],
14
- ["package-lock.json", "npm"],
2
+ import { satisfies, validRange } from "semver";
3
+ import { resolveCommand } from "package-manager-detector/commands";
4
+ import { INSTALL_PAGE } from "package-manager-detector/constants";
5
+ import { detect } from "package-manager-detector/detect";
6
+ /** Every strategy the library offers, in the order it applies them. */
7
+ const strategies = [
8
+ "lockfile",
9
+ "packageManager-field",
10
+ "devEngines-field",
11
+ // Not in the library's default set. `upgrade` always runs on an installed
12
+ // project — it reads `node_modules` to learn the current Flow version — so the
13
+ // marker a manager leaves in there is a real signal, and the last one before
14
+ // the npm fallback.
15
+ "install-metadata",
15
16
  ];
17
+ /** What a project with no signal at all gets. */
18
+ const fallback = { name: "npm", agent: "npm" };
16
19
  /**
17
- * The package manager a project uses, from the lockfiles present.
20
+ * The package manager for the project at `cwd`.
18
21
  *
19
- * Npm is the fallback: it is the one manager that is always available next to
20
- * Node, so a project with no lockfile still gets an install rather than an
21
- * error.
22
+ * Detection walks **up** from `cwd`, which is the whole point: the previous
23
+ * implementation looked for lockfiles in `cwd` only, and a workspace package
24
+ * has none of its own. In a Yarn or pnpm monorepo it therefore fell through to
25
+ * the npm default and ran `npm install`, which on a workspace manifest dies
26
+ * with `EUNSUPPORTEDPROTOCOL Unsupported URL Type "workspace:"` — after
27
+ * `upgrade` had already rewritten `package.json`. It also ignored
28
+ * `packageManager` entirely.
29
+ *
30
+ * Per directory the strategies are applied in order, so a lockfile in `cwd`
31
+ * still beats a `packageManager` pin in a parent — and when the lockfile
32
+ * strategy hits, the library reads that same directory's `package.json` and
33
+ * lets its pin win. That is the precedence we want, and it is the library's,
34
+ * not ours.
35
+ *
36
+ * Npm on `null`, preserving the old fallback: it is the one manager always
37
+ * present next to Node, so a project with no signal gets an install rather than
38
+ * an error.
22
39
  */
23
- export const detectPackageManager = (present) => {
24
- for (const [lockfile, manager] of lockfiles) {
25
- if (present.includes(lockfile)) {
26
- return manager;
27
- }
28
- }
29
- return "npm";
30
- };
31
- export const detectPackageManagerIn = (cwd) => detectPackageManager(lockfiles
32
- .map(([lockfile]) => lockfile)
33
- .filter((lockfile) => existsSync(join(cwd, lockfile))));
40
+ export const detectPackageManagerIn = async (cwd) => (await detect({ cwd, strategies: [...strategies] })) ?? fallback;
34
41
  /**
35
- * How to install with a given manager, so that the install actually updates the
42
+ * How to install with `agent`, so that the install actually updates the
36
43
  * lockfile.
37
44
  *
38
45
  * `upgrade` has just rewritten `package.json`, so the lockfile is deliberately
@@ -40,32 +47,138 @@ export const detectPackageManagerIn = (cwd) => detectPackageManager(lockfiles
40
47
  * themselves when they detect CI, where an install then _fails_ instead of
41
48
  * updating — and CI is exactly where this runs unattended. pnpm takes a flag;
42
49
  * Yarn's flag differs between Classic and Berry, so it gets the environment
43
- * variable instead, which Classic ignores harmlessly.
50
+ * variable instead, which Classic ignores harmlessly. Keeping the env var also
51
+ * keeps this one code path rather than branching on a Berry detection being
52
+ * right.
53
+ *
54
+ * The command itself comes from the library. Its `"install"` is the plain
55
+ * install for every agent — the frozen variants are a separate command there —
56
+ * so these quirks stay ours.
57
+ */
58
+ export const installCommand = (agent) => {
59
+ const frozenOptOut = agent.startsWith("pnpm") ? ["--no-frozen-lockfile"] : [];
60
+ const resolved = resolveCommand(agent, "install", frozenOptOut);
61
+ if (resolved === null) {
62
+ throw new Error(`${agent} has no install command. Install the dependencies yourself, then re-run this command.`);
63
+ }
64
+ return {
65
+ command: resolved.command,
66
+ args: resolved.args,
67
+ env: agent.startsWith("yarn")
68
+ ? { YARN_ENABLE_IMMUTABLE_INSTALLS: "false" }
69
+ : {},
70
+ };
71
+ };
72
+ /**
73
+ * The pin as a semver range, or `undefined` when there is nothing to check.
74
+ *
75
+ * Two values have to be filtered out. `"berry"` is what the library reports for
76
+ * Yarn 2+ — a specifier, not a version — and for an unparseable
77
+ * `packageManager` it hands back the raw string. Neither is a range, and
78
+ * `satisfies` would throw on both.
79
+ *
80
+ * A full pin like `8.15.0` is a valid range meaning exactly that version, so
81
+ * one comparison covers both `pnpm@8` and `pnpm@8.15.0`. That is also why this
82
+ * is `satisfies` and not a string compare: the library reports the
83
+ * `\d+(\.\d+){0,2}` match out of the field, so `packageManager: "pnpm@8"`
84
+ * yields the pin `"8"` while the binary reports `8.15.0` — a string compare
85
+ * would send a perfectly fine setup through corepack.
44
86
  */
45
- export const installCommand = (manager) => {
46
- switch (manager) {
47
- case "pnpm":
48
- return {
49
- command: "pnpm",
50
- args: ["install", "--no-frozen-lockfile"],
51
- env: {},
52
- };
53
- case "yarn":
54
- return {
55
- command: "yarn",
56
- args: ["install"],
57
- env: { YARN_ENABLE_IMMUTABLE_INSTALLS: "false" },
58
- };
59
- case "npm":
60
- case "bun":
61
- return { command: manager, args: ["install"], env: {} };
87
+ export const pinnedRange = (version) => version !== undefined && version !== "berry" && validRange(version) !== null
88
+ ? version
89
+ : undefined;
90
+ export const runProbe = (command, args) => {
91
+ try {
92
+ return execFileSync(command, args, {
93
+ encoding: "utf8",
94
+ stdio: ["ignore", "pipe", "ignore"],
95
+ shell: process.platform === "win32",
96
+ }).trim();
97
+ }
98
+ catch {
99
+ return undefined;
62
100
  }
63
101
  };
102
+ /**
103
+ * What to run for `manager`, honouring a `packageManager` pin.
104
+ *
105
+ * The decision tree, and why it checks before reaching for corepack:
106
+ *
107
+ * | Situation | Behaviour |
108
+ * | ------------------------------------------------- | ------------------------- |
109
+ * | No pin, or the `"berry"` specifier | run directly |
110
+ * | Pin, binary present, version satisfies it | run directly |
111
+ * | Pin, mismatch or binary missing, corepack present | run under corepack |
112
+ * | Pin, mismatch or missing, no corepack | throw, before any install |
113
+ *
114
+ * Corepack ignores `PATH` and downloads into its own cache, so "always
115
+ * corepack" would send every project that has a `packageManager` field — most
116
+ * of them — through a download, offline CI included. pnpm 9.7+ additionally
117
+ * installs its own pinned version, so for pnpm the direct path is usually
118
+ * already right.
119
+ *
120
+ * `COREPACK_ENABLE_DOWNLOAD_PROMPT=0` is required rather than tidy: corepack
121
+ * prompts interactively before a first download, and `upgrade` runs unattended
122
+ * (`-y` is implied with no TTY). Only one `install` is spawned under corepack —
123
+ * no nested per-task spawns — so the corepack footgun in the repo's root
124
+ * `AGENTS.md` does not apply.
125
+ */
126
+ export const planInstall = (manager, probe = runProbe) => {
127
+ const { command, args, env } = installCommand(manager.agent);
128
+ const line = [command, ...args].join(" ");
129
+ const pin = pinnedRange(manager.version);
130
+ if (pin === undefined) {
131
+ return { command, args, env, description: `${manager.agent} — ${line}` };
132
+ }
133
+ const found = probe(command, ["--version"]);
134
+ if (found !== undefined && satisfies(found, pin)) {
135
+ return {
136
+ command,
137
+ args,
138
+ env,
139
+ description: `${manager.agent} ${found} — ${line}`,
140
+ };
141
+ }
142
+ if (probe("corepack", ["--version"]) === undefined) {
143
+ throw new Error(`This project pins ${manager.name}@${pin} in "packageManager", but ${found === undefined
144
+ ? `${command} is not on PATH`
145
+ : `the ${command} on PATH is ${found}`}, and corepack is not available to bridge the gap.\n\nInstall ${manager.name}@${pin} (${INSTALL_PAGE[manager.agent]}) or enable corepack, then re-run this command. Nothing has been installed.`);
146
+ }
147
+ return {
148
+ command: "corepack",
149
+ args: [command, ...args],
150
+ env: { ...env, COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" },
151
+ description: `${manager.agent} ${pin} via corepack — corepack ${line}`,
152
+ };
153
+ };
64
154
  export const runInstall = (manager, cwd) => {
65
- const { command, args, env } = installCommand(manager);
66
- execFileSync(command, args, {
155
+ const plan = planInstall(manager);
156
+ execFileSync(plan.command, plan.args, {
67
157
  cwd,
68
158
  stdio: "inherit",
69
- env: { ...process.env, ...env },
159
+ env: { ...process.env, ...plan.env },
160
+ // Node has refused to spawn `.cmd`/`.bat` without a shell since 2024, which
161
+ // is why every non-npm manager died with ENOENT on Windows. Every argument
162
+ // here is our own literal or the library's, so there is no quoting hazard.
163
+ shell: process.platform === "win32",
70
164
  });
165
+ return plan.description;
166
+ };
167
+ /**
168
+ * How to tell a reader to invoke this package, for the manager they use.
169
+ *
170
+ * `npx …` for npm and Yarn Classic (which has no `dlx`), `pnpm dlx …`, `yarn
171
+ * dlx …`, `bun x …`. The package name is passed as an argument rather than
172
+ * baked in, so the library decides the whole prefix.
173
+ *
174
+ * Falls back to the `npx` form when the agent has no `execute` command: a
175
+ * printed hint is worth more than a gap, and npx is the one form every reader
176
+ * can run.
177
+ */
178
+ export const resolveInvoke = async (cwd, pkg = "@mittwald/flow-codemods@latest") => {
179
+ const { agent } = await detectPackageManagerIn(cwd);
180
+ const resolved = resolveCommand(agent, "execute", [pkg]);
181
+ return resolved === null
182
+ ? `npx ${pkg}`
183
+ : [resolved.command, ...resolved.args].join(" ");
71
184
  };
@@ -0,0 +1,175 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ /**
4
+ * Moves the background value of `AccentBox` from `color` to `backgroundColor`
5
+ * (alpha.786).
6
+ *
7
+ * `color` did not go away, it changed meaning: it used to set the background
8
+ * (`"blue" | "green" | "gradient" | "neutral"`) and now sets the content color
9
+ * (`"default" | "dark" | "light" | "dark-static" | "light-static"`). A blanket
10
+ * rename would therefore break every element that already uses the new meaning.
11
+ * The transform decides per value instead: a value from the new content-color
12
+ * union stays on `color`, every other literal moves to `backgroundColor`.
13
+ *
14
+ * The scope is deliberately narrow. Only JSX elements that resolve to
15
+ * `AccentBox` — imported (named or as a namespace) from
16
+ * `@mittwald/flow-react-components` or
17
+ * `@mittwald/flow-remote-react-components`, including their subpath entries —
18
+ * are touched.
19
+ *
20
+ * Two cases are left alone, because both are undecidable without knowing the
21
+ * value:
22
+ *
23
+ * - Values it cannot read (`color={expression}`), and expressions that mix both
24
+ * meanings (`color={flag ? "blue" : "dark"}`). The same expression means the
25
+ * background in old code and the content color in new code, and a mix has no
26
+ * single correct answer. An expression whose every value position is a
27
+ * literal on the same side of the split _is_ decidable and gets renamed:
28
+ * `color={flag ? "blue" : "green"}`.
29
+ * - An element that already carries `backgroundColor`. Moving `color` there would
30
+ * silently overwrite the explicit value.
31
+ *
32
+ * Both keep their `color` prop and need a look by hand.
33
+ */
34
+ const accentBoxColorToBackgroundColorTransform = (fileInfo, { j }) => {
35
+ const flowPackages = [
36
+ "@mittwald/flow-react-components",
37
+ "@mittwald/flow-remote-react-components",
38
+ ];
39
+ const affectedComponents = new Set(["AccentBox"]);
40
+ /** Values `color` still accepts — everything else was a background color. */
41
+ const contentColors = new Set([
42
+ "default",
43
+ "dark",
44
+ "light",
45
+ "dark-static",
46
+ "light-static",
47
+ ]);
48
+ const isFlowImport = (source) => flowPackages.some((pkg) => source === pkg || source.startsWith(`${pkg}/`));
49
+ /** The value of a string literal, or `undefined` for anything dynamic. */
50
+ const literalValue = (node) => {
51
+ if (!node || typeof node !== "object" || !("type" in node)) {
52
+ return undefined;
53
+ }
54
+ const { type } = node;
55
+ if (type !== "StringLiteral" && type !== "Literal") {
56
+ return undefined;
57
+ }
58
+ const { value } = node;
59
+ return typeof value === "string" ? value : undefined;
60
+ };
61
+ /**
62
+ * Every literal that could become this attribute's value, or `undefined` when
63
+ * any of those positions is something we cannot read. The positions are the
64
+ * expression itself, both ternary branches, and the operands of `??`/`||` —
65
+ * plus only the right operand of `&&`, since `&&` yields its left operand
66
+ * only when that operand is falsy and a non-empty string never is.
67
+ *
68
+ * A list, not a single value, because this transform decides per attribute,
69
+ * not per literal: `color={flag ? "blue" : "dark"}` mixes a background with a
70
+ * content colour, and no single rename is right for both.
71
+ */
72
+ const valueLiterals = (node) => {
73
+ const literal = literalValue(node);
74
+ if (literal !== undefined) {
75
+ return [literal];
76
+ }
77
+ if (!node || typeof node !== "object" || !("type" in node)) {
78
+ return undefined;
79
+ }
80
+ const typed = node;
81
+ if (typed.type === "ConditionalExpression") {
82
+ const consequent = valueLiterals(typed.consequent);
83
+ const alternate = valueLiterals(typed.alternate);
84
+ return consequent && alternate
85
+ ? [...consequent, ...alternate]
86
+ : undefined;
87
+ }
88
+ if (typed.type === "LogicalExpression") {
89
+ const right = valueLiterals(typed.right);
90
+ if (right === undefined) {
91
+ return undefined;
92
+ }
93
+ if (typed.operator === "&&") {
94
+ return right;
95
+ }
96
+ const left = valueLiterals(typed.left);
97
+ return left ? [...left, ...right] : undefined;
98
+ }
99
+ return undefined;
100
+ };
101
+ const root = j(fileInfo.source, { parser: "tsx" });
102
+ // Local JSX identifier -> canonical component name (resolves `as` aliases).
103
+ const localToComponent = new Map();
104
+ // Local names of `import * as Flow` namespace imports from a Flow package.
105
+ const flowNamespaces = new Set();
106
+ root
107
+ .find(j.ImportDeclaration)
108
+ .filter((path) => isFlowImport(String(path.node.source.value)))
109
+ .forEach((path) => {
110
+ for (const specifier of path.node.specifiers ?? []) {
111
+ if (specifier.type === "ImportSpecifier" &&
112
+ specifier.imported.type === "Identifier" &&
113
+ affectedComponents.has(specifier.imported.name)) {
114
+ localToComponent.set(String(specifier.local?.name ?? specifier.imported.name), String(specifier.imported.name));
115
+ }
116
+ else if (specifier.type === "ImportNamespaceSpecifier" &&
117
+ specifier.local) {
118
+ flowNamespaces.add(String(specifier.local.name));
119
+ }
120
+ }
121
+ });
122
+ if (localToComponent.size === 0 && flowNamespaces.size === 0) {
123
+ return fileInfo.source;
124
+ }
125
+ root.find(j.JSXOpeningElement).forEach((path) => {
126
+ const name = path.node.name;
127
+ let isAffected = false;
128
+ if (name.type === "JSXIdentifier") {
129
+ isAffected = localToComponent.has(name.name);
130
+ }
131
+ else if (name.type === "JSXMemberExpression" &&
132
+ name.object.type === "JSXIdentifier" &&
133
+ name.property.type === "JSXIdentifier") {
134
+ isAffected =
135
+ flowNamespaces.has(name.object.name) &&
136
+ affectedComponents.has(name.property.name);
137
+ }
138
+ if (!isAffected) {
139
+ return;
140
+ }
141
+ const attributes = path.node.attributes ?? [];
142
+ const isNamed = (attribute, key) => !!attribute &&
143
+ typeof attribute === "object" &&
144
+ attribute.type === "JSXAttribute" &&
145
+ attribute.name?.type ===
146
+ "JSXIdentifier" &&
147
+ attribute.name?.name === key;
148
+ // An explicit `backgroundColor` is the new API already — never overwrite it.
149
+ if (attributes.some((attribute) => isNamed(attribute, "backgroundColor"))) {
150
+ return;
151
+ }
152
+ for (const attribute of attributes) {
153
+ if (!isNamed(attribute, "color") || attribute.type !== "JSXAttribute") {
154
+ continue;
155
+ }
156
+ const value = attribute.value;
157
+ const literals = value?.type === "JSXExpressionContainer"
158
+ ? valueLiterals(value.expression)
159
+ : valueLiterals(value);
160
+ if (literals === undefined || literals.length === 0) {
161
+ continue;
162
+ }
163
+ // All-or-nothing. Every value has to be a background colour for the
164
+ // rename to be right; all content colours means the element already uses
165
+ // the new meaning, and a mix has no single correct answer.
166
+ const backgrounds = literals.filter((literal) => !contentColors.has(literal));
167
+ if (backgrounds.length !== literals.length) {
168
+ continue;
169
+ }
170
+ attribute.name = j.jsxIdentifier("backgroundColor");
171
+ }
172
+ });
173
+ return root.toSource();
174
+ };
175
+ exports.default = accentBoxColorToBackgroundColorTransform;
@@ -0,0 +1,82 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ /**
4
+ * Renames the `action` prop to `onAction` on `Action`.
5
+ *
6
+ * The scope is deliberately narrow. Only JSX elements that resolve to `Action`
7
+ * — imported (named or as a namespace) from `@mittwald/flow-react-components`
8
+ * or `@mittwald/flow-remote-react-components`, including their subpath entries
9
+ * — are touched. `Action` is not one of the generated remote components, but
10
+ * the remote package re-exports the `flr-universal` surface, which carries it.
11
+ * Same-named components from other packages are left untouched, and so is the
12
+ * `action` attribute of a plain `<form>`.
13
+ *
14
+ * An element that already carries `onAction` keeps it and only loses the stale
15
+ * `action` prop, which mirrors what the runtime fallback does: an explicit
16
+ * `onAction` wins.
17
+ */
18
+ const actionPropToOnActionTransform = (fileInfo, { j }) => {
19
+ const flowPackages = [
20
+ "@mittwald/flow-react-components",
21
+ "@mittwald/flow-remote-react-components",
22
+ ];
23
+ const affectedComponents = new Set(["Action"]);
24
+ const isFlowImport = (source) => flowPackages.some((pkg) => source === pkg || source.startsWith(`${pkg}/`));
25
+ const root = j(fileInfo.source, { parser: "tsx" });
26
+ // Local JSX identifier -> canonical component name (resolves `as` aliases).
27
+ const localToComponent = new Map();
28
+ // Local names of `import * as Flow` namespace imports from a Flow package.
29
+ const flowNamespaces = new Set();
30
+ root
31
+ .find(j.ImportDeclaration)
32
+ .filter((path) => isFlowImport(String(path.node.source.value)))
33
+ .forEach((path) => {
34
+ for (const specifier of path.node.specifiers ?? []) {
35
+ if (specifier.type === "ImportSpecifier" &&
36
+ specifier.imported.type === "Identifier" &&
37
+ affectedComponents.has(specifier.imported.name)) {
38
+ localToComponent.set(String(specifier.local?.name ?? specifier.imported.name), String(specifier.imported.name));
39
+ }
40
+ else if (specifier.type === "ImportNamespaceSpecifier" &&
41
+ specifier.local) {
42
+ flowNamespaces.add(String(specifier.local.name));
43
+ }
44
+ }
45
+ });
46
+ if (localToComponent.size === 0 && flowNamespaces.size === 0) {
47
+ return fileInfo.source;
48
+ }
49
+ root.find(j.JSXOpeningElement).forEach((path) => {
50
+ const name = path.node.name;
51
+ let isAffected = false;
52
+ if (name.type === "JSXIdentifier") {
53
+ isAffected = localToComponent.has(name.name);
54
+ }
55
+ else if (name.type === "JSXMemberExpression" &&
56
+ name.object.type === "JSXIdentifier" &&
57
+ name.property.type === "JSXIdentifier") {
58
+ isAffected =
59
+ flowNamespaces.has(name.object.name) &&
60
+ affectedComponents.has(name.property.name);
61
+ }
62
+ if (!isAffected) {
63
+ return;
64
+ }
65
+ const attributes = path.node.attributes ?? [];
66
+ const isNamed = (attribute, key) => attribute.type === "JSXAttribute" &&
67
+ attribute.name.type === "JSXIdentifier" &&
68
+ attribute.name.name === key;
69
+ const hasOnAction = attributes.some((attribute) => isNamed(attribute, "onAction"));
70
+ if (hasOnAction) {
71
+ path.node.attributes = attributes.filter((attribute) => !isNamed(attribute, "action"));
72
+ return;
73
+ }
74
+ for (const attribute of attributes) {
75
+ if (isNamed(attribute, "action") && attribute.type === "JSXAttribute") {
76
+ attribute.name.name = "onAction";
77
+ }
78
+ }
79
+ });
80
+ return root.toSource();
81
+ };
82
+ exports.default = actionPropToOnActionTransform;
@@ -0,0 +1,166 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ /**
4
+ * Renames the `Align` component to `Combine`, and `AlignProps` to
5
+ * `CombineProps`.
6
+ *
7
+ * Only names imported from `@mittwald/flow-react-components` or
8
+ * `@mittwald/flow-remote-react-components` (including their subpath entries)
9
+ * are touched, so a same-named import from another package is left alone. An
10
+ * `Align` imported under a local alias (`import { Align as Row }`) keeps its
11
+ * alias — only the imported name changes. Namespace usages (`<Flow.Align />`,
12
+ * `Flow.AlignProps`) are rewritten as well.
13
+ */
14
+ const alignToCombineTransform = (fileInfo, { j }) => {
15
+ const flowPackages = [
16
+ "@mittwald/flow-react-components",
17
+ "@mittwald/flow-remote-react-components",
18
+ ];
19
+ const renames = new Map([
20
+ ["Align", "Combine"],
21
+ ["AlignProps", "CombineProps"],
22
+ ]);
23
+ const isFlowImport = (source) => flowPackages.some((pkg) => source === pkg || source.startsWith(`${pkg}/`));
24
+ // ast-types models `importKind` on the declaration only, while babel also
25
+ // puts it on the specifier — which is where a per-specifier `type X` lives.
26
+ const isTypeOnly = (specifier) => specifier.importKind === "type";
27
+ const makeValueImport = (specifier) => {
28
+ specifier.importKind = "value";
29
+ };
30
+ const root = j(fileInfo.source, { parser: "tsx" });
31
+ /** Local identifiers that refer to a renamed export, mapped to the new name. */
32
+ const localRenames = new Map();
33
+ /**
34
+ * Whether any import specifier was rewritten. An aliased `Align as Row` adds
35
+ * nothing to `localRenames` — its local name stays `Row` — so counting those
36
+ * alone would treat an alias-only file as untouched and discard the rewrite.
37
+ */
38
+ let renamedAnImport = false;
39
+ /** Local names of `import * as Flow` namespace imports from a Flow package. */
40
+ const flowNamespaces = new Set();
41
+ const flowImports = root
42
+ .find(j.ImportDeclaration)
43
+ .filter((path) => isFlowImport(String(path.node.source.value)));
44
+ /**
45
+ * `imported:local` pairs the file already binds without a rename. A rename
46
+ * that lands on one of these must not add a second specifier for it — the
47
+ * name is available from the import that already carries it, which is also
48
+ * the entry that really exports it.
49
+ */
50
+ const claimed = new Set();
51
+ flowImports.forEach((path) => {
52
+ for (const specifier of path.node.specifiers ?? []) {
53
+ if (specifier.type === "ImportNamespaceSpecifier" &&
54
+ specifier.local?.name) {
55
+ flowNamespaces.add(String(specifier.local.name));
56
+ continue;
57
+ }
58
+ if (specifier.type !== "ImportSpecifier" ||
59
+ specifier.imported.type !== "Identifier" ||
60
+ renames.has(specifier.imported.name)) {
61
+ continue;
62
+ }
63
+ claimed.add(`${specifier.imported.name}:${String(specifier.local?.name ?? specifier.imported.name)}`);
64
+ }
65
+ });
66
+ flowImports.forEach((path) => {
67
+ const specifiers = path.node.specifiers ?? [];
68
+ const survivors = [];
69
+ for (const specifier of specifiers) {
70
+ if (specifier.type !== "ImportSpecifier" ||
71
+ specifier.imported.type !== "Identifier") {
72
+ survivors.push(specifier);
73
+ continue;
74
+ }
75
+ const imported = specifier.imported.name;
76
+ const renamed = renames.get(imported);
77
+ if (!renamed) {
78
+ survivors.push(specifier);
79
+ continue;
80
+ }
81
+ const local = String(specifier.local?.name ?? imported);
82
+ const isAlias = local !== imported;
83
+ const newLocal = isAlias ? local : renamed;
84
+ const key = `${renamed}:${newLocal}`;
85
+ renamedAnImport = true;
86
+ if (!isAlias) {
87
+ // An aliased import keeps its local name and needs no further change.
88
+ localRenames.set(local, renamed);
89
+ }
90
+ // Renaming can collide with a name the file already binds — directly, or
91
+ // because a second old name maps onto the same new one. Either way the
92
+ // binding already exists, so this specifier goes away instead of
93
+ // producing a duplicate declaration, which would not parse.
94
+ const collision = claimed.has(key);
95
+ claimed.add(key);
96
+ if (collision) {
97
+ // A value import must not lose out to a type-only one.
98
+ if (!isTypeOnly(specifier)) {
99
+ for (const kept of survivors) {
100
+ if (kept.type === "ImportSpecifier" &&
101
+ kept.imported.type === "Identifier" &&
102
+ `${kept.imported.name}:${String(kept.local?.name ?? kept.imported.name)}` === key) {
103
+ makeValueImport(kept);
104
+ }
105
+ }
106
+ }
107
+ continue;
108
+ }
109
+ // Mutate in place so an `import type` / `type X` modifier survives.
110
+ specifier.imported.name = renamed;
111
+ if (!isAlias && specifier.local) {
112
+ specifier.local.name = renamed;
113
+ }
114
+ survivors.push(specifier);
115
+ }
116
+ if (survivors.length === specifiers.length) {
117
+ return;
118
+ }
119
+ // Every specifier moved to an import that already binds the name. Leaving
120
+ // the declaration behind would turn it into a side-effect import of an
121
+ // entry the file no longer uses.
122
+ if (survivors.length === 0) {
123
+ j(path).remove();
124
+ return;
125
+ }
126
+ path.node.specifiers = survivors;
127
+ });
128
+ if (!renamedAnImport && flowNamespaces.size === 0) {
129
+ return fileInfo.source;
130
+ }
131
+ /**
132
+ * `JSXIdentifier` extends `Identifier`, so this one pass covers JSX tags,
133
+ * value references and type references alike. Positions where the name is not
134
+ * a reference to the import — an object key, a member's property, a JSX
135
+ * attribute name — are skipped.
136
+ */
137
+ root.find(j.Identifier).forEach((path) => {
138
+ const parent = path.parent.node;
139
+ const isNamespaceMember = (parent.type === "MemberExpression" ||
140
+ parent.type === "JSXMemberExpression") &&
141
+ parent.property === path.node;
142
+ if (isNamespaceMember) {
143
+ const object = parent.object;
144
+ if (object.type === "Identifier" || object.type === "JSXIdentifier") {
145
+ const renamed = renames.get(path.node.name);
146
+ if (renamed && flowNamespaces.has(String(object.name))) {
147
+ path.node.name = renamed;
148
+ }
149
+ }
150
+ return;
151
+ }
152
+ if (parent.type === "ImportSpecifier" ||
153
+ parent.type === "JSXAttribute" ||
154
+ ((parent.type === "ObjectProperty" || parent.type === "Property") &&
155
+ parent.key === path.node &&
156
+ !parent.computed)) {
157
+ return;
158
+ }
159
+ const renamed = localRenames.get(path.node.name);
160
+ if (renamed) {
161
+ path.node.name = renamed;
162
+ }
163
+ });
164
+ return root.toSource();
165
+ };
166
+ exports.default = alignToCombineTransform;