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