@onrails/codemod 0.1.1 → 0.1.3
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/package.json +2 -1
- package/src/ast.ts +58 -0
- package/src/chains.ts +228 -0
- package/src/cli.ts +132 -0
- package/src/constants.ts +63 -0
- package/src/file-change.ts +57 -0
- package/src/imports.ts +77 -0
- package/src/index.ts +8 -630
- package/src/io.ts +10 -0
- package/src/pkg.ts +62 -0
- package/src/types.ts +46 -0
- package/src/warnings.ts +61 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onrails/codemod",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Codemods for migrating onto @onrails/result (neverthrow → compat shim, and beyond)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Alan R. Soares",
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@onrails/maybe": "0.1.0",
|
|
46
|
+
"@onrails/pattern": "0.1.0",
|
|
46
47
|
"@onrails/result": "0.1.0",
|
|
47
48
|
"typescript": "^6.0.3"
|
|
48
49
|
},
|
package/src/ast.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { fromNullable, type Maybe, match, none, some } from "@onrails/maybe";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { TYPE_ONLY_NATIVE } from "./constants.js";
|
|
4
|
+
import type { Edit } from "./types.js";
|
|
5
|
+
|
|
6
|
+
export const edit = (text: string, imports: readonly string[] = []): Edit => ({
|
|
7
|
+
start: 0,
|
|
8
|
+
end: 0,
|
|
9
|
+
text,
|
|
10
|
+
imports,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export const lookupMap = <K, V, R>(m: Map<K, V>, k: K, f: (v: V) => R): Maybe<R> =>
|
|
14
|
+
match(fromNullable(m.get(k)), (v) => some(f(v)), none);
|
|
15
|
+
|
|
16
|
+
export const concatCollectors =
|
|
17
|
+
<T, R>(...fns: Array<(t: T) => readonly R[]>) =>
|
|
18
|
+
(t: T): readonly R[] =>
|
|
19
|
+
fns.flatMap((f) => f(t));
|
|
20
|
+
|
|
21
|
+
export const walkSource = (
|
|
22
|
+
src: string,
|
|
23
|
+
visit: (node: ts.Node, sf: ts.SourceFile) => unknown,
|
|
24
|
+
): void => {
|
|
25
|
+
const sf = ts.createSourceFile("codemod.ts", src, ts.ScriptTarget.Latest, true);
|
|
26
|
+
const go = (n: ts.Node) => {
|
|
27
|
+
if (visit(n, sf)) return;
|
|
28
|
+
ts.forEachChild(n, go);
|
|
29
|
+
};
|
|
30
|
+
go(sf);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const spanEdit = (node: ts.Node, sf: ts.SourceFile, partial: Edit): Edit => ({
|
|
34
|
+
...partial,
|
|
35
|
+
start: node.getStart(sf),
|
|
36
|
+
end: node.getEnd(),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export const argsToText = (args: ts.NodeArray<ts.Expression>): string =>
|
|
40
|
+
args.map((a) => a.getText()).join(", ");
|
|
41
|
+
|
|
42
|
+
export const countOccurrences = (s: string, sub: string): number => s.split(sub).length - 1;
|
|
43
|
+
|
|
44
|
+
export const splitImportNames = (specifiers: string): readonly string[] =>
|
|
45
|
+
specifiers
|
|
46
|
+
.split(",")
|
|
47
|
+
.map((part) => part.trim())
|
|
48
|
+
.filter((part) => part.length > 0);
|
|
49
|
+
|
|
50
|
+
export const stripInlineType = (specifier: string): string => specifier.replace(/^type\s+/, "");
|
|
51
|
+
|
|
52
|
+
export const importedName = (specifier: string): string =>
|
|
53
|
+
stripInlineType(specifier).split(/\s+as\s+/i)[0] ?? specifier;
|
|
54
|
+
|
|
55
|
+
export const isTypeOnlyNative = (specifier: string): boolean =>
|
|
56
|
+
specifier.startsWith("type ") || TYPE_ONLY_NATIVE.has(importedName(specifier));
|
|
57
|
+
|
|
58
|
+
export const isValueImport = (specifier: string): boolean => !isTypeOnlyNative(specifier);
|
package/src/chains.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { compactMap, isSome, type Maybe, map, none, some } from "@onrails/maybe";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { argsToText, edit, lookupMap, spanEdit, walkSource } from "./ast.js";
|
|
4
|
+
import {
|
|
5
|
+
ASYNC_ROOT_HINTS,
|
|
6
|
+
CHAIN_METHODS,
|
|
7
|
+
PREDICATE_METHODS,
|
|
8
|
+
RESULT_SPECIFIC_CHAIN_METHODS,
|
|
9
|
+
SAFE_BASE_PATTERNS,
|
|
10
|
+
TEE_METHODS,
|
|
11
|
+
TERMINAL_METHODS,
|
|
12
|
+
UNSAFE_UNWRAP_METHODS,
|
|
13
|
+
ZERO_ARG_HELPERS,
|
|
14
|
+
} from "./constants.js";
|
|
15
|
+
import { addNativeValueImports } from "./imports.js";
|
|
16
|
+
import type { ChainStep, Edit, EditAcc, PipelinePart } from "./types.js";
|
|
17
|
+
|
|
18
|
+
export function isSupportedChainCall(node: ts.Node): node is ts.CallExpression {
|
|
19
|
+
if (!ts.isCallExpression(node)) return false;
|
|
20
|
+
if (!ts.isPropertyAccessExpression(node.expression)) return false;
|
|
21
|
+
const method = node.expression.name.text;
|
|
22
|
+
return CHAIN_METHODS.has(method) || TERMINAL_METHODS.has(method);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function identifierCallToNative(node: ts.CallExpression): Maybe<Edit> {
|
|
26
|
+
if (!ts.isIdentifier(node.expression)) return none();
|
|
27
|
+
const name = node.expression.text;
|
|
28
|
+
if (node.arguments.length === 0) {
|
|
29
|
+
const zero = lookupMap(ZERO_ARG_HELPERS, name, (text) => edit(text));
|
|
30
|
+
if (isSome(zero)) return zero;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (name === "sequenceTupleAsync") {
|
|
34
|
+
return some(edit(`ResultAsync.combineTuple(${argsToText(node.arguments)})`, ["ResultAsync"]));
|
|
35
|
+
}
|
|
36
|
+
if (name === "getOrElse") {
|
|
37
|
+
return some(edit(`unwrapOr(${argsToText(node.arguments)})`, ["unwrapOr"]));
|
|
38
|
+
}
|
|
39
|
+
if (name === "collect") {
|
|
40
|
+
return some(edit(`combine(${argsToText(node.arguments)})`, ["combine"]));
|
|
41
|
+
}
|
|
42
|
+
if (name === "matchResult" || name === "matchMaybe") {
|
|
43
|
+
return some(edit(`match(${argsToText(node.arguments)})`, ["match"]));
|
|
44
|
+
}
|
|
45
|
+
if (name === "fold") {
|
|
46
|
+
return map(foldHandlerTexts(node.arguments[0]), ({ okText, errText }) =>
|
|
47
|
+
edit(`match(${okText}, ${errText})`, ["match"]),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return none();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type FoldHandlerTexts = { okText: string; errText: string };
|
|
54
|
+
|
|
55
|
+
// Bails (none) unless both `ok` and `err` handlers are plain or shorthand
|
|
56
|
+
// properties — spread/computed handlers can't be rewritten textually.
|
|
57
|
+
function foldHandlerTexts(objArg: ts.Expression | undefined): Maybe<FoldHandlerTexts> {
|
|
58
|
+
if (!objArg || !ts.isObjectLiteralExpression(objArg)) return none();
|
|
59
|
+
let okText: string | undefined;
|
|
60
|
+
let errText: string | undefined;
|
|
61
|
+
for (const prop of objArg.properties) {
|
|
62
|
+
if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) {
|
|
63
|
+
if (prop.name.text === "ok") {
|
|
64
|
+
okText = prop.initializer.getText();
|
|
65
|
+
} else if (prop.name.text === "err") {
|
|
66
|
+
errText = prop.initializer.getText();
|
|
67
|
+
}
|
|
68
|
+
} else if (ts.isShorthandPropertyAssignment(prop)) {
|
|
69
|
+
if (prop.name.text === "ok") {
|
|
70
|
+
okText = "ok";
|
|
71
|
+
} else if (prop.name.text === "err") {
|
|
72
|
+
errText = "err";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return okText !== undefined && errText !== undefined ? some({ okText, errText }) : none();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function curriedFoldToNative(node: ts.CallExpression): Maybe<Edit> {
|
|
80
|
+
if (
|
|
81
|
+
!ts.isCallExpression(node.expression) ||
|
|
82
|
+
!ts.isIdentifier(node.expression.expression) ||
|
|
83
|
+
node.expression.expression.text !== "fold"
|
|
84
|
+
) {
|
|
85
|
+
return none();
|
|
86
|
+
}
|
|
87
|
+
const receiver = node.arguments[0];
|
|
88
|
+
if (!receiver) return none();
|
|
89
|
+
return map(foldHandlerTexts(node.expression.arguments[0]), ({ okText, errText }) =>
|
|
90
|
+
edit(`match(${okText}, ${errText})(${receiver.getText()})`, ["match"]),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function helperCallToNative(node: ts.CallExpression): Maybe<Edit> {
|
|
95
|
+
const ident = identifierCallToNative(node);
|
|
96
|
+
if (isSome(ident)) return ident;
|
|
97
|
+
|
|
98
|
+
const curried = curriedFoldToNative(node);
|
|
99
|
+
if (isSome(curried)) return curried;
|
|
100
|
+
|
|
101
|
+
if (!ts.isPropertyAccessExpression(node.expression)) return none();
|
|
102
|
+
const method = node.expression.name.text;
|
|
103
|
+
const base = node.expression.expression
|
|
104
|
+
.getText()
|
|
105
|
+
.replaceAll(".andTee(", ".tap(")
|
|
106
|
+
.replaceAll(".orTee(", ".tapErr(");
|
|
107
|
+
const argsText = argsToText(node.arguments);
|
|
108
|
+
|
|
109
|
+
const tee = lookupMap(TEE_METHODS, method, (t) => edit(`${base}.${t}(${argsText})`));
|
|
110
|
+
if (isSome(tee)) return tee;
|
|
111
|
+
|
|
112
|
+
if (PREDICATE_METHODS.has(method)) {
|
|
113
|
+
return some(edit(`${method}(${base})`, [method]));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return lookupMap(UNSAFE_UNWRAP_METHODS, method, (u) => edit(`${u}(${base})`, [u]));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function isNestedInSupportedChain(node: ts.Node): boolean {
|
|
120
|
+
const parent = node.parent;
|
|
121
|
+
if (!parent) return false;
|
|
122
|
+
if (!ts.isPropertyAccessExpression(parent)) return false;
|
|
123
|
+
return isSupportedChainCall(parent.parent);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function collectChain(node: ts.CallExpression): { base: string; steps: ChainStep[] } {
|
|
127
|
+
const steps: ChainStep[] = [];
|
|
128
|
+
let current: ts.Expression = node;
|
|
129
|
+
|
|
130
|
+
while (ts.isCallExpression(current) && ts.isPropertyAccessExpression(current.expression)) {
|
|
131
|
+
const method = current.expression.name.text;
|
|
132
|
+
if (!CHAIN_METHODS.has(method) && !TERMINAL_METHODS.has(method)) break;
|
|
133
|
+
steps.push({
|
|
134
|
+
method,
|
|
135
|
+
argsText: argsToText(current.arguments),
|
|
136
|
+
argCount: current.arguments.length,
|
|
137
|
+
});
|
|
138
|
+
current = current.expression.expression;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
base: current.getText(),
|
|
143
|
+
steps: steps.reverse(),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export const hasAsyncRootHint = (base: string): boolean =>
|
|
148
|
+
ASYNC_ROOT_HINTS.some((hint) => base.includes(hint));
|
|
149
|
+
|
|
150
|
+
export const isSafeSyncChainBase = (base: string): boolean =>
|
|
151
|
+
SAFE_BASE_PATTERNS.some((re) => re.test(base));
|
|
152
|
+
|
|
153
|
+
const isTerminalStep = (s: ChainStep): boolean => TERMINAL_METHODS.has(s.method);
|
|
154
|
+
const isResultSpecificStep = (s: ChainStep): boolean => RESULT_SPECIFIC_CHAIN_METHODS.has(s.method);
|
|
155
|
+
const isChainStep = (s: ChainStep): boolean => CHAIN_METHODS.has(s.method);
|
|
156
|
+
|
|
157
|
+
export function chainToNative(base: string, steps: readonly ChainStep[]): Maybe<Edit> {
|
|
158
|
+
if (steps.length === 0 || hasAsyncRootHint(base)) return none();
|
|
159
|
+
const terminalStep = steps.find(isTerminalStep);
|
|
160
|
+
if (!isSafeSyncChainBase(base)) return none();
|
|
161
|
+
if (!terminalStep && !steps.some(isResultSpecificStep)) return none();
|
|
162
|
+
if (terminalStep?.method === "match" && terminalStep.argCount !== 2) return none();
|
|
163
|
+
const pipelineSteps = steps.filter(isChainStep);
|
|
164
|
+
const pipelineParts = compactMap(pipelineSteps, (step) =>
|
|
165
|
+
lookupMap(
|
|
166
|
+
CHAIN_METHODS,
|
|
167
|
+
step.method,
|
|
168
|
+
(nativeName): PipelinePart => ({
|
|
169
|
+
importName: nativeName,
|
|
170
|
+
text: `${nativeName}(${step.argsText})`,
|
|
171
|
+
}),
|
|
172
|
+
),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
if (pipelineParts.length !== pipelineSteps.length) return none();
|
|
176
|
+
|
|
177
|
+
const parts = pipelineParts.map((part) => part.text);
|
|
178
|
+
const imports = new Set(pipelineParts.map((part) => part.importName));
|
|
179
|
+
const pipeline = parts.length > 0 ? `pipe(${[base, ...parts].join(", ")})` : base;
|
|
180
|
+
|
|
181
|
+
if (parts.length > 0) imports.add("pipe");
|
|
182
|
+
|
|
183
|
+
if (!terminalStep) {
|
|
184
|
+
return some(edit(pipeline, [...imports]));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
imports.add(terminalStep.method);
|
|
188
|
+
return some(edit(`${terminalStep.method}(${pipeline}, ${terminalStep.argsText})`, [...imports]));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export const byStartDesc = (a: Edit, b: Edit): number => b.start - a.start;
|
|
192
|
+
|
|
193
|
+
export const applyEditStep = (acc: EditAcc, e: Edit): EditAcc => ({
|
|
194
|
+
src: `${acc.src.slice(0, e.start)}${e.text}${acc.src.slice(e.end)}`,
|
|
195
|
+
imports: new Set([...acc.imports, ...e.imports]),
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
export function rewriteCompatMethodChainsToNative(src: string): string {
|
|
199
|
+
const edits: Edit[] = [];
|
|
200
|
+
|
|
201
|
+
walkSource(src, (node, sf) => {
|
|
202
|
+
if (ts.isCallExpression(node)) {
|
|
203
|
+
const helper = helperCallToNative(node);
|
|
204
|
+
if (isSome(helper)) {
|
|
205
|
+
edits.push(spanEdit(node, sf, helper.value));
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (
|
|
211
|
+
isSupportedChainCall(node) &&
|
|
212
|
+
!isNestedInSupportedChain(node) &&
|
|
213
|
+
!ts.isPropertyAccessExpression(node.parent)
|
|
214
|
+
) {
|
|
215
|
+
const chain = collectChain(node);
|
|
216
|
+
const chained = chainToNative(chain.base, chain.steps);
|
|
217
|
+
if (isSome(chained)) edits.push(spanEdit(node, sf, chained.value));
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
if (edits.length === 0) return src;
|
|
222
|
+
|
|
223
|
+
const { src: next, imports } = edits
|
|
224
|
+
.sort(byStartDesc)
|
|
225
|
+
.reduce(applyEditStep, { src, imports: new Set<string>() });
|
|
226
|
+
|
|
227
|
+
return addNativeValueImports(next, [...imports]);
|
|
228
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { relative, resolve } from "node:path";
|
|
2
|
+
import * as Maybe from "@onrails/maybe";
|
|
3
|
+
import { match } from "@onrails/pattern";
|
|
4
|
+
import { err, isErr, ok, type Result, ResultAsync } from "@onrails/result";
|
|
5
|
+
import { Glob } from "bun";
|
|
6
|
+
import { CODE_EXT, SKIP } from "./constants.js";
|
|
7
|
+
import { computeFileChange, toFileChange } from "./file-change.js";
|
|
8
|
+
import { readFileText, writeFileText } from "./io.js";
|
|
9
|
+
import { rewritePkg } from "./pkg.js";
|
|
10
|
+
import type { ArgState, Args, FileChange, Mode, PkgChange } from "./types.js";
|
|
11
|
+
|
|
12
|
+
const ONRAILS_FLAG = "--onrails=";
|
|
13
|
+
|
|
14
|
+
const applyArg = (s: ArgState, a: string): ArgState =>
|
|
15
|
+
match(a)
|
|
16
|
+
.returnType<ArgState>()
|
|
17
|
+
.withEither("--dry", "-n", () => ({ ...s, dry: true }))
|
|
18
|
+
.with("--to-native", () => ({ ...s, mode: "native" }))
|
|
19
|
+
.with(
|
|
20
|
+
(x) => x.startsWith(ONRAILS_FLAG),
|
|
21
|
+
(x) => ({ ...s, onrails: resolve(x.slice(ONRAILS_FLAG.length)) }),
|
|
22
|
+
)
|
|
23
|
+
.with(
|
|
24
|
+
(x) => !x.startsWith("--"),
|
|
25
|
+
(x) => ({ ...s, positional: [...s.positional, x] }),
|
|
26
|
+
)
|
|
27
|
+
.otherwise(() => s);
|
|
28
|
+
|
|
29
|
+
const USAGE =
|
|
30
|
+
"usage: onrails-codemod-neverthrow <target-dir> [--dry] [--to-native] [--onrails=<abs-path>]";
|
|
31
|
+
|
|
32
|
+
export function parseArgs(argv: string[]): Result<Args, string> {
|
|
33
|
+
const initial: ArgState = {
|
|
34
|
+
positional: [],
|
|
35
|
+
dry: false,
|
|
36
|
+
mode: "compat",
|
|
37
|
+
onrails: resolve(import.meta.dir, "../../..", "packages/result"),
|
|
38
|
+
};
|
|
39
|
+
const { positional, dry, mode, onrails } = argv.reduce(applyArg, initial);
|
|
40
|
+
if (positional.length !== 1) {
|
|
41
|
+
return err(USAGE);
|
|
42
|
+
}
|
|
43
|
+
return ok({
|
|
44
|
+
target: resolve(positional[0] ?? "."),
|
|
45
|
+
dry,
|
|
46
|
+
onrails,
|
|
47
|
+
mode,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const shouldSkip = (path: string) => path.split("/").some((seg) => SKIP.has(seg));
|
|
52
|
+
|
|
53
|
+
async function* walk(root: string): AsyncGenerator<string> {
|
|
54
|
+
const glob = new Glob("**/*");
|
|
55
|
+
for await (const rel of glob.scan({ cwd: root, dot: false, onlyFiles: true })) {
|
|
56
|
+
if (!shouldSkip(rel)) yield `${root}/${rel}`;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const rewriteCode = (
|
|
61
|
+
path: string,
|
|
62
|
+
dry: boolean,
|
|
63
|
+
mode: Mode,
|
|
64
|
+
): ResultAsync<Maybe.Maybe<FileChange>, Error> =>
|
|
65
|
+
readFileText(path).flatMap((src) =>
|
|
66
|
+
Maybe.match(
|
|
67
|
+
computeFileChange(src, mode),
|
|
68
|
+
(c) => {
|
|
69
|
+
const change = toFileChange(path, c);
|
|
70
|
+
return c.changed && !dry
|
|
71
|
+
? writeFileText(path, c.next).map(() => Maybe.some(change))
|
|
72
|
+
: ResultAsync.ok<Maybe.Maybe<FileChange>, Error>(Maybe.some(change));
|
|
73
|
+
},
|
|
74
|
+
() => ResultAsync.ok<Maybe.Maybe<FileChange>, Error>(Maybe.none()),
|
|
75
|
+
),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const collectInto =
|
|
79
|
+
<T>(into: T[], file: string) =>
|
|
80
|
+
(r: ResultAsync<Maybe.Maybe<T>, Error>): Promise<void> =>
|
|
81
|
+
r.match(
|
|
82
|
+
(m) => void Maybe.tap(m, (c) => into.push(c)),
|
|
83
|
+
(e) => console.error(`error processing ${file}:`, e.message),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const printReport = (
|
|
87
|
+
{ target, dry, onrails, mode }: Args,
|
|
88
|
+
codeChanges: readonly FileChange[],
|
|
89
|
+
pkgChanges: readonly PkgChange[],
|
|
90
|
+
): void => {
|
|
91
|
+
const label = dry ? "DRY" : "APPLY";
|
|
92
|
+
console.log(`[${label}] target=${target}`);
|
|
93
|
+
console.log(`[${label}] mode=${mode}`);
|
|
94
|
+
if (mode === "compat") console.log(`[${label}] onrails=${onrails}`);
|
|
95
|
+
const changedCodeCount = codeChanges.filter((c) => c.changed).length;
|
|
96
|
+
console.log(`[${label}] code files changed: ${changedCodeCount}`);
|
|
97
|
+
if (mode === "native") {
|
|
98
|
+
console.log(`[${label}] code files reported only: ${codeChanges.length - changedCodeCount}`);
|
|
99
|
+
}
|
|
100
|
+
for (const c of codeChanges) {
|
|
101
|
+
console.log(` ${relative(target, c.path)} (${c.before} import${c.before === 1 ? "" : "s"})`);
|
|
102
|
+
for (const warning of c.warnings) {
|
|
103
|
+
console.log(` TODO line ${warning.line}: ${warning.label}: ${warning.text}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (mode === "compat") {
|
|
107
|
+
console.log(`[${label}] package.json updated: ${pkgChanges.length}`);
|
|
108
|
+
for (const c of pkgChanges) {
|
|
109
|
+
console.log(` ${relative(target, c.path)} [${c.removed.join(", ")}] -> ${c.addedAs}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (dry) console.log(`[${label}] no files written. re-run without --dry to apply.`);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export async function main() {
|
|
116
|
+
const parsed = parseArgs(Bun.argv.slice(2));
|
|
117
|
+
if (isErr(parsed)) {
|
|
118
|
+
console.error(parsed.error);
|
|
119
|
+
process.exit(2);
|
|
120
|
+
}
|
|
121
|
+
const args = parsed.value;
|
|
122
|
+
const { target, dry, onrails, mode } = args;
|
|
123
|
+
const codeChanges: FileChange[] = [];
|
|
124
|
+
const pkgChanges: PkgChange[] = [];
|
|
125
|
+
for await (const file of walk(target)) {
|
|
126
|
+
if (mode === "compat" && file.endsWith("/package.json"))
|
|
127
|
+
await collectInto(pkgChanges, file)(rewritePkg(file, onrails, dry));
|
|
128
|
+
else if (CODE_EXT.test(file))
|
|
129
|
+
await collectInto(codeChanges, file)(rewriteCode(file, dry, mode));
|
|
130
|
+
}
|
|
131
|
+
printReport(args, codeChanges, pkgChanges);
|
|
132
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export const SKIP = new Set([
|
|
2
|
+
"node_modules",
|
|
3
|
+
"dist",
|
|
4
|
+
".git",
|
|
5
|
+
".next",
|
|
6
|
+
".turbo",
|
|
7
|
+
"coverage",
|
|
8
|
+
"build",
|
|
9
|
+
]);
|
|
10
|
+
export const CODE_EXT = /\.(ts|tsx|mts|cts)$/;
|
|
11
|
+
export const IMPORT_RE = /(from\s+|import\s*\(\s*)(['"])neverthrow\2/g;
|
|
12
|
+
export const COMPAT_SPEC = "@onrails/result/compat/neverthrow";
|
|
13
|
+
export const NATIVE_SPEC = "@onrails/result";
|
|
14
|
+
export const DEP_KEYS = ["dependencies", "devDependencies", "peerDependencies"] as const;
|
|
15
|
+
export const TYPE_ONLY_NATIVE = new Set(["Result", "Ok", "Err", "UnexpectedError"]);
|
|
16
|
+
export const COMPAT_ONLY_PATTERNS = [
|
|
17
|
+
{ pattern: /\bResult\.(combine|fromThrowable)\b/, label: "Result static helper" },
|
|
18
|
+
{ pattern: /\._unsafeUnwrap(Err)?\s*\(/, label: "unsafe compat unwrap" },
|
|
19
|
+
] as const;
|
|
20
|
+
export const RESULT_SPECIFIC_CHAIN_METHODS = new Set([
|
|
21
|
+
"andThen",
|
|
22
|
+
"chain",
|
|
23
|
+
"flatMap",
|
|
24
|
+
"mapErr",
|
|
25
|
+
"orElse",
|
|
26
|
+
"recover",
|
|
27
|
+
"tapErr",
|
|
28
|
+
"match",
|
|
29
|
+
"unwrapOr",
|
|
30
|
+
]);
|
|
31
|
+
export const CHAIN_METHODS = new Map([
|
|
32
|
+
["map", "map"],
|
|
33
|
+
["mapErr", "mapErr"],
|
|
34
|
+
["andThen", "flatMap"],
|
|
35
|
+
["chain", "flatMap"],
|
|
36
|
+
["flatMap", "flatMap"],
|
|
37
|
+
["orElse", "recover"],
|
|
38
|
+
["recover", "recover"],
|
|
39
|
+
["tap", "tap"],
|
|
40
|
+
["tapErr", "tapErr"],
|
|
41
|
+
]);
|
|
42
|
+
export const TERMINAL_METHODS = new Set(["match", "unwrapOr"]);
|
|
43
|
+
export const ASYNC_ROOT_HINTS = [
|
|
44
|
+
"ResultAsync",
|
|
45
|
+
"okAsync",
|
|
46
|
+
"errAsync",
|
|
47
|
+
"fromPromise",
|
|
48
|
+
"fromSafePromise",
|
|
49
|
+
];
|
|
50
|
+
export const ZERO_ARG_HELPERS = new Map([
|
|
51
|
+
["ok", "ok(undefined)"],
|
|
52
|
+
["okAsync", "okAsync(undefined)"],
|
|
53
|
+
]);
|
|
54
|
+
export const TEE_METHODS = new Map([
|
|
55
|
+
["andTee", "tap"],
|
|
56
|
+
["orTee", "tapErr"],
|
|
57
|
+
]);
|
|
58
|
+
export const PREDICATE_METHODS = new Set(["isOk", "isErr"]);
|
|
59
|
+
export const UNSAFE_UNWRAP_METHODS = new Map([
|
|
60
|
+
["_unsafeUnwrap", "unwrapOk"],
|
|
61
|
+
["_unsafeUnwrapErr", "unwrapErr"],
|
|
62
|
+
]);
|
|
63
|
+
export const SAFE_BASE_PATTERNS = [/^(ok|err)\s*\(/, /^Result\./, /^[a-zA-Z_$][\w$]*$/] as const;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { type Maybe, none, some } from "@onrails/maybe";
|
|
2
|
+
import { flow } from "@onrails/result";
|
|
3
|
+
import { concatCollectors, countOccurrences } from "./ast.js";
|
|
4
|
+
import { rewriteCompatMethodChainsToNative } from "./chains.js";
|
|
5
|
+
import { COMPAT_SPEC, IMPORT_RE, NATIVE_SPEC } from "./constants.js";
|
|
6
|
+
import { rewriteCompatImportsToNative } from "./imports.js";
|
|
7
|
+
import type { ComputedChange, FileChange, Mode, ModeStrategy } from "./types.js";
|
|
8
|
+
import {
|
|
9
|
+
collectNativeMigrationWarnings,
|
|
10
|
+
collectUnsupportedCompatImportWarnings,
|
|
11
|
+
} from "./warnings.js";
|
|
12
|
+
|
|
13
|
+
const rewriteCompatToNative = flow(rewriteCompatImportsToNative, rewriteCompatMethodChainsToNative);
|
|
14
|
+
|
|
15
|
+
const rewriteNeverthrowToCompat = (src: string): string =>
|
|
16
|
+
src.replace(IMPORT_RE, (_, lead, quote) => `${lead}${quote}${COMPAT_SPEC}${quote}`);
|
|
17
|
+
|
|
18
|
+
const collectAllNativeWarnings = concatCollectors(
|
|
19
|
+
collectUnsupportedCompatImportWarnings,
|
|
20
|
+
collectNativeMigrationWarnings,
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
const MODES: Record<Mode, ModeStrategy> = {
|
|
24
|
+
compat: {
|
|
25
|
+
countBefore: (src) => (src.match(IMPORT_RE) ?? []).length,
|
|
26
|
+
earlyExit: (_src, before) => before === 0,
|
|
27
|
+
transform: rewriteNeverthrowToCompat,
|
|
28
|
+
warnings: () => [],
|
|
29
|
+
countAfter: () => 0,
|
|
30
|
+
},
|
|
31
|
+
native: {
|
|
32
|
+
countBefore: (src) => countOccurrences(src, COMPAT_SPEC),
|
|
33
|
+
earlyExit: () => false,
|
|
34
|
+
transform: rewriteCompatToNative,
|
|
35
|
+
warnings: collectAllNativeWarnings,
|
|
36
|
+
countAfter: (next) => countOccurrences(next, NATIVE_SPEC),
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const computeFileChange = (src: string, mode: Mode): Maybe<ComputedChange> => {
|
|
41
|
+
const strat = MODES[mode];
|
|
42
|
+
const before = strat.countBefore(src);
|
|
43
|
+
if (strat.earlyExit(src, before)) return none();
|
|
44
|
+
const next = strat.transform(src);
|
|
45
|
+
const changed = next !== src;
|
|
46
|
+
const warnings = strat.warnings(next);
|
|
47
|
+
if (!changed && warnings.length === 0) return none();
|
|
48
|
+
return some({ next, before, after: strat.countAfter(next), changed, warnings });
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const toFileChange = (path: string, c: ComputedChange): FileChange => ({
|
|
52
|
+
path,
|
|
53
|
+
before: c.before,
|
|
54
|
+
after: c.after,
|
|
55
|
+
changed: c.changed,
|
|
56
|
+
warnings: c.warnings,
|
|
57
|
+
});
|
package/src/imports.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { isTypeOnlyNative, isValueImport, splitImportNames, stripInlineType } from "./ast.js";
|
|
2
|
+
import { COMPAT_SPEC, NATIVE_SPEC } from "./constants.js";
|
|
3
|
+
|
|
4
|
+
// Every entry must have a matching call-site rewrite in chains.ts, or the
|
|
5
|
+
// renamed import strands the old name at call sites. `of` is intentionally
|
|
6
|
+
// absent: bare `of(...)` calls collide with other libraries (e.g. RxJS) and
|
|
7
|
+
// the canonical target differs per carrier (result `ok` vs maybe `some`) —
|
|
8
|
+
// the native package still exports the deprecated alias, so imports of `of`
|
|
9
|
+
// pass through unchanged and the lint rules flag them for manual migration.
|
|
10
|
+
const IMPORT_RENAMES = new Map([
|
|
11
|
+
["sequenceTupleAsync", "ResultAsync"],
|
|
12
|
+
["getOrElse", "unwrapOr"],
|
|
13
|
+
["collect", "combine"],
|
|
14
|
+
["matchResult", "match"],
|
|
15
|
+
["matchMaybe", "match"],
|
|
16
|
+
["fold", "match"],
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
export function toNativeImport(full: string, specifiers: string, quote: string): string {
|
|
20
|
+
const imports = splitImportNames(specifiers);
|
|
21
|
+
const typeNames = imports.filter(isTypeOnlyNative).map(stripInlineType);
|
|
22
|
+
const rawValueNames = imports.filter(isValueImport);
|
|
23
|
+
const valueNames = rawValueNames.map((name) => IMPORT_RENAMES.get(name) ?? name);
|
|
24
|
+
|
|
25
|
+
const uniqueValueNames = [...new Set(valueNames)];
|
|
26
|
+
const uniqueTypeNames = [...new Set(typeNames)];
|
|
27
|
+
const chunks: string[] = [];
|
|
28
|
+
|
|
29
|
+
if (uniqueValueNames.length > 0) {
|
|
30
|
+
chunks.push(`import { ${uniqueValueNames.join(", ")} } from ${quote}${NATIVE_SPEC}${quote};`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (uniqueTypeNames.length > 0) {
|
|
34
|
+
chunks.push(
|
|
35
|
+
`import type { ${uniqueTypeNames.join(", ")} } from ${quote}${NATIVE_SPEC}${quote};`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return chunks.length > 0 ? chunks.join("\n") : full;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function rewriteCompatImportsToNative(src: string): string {
|
|
43
|
+
const namedImportRe = new RegExp(
|
|
44
|
+
String.raw`import\s+\{\s*([^}]+?)\s*\}\s+from\s+(['"])${COMPAT_SPEC.replaceAll("/", String.raw`\/`)}\2\s*;?`,
|
|
45
|
+
"g",
|
|
46
|
+
);
|
|
47
|
+
const typeNamedImportRe = new RegExp(
|
|
48
|
+
String.raw`import\s+type\s+\{\s*([^}]+?)\s*\}\s+from\s+(['"])${COMPAT_SPEC.replaceAll("/", String.raw`\/`)}\2\s*;?`,
|
|
49
|
+
"g",
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
return src
|
|
53
|
+
.replace(typeNamedImportRe, (_full, specifiers: string, quote: string) => {
|
|
54
|
+
const imports = splitImportNames(specifiers);
|
|
55
|
+
return `import type { ${imports.join(", ")} } from ${quote}${NATIVE_SPEC}${quote};`;
|
|
56
|
+
})
|
|
57
|
+
.replace(namedImportRe, (full: string, specifiers: string, quote: string) =>
|
|
58
|
+
toNativeImport(full, specifiers, quote),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function addNativeValueImports(src: string, imports: readonly string[]): string {
|
|
63
|
+
const names = [...new Set(imports)].sort();
|
|
64
|
+
if (names.length === 0) return src;
|
|
65
|
+
const importRe = new RegExp(
|
|
66
|
+
String.raw`import\s+\{\s*([^}]*?)\s*\}\s+from\s+(['"])${NATIVE_SPEC.replaceAll("/", String.raw`\/`)}\2\s*;?`,
|
|
67
|
+
);
|
|
68
|
+
const match = importRe.exec(src);
|
|
69
|
+
|
|
70
|
+
if (!match?.[1]) {
|
|
71
|
+
return `import { ${names.join(", ")} } from "${NATIVE_SPEC}";\n${src}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const existing = splitImportNames(match[1]).map(stripInlineType);
|
|
75
|
+
const next = [...new Set([...existing, ...names])].sort();
|
|
76
|
+
return `${src.slice(0, match.index)}import { ${next.join(", ")} } from ${match[2]}${NATIVE_SPEC}${match[2]};${src.slice(match.index + match[0].length)}`;
|
|
77
|
+
}
|