@onrails/codemod 0.2.0 → 0.2.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.2.0",
3
+ "version": "0.2.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",
@@ -42,9 +42,9 @@
42
42
  ".": "./src/index.ts"
43
43
  },
44
44
  "dependencies": {
45
- "@onrails/maybe": "0.1.4",
46
- "@onrails/pattern": "0.1.4",
47
- "@onrails/result": "0.1.4",
45
+ "@onrails/maybe": "0.2.0",
46
+ "@onrails/pattern": "0.2.0",
47
+ "@onrails/result": "0.2.0",
48
48
  "typescript": "^6.0.3"
49
49
  },
50
50
  "scripts": {
package/src/ast.ts CHANGED
@@ -21,8 +21,19 @@ export const concatCollectors =
21
21
  export const walkSource = (
22
22
  src: string,
23
23
  visit: (node: ts.Node, sf: ts.SourceFile) => unknown,
24
+ jsx = false,
24
25
  ): void => {
25
- const sf = ts.createSourceFile("codemod.ts", src, ts.ScriptTarget.Latest, true);
26
+ // Parse JSX files in TSX mode — otherwise `<`/`>` are read as comparison
27
+ // operators and `<T>expr` as a cast, which truncates node boundaries and
28
+ // corrupts span-based edits. The scriptKind is set explicitly (not just via
29
+ // the filename) so it can't drift from the extension.
30
+ const sf = ts.createSourceFile(
31
+ jsx ? "codemod.tsx" : "codemod.ts",
32
+ src,
33
+ ts.ScriptTarget.Latest,
34
+ true,
35
+ jsx ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
36
+ );
26
37
  const go = (n: ts.Node) => {
27
38
  if (visit(n, sf)) return;
28
39
  ts.forEachChild(n, go);
@@ -0,0 +1,153 @@
1
+ /**
2
+ * boundary-spec.ts — the single owning module for the boundary idioms
3
+ * onrails enforces on consumers:
4
+ *
5
+ * 1. no `Promise<Result<…>>` in public signatures (`no-promise-result`)
6
+ * 2. no unsafe unwraps outside tests (`no-unsafe-unwrap`)
7
+ * 3. no deprecated neverthrow-era synonyms (`no-deprecated-synonyms`)
8
+ *
9
+ * The ESLint plugin (plain JS, `packages/eslint-plugin/src/index.js`) and the
10
+ * Biome plugin (GritQL, `packages/biome-plugin/rules/*.grit`) cannot import
11
+ * this module — cross-package imports are forbidden — so they hand-copy the
12
+ * names. `packages/codemod/test/boundary-conformance.spec.ts` reads those
13
+ * artifacts off disk and asserts they stay in sync with this spec.
14
+ *
15
+ * Deliberate per-engine divergences are declared in `ENGINE_DIVERGENCES`;
16
+ * anything not declared there is drift.
17
+ */
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Unsafe unwraps
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /**
24
+ * neverthrow-compat unsafe-unwrap methods → native named equivalents.
25
+ * The codemod rewrites `r._unsafeUnwrap()` to `unwrapOk(r)`; the natives are
26
+ * still unsafe (throw on the wrong branch) and stay lint-flagged.
27
+ */
28
+ export const UNSAFE_UNWRAP_MEMBER_RENAMES: ReadonlyMap<string, string> = new Map([
29
+ ["_unsafeUnwrap", "unwrapOk"],
30
+ ["_unsafeUnwrapErr", "unwrapErr"],
31
+ ]);
32
+
33
+ /** Free-function unwraps that throw on the unhappy branch. */
34
+ export const UNSAFE_UNWRAP_CALL_NAMES = ["unwrapOk", "unwrapErr", "unwrap"] as const;
35
+
36
+ /** Every identifier the `no-unsafe-unwrap` rule flags, across engines. */
37
+ export const UNSAFE_UNWRAP_NAMES: ReadonlySet<string> = new Set([
38
+ ...UNSAFE_UNWRAP_MEMBER_RENAMES.keys(),
39
+ ...UNSAFE_UNWRAP_CALL_NAMES,
40
+ ]);
41
+
42
+ /**
43
+ * Matches compat unsafe-unwrap member calls (`._unsafeUnwrap(` /
44
+ * `._unsafeUnwrapErr(`) in raw source. Derived from the rename map so the
45
+ * codemod's compat-only detection cannot drift from the spec.
46
+ */
47
+ export const UNSAFE_UNWRAP_MEMBER_CALL_RE = new RegExp(
48
+ String.raw`\.(?:${[...UNSAFE_UNWRAP_MEMBER_RENAMES.keys()].join("|")})\s*\(`,
49
+ );
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Deprecated synonyms
53
+ // ---------------------------------------------------------------------------
54
+
55
+ export interface DeprecatedSynonym {
56
+ /** The deprecated identifier as it appears in consumer code. */
57
+ readonly name: string;
58
+ /** `method` — flagged as `x.name(…)`; `call` — flagged as `name(…)`. */
59
+ readonly form: "method" | "call";
60
+ /** Canonical replacement identifier. */
61
+ readonly canonical: string;
62
+ /** Extra guidance when the rename is not a plain identifier swap. */
63
+ readonly hint?: string;
64
+ }
65
+
66
+ /**
67
+ * Deprecated neverthrow/fp-era synonyms → canonical onrails names.
68
+ * Matching is name-based in every engine — neither GritQL nor the ESLint
69
+ * rules scope by import, so same-named calls from other libraries (RxJS
70
+ * `of`, fp-ts `fold`) are flagged too.
71
+ */
72
+ export const DEPRECATED_SYNONYMS: readonly DeprecatedSynonym[] = [
73
+ { name: "chain", form: "method", canonical: "flatMap" },
74
+ {
75
+ name: "isOk",
76
+ form: "method",
77
+ canonical: "isOk",
78
+ hint: "await the ResultAsync, then narrow with the isOk() free function",
79
+ },
80
+ {
81
+ name: "isErr",
82
+ form: "method",
83
+ canonical: "isErr",
84
+ hint: "await the ResultAsync, then narrow with the isErr() free function",
85
+ },
86
+ { name: "fold", form: "call", canonical: "match" },
87
+ { name: "matchResult", form: "call", canonical: "match" },
88
+ { name: "matchMaybe", form: "call", canonical: "match" },
89
+ { name: "getOrElse", form: "call", canonical: "unwrapOr" },
90
+ { name: "sequenceTupleAsync", form: "call", canonical: "ResultAsync.combineTuple" },
91
+ { name: "collect", form: "call", canonical: "combine" },
92
+ { name: "of", form: "call", canonical: "ok", hint: "or some() for Maybe" },
93
+ ];
94
+
95
+ /** Flat old-name → canonical-name view of {@link DEPRECATED_SYNONYMS}. */
96
+ export const DEPRECATED_SYNONYM_RENAMES: ReadonlyMap<string, string> = new Map(
97
+ DEPRECATED_SYNONYMS.map((s) => [s.name, s.canonical]),
98
+ );
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Rule descriptors
102
+ // ---------------------------------------------------------------------------
103
+
104
+ /** `Promise<Result<…>>` in a type position → `ResultAsync<T, E>`. */
105
+ export const NO_PROMISE_RESULT_RULE = {
106
+ id: "no-promise-result",
107
+ replacement: "ResultAsync",
108
+ boundaryHelpers: ["fromAsync", "tryAsync"],
109
+ message:
110
+ "Promise<Result<…>> in a type position — use ResultAsync<T, E> at the boundary (fromAsync / tryAsync).",
111
+ } as const;
112
+
113
+ /** Unsafe unwraps outside tests. Message is shared verbatim by both engines. */
114
+ export const NO_UNSAFE_UNWRAP_RULE = {
115
+ id: "no-unsafe-unwrap",
116
+ message:
117
+ "Avoid _unsafeUnwrap* and unwrap* outside tests — use match(), resolve(), or yieldResult() in tryGen.",
118
+ } as const;
119
+
120
+ /** Deprecated synonym identifiers. Per-engine message wording differs. */
121
+ export const NO_DEPRECATED_SYNONYMS_RULE = {
122
+ id: "no-deprecated-synonyms",
123
+ } as const;
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Declared divergences
127
+ // ---------------------------------------------------------------------------
128
+
129
+ export interface EngineDivergence {
130
+ readonly rule: string;
131
+ /** Engine whose behaviour differs from the other(s). */
132
+ readonly engine: "eslint" | "biome" | "codemod";
133
+ /** Stable id the conformance test keys on. */
134
+ readonly divergence: string;
135
+ readonly detail: string;
136
+ }
137
+
138
+ /**
139
+ * Deliberate cross-engine behaviour differences. The conformance test treats
140
+ * these as declared (allowed); any other difference is accidental drift.
141
+ */
142
+ export const ENGINE_DIVERGENCES: readonly EngineDivergence[] = [
143
+ {
144
+ rule: "no-unsafe-unwrap",
145
+ engine: "biome",
146
+ divergence: "no-test-file-exemption",
147
+ detail:
148
+ "The ESLint rule skips *.spec.* / *.test.* files where unsafe unwraps are " +
149
+ "acceptable; GritQL patterns cannot read the file path, so the Biome rule " +
150
+ "flags test code too. Suppress with a biome-ignore comment or a linter " +
151
+ "override for test globs.",
152
+ },
153
+ ];
package/src/chains.ts CHANGED
@@ -213,28 +213,32 @@ export const applyEditStep = (acc: EditAcc, e: Edit): EditAcc => ({
213
213
  imports: new Set([...acc.imports, ...e.imports]),
214
214
  });
215
215
 
216
- export function rewriteCompatMethodChainsToNative(src: string): string {
216
+ export function rewriteCompatMethodChainsToNative(src: string, jsx = false): string {
217
217
  const edits: Edit[] = [];
218
218
 
219
- walkSource(src, (node, sf) => {
220
- if (ts.isCallExpression(node)) {
221
- const helper = helperCallToNative(node);
222
- if (isSome(helper)) {
223
- edits.push(spanEdit(node, sf, helper.value));
224
- return true;
219
+ walkSource(
220
+ src,
221
+ (node, sf) => {
222
+ if (ts.isCallExpression(node)) {
223
+ const helper = helperCallToNative(node);
224
+ if (isSome(helper)) {
225
+ edits.push(spanEdit(node, sf, helper.value));
226
+ return true;
227
+ }
225
228
  }
226
- }
227
229
 
228
- if (
229
- isSupportedChainCall(node) &&
230
- !isNestedInSupportedChain(node) &&
231
- !ts.isPropertyAccessExpression(node.parent)
232
- ) {
233
- const chain = collectChain(node);
234
- const chained = chainToNative(chain.base, chain.steps);
235
- if (isSome(chained)) edits.push(spanEdit(node, sf, chained.value));
236
- }
237
- });
230
+ if (
231
+ isSupportedChainCall(node) &&
232
+ !isNestedInSupportedChain(node) &&
233
+ !ts.isPropertyAccessExpression(node.parent)
234
+ ) {
235
+ const chain = collectChain(node);
236
+ const chained = chainToNative(chain.base, chain.steps);
237
+ if (isSome(chained)) edits.push(spanEdit(node, sf, chained.value));
238
+ }
239
+ },
240
+ jsx,
241
+ );
238
242
 
239
243
  if (edits.length === 0) return src;
240
244
 
package/src/cli.ts CHANGED
@@ -3,7 +3,7 @@ import * as Maybe from "@onrails/maybe";
3
3
  import { match } from "@onrails/pattern";
4
4
  import { err, isErr, ok, type Result, ResultAsync } from "@onrails/result";
5
5
  import { Glob } from "bun";
6
- import { CODE_EXT, SKIP } from "./constants.js";
6
+ import { CODE_EXT, JSX_EXT, SKIP } from "./constants.js";
7
7
  import { computeFileChange, toFileChange } from "./file-change.js";
8
8
  import { readFileText, writeFileText } from "./io.js";
9
9
  import { rewritePkg } from "./pkg.js";
@@ -64,7 +64,7 @@ const rewriteCode = (
64
64
  ): ResultAsync<Maybe.Maybe<FileChange>, Error> =>
65
65
  readFileText(path).flatMap((src) =>
66
66
  Maybe.match(
67
- computeFileChange(src, mode),
67
+ computeFileChange(src, mode, JSX_EXT.test(path)),
68
68
  (c) => {
69
69
  const change = toFileChange(path, c);
70
70
  return c.changed && !dry
package/src/constants.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { UNSAFE_UNWRAP_MEMBER_CALL_RE, UNSAFE_UNWRAP_MEMBER_RENAMES } from "./boundary-spec.js";
2
+
1
3
  export const SKIP = new Set([
2
4
  "node_modules",
3
5
  "dist",
@@ -8,6 +10,7 @@ export const SKIP = new Set([
8
10
  "build",
9
11
  ]);
10
12
  export const CODE_EXT = /\.(ts|tsx|mts|cts)$/;
13
+ export const JSX_EXT = /\.(tsx|jsx)$/;
11
14
  export const IMPORT_RE = /(from\s+|import\s*\(\s*)(['"])neverthrow\2/g;
12
15
  export const COMPAT_SPEC = "@onrails/result/compat/neverthrow";
13
16
  export const NATIVE_SPEC = "@onrails/result";
@@ -15,7 +18,7 @@ export const DEP_KEYS = ["dependencies", "devDependencies", "peerDependencies"]
15
18
  export const TYPE_ONLY_NATIVE = new Set(["Result", "Ok", "Err", "UnexpectedError"]);
16
19
  export const COMPAT_ONLY_PATTERNS = [
17
20
  { pattern: /\bResult\.(combine|fromThrowable)\b/, label: "Result static helper" },
18
- { pattern: /\._unsafeUnwrap(Err)?\s*\(/, label: "unsafe compat unwrap" },
21
+ { pattern: UNSAFE_UNWRAP_MEMBER_CALL_RE, label: "unsafe compat unwrap" },
19
22
  ] as const;
20
23
  export const RESULT_SPECIFIC_CHAIN_METHODS = new Set([
21
24
  "andThen",
@@ -56,8 +59,5 @@ export const TEE_METHODS = new Map([
56
59
  ["orTee", "tapErr"],
57
60
  ]);
58
61
  export const PREDICATE_METHODS = new Set(["isOk", "isErr"]);
59
- export const UNSAFE_UNWRAP_METHODS = new Map([
60
- ["_unsafeUnwrap", "unwrapOk"],
61
- ["_unsafeUnwrapErr", "unwrapErr"],
62
- ]);
62
+ export const UNSAFE_UNWRAP_METHODS = new Map(UNSAFE_UNWRAP_MEMBER_RENAMES);
63
63
  export const SAFE_BASE_PATTERNS = [/^(ok|err)\s*\(/, /^Result\./, /^[a-zA-Z_$][\w$]*$/] as const;
@@ -1,25 +1,25 @@
1
1
  import { type Maybe, none, some } from "@onrails/maybe";
2
- import { flow } from "@onrails/result";
3
- import { concatCollectors, countOccurrences } from "./ast.js";
2
+ import { countOccurrences } from "./ast.js";
4
3
  import { rewriteCompatMethodChainsToNative } from "./chains.js";
5
4
  import { COMPAT_SPEC, IMPORT_RE, NATIVE_SPEC } from "./constants.js";
6
5
  import { rewriteCompatImportsToNative } from "./imports.js";
7
6
  import { tersify } from "./tersify.js";
8
- import type { ComputedChange, FileChange, Mode, ModeStrategy } from "./types.js";
7
+ import type { ComputedChange, FileChange, Mode, ModeStrategy, Warning } from "./types.js";
9
8
  import {
10
9
  collectNativeMigrationWarnings,
11
10
  collectUnsupportedCompatImportWarnings,
12
11
  } from "./warnings.js";
13
12
 
14
- const rewriteCompatToNative = flow(rewriteCompatImportsToNative, rewriteCompatMethodChainsToNative);
13
+ const rewriteCompatToNative = (src: string, jsx: boolean): string =>
14
+ rewriteCompatMethodChainsToNative(rewriteCompatImportsToNative(src), jsx);
15
15
 
16
16
  const rewriteNeverthrowToCompat = (src: string): string =>
17
17
  src.replace(IMPORT_RE, (_, lead, quote) => `${lead}${quote}${COMPAT_SPEC}${quote}`);
18
18
 
19
- const collectAllNativeWarnings = concatCollectors(
20
- collectUnsupportedCompatImportWarnings,
21
- collectNativeMigrationWarnings,
22
- );
19
+ const collectAllNativeWarnings = (next: string, jsx: boolean): readonly Warning[] => [
20
+ ...collectUnsupportedCompatImportWarnings(next),
21
+ ...collectNativeMigrationWarnings(next, jsx),
22
+ ];
23
23
 
24
24
  const MODES: Record<Mode, ModeStrategy> = {
25
25
  compat: {
@@ -45,13 +45,13 @@ const MODES: Record<Mode, ModeStrategy> = {
45
45
  },
46
46
  };
47
47
 
48
- export const computeFileChange = (src: string, mode: Mode): Maybe<ComputedChange> => {
48
+ export const computeFileChange = (src: string, mode: Mode, jsx = false): Maybe<ComputedChange> => {
49
49
  const strat = MODES[mode];
50
50
  const before = strat.countBefore(src);
51
51
  if (strat.earlyExit(src, before)) return none();
52
- const next = strat.transform(src);
52
+ const next = strat.transform(src, jsx);
53
53
  const changed = next !== src;
54
- const warnings = strat.warnings(next);
54
+ const warnings = strat.warnings(next, jsx);
55
55
  return !changed && warnings.length === 0
56
56
  ? none()
57
57
  : some({ next, before, after: strat.countAfter(next), changed, warnings });
package/src/index.ts CHANGED
@@ -1,6 +1,20 @@
1
1
  #!/usr/bin/env bun
2
2
  import { main } from "./cli.js";
3
3
 
4
+ export {
5
+ DEPRECATED_SYNONYM_RENAMES,
6
+ DEPRECATED_SYNONYMS,
7
+ type DeprecatedSynonym,
8
+ ENGINE_DIVERGENCES,
9
+ type EngineDivergence,
10
+ NO_DEPRECATED_SYNONYMS_RULE,
11
+ NO_PROMISE_RESULT_RULE,
12
+ NO_UNSAFE_UNWRAP_RULE,
13
+ UNSAFE_UNWRAP_CALL_NAMES,
14
+ UNSAFE_UNWRAP_MEMBER_CALL_RE,
15
+ UNSAFE_UNWRAP_MEMBER_RENAMES,
16
+ UNSAFE_UNWRAP_NAMES,
17
+ } from "./boundary-spec.js";
4
18
  export { rewriteCompatMethodChainsToNative } from "./chains.js";
5
19
  export { main } from "./cli.js";
6
20
  export { computeFileChange } from "./file-change.js";
@@ -0,0 +1,177 @@
1
+ import ts from "typescript";
2
+
3
+ // Helper to check for single statement return
4
+ export function getSingleReturnExpression(statement: ts.Statement): ts.Expression | undefined {
5
+ if (ts.isReturnStatement(statement)) {
6
+ return statement.expression;
7
+ }
8
+ if (ts.isBlock(statement)) {
9
+ if (statement.statements.length === 1) {
10
+ const single = statement.statements[0];
11
+ if (single && ts.isReturnStatement(single)) {
12
+ return single.expression;
13
+ }
14
+ }
15
+ }
16
+ return undefined;
17
+ }
18
+
19
+ // Helper to check if body uses 'this' or 'arguments'
20
+ export function usesThisOrArguments(body: ts.Node): boolean {
21
+ let found = false;
22
+ const visit = (n: ts.Node) => {
23
+ if (found) return;
24
+ if (n.kind === ts.SyntaxKind.ThisKeyword) {
25
+ found = true;
26
+ return;
27
+ }
28
+ if (ts.isIdentifier(n) && n.text === "arguments") {
29
+ found = true;
30
+ return;
31
+ }
32
+ if (
33
+ ts.isFunctionDeclaration(n) ||
34
+ ts.isFunctionExpression(n) ||
35
+ ts.isArrowFunction(n) ||
36
+ ts.isMethodDeclaration(n) ||
37
+ ts.isGetAccessorDeclaration(n) ||
38
+ ts.isSetAccessorDeclaration(n)
39
+ ) {
40
+ return; // Stop recursion for nested scopes
41
+ }
42
+ ts.forEachChild(n, visit);
43
+ };
44
+ ts.forEachChild(body, visit);
45
+ return found;
46
+ }
47
+
48
+ // Helper to check if identifier referenced before pos
49
+ export function isIdentifierReferencedBefore(
50
+ name: string,
51
+ pos: number,
52
+ sf: ts.SourceFile,
53
+ ): boolean {
54
+ let referenced = false;
55
+ const visit = (n: ts.Node) => {
56
+ if (referenced) return;
57
+ if (n.getStart(sf) >= pos) return;
58
+ if (ts.isIdentifier(n) && n.text === name) {
59
+ const parent = n.parent;
60
+ if (parent) {
61
+ if (ts.isPropertyAccessExpression(parent) && parent.name === n) {
62
+ return;
63
+ }
64
+ if (ts.isPropertyAssignment(parent) && parent.name === n) {
65
+ return;
66
+ }
67
+ if (ts.isMethodDeclaration(parent) && parent.name === n) {
68
+ return;
69
+ }
70
+ if (ts.isPropertyDeclaration(parent) && parent.name === n) {
71
+ return;
72
+ }
73
+ if (ts.isBindingElement(parent) && parent.propertyName === n) {
74
+ return;
75
+ }
76
+ }
77
+ referenced = true;
78
+ return;
79
+ }
80
+ ts.forEachChild(n, visit);
81
+ };
82
+ ts.forEachChild(sf, visit);
83
+ return referenced;
84
+ }
85
+
86
+ export function getTypeParametersText(
87
+ node: ts.FunctionDeclaration | ts.FunctionExpression,
88
+ sf: ts.SourceFile,
89
+ ): string {
90
+ if (node.typeParameters && node.typeParameters.length > 0) {
91
+ const params = node.typeParameters.map((p) => p.getText(sf));
92
+ return params.length === 1 ? `<${params[0]},>` : `<${params.join(", ")}>`;
93
+ }
94
+ return "";
95
+ }
96
+
97
+ export const isSimpleExpression = (expr: ts.Expression): boolean =>
98
+ ts.isIdentifier(expr) ||
99
+ ts.isPropertyAccessExpression(expr) ||
100
+ ts.isElementAccessExpression(expr) ||
101
+ ts.isCallExpression(expr);
102
+
103
+ export function containsConditional(node: ts.Node): boolean {
104
+ if (ts.isConditionalExpression(node)) {
105
+ return true;
106
+ }
107
+ let found = false;
108
+ ts.forEachChild(node, (child) => {
109
+ if (found) return;
110
+ if (containsConditional(child)) {
111
+ found = true;
112
+ }
113
+ });
114
+ return found;
115
+ }
116
+
117
+ // A concise arrow body that is — or unwraps through `as` / `satisfies` to — an
118
+ // object literal must be parenthesized, else `{ … }` parses as a block body.
119
+ export function startsWithObjectLiteral(expr: ts.Expression): boolean {
120
+ let e: ts.Expression = expr;
121
+ while (ts.isAsExpression(e) || ts.isSatisfiesExpression(e)) {
122
+ e = e.expression;
123
+ }
124
+ return ts.isObjectLiteralExpression(e);
125
+ }
126
+
127
+ export function getTernaryBranchText(node: ts.Expression, sf: ts.SourceFile): string {
128
+ let current = node;
129
+ let hasParens = false;
130
+ while (ts.isParenthesizedExpression(current)) {
131
+ current = current.expression;
132
+ hasParens = true;
133
+ }
134
+
135
+ let unwrapped: ts.Expression | undefined;
136
+ if (ts.isJsxFragment(current)) {
137
+ const children = current.children;
138
+ const nonTriviaChildren = children.filter((child) => {
139
+ if (ts.isJsxText(child)) {
140
+ return !child.containsOnlyTriviaWhiteSpaces;
141
+ }
142
+ return true;
143
+ });
144
+ if (nonTriviaChildren.length === 1) {
145
+ const soleChild = nonTriviaChildren[0];
146
+ if (soleChild && ts.isJsxExpression(soleChild) && soleChild.expression) {
147
+ unwrapped = soleChild.expression;
148
+ }
149
+ }
150
+ } else if (ts.isJsxElement(current)) {
151
+ const tagName = current.openingElement.tagName.getText(sf);
152
+ if (tagName === "Fragment" || tagName === "React.Fragment" || tagName.endsWith(".Fragment")) {
153
+ if (current.openingElement.attributes.properties.length === 0) {
154
+ const children = current.children;
155
+ const nonTriviaChildren = children.filter((child) => {
156
+ if (ts.isJsxText(child)) {
157
+ return !child.containsOnlyTriviaWhiteSpaces;
158
+ }
159
+ return true;
160
+ });
161
+ if (nonTriviaChildren.length === 1) {
162
+ const soleChild = nonTriviaChildren[0];
163
+ if (soleChild && ts.isJsxExpression(soleChild) && soleChild.expression) {
164
+ unwrapped = soleChild.expression;
165
+ }
166
+ }
167
+ }
168
+ }
169
+ }
170
+
171
+ if (unwrapped) {
172
+ const unwrappedText = getTernaryBranchText(unwrapped, sf);
173
+ return hasParens ? `(${unwrappedText})` : unwrappedText;
174
+ }
175
+
176
+ return node.getText(sf);
177
+ }
package/src/tersify.ts CHANGED
@@ -3,118 +3,18 @@ import ts from "typescript";
3
3
  import { edit, spanEdit, walkSource } from "./ast.js";
4
4
  import { applyEditStep, byStartDesc } from "./chains.js";
5
5
  import { scanIfToSwitchSequences } from "./switch.js";
6
+ import {
7
+ containsConditional,
8
+ getSingleReturnExpression,
9
+ getTernaryBranchText,
10
+ getTypeParametersText,
11
+ isIdentifierReferencedBefore,
12
+ isSimpleExpression,
13
+ startsWithObjectLiteral,
14
+ usesThisOrArguments,
15
+ } from "./tersify-utils.js";
6
16
  import type { Edit } from "./types.js";
7
17
 
8
- // Helper to check for single statement return
9
- function getSingleReturnExpression(statement: ts.Statement): ts.Expression | undefined {
10
- if (ts.isReturnStatement(statement)) {
11
- return statement.expression;
12
- }
13
- if (ts.isBlock(statement)) {
14
- if (statement.statements.length === 1) {
15
- const single = statement.statements[0];
16
- if (single && ts.isReturnStatement(single)) {
17
- return single.expression;
18
- }
19
- }
20
- }
21
- return undefined;
22
- }
23
-
24
- // Helper to check if body uses 'this' or 'arguments'
25
- function usesThisOrArguments(body: ts.Node): boolean {
26
- let found = false;
27
- const visit = (n: ts.Node) => {
28
- if (found) return;
29
- if (n.kind === ts.SyntaxKind.ThisKeyword) {
30
- found = true;
31
- return;
32
- }
33
- if (ts.isIdentifier(n) && n.text === "arguments") {
34
- found = true;
35
- return;
36
- }
37
- if (
38
- ts.isFunctionDeclaration(n) ||
39
- ts.isFunctionExpression(n) ||
40
- ts.isArrowFunction(n) ||
41
- ts.isMethodDeclaration(n) ||
42
- ts.isGetAccessorDeclaration(n) ||
43
- ts.isSetAccessorDeclaration(n)
44
- ) {
45
- return; // Stop recursion for nested scopes
46
- }
47
- ts.forEachChild(n, visit);
48
- };
49
- ts.forEachChild(body, visit);
50
- return found;
51
- }
52
-
53
- // Helper to check if identifier referenced before pos
54
- function isIdentifierReferencedBefore(name: string, pos: number, sf: ts.SourceFile): boolean {
55
- let referenced = false;
56
- const visit = (n: ts.Node) => {
57
- if (referenced) return;
58
- if (n.getStart(sf) >= pos) return;
59
- if (ts.isIdentifier(n) && n.text === name) {
60
- const parent = n.parent;
61
- if (parent) {
62
- if (ts.isPropertyAccessExpression(parent) && parent.name === n) {
63
- return;
64
- }
65
- if (ts.isPropertyAssignment(parent) && parent.name === n) {
66
- return;
67
- }
68
- if (ts.isMethodDeclaration(parent) && parent.name === n) {
69
- return;
70
- }
71
- if (ts.isPropertyDeclaration(parent) && parent.name === n) {
72
- return;
73
- }
74
- if (ts.isBindingElement(parent) && parent.propertyName === n) {
75
- return;
76
- }
77
- }
78
- referenced = true;
79
- return;
80
- }
81
- ts.forEachChild(n, visit);
82
- };
83
- ts.forEachChild(sf, visit);
84
- return referenced;
85
- }
86
-
87
- function getTypeParametersText(
88
- node: ts.FunctionDeclaration | ts.FunctionExpression,
89
- sf: ts.SourceFile,
90
- ): string {
91
- if (node.typeParameters && node.typeParameters.length > 0) {
92
- const params = node.typeParameters.map((p) => p.getText(sf));
93
- return params.length === 1 ? `<${params[0]},>` : `<${params.join(", ")}>`;
94
- }
95
- return "";
96
- }
97
-
98
- const isSimpleExpression = (expr: ts.Expression): boolean =>
99
- ts.isIdentifier(expr) ||
100
- ts.isPropertyAccessExpression(expr) ||
101
- ts.isElementAccessExpression(expr) ||
102
- ts.isCallExpression(expr);
103
-
104
- function containsConditional(node: ts.Node): boolean {
105
- if (ts.isConditionalExpression(node)) {
106
- return true;
107
- }
108
- let found = false;
109
- ts.forEachChild(node, (child) => {
110
- if (found) return;
111
- if (containsConditional(child)) {
112
- found = true;
113
- }
114
- });
115
- return found;
116
- }
117
-
118
18
  function tryTersifyIfElse(node: ts.Node, sf: ts.SourceFile): Maybe<Edit> {
119
19
  if (ts.isIfStatement(node) && node.elseStatement) {
120
20
  const x = getSingleReturnExpression(node.thenStatement);
@@ -138,24 +38,14 @@ function tryTersifyIfElse(node: ts.Node, sf: ts.SourceFile): Maybe<Edit> {
138
38
  return some(spanEdit(node, sf, edit(text)));
139
39
  }
140
40
 
141
- const xText = x.getText(sf);
142
- const yText = y.getText(sf);
41
+ const xText = getTernaryBranchText(x, sf);
42
+ const yText = getTernaryBranchText(y, sf);
143
43
  return some(spanEdit(node, sf, edit(`return ${condText} ? ${xText} : ${yText};`)));
144
44
  }
145
45
  }
146
46
  return none();
147
47
  }
148
48
 
149
- // A concise arrow body that is — or unwraps through `as` / `satisfies` to — an
150
- // object literal must be parenthesized, else `{ … }` parses as a block body.
151
- function startsWithObjectLiteral(expr: ts.Expression): boolean {
152
- let e: ts.Expression = expr;
153
- while (ts.isAsExpression(e) || ts.isSatisfiesExpression(e)) {
154
- e = e.expression;
155
- }
156
- return ts.isObjectLiteralExpression(e);
157
- }
158
-
159
49
  function tryTersifyArrowBlock(node: ts.Node, sf: ts.SourceFile): Maybe<Edit> {
160
50
  if (ts.isArrowFunction(node) && ts.isBlock(node.body)) {
161
51
  const expr = getSingleReturnExpression(node.body);
@@ -353,8 +243,8 @@ function tryTersifyTernaryOrBooleanSequence(
353
243
  const needsParens = !isSimpleExpression(stmt.expression);
354
244
  text = needsParens ? `return !(${condText});` : `return !${condText};`;
355
245
  } else {
356
- const xText = x.getText(sf);
357
- const yText = y.getText(sf);
246
+ const xText = getTernaryBranchText(x, sf);
247
+ const yText = getTernaryBranchText(y, sf);
358
248
  text = `return ${condText} ? ${xText} : ${yText};`;
359
249
  }
360
250
 
@@ -422,29 +312,33 @@ const TRANSFORMS: ReadonlyArray<(node: ts.Node, sf: ts.SourceFile) => Maybe<Edit
422
312
  tryTersifyIdentityFilter,
423
313
  ];
424
314
 
425
- function tersifyOnce(src: string): string {
315
+ function tersifyOnce(src: string, jsx: boolean): string {
426
316
  const edits: Edit[] = [];
427
317
  const skippedNodes = new Set<ts.Node>();
428
318
 
429
- walkSource(src, (node, sf) => {
430
- if (skippedNodes.has(node)) {
431
- return true;
432
- }
319
+ walkSource(
320
+ src,
321
+ (node, sf) => {
322
+ if (skippedNodes.has(node)) {
323
+ return true;
324
+ }
433
325
 
434
- scanIfReturnSequences(node, sf, skippedNodes, edits);
435
- scanIfToSwitchSequences(node, sf, skippedNodes, edits);
326
+ scanIfReturnSequences(node, sf, skippedNodes, edits);
327
+ scanIfToSwitchSequences(node, sf, skippedNodes, edits);
436
328
 
437
- for (const transform of TRANSFORMS) {
438
- const editResult = transform(node, sf);
439
- if (editResult._tag === "Some") {
440
- edits.push(editResult.value);
441
- skippedNodes.add(node);
442
- return true;
329
+ for (const transform of TRANSFORMS) {
330
+ const editResult = transform(node, sf);
331
+ if (editResult._tag === "Some") {
332
+ edits.push(editResult.value);
333
+ skippedNodes.add(node);
334
+ return true;
335
+ }
443
336
  }
444
- }
445
337
 
446
- return false;
447
- });
338
+ return false;
339
+ },
340
+ jsx,
341
+ );
448
342
 
449
343
  if (edits.length === 0) return src;
450
344
 
@@ -455,11 +349,11 @@ function tersifyOnce(src: string): string {
455
349
  return next;
456
350
  }
457
351
 
458
- export function tersify(src: string): string {
352
+ export function tersify(src: string, jsx = false): string {
459
353
  let current = src;
460
354
  let iterations = 0;
461
355
  while (iterations < 10) {
462
- const next = tersifyOnce(current);
356
+ const next = tersifyOnce(current, jsx);
463
357
  if (next === current) {
464
358
  break;
465
359
  }
package/src/types.ts CHANGED
@@ -24,8 +24,8 @@ export type ComputedChange = {
24
24
  export type ModeStrategy = {
25
25
  countBefore: (src: string) => number;
26
26
  earlyExit: (src: string, before: number) => boolean;
27
- transform: (src: string) => string;
28
- warnings: (next: string) => readonly Warning[];
27
+ transform: (src: string, jsx: boolean) => string;
28
+ warnings: (next: string, jsx: boolean) => readonly Warning[];
29
29
  countAfter: (next: string) => number;
30
30
  };
31
31
 
package/src/warnings.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import ts from "typescript";
2
- import { concatCollectors, walkSource } from "./ast.js";
2
+ import { walkSource } from "./ast.js";
3
3
  import { COMPAT_ONLY_PATTERNS, COMPAT_SPEC } from "./constants.js";
4
4
  import type { Warning } from "./types.js";
5
5
 
@@ -12,7 +12,7 @@ export const collectRegexLineWarnings = (src: string): readonly Warning[] =>
12
12
  ),
13
13
  );
14
14
 
15
- export const collectAstCompatWarnings = (src: string): readonly Warning[] => {
15
+ export const collectAstCompatWarnings = (src: string, jsx = false): readonly Warning[] => {
16
16
  const warnings: Warning[] = [];
17
17
  const lines = src.split(/\r?\n/);
18
18
  const lineTextAt = (sf: ts.SourceFile, node: ts.Node) => {
@@ -20,32 +20,36 @@ export const collectAstCompatWarnings = (src: string): readonly Warning[] => {
20
20
  return { line, text: lines[line - 1]?.trim() ?? node.getText(sf) };
21
21
  };
22
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" });
23
+ walkSource(
24
+ src,
25
+ (node, sf) => {
26
+ if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
27
+ const method = node.expression.name.text;
28
+ if (method === "isOk" || method === "isErr") {
29
+ warnings.push({ ...lineTextAt(sf, node), label: "compat predicate method" });
30
+ }
28
31
  }
29
- }
30
32
 
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" });
33
+ if (ts.isPropertyAccessExpression(node)) {
34
+ const property = node.name.text;
35
+ if (property === "value" || property === "error") {
36
+ const receiver = node.expression.getText(sf);
37
+ if (/result$/i.test(receiver) || /^result$/i.test(receiver)) {
38
+ warnings.push({ ...lineTextAt(sf, node), label: "compat value/error property" });
39
+ }
37
40
  }
38
41
  }
39
- }
40
- });
42
+ },
43
+ jsx,
44
+ );
41
45
 
42
46
  return warnings;
43
47
  };
44
48
 
45
- export const collectNativeMigrationWarnings = concatCollectors(
46
- collectRegexLineWarnings,
47
- collectAstCompatWarnings,
48
- );
49
+ export const collectNativeMigrationWarnings = (src: string, jsx = false): readonly Warning[] => [
50
+ ...collectRegexLineWarnings(src),
51
+ ...collectAstCompatWarnings(src, jsx),
52
+ ];
49
53
 
50
54
  export const collectUnsupportedCompatImportWarnings = (src: string): readonly Warning[] =>
51
55
  src.split(/\r?\n/).flatMap((line, i) =>