@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/src/index.ts
CHANGED
|
@@ -1,633 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
some,
|
|
11
|
-
} from "@onrails/maybe";
|
|
12
|
-
import { flow, type Result, ResultAsync, trySync } from "@onrails/result";
|
|
13
|
-
import { Glob } from "bun";
|
|
14
|
-
import ts from "typescript";
|
|
15
|
-
|
|
16
|
-
type Mode = "compat" | "native";
|
|
17
|
-
type Args = { target: string; dry: boolean; onrails: string; mode: Mode };
|
|
18
|
-
|
|
19
|
-
const SKIP = new Set(["node_modules", "dist", ".git", ".next", ".turbo", "coverage", "build"]);
|
|
20
|
-
const CODE_EXT = /\.(ts|tsx|mts|cts)$/;
|
|
21
|
-
const IMPORT_RE = /(from\s+|import\s*\(\s*)(['"])neverthrow\2/g;
|
|
22
|
-
const COMPAT_SPEC = "@onrails/result/compat/neverthrow";
|
|
23
|
-
const NATIVE_SPEC = "@onrails/result";
|
|
24
|
-
const DEP_KEYS = ["dependencies", "devDependencies", "peerDependencies"] as const;
|
|
25
|
-
const TYPE_ONLY_NATIVE = new Set(["Result", "Ok", "Err", "UnexpectedError"]);
|
|
26
|
-
const COMPAT_ONLY_PATTERNS = [
|
|
27
|
-
{ pattern: /\bResult\.(combine|fromThrowable)\b/, label: "Result static helper" },
|
|
28
|
-
{ pattern: /\._unsafeUnwrap(Err)?\s*\(/, label: "unsafe compat unwrap" },
|
|
29
|
-
] as const;
|
|
30
|
-
const RESULT_SPECIFIC_CHAIN_METHODS = new Set([
|
|
31
|
-
"andThen",
|
|
32
|
-
"chain",
|
|
33
|
-
"flatMap",
|
|
34
|
-
"mapErr",
|
|
35
|
-
"orElse",
|
|
36
|
-
"recover",
|
|
37
|
-
"tapErr",
|
|
38
|
-
"match",
|
|
39
|
-
"unwrapOr",
|
|
40
|
-
]);
|
|
41
|
-
const CHAIN_METHODS = new Map([
|
|
42
|
-
["map", "map"],
|
|
43
|
-
["mapErr", "mapErr"],
|
|
44
|
-
["andThen", "flatMap"],
|
|
45
|
-
["chain", "flatMap"],
|
|
46
|
-
["flatMap", "flatMap"],
|
|
47
|
-
["orElse", "recover"],
|
|
48
|
-
["recover", "recover"],
|
|
49
|
-
["tap", "tap"],
|
|
50
|
-
["tapErr", "tapErr"],
|
|
51
|
-
]);
|
|
52
|
-
const TERMINAL_METHODS = new Set(["match", "unwrapOr"]);
|
|
53
|
-
const ASYNC_ROOT_HINTS = ["ResultAsync", "okAsync", "errAsync", "fromPromise", "fromSafePromise"];
|
|
54
|
-
const ZERO_ARG_HELPERS = new Map([
|
|
55
|
-
["ok", "ok(undefined)"],
|
|
56
|
-
["okAsync", "okAsync(undefined)"],
|
|
57
|
-
]);
|
|
58
|
-
const TEE_METHODS = new Map([
|
|
59
|
-
["andTee", "tap"],
|
|
60
|
-
["orTee", "tapErr"],
|
|
61
|
-
]);
|
|
62
|
-
const PREDICATE_METHODS = new Set(["isOk", "isErr"]);
|
|
63
|
-
const UNSAFE_UNWRAP_METHODS = new Map([
|
|
64
|
-
["_unsafeUnwrap", "unwrapOk"],
|
|
65
|
-
["_unsafeUnwrapErr", "unwrapErr"],
|
|
66
|
-
]);
|
|
67
|
-
|
|
68
|
-
type ArgState = { positional: readonly string[]; dry: boolean; mode: Mode; onrails: string };
|
|
69
|
-
|
|
70
|
-
const applyArg = (s: ArgState, a: string): ArgState =>
|
|
71
|
-
a === "--dry" || a === "-n"
|
|
72
|
-
? { ...s, dry: true }
|
|
73
|
-
: a === "--to-native"
|
|
74
|
-
? { ...s, mode: "native" }
|
|
75
|
-
: a.startsWith("--onrails=")
|
|
76
|
-
? { ...s, onrails: resolve(a.slice("--onrails=".length)) }
|
|
77
|
-
: !a.startsWith("--")
|
|
78
|
-
? { ...s, positional: [...s.positional, a] }
|
|
79
|
-
: s;
|
|
80
|
-
|
|
81
|
-
function parseArgs(argv: string[]): Args {
|
|
82
|
-
const initial: ArgState = {
|
|
83
|
-
positional: [],
|
|
84
|
-
dry: false,
|
|
85
|
-
mode: "compat",
|
|
86
|
-
onrails: resolve(import.meta.dir, "../../..", "packages/result"),
|
|
87
|
-
};
|
|
88
|
-
const { positional, dry, mode, onrails } = argv.reduce(applyArg, initial);
|
|
89
|
-
if (positional.length !== 1) {
|
|
90
|
-
console.error(
|
|
91
|
-
"usage: onrails-codemod-neverthrow <target-dir> [--dry] [--to-native] [--onrails=<abs-path>]",
|
|
92
|
-
);
|
|
93
|
-
process.exit(2);
|
|
94
|
-
}
|
|
95
|
-
return {
|
|
96
|
-
target: resolve(positional[0] ?? "."),
|
|
97
|
-
dry,
|
|
98
|
-
onrails,
|
|
99
|
-
mode,
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const shouldSkip = (path: string) => path.split("/").some((seg) => SKIP.has(seg));
|
|
104
|
-
|
|
105
|
-
async function* walk(root: string): AsyncGenerator<string> {
|
|
106
|
-
const glob = new Glob("**/*");
|
|
107
|
-
for await (const rel of glob.scan({ cwd: root, dot: false, onlyFiles: true })) {
|
|
108
|
-
if (!shouldSkip(rel)) yield `${root}/${rel}`;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
type Warning = { line: number; label: string; text: string };
|
|
113
|
-
type FileChange = {
|
|
114
|
-
path: string;
|
|
115
|
-
before: number;
|
|
116
|
-
after: number;
|
|
117
|
-
changed: boolean;
|
|
118
|
-
warnings: readonly Warning[];
|
|
119
|
-
};
|
|
120
|
-
type Edit = { start: number; end: number; text: string; imports: readonly string[] };
|
|
121
|
-
type ChainStep = { method: string; argsText: string; argCount: number };
|
|
122
|
-
type PipelinePart = { importName: string; text: string };
|
|
123
|
-
|
|
124
|
-
const edit = (text: string, imports: readonly string[] = []): Edit => ({
|
|
125
|
-
start: 0,
|
|
126
|
-
end: 0,
|
|
127
|
-
text,
|
|
128
|
-
imports,
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
const lookupMap = <K, V, R>(m: Map<K, V>, k: K, f: (v: V) => R): Maybe<R> =>
|
|
132
|
-
matchMaybe(fromNullable(m.get(k)), (v) => some(f(v)), none);
|
|
133
|
-
|
|
134
|
-
const concatCollectors =
|
|
135
|
-
<T, R>(...fns: Array<(t: T) => readonly R[]>) =>
|
|
136
|
-
(t: T): readonly R[] =>
|
|
137
|
-
fns.flatMap((f) => f(t));
|
|
138
|
-
|
|
139
|
-
const walkSource = (src: string, visit: (node: ts.Node, sf: ts.SourceFile) => unknown): void => {
|
|
140
|
-
const sf = ts.createSourceFile("codemod.ts", src, ts.ScriptTarget.Latest, true);
|
|
141
|
-
const go = (n: ts.Node) => {
|
|
142
|
-
if (visit(n, sf)) return;
|
|
143
|
-
ts.forEachChild(n, go);
|
|
144
|
-
};
|
|
145
|
-
go(sf);
|
|
146
|
-
};
|
|
147
|
-
|
|
148
|
-
const spanEdit = (node: ts.Node, sf: ts.SourceFile, partial: Edit): Edit => ({
|
|
149
|
-
...partial,
|
|
150
|
-
start: node.getStart(sf),
|
|
151
|
-
end: node.getEnd(),
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
const argsToText = (args: ts.NodeArray<ts.Expression>): string =>
|
|
155
|
-
args.map((a) => a.getText()).join(", ");
|
|
156
|
-
|
|
157
|
-
const countOccurrences = (s: string, sub: string): number => s.split(sub).length - 1;
|
|
158
|
-
|
|
159
|
-
const splitImportNames = (specifiers: string): readonly string[] =>
|
|
160
|
-
specifiers
|
|
161
|
-
.split(",")
|
|
162
|
-
.map((part) => part.trim())
|
|
163
|
-
.filter((part) => part.length > 0);
|
|
164
|
-
|
|
165
|
-
const stripInlineType = (specifier: string): string => specifier.replace(/^type\s+/, "");
|
|
166
|
-
|
|
167
|
-
const importedName = (specifier: string): string =>
|
|
168
|
-
stripInlineType(specifier).split(/\s+as\s+/i)[0] ?? specifier;
|
|
169
|
-
|
|
170
|
-
const isTypeOnlyNative = (specifier: string): boolean =>
|
|
171
|
-
specifier.startsWith("type ") || TYPE_ONLY_NATIVE.has(importedName(specifier));
|
|
172
|
-
|
|
173
|
-
const isValueImport = (specifier: string): boolean => !isTypeOnlyNative(specifier);
|
|
174
|
-
|
|
175
|
-
function toNativeImport(full: string, specifiers: string, quote: string): string {
|
|
176
|
-
const imports = splitImportNames(specifiers);
|
|
177
|
-
const typeNames = imports.filter(isTypeOnlyNative).map(stripInlineType);
|
|
178
|
-
const valueNames = imports.filter(isValueImport);
|
|
179
|
-
const chunks: string[] = [];
|
|
180
|
-
|
|
181
|
-
if (valueNames.length > 0) {
|
|
182
|
-
chunks.push(`import { ${valueNames.join(", ")} } from ${quote}${NATIVE_SPEC}${quote};`);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
if (typeNames.length > 0) {
|
|
186
|
-
chunks.push(`import type { ${typeNames.join(", ")} } from ${quote}${NATIVE_SPEC}${quote};`);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
return chunks.length > 0 ? chunks.join("\n") : full;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
export function rewriteCompatImportsToNative(src: string): string {
|
|
193
|
-
const namedImportRe = new RegExp(
|
|
194
|
-
String.raw`import\s+\{\s*([^}]+?)\s*\}\s+from\s+(['"])${COMPAT_SPEC.replaceAll("/", String.raw`\/`)}\2\s*;?`,
|
|
195
|
-
"g",
|
|
196
|
-
);
|
|
197
|
-
const typeNamedImportRe = new RegExp(
|
|
198
|
-
String.raw`import\s+type\s+\{\s*([^}]+?)\s*\}\s+from\s+(['"])${COMPAT_SPEC.replaceAll("/", String.raw`\/`)}\2\s*;?`,
|
|
199
|
-
"g",
|
|
200
|
-
);
|
|
201
|
-
|
|
202
|
-
return src
|
|
203
|
-
.replace(typeNamedImportRe, (_full, specifiers: string, quote: string) => {
|
|
204
|
-
const imports = splitImportNames(specifiers);
|
|
205
|
-
return `import type { ${imports.join(", ")} } from ${quote}${NATIVE_SPEC}${quote};`;
|
|
206
|
-
})
|
|
207
|
-
.replace(namedImportRe, (full: string, specifiers: string, quote: string) =>
|
|
208
|
-
toNativeImport(full, specifiers, quote),
|
|
209
|
-
);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function isSupportedChainCall(node: ts.Node): node is ts.CallExpression {
|
|
213
|
-
if (!ts.isCallExpression(node)) return false;
|
|
214
|
-
if (!ts.isPropertyAccessExpression(node.expression)) return false;
|
|
215
|
-
const method = node.expression.name.text;
|
|
216
|
-
return CHAIN_METHODS.has(method) || TERMINAL_METHODS.has(method);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function helperCallToNative(node: ts.CallExpression): Maybe<Edit> {
|
|
220
|
-
if (ts.isIdentifier(node.expression) && node.arguments.length === 0) {
|
|
221
|
-
return lookupMap(ZERO_ARG_HELPERS, node.expression.text, (text) => edit(text));
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
if (!ts.isPropertyAccessExpression(node.expression)) return none();
|
|
225
|
-
const method = node.expression.name.text;
|
|
226
|
-
const base = node.expression.expression
|
|
227
|
-
.getText()
|
|
228
|
-
.replaceAll(".andTee(", ".tap(")
|
|
229
|
-
.replaceAll(".orTee(", ".tapErr(");
|
|
230
|
-
const argsText = argsToText(node.arguments);
|
|
231
|
-
|
|
232
|
-
const tee = lookupMap(TEE_METHODS, method, (t) => edit(`${base}.${t}(${argsText})`));
|
|
233
|
-
if (isSome(tee)) return tee;
|
|
234
|
-
|
|
235
|
-
if (PREDICATE_METHODS.has(method)) {
|
|
236
|
-
return some(edit(`${method}(${base})`, [method]));
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
return lookupMap(UNSAFE_UNWRAP_METHODS, method, (u) => edit(`${u}(${base})`, [u]));
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
function isNestedInSupportedChain(node: ts.Node): boolean {
|
|
243
|
-
const parent = node.parent;
|
|
244
|
-
if (!parent) return false;
|
|
245
|
-
if (!ts.isPropertyAccessExpression(parent)) return false;
|
|
246
|
-
return isSupportedChainCall(parent.parent);
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
function collectChain(node: ts.CallExpression): { base: string; steps: ChainStep[] } {
|
|
250
|
-
const steps: ChainStep[] = [];
|
|
251
|
-
let current: ts.Expression = node;
|
|
252
|
-
|
|
253
|
-
while (ts.isCallExpression(current) && ts.isPropertyAccessExpression(current.expression)) {
|
|
254
|
-
const method = current.expression.name.text;
|
|
255
|
-
if (!CHAIN_METHODS.has(method) && !TERMINAL_METHODS.has(method)) break;
|
|
256
|
-
steps.push({
|
|
257
|
-
method,
|
|
258
|
-
argsText: argsToText(current.arguments),
|
|
259
|
-
argCount: current.arguments.length,
|
|
260
|
-
});
|
|
261
|
-
current = current.expression.expression;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
return {
|
|
265
|
-
base: current.getText(),
|
|
266
|
-
steps: steps.reverse(),
|
|
267
|
-
};
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
const hasAsyncRootHint = (base: string): boolean =>
|
|
271
|
-
ASYNC_ROOT_HINTS.some((hint) => base.includes(hint));
|
|
272
|
-
|
|
273
|
-
const SAFE_BASE_PATTERNS = [/^(ok|err)\s*\(/, /^Result\./, /^[a-zA-Z_$][\w$]*$/] as const;
|
|
274
|
-
|
|
275
|
-
const isSafeSyncChainBase = (base: string): boolean =>
|
|
276
|
-
SAFE_BASE_PATTERNS.some((re) => re.test(base));
|
|
277
|
-
|
|
278
|
-
const isTerminalStep = (s: ChainStep): boolean => TERMINAL_METHODS.has(s.method);
|
|
279
|
-
const isResultSpecificStep = (s: ChainStep): boolean => RESULT_SPECIFIC_CHAIN_METHODS.has(s.method);
|
|
280
|
-
const isChainStep = (s: ChainStep): boolean => CHAIN_METHODS.has(s.method);
|
|
281
|
-
|
|
282
|
-
function chainToNative(base: string, steps: readonly ChainStep[]): Maybe<Edit> {
|
|
283
|
-
if (steps.length === 0 || hasAsyncRootHint(base)) return none();
|
|
284
|
-
const terminalStep = steps.find(isTerminalStep);
|
|
285
|
-
if (!isSafeSyncChainBase(base)) return none();
|
|
286
|
-
if (!terminalStep && !steps.some(isResultSpecificStep)) return none();
|
|
287
|
-
if (terminalStep?.method === "match" && terminalStep.argCount !== 2) return none();
|
|
288
|
-
const pipelineSteps = steps.filter(isChainStep);
|
|
289
|
-
const pipelineParts = compactMap(pipelineSteps, (step) =>
|
|
290
|
-
lookupMap(
|
|
291
|
-
CHAIN_METHODS,
|
|
292
|
-
step.method,
|
|
293
|
-
(nativeName): PipelinePart => ({
|
|
294
|
-
importName: nativeName,
|
|
295
|
-
text: `${nativeName}(${step.argsText})`,
|
|
296
|
-
}),
|
|
297
|
-
),
|
|
298
|
-
);
|
|
299
|
-
|
|
300
|
-
if (pipelineParts.length !== pipelineSteps.length) return none();
|
|
301
|
-
|
|
302
|
-
const parts = pipelineParts.map((part) => part.text);
|
|
303
|
-
const imports = new Set(pipelineParts.map((part) => part.importName));
|
|
304
|
-
const pipeline = parts.length > 0 ? `pipe(${[base, ...parts].join(", ")})` : base;
|
|
305
|
-
|
|
306
|
-
if (parts.length > 0) imports.add("pipe");
|
|
307
|
-
|
|
308
|
-
if (!terminalStep) {
|
|
309
|
-
return some(edit(pipeline, [...imports]));
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
imports.add(terminalStep.method);
|
|
313
|
-
return some(edit(`${terminalStep.method}(${pipeline}, ${terminalStep.argsText})`, [...imports]));
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
function addNativeValueImports(src: string, imports: readonly string[]): string {
|
|
317
|
-
const names = [...new Set(imports)].sort();
|
|
318
|
-
if (names.length === 0) return src;
|
|
319
|
-
const importRe = new RegExp(
|
|
320
|
-
String.raw`import\s+\{\s*([^}]*?)\s*\}\s+from\s+(['"])${NATIVE_SPEC.replaceAll("/", String.raw`\/`)}\2\s*;?`,
|
|
321
|
-
);
|
|
322
|
-
const match = importRe.exec(src);
|
|
323
|
-
|
|
324
|
-
if (!match?.[1]) {
|
|
325
|
-
return `import { ${names.join(", ")} } from "${NATIVE_SPEC}";\n${src}`;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
const existing = splitImportNames(match[1]).map(stripInlineType);
|
|
329
|
-
const next = [...new Set([...existing, ...names])].sort();
|
|
330
|
-
return `${src.slice(0, match.index)}import { ${next.join(", ")} } from ${match[2]}${NATIVE_SPEC}${match[2]};${src.slice(match.index + match[0].length)}`;
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
export function rewriteCompatMethodChainsToNative(src: string): string {
|
|
334
|
-
const edits: Edit[] = [];
|
|
335
|
-
|
|
336
|
-
walkSource(src, (node, sf) => {
|
|
337
|
-
if (ts.isCallExpression(node)) {
|
|
338
|
-
const helper = helperCallToNative(node);
|
|
339
|
-
if (isSome(helper)) {
|
|
340
|
-
edits.push(spanEdit(node, sf, helper.value));
|
|
341
|
-
return true;
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
if (
|
|
346
|
-
isSupportedChainCall(node) &&
|
|
347
|
-
!isNestedInSupportedChain(node) &&
|
|
348
|
-
!ts.isPropertyAccessExpression(node.parent)
|
|
349
|
-
) {
|
|
350
|
-
const chain = collectChain(node);
|
|
351
|
-
const chained = chainToNative(chain.base, chain.steps);
|
|
352
|
-
if (isSome(chained)) edits.push(spanEdit(node, sf, chained.value));
|
|
353
|
-
}
|
|
354
|
-
});
|
|
355
|
-
|
|
356
|
-
if (edits.length === 0) return src;
|
|
357
|
-
|
|
358
|
-
const { src: next, imports } = edits
|
|
359
|
-
.sort(byStartDesc)
|
|
360
|
-
.reduce(applyEditStep, { src, imports: new Set<string>() });
|
|
361
|
-
|
|
362
|
-
return addNativeValueImports(next, [...imports]);
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
type EditAcc = { src: string; imports: Set<string> };
|
|
366
|
-
|
|
367
|
-
const byStartDesc = (a: Edit, b: Edit): number => b.start - a.start;
|
|
368
|
-
|
|
369
|
-
const applyEditStep = (acc: EditAcc, e: Edit): EditAcc => ({
|
|
370
|
-
src: `${acc.src.slice(0, e.start)}${e.text}${acc.src.slice(e.end)}`,
|
|
371
|
-
imports: new Set([...acc.imports, ...e.imports]),
|
|
372
|
-
});
|
|
373
|
-
|
|
374
|
-
const collectRegexLineWarnings = (src: string): readonly Warning[] =>
|
|
375
|
-
src
|
|
376
|
-
.split(/\r?\n/)
|
|
377
|
-
.flatMap((line, i) =>
|
|
378
|
-
COMPAT_ONLY_PATTERNS.flatMap(({ pattern, label }) =>
|
|
379
|
-
pattern.test(line) ? [{ line: i + 1, label, text: line.trim() }] : [],
|
|
380
|
-
),
|
|
381
|
-
);
|
|
382
|
-
|
|
383
|
-
const collectAstCompatWarnings = (src: string): readonly Warning[] => {
|
|
384
|
-
const warnings: Warning[] = [];
|
|
385
|
-
const lines = src.split(/\r?\n/);
|
|
386
|
-
const lineTextAt = (sf: ts.SourceFile, node: ts.Node) => {
|
|
387
|
-
const line = sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
|
|
388
|
-
return { line, text: lines[line - 1]?.trim() ?? node.getText(sf) };
|
|
389
|
-
};
|
|
390
|
-
|
|
391
|
-
walkSource(src, (node, sf) => {
|
|
392
|
-
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
|
|
393
|
-
const method = node.expression.name.text;
|
|
394
|
-
if (method === "isOk" || method === "isErr") {
|
|
395
|
-
warnings.push({ ...lineTextAt(sf, node), label: "compat predicate method" });
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
if (ts.isPropertyAccessExpression(node)) {
|
|
400
|
-
const property = node.name.text;
|
|
401
|
-
if (property === "value" || property === "error") {
|
|
402
|
-
const receiver = node.expression.getText(sf);
|
|
403
|
-
if (/result$/i.test(receiver) || /^result$/i.test(receiver)) {
|
|
404
|
-
warnings.push({ ...lineTextAt(sf, node), label: "compat value/error property" });
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
});
|
|
409
|
-
|
|
410
|
-
return warnings;
|
|
411
|
-
};
|
|
412
|
-
|
|
413
|
-
export const collectNativeMigrationWarnings = concatCollectors(
|
|
414
|
-
collectRegexLineWarnings,
|
|
415
|
-
collectAstCompatWarnings,
|
|
416
|
-
);
|
|
417
|
-
|
|
418
|
-
const collectUnsupportedCompatImportWarnings = (src: string): readonly Warning[] =>
|
|
419
|
-
src.split(/\r?\n/).flatMap((line, i) =>
|
|
420
|
-
line.includes(COMPAT_SPEC)
|
|
421
|
-
? [
|
|
422
|
-
{
|
|
423
|
-
line: i + 1,
|
|
424
|
-
label: "unsupported compat import",
|
|
425
|
-
text: line.trim(),
|
|
426
|
-
},
|
|
427
|
-
]
|
|
428
|
-
: [],
|
|
429
|
-
);
|
|
430
|
-
|
|
431
|
-
const rewriteCompatToNative = flow(rewriteCompatImportsToNative, rewriteCompatMethodChainsToNative);
|
|
432
|
-
|
|
433
|
-
const rewriteNeverthrowToCompat = (src: string): string =>
|
|
434
|
-
src.replace(IMPORT_RE, (_, lead, quote) => `${lead}${quote}${COMPAT_SPEC}${quote}`);
|
|
435
|
-
|
|
436
|
-
const collectAllNativeWarnings = concatCollectors(
|
|
437
|
-
collectUnsupportedCompatImportWarnings,
|
|
438
|
-
collectNativeMigrationWarnings,
|
|
439
|
-
);
|
|
440
|
-
|
|
441
|
-
type ModeStrategy = {
|
|
442
|
-
countBefore: (src: string) => number;
|
|
443
|
-
earlyExit: (src: string, before: number) => boolean;
|
|
444
|
-
transform: (src: string) => string;
|
|
445
|
-
warnings: (next: string) => readonly Warning[];
|
|
446
|
-
countAfter: (next: string) => number;
|
|
447
|
-
};
|
|
448
|
-
|
|
449
|
-
const MODES: Record<Mode, ModeStrategy> = {
|
|
450
|
-
compat: {
|
|
451
|
-
countBefore: (src) => (src.match(IMPORT_RE) ?? []).length,
|
|
452
|
-
earlyExit: (_src, before) => before === 0,
|
|
453
|
-
transform: rewriteNeverthrowToCompat,
|
|
454
|
-
warnings: () => [],
|
|
455
|
-
countAfter: () => 0,
|
|
456
|
-
},
|
|
457
|
-
native: {
|
|
458
|
-
countBefore: (src) => countOccurrences(src, COMPAT_SPEC),
|
|
459
|
-
earlyExit: () => false,
|
|
460
|
-
transform: rewriteCompatToNative,
|
|
461
|
-
warnings: collectAllNativeWarnings,
|
|
462
|
-
countAfter: (next) => countOccurrences(next, NATIVE_SPEC),
|
|
463
|
-
},
|
|
464
|
-
};
|
|
465
|
-
|
|
466
|
-
type ComputedChange = {
|
|
467
|
-
next: string;
|
|
468
|
-
before: number;
|
|
469
|
-
after: number;
|
|
470
|
-
changed: boolean;
|
|
471
|
-
warnings: readonly Warning[];
|
|
472
|
-
};
|
|
473
|
-
|
|
474
|
-
export const computeFileChange = (src: string, mode: Mode): Maybe<ComputedChange> => {
|
|
475
|
-
const strat = MODES[mode];
|
|
476
|
-
const before = strat.countBefore(src);
|
|
477
|
-
if (strat.earlyExit(src, before)) return none();
|
|
478
|
-
const next = strat.transform(src);
|
|
479
|
-
const changed = next !== src;
|
|
480
|
-
const warnings = strat.warnings(next);
|
|
481
|
-
if (!changed && warnings.length === 0) return none();
|
|
482
|
-
return some({ next, before, after: strat.countAfter(next), changed, warnings });
|
|
483
|
-
};
|
|
484
|
-
|
|
485
|
-
const toFileChange = (path: string, c: ComputedChange): FileChange => ({
|
|
486
|
-
path,
|
|
487
|
-
before: c.before,
|
|
488
|
-
after: c.after,
|
|
489
|
-
changed: c.changed,
|
|
490
|
-
warnings: c.warnings,
|
|
491
|
-
});
|
|
492
|
-
|
|
493
|
-
const readFileText = (path: string): ResultAsync<string, Error> =>
|
|
494
|
-
ResultAsync.fromPromise(Bun.file(path).text(), toError);
|
|
495
|
-
|
|
496
|
-
const writeFileText = (path: string, content: string): ResultAsync<unknown, Error> =>
|
|
497
|
-
ResultAsync.fromPromise(Bun.write(path, content), toError);
|
|
498
|
-
|
|
499
|
-
const rewriteCode = (
|
|
500
|
-
path: string,
|
|
501
|
-
dry: boolean,
|
|
502
|
-
mode: Mode,
|
|
503
|
-
): ResultAsync<Maybe<FileChange>, Error> =>
|
|
504
|
-
readFileText(path).flatMap((src) =>
|
|
505
|
-
matchMaybe(
|
|
506
|
-
computeFileChange(src, mode),
|
|
507
|
-
(c) => {
|
|
508
|
-
const change = toFileChange(path, c);
|
|
509
|
-
return c.changed && !dry
|
|
510
|
-
? writeFileText(path, c.next).map(() => some(change))
|
|
511
|
-
: ResultAsync.ok<Maybe<FileChange>, Error>(some(change));
|
|
512
|
-
},
|
|
513
|
-
() => ResultAsync.ok<Maybe<FileChange>, Error>(none()),
|
|
514
|
-
),
|
|
515
|
-
);
|
|
516
|
-
|
|
517
|
-
type PkgChange = { path: string; removed: string[]; addedAs: string };
|
|
518
|
-
|
|
519
|
-
const reorderDeps = (deps: Record<string, string>): Record<string, string> =>
|
|
520
|
-
Object.fromEntries(Object.entries(deps).sort(([a], [b]) => a.localeCompare(b)));
|
|
521
|
-
|
|
522
|
-
const toError = (error: unknown): Error =>
|
|
523
|
-
error instanceof Error ? error : new Error(String(error));
|
|
524
|
-
|
|
525
|
-
const parsePackageJson = (raw: string): Result<Record<string, unknown>, Error> =>
|
|
526
|
-
trySync(() => JSON.parse(raw) as Record<string, unknown>, toError)();
|
|
527
|
-
|
|
528
|
-
type PkgUpdate = { json: Record<string, unknown>; removed: readonly string[] };
|
|
529
|
-
|
|
530
|
-
const applyDepRewrite =
|
|
531
|
-
(fileSpec: string) =>
|
|
532
|
-
(acc: PkgUpdate, key: string): PkgUpdate => {
|
|
533
|
-
const deps = acc.json[key] as Record<string, string> | undefined;
|
|
534
|
-
if (!deps || typeof deps !== "object" || !("neverthrow" in deps)) return acc;
|
|
535
|
-
const { neverthrow: _drop, ...rest } = deps;
|
|
536
|
-
return {
|
|
537
|
-
json: { ...acc.json, [key]: reorderDeps({ ...rest, "@onrails/result": fileSpec }) },
|
|
538
|
-
removed: [...acc.removed, key],
|
|
539
|
-
};
|
|
540
|
-
};
|
|
541
|
-
|
|
542
|
-
type ComputedPkg = { json: Record<string, unknown>; removed: readonly string[]; fileSpec: string };
|
|
543
|
-
|
|
544
|
-
export const computePkgRewrite = (
|
|
545
|
-
json: Record<string, unknown>,
|
|
546
|
-
path: string,
|
|
547
|
-
onrailsAbs: string,
|
|
548
|
-
): Maybe<ComputedPkg> => {
|
|
549
|
-
const fileSpec = `file:${relative(path.replace(/\/package\.json$/, ""), onrailsAbs)}`;
|
|
550
|
-
const updated = DEP_KEYS.reduce(applyDepRewrite(fileSpec), { json, removed: [] } as PkgUpdate);
|
|
551
|
-
return updated.removed.length === 0
|
|
552
|
-
? none()
|
|
553
|
-
: some({ json: updated.json, removed: updated.removed, fileSpec });
|
|
554
|
-
};
|
|
555
|
-
|
|
556
|
-
const toPkgChange = (path: string, c: ComputedPkg): PkgChange => ({
|
|
557
|
-
path,
|
|
558
|
-
removed: [...c.removed],
|
|
559
|
-
addedAs: c.fileSpec,
|
|
560
|
-
});
|
|
561
|
-
|
|
562
|
-
const rewritePkg = (
|
|
563
|
-
path: string,
|
|
564
|
-
onrailsAbs: string,
|
|
565
|
-
dry: boolean,
|
|
566
|
-
): ResultAsync<Maybe<PkgChange>, Error> =>
|
|
567
|
-
readFileText(path)
|
|
568
|
-
.flatMap((raw) => ResultAsync.fromResult(parsePackageJson(raw)))
|
|
569
|
-
.flatMap((json) =>
|
|
570
|
-
matchMaybe(
|
|
571
|
-
computePkgRewrite(json, path, onrailsAbs),
|
|
572
|
-
(c) => {
|
|
573
|
-
const change = toPkgChange(path, c);
|
|
574
|
-
return dry
|
|
575
|
-
? ResultAsync.ok<Maybe<PkgChange>, Error>(some(change))
|
|
576
|
-
: writeFileText(path, `${JSON.stringify(c.json, null, 2)}\n`).map(() => some(change));
|
|
577
|
-
},
|
|
578
|
-
() => ResultAsync.ok<Maybe<PkgChange>, Error>(none()),
|
|
579
|
-
),
|
|
580
|
-
);
|
|
581
|
-
|
|
582
|
-
async function main() {
|
|
583
|
-
const { target, dry, onrails, mode } = parseArgs(Bun.argv.slice(2));
|
|
584
|
-
const codeChanges: FileChange[] = [];
|
|
585
|
-
const pkgChanges: PkgChange[] = [];
|
|
586
|
-
for await (const file of walk(target)) {
|
|
587
|
-
if (mode === "compat" && file.endsWith("/package.json")) {
|
|
588
|
-
await rewritePkg(file, onrails, dry).match(
|
|
589
|
-
(m) =>
|
|
590
|
-
matchMaybe(
|
|
591
|
-
m,
|
|
592
|
-
(c) => void pkgChanges.push(c),
|
|
593
|
-
() => undefined,
|
|
594
|
-
),
|
|
595
|
-
(e) => console.error(`error processing ${file}:`, e.message),
|
|
596
|
-
);
|
|
597
|
-
} else if (CODE_EXT.test(file)) {
|
|
598
|
-
await rewriteCode(file, dry, mode).match(
|
|
599
|
-
(m) =>
|
|
600
|
-
matchMaybe(
|
|
601
|
-
m,
|
|
602
|
-
(c) => void codeChanges.push(c),
|
|
603
|
-
() => undefined,
|
|
604
|
-
),
|
|
605
|
-
(e) => console.error(`error processing ${file}:`, e.message),
|
|
606
|
-
);
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
const label = dry ? "DRY" : "APPLY";
|
|
610
|
-
console.log(`[${label}] target=${target}`);
|
|
611
|
-
console.log(`[${label}] mode=${mode}`);
|
|
612
|
-
if (mode === "compat") console.log(`[${label}] onrails=${onrails}`);
|
|
613
|
-
const changedCodeCount = codeChanges.filter((c) => c.changed).length;
|
|
614
|
-
console.log(`[${label}] code files changed: ${changedCodeCount}`);
|
|
615
|
-
if (mode === "native") {
|
|
616
|
-
console.log(`[${label}] code files reported only: ${codeChanges.length - changedCodeCount}`);
|
|
617
|
-
}
|
|
618
|
-
for (const c of codeChanges) {
|
|
619
|
-
console.log(` ${relative(target, c.path)} (${c.before} import${c.before === 1 ? "" : "s"})`);
|
|
620
|
-
for (const warning of c.warnings) {
|
|
621
|
-
console.log(` TODO line ${warning.line}: ${warning.label}: ${warning.text}`);
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
if (mode === "compat") {
|
|
625
|
-
console.log(`[${label}] package.json updated: ${pkgChanges.length}`);
|
|
626
|
-
for (const c of pkgChanges) {
|
|
627
|
-
console.log(` ${relative(target, c.path)} [${c.removed.join(", ")}] -> ${c.addedAs}`);
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
if (dry) console.log(`[${label}] no files written. re-run without --dry to apply.`);
|
|
631
|
-
}
|
|
2
|
+
import { main } from "./cli.js";
|
|
3
|
+
|
|
4
|
+
export { rewriteCompatMethodChainsToNative } from "./chains.js";
|
|
5
|
+
export { main } from "./cli.js";
|
|
6
|
+
export { computeFileChange } from "./file-change.js";
|
|
7
|
+
export { rewriteCompatImportsToNative } from "./imports.js";
|
|
8
|
+
export { computePkgRewrite } from "./pkg.js";
|
|
9
|
+
export { collectNativeMigrationWarnings } from "./warnings.js";
|
|
632
10
|
|
|
633
11
|
if (import.meta.main) await main();
|
package/src/io.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { ResultAsync } from "@onrails/result";
|
|
2
|
+
|
|
3
|
+
export const toError = (error: unknown): Error =>
|
|
4
|
+
error instanceof Error ? error : new Error(String(error));
|
|
5
|
+
|
|
6
|
+
export const readFileText = (path: string): ResultAsync<string, Error> =>
|
|
7
|
+
ResultAsync.fromPromise(Bun.file(path).text(), toError);
|
|
8
|
+
|
|
9
|
+
export const writeFileText = (path: string, content: string): ResultAsync<unknown, Error> =>
|
|
10
|
+
ResultAsync.fromPromise(Bun.write(path, content), toError);
|