@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.
Files changed (3) hide show
  1. package/README.md +48 -2
  2. package/package.json +8 -2
  3. 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
- Rewrites every `from "neverthrow"` import and every `neverthrow` `package.json` dep to point at `@onrails/result/compat/neverthrow`.
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.0",
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
- * 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
- */
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 Args = { target: string; dry: boolean; onrails: string };
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 NEW_SPEC = "@onrails/result/compat/neverthrow";
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 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
- }
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: bun run codemod-neverthrow.ts <target-dir> [--dry] [--onrails=<abs-path>]",
91
+ "usage: onrails-codemod-neverthrow <target-dir> [--dry] [--to-native] [--onrails=<abs-path>]",
41
92
  );
42
93
  process.exit(2);
43
94
  }
44
- return { target: resolve(positional[0] ?? "."), dry, onrails };
95
+ return {
96
+ target: resolve(positional[0] ?? "."),
97
+ dry,
98
+ onrails,
99
+ mode,
100
+ };
45
101
  }
46
102
 
47
- function shouldSkip(path: string): boolean {
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 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 };
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
- type PkgChange = { path: string; removed: string[]; addedAs: string };
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
- function reorderDeps(deps: Record<string, string>): Record<string, string> {
74
- return Object.fromEntries(Object.entries(deps).sort(([a], [b]) => a.localeCompare(b)));
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
- 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;
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
- 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;
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
- 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 };
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
- const c = await rewritePkg(file, onrails, dry);
117
- if (c) pkgChanges.push(c);
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
- const c = await rewriteCode(file, dry);
120
- if (c) codeChanges.push(c);
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}] onrails=${onrails}`);
126
- console.log(`[${label}] code files rewritten: ${codeChanges.length}`);
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
- 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}`);
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();