@onrails/codemod 0.1.0 → 0.1.2
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 +9 -2
- package/src/ast.ts +58 -0
- package/src/chains.ts +157 -0
- package/src/cli.ts +132 -0
- package/src/constants.ts +63 -0
- package/src/file-change.ts +57 -0
- package/src/imports.ts +56 -0
- package/src/index.ts +8 -134
- 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/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.2",
|
|
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,14 @@
|
|
|
41
41
|
"exports": {
|
|
42
42
|
".": "./src/index.ts"
|
|
43
43
|
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@onrails/maybe": "0.1.0",
|
|
46
|
+
"@onrails/pattern": "0.1.0",
|
|
47
|
+
"@onrails/result": "0.1.0",
|
|
48
|
+
"typescript": "^6.0.3"
|
|
49
|
+
},
|
|
44
50
|
"scripts": {
|
|
45
|
-
"typecheck": "tsc --noEmit"
|
|
51
|
+
"typecheck": "tsc --noEmit",
|
|
52
|
+
"test": "bun test"
|
|
46
53
|
}
|
|
47
54
|
}
|
package/src/ast.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { fromNullable, type Maybe, match as matchMaybe, 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
|
+
matchMaybe(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,157 @@
|
|
|
1
|
+
import { compactMap, isSome, type Maybe, 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
|
+
export function helperCallToNative(node: ts.CallExpression): Maybe<Edit> {
|
|
26
|
+
if (ts.isIdentifier(node.expression) && node.arguments.length === 0) {
|
|
27
|
+
return lookupMap(ZERO_ARG_HELPERS, node.expression.text, (text) => edit(text));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!ts.isPropertyAccessExpression(node.expression)) return none();
|
|
31
|
+
const method = node.expression.name.text;
|
|
32
|
+
const base = node.expression.expression
|
|
33
|
+
.getText()
|
|
34
|
+
.replaceAll(".andTee(", ".tap(")
|
|
35
|
+
.replaceAll(".orTee(", ".tapErr(");
|
|
36
|
+
const argsText = argsToText(node.arguments);
|
|
37
|
+
|
|
38
|
+
const tee = lookupMap(TEE_METHODS, method, (t) => edit(`${base}.${t}(${argsText})`));
|
|
39
|
+
if (isSome(tee)) return tee;
|
|
40
|
+
|
|
41
|
+
if (PREDICATE_METHODS.has(method)) {
|
|
42
|
+
return some(edit(`${method}(${base})`, [method]));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return lookupMap(UNSAFE_UNWRAP_METHODS, method, (u) => edit(`${u}(${base})`, [u]));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function isNestedInSupportedChain(node: ts.Node): boolean {
|
|
49
|
+
const parent = node.parent;
|
|
50
|
+
if (!parent) return false;
|
|
51
|
+
if (!ts.isPropertyAccessExpression(parent)) return false;
|
|
52
|
+
return isSupportedChainCall(parent.parent);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function collectChain(node: ts.CallExpression): { base: string; steps: ChainStep[] } {
|
|
56
|
+
const steps: ChainStep[] = [];
|
|
57
|
+
let current: ts.Expression = node;
|
|
58
|
+
|
|
59
|
+
while (ts.isCallExpression(current) && ts.isPropertyAccessExpression(current.expression)) {
|
|
60
|
+
const method = current.expression.name.text;
|
|
61
|
+
if (!CHAIN_METHODS.has(method) && !TERMINAL_METHODS.has(method)) break;
|
|
62
|
+
steps.push({
|
|
63
|
+
method,
|
|
64
|
+
argsText: argsToText(current.arguments),
|
|
65
|
+
argCount: current.arguments.length,
|
|
66
|
+
});
|
|
67
|
+
current = current.expression.expression;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
base: current.getText(),
|
|
72
|
+
steps: steps.reverse(),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const hasAsyncRootHint = (base: string): boolean =>
|
|
77
|
+
ASYNC_ROOT_HINTS.some((hint) => base.includes(hint));
|
|
78
|
+
|
|
79
|
+
export const isSafeSyncChainBase = (base: string): boolean =>
|
|
80
|
+
SAFE_BASE_PATTERNS.some((re) => re.test(base));
|
|
81
|
+
|
|
82
|
+
const isTerminalStep = (s: ChainStep): boolean => TERMINAL_METHODS.has(s.method);
|
|
83
|
+
const isResultSpecificStep = (s: ChainStep): boolean => RESULT_SPECIFIC_CHAIN_METHODS.has(s.method);
|
|
84
|
+
const isChainStep = (s: ChainStep): boolean => CHAIN_METHODS.has(s.method);
|
|
85
|
+
|
|
86
|
+
export function chainToNative(base: string, steps: readonly ChainStep[]): Maybe<Edit> {
|
|
87
|
+
if (steps.length === 0 || hasAsyncRootHint(base)) return none();
|
|
88
|
+
const terminalStep = steps.find(isTerminalStep);
|
|
89
|
+
if (!isSafeSyncChainBase(base)) return none();
|
|
90
|
+
if (!terminalStep && !steps.some(isResultSpecificStep)) return none();
|
|
91
|
+
if (terminalStep?.method === "match" && terminalStep.argCount !== 2) return none();
|
|
92
|
+
const pipelineSteps = steps.filter(isChainStep);
|
|
93
|
+
const pipelineParts = compactMap(pipelineSteps, (step) =>
|
|
94
|
+
lookupMap(
|
|
95
|
+
CHAIN_METHODS,
|
|
96
|
+
step.method,
|
|
97
|
+
(nativeName): PipelinePart => ({
|
|
98
|
+
importName: nativeName,
|
|
99
|
+
text: `${nativeName}(${step.argsText})`,
|
|
100
|
+
}),
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
if (pipelineParts.length !== pipelineSteps.length) return none();
|
|
105
|
+
|
|
106
|
+
const parts = pipelineParts.map((part) => part.text);
|
|
107
|
+
const imports = new Set(pipelineParts.map((part) => part.importName));
|
|
108
|
+
const pipeline = parts.length > 0 ? `pipe(${[base, ...parts].join(", ")})` : base;
|
|
109
|
+
|
|
110
|
+
if (parts.length > 0) imports.add("pipe");
|
|
111
|
+
|
|
112
|
+
if (!terminalStep) {
|
|
113
|
+
return some(edit(pipeline, [...imports]));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
imports.add(terminalStep.method);
|
|
117
|
+
return some(edit(`${terminalStep.method}(${pipeline}, ${terminalStep.argsText})`, [...imports]));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export const byStartDesc = (a: Edit, b: Edit): number => b.start - a.start;
|
|
121
|
+
|
|
122
|
+
export const applyEditStep = (acc: EditAcc, e: Edit): EditAcc => ({
|
|
123
|
+
src: `${acc.src.slice(0, e.start)}${e.text}${acc.src.slice(e.end)}`,
|
|
124
|
+
imports: new Set([...acc.imports, ...e.imports]),
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
export function rewriteCompatMethodChainsToNative(src: string): string {
|
|
128
|
+
const edits: Edit[] = [];
|
|
129
|
+
|
|
130
|
+
walkSource(src, (node, sf) => {
|
|
131
|
+
if (ts.isCallExpression(node)) {
|
|
132
|
+
const helper = helperCallToNative(node);
|
|
133
|
+
if (isSome(helper)) {
|
|
134
|
+
edits.push(spanEdit(node, sf, helper.value));
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (
|
|
140
|
+
isSupportedChainCall(node) &&
|
|
141
|
+
!isNestedInSupportedChain(node) &&
|
|
142
|
+
!ts.isPropertyAccessExpression(node.parent)
|
|
143
|
+
) {
|
|
144
|
+
const chain = collectChain(node);
|
|
145
|
+
const chained = chainToNative(chain.base, chain.steps);
|
|
146
|
+
if (isSome(chained)) edits.push(spanEdit(node, sf, chained.value));
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
if (edits.length === 0) return src;
|
|
151
|
+
|
|
152
|
+
const { src: next, imports } = edits
|
|
153
|
+
.sort(byStartDesc)
|
|
154
|
+
.reduce(applyEditStep, { src, imports: new Set<string>() });
|
|
155
|
+
|
|
156
|
+
return addNativeValueImports(next, [...imports]);
|
|
157
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { relative, resolve } from "node:path";
|
|
2
|
+
import { type Maybe, match as matchMaybe, none, some, tap } 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<FileChange>, Error> =>
|
|
65
|
+
readFileText(path).flatMap((src) =>
|
|
66
|
+
matchMaybe(
|
|
67
|
+
computeFileChange(src, mode),
|
|
68
|
+
(c) => {
|
|
69
|
+
const change = toFileChange(path, c);
|
|
70
|
+
return c.changed && !dry
|
|
71
|
+
? writeFileText(path, c.next).map(() => some(change))
|
|
72
|
+
: ResultAsync.ok<Maybe<FileChange>, Error>(some(change));
|
|
73
|
+
},
|
|
74
|
+
() => ResultAsync.ok<Maybe<FileChange>, Error>(none()),
|
|
75
|
+
),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const collectInto =
|
|
79
|
+
<T>(into: T[], file: string) =>
|
|
80
|
+
(r: ResultAsync<Maybe<T>, Error>): Promise<void> =>
|
|
81
|
+
r.match(
|
|
82
|
+
(m) => void 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,56 @@
|
|
|
1
|
+
import { isTypeOnlyNative, isValueImport, splitImportNames, stripInlineType } from "./ast.js";
|
|
2
|
+
import { COMPAT_SPEC, NATIVE_SPEC } from "./constants.js";
|
|
3
|
+
|
|
4
|
+
export function toNativeImport(full: string, specifiers: string, quote: string): string {
|
|
5
|
+
const imports = splitImportNames(specifiers);
|
|
6
|
+
const typeNames = imports.filter(isTypeOnlyNative).map(stripInlineType);
|
|
7
|
+
const valueNames = imports.filter(isValueImport);
|
|
8
|
+
const chunks: string[] = [];
|
|
9
|
+
|
|
10
|
+
if (valueNames.length > 0) {
|
|
11
|
+
chunks.push(`import { ${valueNames.join(", ")} } from ${quote}${NATIVE_SPEC}${quote};`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (typeNames.length > 0) {
|
|
15
|
+
chunks.push(`import type { ${typeNames.join(", ")} } from ${quote}${NATIVE_SPEC}${quote};`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return chunks.length > 0 ? chunks.join("\n") : full;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function rewriteCompatImportsToNative(src: string): string {
|
|
22
|
+
const namedImportRe = new RegExp(
|
|
23
|
+
String.raw`import\s+\{\s*([^}]+?)\s*\}\s+from\s+(['"])${COMPAT_SPEC.replaceAll("/", String.raw`\/`)}\2\s*;?`,
|
|
24
|
+
"g",
|
|
25
|
+
);
|
|
26
|
+
const typeNamedImportRe = new RegExp(
|
|
27
|
+
String.raw`import\s+type\s+\{\s*([^}]+?)\s*\}\s+from\s+(['"])${COMPAT_SPEC.replaceAll("/", String.raw`\/`)}\2\s*;?`,
|
|
28
|
+
"g",
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
return src
|
|
32
|
+
.replace(typeNamedImportRe, (_full, specifiers: string, quote: string) => {
|
|
33
|
+
const imports = splitImportNames(specifiers);
|
|
34
|
+
return `import type { ${imports.join(", ")} } from ${quote}${NATIVE_SPEC}${quote};`;
|
|
35
|
+
})
|
|
36
|
+
.replace(namedImportRe, (full: string, specifiers: string, quote: string) =>
|
|
37
|
+
toNativeImport(full, specifiers, quote),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function addNativeValueImports(src: string, imports: readonly string[]): string {
|
|
42
|
+
const names = [...new Set(imports)].sort();
|
|
43
|
+
if (names.length === 0) return src;
|
|
44
|
+
const importRe = new RegExp(
|
|
45
|
+
String.raw`import\s+\{\s*([^}]*?)\s*\}\s+from\s+(['"])${NATIVE_SPEC.replaceAll("/", String.raw`\/`)}\2\s*;?`,
|
|
46
|
+
);
|
|
47
|
+
const match = importRe.exec(src);
|
|
48
|
+
|
|
49
|
+
if (!match?.[1]) {
|
|
50
|
+
return `import { ${names.join(", ")} } from "${NATIVE_SPEC}";\n${src}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const existing = splitImportNames(match[1]).map(stripInlineType);
|
|
54
|
+
const next = [...new Set([...existing, ...names])].sort();
|
|
55
|
+
return `${src.slice(0, match.index)}import { ${next.join(", ")} } from ${match[2]}${NATIVE_SPEC}${match[2]};${src.slice(match.index + match[0].length)}`;
|
|
56
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,137 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import {
|
|
3
|
-
/**
|
|
4
|
-
* Codemod: migrate `neverthrow` imports + package.json deps to
|
|
5
|
-
* `@onrails/result/compat/neverthrow`.
|
|
6
|
-
*
|
|
7
|
-
* Usage:
|
|
8
|
-
* bun run packages/result/scripts/codemod-neverthrow.ts <target-dir> [--dry] [--onrails=<abs-path>]
|
|
9
|
-
*
|
|
10
|
-
* - Rewrites import specifiers `"neverthrow"` -> `"@onrails/result/compat/neverthrow"`
|
|
11
|
-
* in `.ts` / `.tsx` / `.mts` / `.cts` files.
|
|
12
|
-
* - Updates every `package.json` that lists `neverthrow` under
|
|
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
|
-
*/
|
|
19
|
-
import { Glob } from "bun";
|
|
2
|
+
import { main } from "./cli.js";
|
|
20
3
|
|
|
21
|
-
|
|
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";
|
|
22
10
|
|
|
23
|
-
|
|
24
|
-
const CODE_EXT = /\.(ts|tsx|mts|cts)$/;
|
|
25
|
-
const IMPORT_RE = /(from\s+|import\s*\(\s*)(['"])neverthrow\2/g;
|
|
26
|
-
const NEW_SPEC = "@onrails/result/compat/neverthrow";
|
|
27
|
-
const DEP_KEYS = ["dependencies", "devDependencies", "peerDependencies"] as const;
|
|
28
|
-
|
|
29
|
-
function parseArgs(argv: string[]): Args {
|
|
30
|
-
const positional: string[] = [];
|
|
31
|
-
let dry = false;
|
|
32
|
-
let onrails = resolve(import.meta.dir, "../../..", "packages/result");
|
|
33
|
-
for (const a of argv) {
|
|
34
|
-
if (a === "--dry" || a === "-n") dry = true;
|
|
35
|
-
else if (a.startsWith("--onrails=")) onrails = resolve(a.slice("--onrails=".length));
|
|
36
|
-
else if (!a.startsWith("--")) positional.push(a);
|
|
37
|
-
}
|
|
38
|
-
if (positional.length !== 1) {
|
|
39
|
-
console.error(
|
|
40
|
-
"usage: bun run codemod-neverthrow.ts <target-dir> [--dry] [--onrails=<abs-path>]",
|
|
41
|
-
);
|
|
42
|
-
process.exit(2);
|
|
43
|
-
}
|
|
44
|
-
return { target: resolve(positional[0] ?? "."), dry, onrails };
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function shouldSkip(path: string): boolean {
|
|
48
|
-
return path.split("/").some((seg) => SKIP.has(seg));
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
async function* walk(root: string): AsyncGenerator<string> {
|
|
52
|
-
const glob = new Glob("**/*");
|
|
53
|
-
for await (const rel of glob.scan({ cwd: root, dot: false, onlyFiles: true })) {
|
|
54
|
-
if (!shouldSkip(rel)) yield `${root}/${rel}`;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
type FileChange = { path: string; before: number; after: number };
|
|
59
|
-
|
|
60
|
-
async function rewriteCode(path: string, dry: boolean): Promise<FileChange | null> {
|
|
61
|
-
const src = await Bun.file(path).text();
|
|
62
|
-
if (!src.includes("neverthrow")) return null;
|
|
63
|
-
const before = (src.match(IMPORT_RE) ?? []).length;
|
|
64
|
-
if (before === 0) return null;
|
|
65
|
-
const next = src.replace(IMPORT_RE, (_, lead, quote) => `${lead}${quote}${NEW_SPEC}${quote}`);
|
|
66
|
-
if (next === src) return null;
|
|
67
|
-
if (!dry) await Bun.write(path, next);
|
|
68
|
-
return { path, before, after: 0 };
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
type PkgChange = { path: string; removed: string[]; addedAs: string };
|
|
72
|
-
|
|
73
|
-
function reorderDeps(deps: Record<string, string>): Record<string, string> {
|
|
74
|
-
return Object.fromEntries(Object.entries(deps).sort(([a], [b]) => a.localeCompare(b)));
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
async function rewritePkg(
|
|
78
|
-
path: string,
|
|
79
|
-
onrailsAbs: string,
|
|
80
|
-
dry: boolean,
|
|
81
|
-
): Promise<PkgChange | null> {
|
|
82
|
-
const raw = await Bun.file(path).text();
|
|
83
|
-
let json: Record<string, unknown>;
|
|
84
|
-
try {
|
|
85
|
-
json = JSON.parse(raw) as Record<string, unknown>;
|
|
86
|
-
} catch {
|
|
87
|
-
return null;
|
|
88
|
-
}
|
|
89
|
-
const removed: string[] = [];
|
|
90
|
-
let touched = false;
|
|
91
|
-
const pkgDir = path.replace(/\/package\.json$/, "");
|
|
92
|
-
const filePath = relative(pkgDir, onrailsAbs);
|
|
93
|
-
const fileSpec = `file:${filePath}`;
|
|
94
|
-
for (const key of DEP_KEYS) {
|
|
95
|
-
const deps = json[key] as Record<string, string> | undefined;
|
|
96
|
-
if (!deps || typeof deps !== "object") continue;
|
|
97
|
-
if (!("neverthrow" in deps)) continue;
|
|
98
|
-
delete deps.neverthrow;
|
|
99
|
-
removed.push(key);
|
|
100
|
-
deps["@onrails/result"] = fileSpec;
|
|
101
|
-
json[key] = reorderDeps(deps);
|
|
102
|
-
touched = true;
|
|
103
|
-
}
|
|
104
|
-
if (!touched) return null;
|
|
105
|
-
const out = `${JSON.stringify(json, null, 2)}\n`;
|
|
106
|
-
if (!dry) await Bun.write(path, out);
|
|
107
|
-
return { path, removed, addedAs: fileSpec };
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
async function main() {
|
|
111
|
-
const { target, dry, onrails } = parseArgs(Bun.argv.slice(2));
|
|
112
|
-
const codeChanges: FileChange[] = [];
|
|
113
|
-
const pkgChanges: PkgChange[] = [];
|
|
114
|
-
for await (const file of walk(target)) {
|
|
115
|
-
if (file.endsWith("/package.json")) {
|
|
116
|
-
const c = await rewritePkg(file, onrails, dry);
|
|
117
|
-
if (c) pkgChanges.push(c);
|
|
118
|
-
} else if (CODE_EXT.test(file)) {
|
|
119
|
-
const c = await rewriteCode(file, dry);
|
|
120
|
-
if (c) codeChanges.push(c);
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
const label = dry ? "DRY" : "APPLY";
|
|
124
|
-
console.log(`[${label}] target=${target}`);
|
|
125
|
-
console.log(`[${label}] onrails=${onrails}`);
|
|
126
|
-
console.log(`[${label}] code files rewritten: ${codeChanges.length}`);
|
|
127
|
-
for (const c of codeChanges) {
|
|
128
|
-
console.log(` ${relative(target, c.path)} (${c.before} import${c.before === 1 ? "" : "s"})`);
|
|
129
|
-
}
|
|
130
|
-
console.log(`[${label}] package.json updated: ${pkgChanges.length}`);
|
|
131
|
-
for (const c of pkgChanges) {
|
|
132
|
-
console.log(` ${relative(target, c.path)} [${c.removed.join(", ")}] -> ${c.addedAs}`);
|
|
133
|
-
}
|
|
134
|
-
if (dry) console.log(`[${label}] no files written. re-run without --dry to apply.`);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
await main();
|
|
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);
|
package/src/pkg.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { relative } from "node:path";
|
|
2
|
+
import { type Maybe, match as matchMaybe, none, some } from "@onrails/maybe";
|
|
3
|
+
import { type Result, ResultAsync, trySync } from "@onrails/result";
|
|
4
|
+
import { DEP_KEYS } from "./constants.js";
|
|
5
|
+
import { readFileText, toError, writeFileText } from "./io.js";
|
|
6
|
+
import type { ComputedPkg, PkgChange, PkgUpdate } from "./types.js";
|
|
7
|
+
|
|
8
|
+
const reorderDeps = (deps: Record<string, string>): Record<string, string> =>
|
|
9
|
+
Object.fromEntries(Object.entries(deps).sort(([a], [b]) => a.localeCompare(b)));
|
|
10
|
+
|
|
11
|
+
export const parsePackageJson = (raw: string): Result<Record<string, unknown>, Error> =>
|
|
12
|
+
trySync(() => JSON.parse(raw) as Record<string, unknown>, toError)();
|
|
13
|
+
|
|
14
|
+
const applyDepRewrite =
|
|
15
|
+
(fileSpec: string) =>
|
|
16
|
+
(acc: PkgUpdate, key: string): PkgUpdate => {
|
|
17
|
+
const deps = acc.json[key] as Record<string, string> | undefined;
|
|
18
|
+
if (!deps || typeof deps !== "object" || !("neverthrow" in deps)) return acc;
|
|
19
|
+
const { neverthrow: _drop, ...rest } = deps;
|
|
20
|
+
return {
|
|
21
|
+
json: { ...acc.json, [key]: reorderDeps({ ...rest, "@onrails/result": fileSpec }) },
|
|
22
|
+
removed: [...acc.removed, key],
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const computePkgRewrite = (
|
|
27
|
+
json: Record<string, unknown>,
|
|
28
|
+
path: string,
|
|
29
|
+
onrailsAbs: string,
|
|
30
|
+
): Maybe<ComputedPkg> => {
|
|
31
|
+
const fileSpec = `file:${relative(path.replace(/\/package\.json$/, ""), onrailsAbs)}`;
|
|
32
|
+
const updated = DEP_KEYS.reduce(applyDepRewrite(fileSpec), { json, removed: [] } as PkgUpdate);
|
|
33
|
+
return updated.removed.length === 0
|
|
34
|
+
? none()
|
|
35
|
+
: some({ json: updated.json, removed: updated.removed, fileSpec });
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const toPkgChange = (path: string, c: ComputedPkg): PkgChange => ({
|
|
39
|
+
path,
|
|
40
|
+
removed: [...c.removed],
|
|
41
|
+
addedAs: c.fileSpec,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export const rewritePkg = (
|
|
45
|
+
path: string,
|
|
46
|
+
onrailsAbs: string,
|
|
47
|
+
dry: boolean,
|
|
48
|
+
): ResultAsync<Maybe<PkgChange>, Error> =>
|
|
49
|
+
readFileText(path)
|
|
50
|
+
.flatMap((raw) => ResultAsync.fromResult(parsePackageJson(raw)))
|
|
51
|
+
.flatMap((json) =>
|
|
52
|
+
matchMaybe(
|
|
53
|
+
computePkgRewrite(json, path, onrailsAbs),
|
|
54
|
+
(c) => {
|
|
55
|
+
const change = toPkgChange(path, c);
|
|
56
|
+
return dry
|
|
57
|
+
? ResultAsync.ok<Maybe<PkgChange>, Error>(some(change))
|
|
58
|
+
: writeFileText(path, `${JSON.stringify(c.json, null, 2)}\n`).map(() => some(change));
|
|
59
|
+
},
|
|
60
|
+
() => ResultAsync.ok<Maybe<PkgChange>, Error>(none()),
|
|
61
|
+
),
|
|
62
|
+
);
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export type Mode = "compat" | "native";
|
|
2
|
+
export type Args = { target: string; dry: boolean; onrails: string; mode: Mode };
|
|
3
|
+
|
|
4
|
+
export type Warning = { line: number; label: string; text: string };
|
|
5
|
+
export type FileChange = {
|
|
6
|
+
path: string;
|
|
7
|
+
before: number;
|
|
8
|
+
after: number;
|
|
9
|
+
changed: boolean;
|
|
10
|
+
warnings: readonly Warning[];
|
|
11
|
+
};
|
|
12
|
+
export type Edit = { start: number; end: number; text: string; imports: readonly string[] };
|
|
13
|
+
export type ChainStep = { method: string; argsText: string; argCount: number };
|
|
14
|
+
export type PipelinePart = { importName: string; text: string };
|
|
15
|
+
|
|
16
|
+
export type ComputedChange = {
|
|
17
|
+
next: string;
|
|
18
|
+
before: number;
|
|
19
|
+
after: number;
|
|
20
|
+
changed: boolean;
|
|
21
|
+
warnings: readonly Warning[];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type ModeStrategy = {
|
|
25
|
+
countBefore: (src: string) => number;
|
|
26
|
+
earlyExit: (src: string, before: number) => boolean;
|
|
27
|
+
transform: (src: string) => string;
|
|
28
|
+
warnings: (next: string) => readonly Warning[];
|
|
29
|
+
countAfter: (next: string) => number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type PkgChange = { path: string; removed: string[]; addedAs: string };
|
|
33
|
+
export type PkgUpdate = { json: Record<string, unknown>; removed: readonly string[] };
|
|
34
|
+
export type ComputedPkg = {
|
|
35
|
+
json: Record<string, unknown>;
|
|
36
|
+
removed: readonly string[];
|
|
37
|
+
fileSpec: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type ArgState = {
|
|
41
|
+
positional: readonly string[];
|
|
42
|
+
dry: boolean;
|
|
43
|
+
mode: Mode;
|
|
44
|
+
onrails: string;
|
|
45
|
+
};
|
|
46
|
+
export type EditAcc = { src: string; imports: Set<string> };
|
package/src/warnings.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import { concatCollectors, walkSource } from "./ast.js";
|
|
3
|
+
import { COMPAT_ONLY_PATTERNS, COMPAT_SPEC } from "./constants.js";
|
|
4
|
+
import type { Warning } from "./types.js";
|
|
5
|
+
|
|
6
|
+
export const collectRegexLineWarnings = (src: string): readonly Warning[] =>
|
|
7
|
+
src
|
|
8
|
+
.split(/\r?\n/)
|
|
9
|
+
.flatMap((line, i) =>
|
|
10
|
+
COMPAT_ONLY_PATTERNS.flatMap(({ pattern, label }) =>
|
|
11
|
+
pattern.test(line) ? [{ line: i + 1, label, text: line.trim() }] : [],
|
|
12
|
+
),
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
export const collectAstCompatWarnings = (src: string): readonly Warning[] => {
|
|
16
|
+
const warnings: Warning[] = [];
|
|
17
|
+
const lines = src.split(/\r?\n/);
|
|
18
|
+
const lineTextAt = (sf: ts.SourceFile, node: ts.Node) => {
|
|
19
|
+
const line = sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
|
|
20
|
+
return { line, text: lines[line - 1]?.trim() ?? node.getText(sf) };
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
walkSource(src, (node, sf) => {
|
|
24
|
+
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
|
|
25
|
+
const method = node.expression.name.text;
|
|
26
|
+
if (method === "isOk" || method === "isErr") {
|
|
27
|
+
warnings.push({ ...lineTextAt(sf, node), label: "compat predicate method" });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (ts.isPropertyAccessExpression(node)) {
|
|
32
|
+
const property = node.name.text;
|
|
33
|
+
if (property === "value" || property === "error") {
|
|
34
|
+
const receiver = node.expression.getText(sf);
|
|
35
|
+
if (/result$/i.test(receiver) || /^result$/i.test(receiver)) {
|
|
36
|
+
warnings.push({ ...lineTextAt(sf, node), label: "compat value/error property" });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return warnings;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const collectNativeMigrationWarnings = concatCollectors(
|
|
46
|
+
collectRegexLineWarnings,
|
|
47
|
+
collectAstCompatWarnings,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
export const collectUnsupportedCompatImportWarnings = (src: string): readonly Warning[] =>
|
|
51
|
+
src.split(/\r?\n/).flatMap((line, i) =>
|
|
52
|
+
line.includes(COMPAT_SPEC)
|
|
53
|
+
? [
|
|
54
|
+
{
|
|
55
|
+
line: i + 1,
|
|
56
|
+
label: "unsupported compat import",
|
|
57
|
+
text: line.trim(),
|
|
58
|
+
},
|
|
59
|
+
]
|
|
60
|
+
: [],
|
|
61
|
+
);
|