@onrails/codemod 0.1.0 → 0.1.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 +48 -2
- package/package.json +8 -2
- package/src/index.ts +582 -86
package/README.md
CHANGED
|
@@ -16,13 +16,20 @@ bunx @onrails/codemod /path/to/target-repo --dry
|
|
|
16
16
|
|
|
17
17
|
## What it does
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
By default, rewrites every `from "neverthrow"` import and every `neverthrow`
|
|
20
|
+
`package.json` dep to point at `@onrails/result/compat/neverthrow`.
|
|
20
21
|
|
|
21
22
|
- Walks `.ts` / `.tsx` / `.mts` / `.cts`. Skips `node_modules`, `dist`, `.git`, `.next`, `.turbo`, `coverage`, `build`.
|
|
22
23
|
- Rewrites only the specifier in `import` / `from` / `import(...)`; string mentions of `"neverthrow"` in regular code are left alone.
|
|
23
24
|
- Updates `dependencies` / `devDependencies` / `peerDependencies` in every nested `package.json` that lists `neverthrow`, replacing it with `@onrails/result` at a path relative to that `package.json`.
|
|
24
25
|
- Idempotent. Safe to re-run.
|
|
25
26
|
|
|
27
|
+
With `--to-native`, rewrites safe `@onrails/result/compat/neverthrow`
|
|
28
|
+
imports to native `@onrails/result` imports, then rewrites supported sync
|
|
29
|
+
method chains to native `pipe(...)` calls. This mode also reports TODO lines
|
|
30
|
+
for compat-only properties and unsupported import shapes that still need manual
|
|
31
|
+
migration.
|
|
32
|
+
|
|
26
33
|
## Usage
|
|
27
34
|
|
|
28
35
|
```bash
|
|
@@ -35,6 +42,10 @@ bunx @onrails/codemod /path/to/target-repo
|
|
|
35
42
|
# point at a local @onrails/result checkout (file: install)
|
|
36
43
|
bunx @onrails/codemod /path/to/target-repo \
|
|
37
44
|
--onrails=/Users/me/dev/onrails/packages/result
|
|
45
|
+
|
|
46
|
+
# stage 2: compat shim -> native imports
|
|
47
|
+
bunx @onrails/codemod /path/to/target-repo --to-native --dry
|
|
48
|
+
bunx @onrails/codemod /path/to/target-repo --to-native
|
|
38
49
|
```
|
|
39
50
|
|
|
40
51
|
After applying:
|
|
@@ -47,10 +58,45 @@ bun typecheck
|
|
|
47
58
|
bun test
|
|
48
59
|
```
|
|
49
60
|
|
|
61
|
+
## Native migration
|
|
62
|
+
|
|
63
|
+
`--to-native` is intentionally conservative. It rewrites imports that have a
|
|
64
|
+
native equivalent:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { ok, err, Result } from "@onrails/result/compat/neverthrow";
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
becomes:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
import { ok, err } from "@onrails/result";
|
|
74
|
+
import type { Result } from "@onrails/result";
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
It does not rewrite arbitrary method chains or namespace/re-export shapes yet.
|
|
78
|
+
The CLI rewrites supported sync chains such as:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
ok(1).map(double).andThen(validate).orElse(recoverInput);
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
to:
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
pipe(ok(1), map(double), flatMap(validate), recover(recoverInput));
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Terminal `.match(...)` and `.unwrapOr(...)` become data-first native calls.
|
|
91
|
+
`ResultAsync`-looking chains stay method-style because the native async API
|
|
92
|
+
already supports those methods. The CLI still reports TODO lines for compat-only
|
|
93
|
+
surface such as `.isOk()`, `.value`, `Result.fromThrowable(...)`,
|
|
94
|
+
`import * as neverthrow from ...`, and `export { ok } from ...`.
|
|
95
|
+
|
|
50
96
|
## Roadmap
|
|
51
97
|
|
|
52
|
-
- Stage-2 codemod (compat → native `@onrails/result` API): planned, not shipped. Tracking in the repo issues.
|
|
53
98
|
- Generalized rename codemod (any `from "A"` → `from "B"` + deps): planned.
|
|
99
|
+
- AST-backed compat method-chain rewrite to `pipe(...)` / `fluent(...)`: planned.
|
|
54
100
|
|
|
55
101
|
## License
|
|
56
102
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onrails/codemod",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Codemods for migrating onto @onrails/result (neverthrow → compat shim, and beyond)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Alan R. Soares",
|
|
@@ -41,7 +41,13 @@
|
|
|
41
41
|
"exports": {
|
|
42
42
|
".": "./src/index.ts"
|
|
43
43
|
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@onrails/maybe": "0.1.0",
|
|
46
|
+
"@onrails/result": "0.1.0",
|
|
47
|
+
"typescript": "^6.0.3"
|
|
48
|
+
},
|
|
44
49
|
"scripts": {
|
|
45
|
-
"typecheck": "tsc --noEmit"
|
|
50
|
+
"typecheck": "tsc --noEmit",
|
|
51
|
+
"test": "bun test"
|
|
46
52
|
}
|
|
47
53
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,52 +1,106 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { relative, resolve } from "node:path";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
* `dependencies` / `devDependencies` / `peerDependencies`:
|
|
14
|
-
* - removes the `neverthrow` entry
|
|
15
|
-
* - adds `@onrails/result` with a `file:` path relative to the package.json
|
|
16
|
-
* - Skips `node_modules`, `dist`, `.git`, `.next`, `.turbo`, `coverage`.
|
|
17
|
-
* - Idempotent. Safe to re-run.
|
|
18
|
-
*/
|
|
3
|
+
import {
|
|
4
|
+
compactMap,
|
|
5
|
+
fromNullable,
|
|
6
|
+
isSome,
|
|
7
|
+
type Maybe,
|
|
8
|
+
match as matchMaybe,
|
|
9
|
+
none,
|
|
10
|
+
some,
|
|
11
|
+
} from "@onrails/maybe";
|
|
12
|
+
import { flow, type Result, ResultAsync, trySync } from "@onrails/result";
|
|
19
13
|
import { Glob } from "bun";
|
|
14
|
+
import ts from "typescript";
|
|
20
15
|
|
|
21
|
-
type
|
|
16
|
+
type Mode = "compat" | "native";
|
|
17
|
+
type Args = { target: string; dry: boolean; onrails: string; mode: Mode };
|
|
22
18
|
|
|
23
19
|
const SKIP = new Set(["node_modules", "dist", ".git", ".next", ".turbo", "coverage", "build"]);
|
|
24
20
|
const CODE_EXT = /\.(ts|tsx|mts|cts)$/;
|
|
25
21
|
const IMPORT_RE = /(from\s+|import\s*\(\s*)(['"])neverthrow\2/g;
|
|
26
|
-
const
|
|
22
|
+
const COMPAT_SPEC = "@onrails/result/compat/neverthrow";
|
|
23
|
+
const NATIVE_SPEC = "@onrails/result";
|
|
27
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;
|
|
28
80
|
|
|
29
81
|
function parseArgs(argv: string[]): Args {
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
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);
|
|
38
89
|
if (positional.length !== 1) {
|
|
39
90
|
console.error(
|
|
40
|
-
"usage:
|
|
91
|
+
"usage: onrails-codemod-neverthrow <target-dir> [--dry] [--to-native] [--onrails=<abs-path>]",
|
|
41
92
|
);
|
|
42
93
|
process.exit(2);
|
|
43
94
|
}
|
|
44
|
-
return {
|
|
95
|
+
return {
|
|
96
|
+
target: resolve(positional[0] ?? "."),
|
|
97
|
+
dry,
|
|
98
|
+
onrails,
|
|
99
|
+
mode,
|
|
100
|
+
};
|
|
45
101
|
}
|
|
46
102
|
|
|
47
|
-
|
|
48
|
-
return path.split("/").some((seg) => SKIP.has(seg));
|
|
49
|
-
}
|
|
103
|
+
const shouldSkip = (path: string) => path.split("/").some((seg) => SKIP.has(seg));
|
|
50
104
|
|
|
51
105
|
async function* walk(root: string): AsyncGenerator<string> {
|
|
52
106
|
const glob = new Glob("**/*");
|
|
@@ -55,83 +109,525 @@ async function* walk(root: string): AsyncGenerator<string> {
|
|
|
55
109
|
}
|
|
56
110
|
}
|
|
57
111
|
|
|
58
|
-
type
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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;
|
|
69
190
|
}
|
|
70
191
|
|
|
71
|
-
|
|
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
|
+
);
|
|
72
201
|
|
|
73
|
-
|
|
74
|
-
|
|
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
|
+
);
|
|
75
210
|
}
|
|
76
211
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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]));
|
|
88
310
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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}`;
|
|
103
326
|
}
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
return {
|
|
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]);
|
|
108
363
|
}
|
|
109
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
|
+
|
|
110
582
|
async function main() {
|
|
111
|
-
const { target, dry, onrails } = parseArgs(Bun.argv.slice(2));
|
|
583
|
+
const { target, dry, onrails, mode } = parseArgs(Bun.argv.slice(2));
|
|
112
584
|
const codeChanges: FileChange[] = [];
|
|
113
585
|
const pkgChanges: PkgChange[] = [];
|
|
114
586
|
for await (const file of walk(target)) {
|
|
115
|
-
if (file.endsWith("/package.json")) {
|
|
116
|
-
|
|
117
|
-
|
|
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
|
+
);
|
|
118
597
|
} else if (CODE_EXT.test(file)) {
|
|
119
|
-
|
|
120
|
-
|
|
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
|
+
);
|
|
121
607
|
}
|
|
122
608
|
}
|
|
123
609
|
const label = dry ? "DRY" : "APPLY";
|
|
124
610
|
console.log(`[${label}] target=${target}`);
|
|
125
|
-
console.log(`[${label}]
|
|
126
|
-
console.log(`[${label}]
|
|
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
|
+
}
|
|
127
618
|
for (const c of codeChanges) {
|
|
128
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
|
+
}
|
|
129
623
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
+
}
|
|
133
629
|
}
|
|
134
630
|
if (dry) console.log(`[${label}] no files written. re-run without --dry to apply.`);
|
|
135
631
|
}
|
|
136
632
|
|
|
137
|
-
await main();
|
|
633
|
+
if (import.meta.main) await main();
|