@tailor-platform/sdk-codemod 0.3.0-next.1 → 0.3.0-next.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/CHANGELOG.md +65 -0
- package/dist/codemods/v2/apply-to-deploy/scripts/transform.js +17 -1
- package/dist/codemods/v2/auth-invoker-unwrap/scripts/transform.js +99 -11
- package/dist/codemods/v2/execute-script-arg/scripts/transform.js +60 -0
- package/dist/codemods/v2/principal-unify/scripts/transform.js +1131 -43
- package/dist/index.js +484 -21
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,70 @@
|
|
|
1
1
|
# @tailor-platform/sdk-codemod
|
|
2
2
|
|
|
3
|
+
## 0.3.0-next.2
|
|
4
|
+
### Minor Changes
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
- [#1473](https://github.com/tailor-platform/sdk/pull/1473) [`7ddf3c7`](https://github.com/tailor-platform/sdk/commit/7ddf3c716adf85a66a75d554da7730b5406f84b1) Thanks [@dqn](https://github.com/dqn)! - Add the `v2/principal-unify` codemod so `tailor-sdk upgrade` can migrate SDK principal APIs to `TailorPrincipal`.
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
- [#1515](https://github.com/tailor-platform/sdk/pull/1515) [`dcf66a1`](https://github.com/tailor-platform/sdk/commit/dcf66a1e648f5287eaea9ea330eb4ad726a4d363) Thanks [@dqn](https://github.com/dqn)! - Rewrite `tailor-sdk apply` to `tailor-sdk deploy` in source files that contain embedded CLI command strings.
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
- [#1482](https://github.com/tailor-platform/sdk/pull/1482) [`8b5870e`](https://github.com/tailor-platform/sdk/commit/8b5870e85db1efec7647acb98226f8161e3d1583) Thanks [@toiroakr](https://github.com/toiroakr)! - Add LLM-assisted review support to the codemod runner. A codemod can declare `suspiciousPatterns` plus a `prompt`; after running, files whose post-transform content still matches a suspicious pattern are reported as `llmReviews` (in the JSON output and on stderr) together with the codemod's migration prompt. This surfaces the cases a deterministic transform cannot safely complete (e.g. a value reached through a variable) so they can be finished with an LLM. The `auth.invoker(...)` codemod adopts this for its non-literal-argument cases.
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
- [#1495](https://github.com/tailor-platform/sdk/pull/1495) [`6234022`](https://github.com/tailor-platform/sdk/commit/6234022d7dc03813b8dade831b86f63a5f7a20e6) Thanks [@toiroakr](https://github.com/toiroakr)! - Generate the v2 migration guide (`packages/sdk/docs/migration/v2.md`) from the codemod registry, which is the single source of truth. Each entry renders its name, automation level (Automatic / Partially automatic / Manual), description, optional before/after `examples`, and — for changes the codemods cannot fully migrate on their own — the LLM/manual migration prompt. Run `pnpm codemod:docs:update` to regenerate and `pnpm codemod:docs:check` (wired into `pnpm check`) to verify it is in sync.
|
|
24
|
+
|
|
25
|
+
`scriptPath` is now optional, so the registry can also describe codemod-less ("manual") migrations that ship only guidance (`examples` / `prompt` / `suspiciousPatterns`) with no automatic transform. A manual entry with a `prompt` but no scoping pattern is surfaced as a project-wide `llmReviews` entry at runtime.
|
|
26
|
+
|
|
27
|
+
Add a `sdk-codemod list` command that prints every registered rule (id, name, kind, version range).
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
- [#1517](https://github.com/tailor-platform/sdk/pull/1517) [`a649764`](https://github.com/tailor-platform/sdk/commit/a6497649be2786b3f6e410c8aa98c4247a599258) Thanks [@dqn](https://github.com/dqn)! - Reduce false-positive v2 codemod warnings and LLM-review prompts from source comments, string literals, and identifier substring matches.
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
- [#1476](https://github.com/tailor-platform/sdk/pull/1476) [`fa83075`](https://github.com/tailor-platform/sdk/commit/fa83075f5e0e91085c0ef0cb44b7058a28a79ec3) Thanks [@toiroakr](https://github.com/toiroakr)! - `executeScript` now takes its `arg` as a JSON-serializable value instead of a pre-serialized JSON string. Pass the value directly (e.g. `arg: { a: 1 }`) instead of `arg: JSON.stringify({ a: 1 })`.
|
|
35
|
+
|
|
36
|
+
Add the `v2/execute-script-arg` codemod, which unwraps `JSON.stringify(...)` passed as the `executeScript` `arg` option. Indirect forms (a stringified value held in a variable, etc.) cannot be rewritten automatically and are surfaced as an LLM-assisted review task with a migration prompt.
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
- [#1518](https://github.com/tailor-platform/sdk/pull/1518) [`ab10b1f`](https://github.com/tailor-platform/sdk/commit/ab10b1fea309ec5496e09bdca394d46d58603f5f) Thanks [@dqn](https://github.com/dqn)! - Reduce noisy `executeScript` LLM-review prompts by flagging files only when unresolved `arg` stringification remains likely.
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
- [#1509](https://github.com/tailor-platform/sdk/pull/1509) [`7cadaa7`](https://github.com/tailor-platform/sdk/commit/7cadaa7c4987b81130ca80ba80bc5d5b26276394) Thanks [@dqn](https://github.com/dqn)! - Rename resolver, executor, workflow trigger, and typed workflow start machine-user options from `authInvoker` to `invoker`.
|
|
44
|
+
|
|
45
|
+
Update create-sdk templates and the v2 auth invoker codemod to generate the new `invoker` option.
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
- [#1519](https://github.com/tailor-platform/sdk/pull/1519) [`4e3fa47`](https://github.com/tailor-platform/sdk/commit/4e3fa47d24e6bb1145eac13c355e976f2d594851) Thanks [@dqn](https://github.com/dqn)! - Limit the `openDownloadStream` migration review prompt to files that reference deprecated download stream APIs.
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
- [#1521](https://github.com/tailor-platform/sdk/pull/1521) [`2d0689e`](https://github.com/tailor-platform/sdk/commit/2d0689e8ac0079473294fab367799a5431c130f4) Thanks [@dqn](https://github.com/dqn)! - Flag files that need project-specific review after the v2 principal migration, including resolver helper adapters and nullable `caller` follow-ups.
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
- [#1525](https://github.com/tailor-platform/sdk/pull/1525) [`425a19d`](https://github.com/tailor-platform/sdk/commit/425a19dd58da6e373b739d3b3e838c2ff3d1736a) Thanks [@renovate](https://github.com/apps/renovate)! - fix(deps): update dependency semver to v7.8.5
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
- [#1520](https://github.com/tailor-platform/sdk/pull/1520) [`ed3d338`](https://github.com/tailor-platform/sdk/commit/ed3d338ce71d68904ef1fb83afbbd06a7e5f6973) Thanks [@dqn](https://github.com/dqn)! - Flag files that still reference ambient Tailor runtime globals so the v2 migration can opt them into `@tailor-platform/sdk/runtime/globals`.
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
- [#1439](https://github.com/tailor-platform/sdk/pull/1439) [`c5b10d2`](https://github.com/tailor-platform/sdk/commit/c5b10d2841ded08927285bce538c05220cde5e4c) Thanks [@dqn](https://github.com/dqn)! - Unify function principal context around `TailorPrincipal`.
|
|
65
|
+
|
|
66
|
+
Resolver contexts now use `caller` and `invoker` as `TailorPrincipal | null`, workflow and executor invokers also use `TailorPrincipal | null`, and event executor `actor` uses `TailorPrincipal | null` with `id`/`type` fields. The legacy `TailorUser`, `TailorInvoker`, `TailorActor`, `TailorActorType`, and `unauthenticatedTailorUser` exports are removed.
|
|
67
|
+
|
|
3
68
|
## 0.3.0-next.1
|
|
4
69
|
### Patch Changes
|
|
5
70
|
|
|
@@ -3,6 +3,16 @@ import * as path from "pathe";
|
|
|
3
3
|
const ARG_VALUE = `(?:[^\\s'"\`;&|]+|'[^']*'|"(?:(?:\\\\.)|[^"\\\\])*")`;
|
|
4
4
|
const GLOBAL_ARG_PATTERN = `(?:(?:\\s+(?:--verbose|--json|-j))|(?:\\s+(?:--env-file|--env-file-if-exists|-e)(?:=${ARG_VALUE}|\\s+${ARG_VALUE})))*`;
|
|
5
5
|
const APPLY_PATTERN = new RegExp(`(?<![\\w-])tailor-sdk(?:@[^\\s'"\`]+)?(?![\\w-])(${GLOBAL_ARG_PATTERN}\\s+)apply(?![-\\w])`, "g");
|
|
6
|
+
const SOURCE_EXTENSIONS = new Set([
|
|
7
|
+
".ts",
|
|
8
|
+
".tsx",
|
|
9
|
+
".mts",
|
|
10
|
+
".cts",
|
|
11
|
+
".js",
|
|
12
|
+
".jsx",
|
|
13
|
+
".mjs",
|
|
14
|
+
".cjs"
|
|
15
|
+
]);
|
|
6
16
|
function replaceApply(value) {
|
|
7
17
|
return value.replace(APPLY_PATTERN, (match) => `${match.slice(0, -5)}deploy`);
|
|
8
18
|
}
|
|
@@ -12,6 +22,10 @@ function transformText(source) {
|
|
|
12
22
|
const updated = replaceApply(source);
|
|
13
23
|
return updated === source ? null : updated;
|
|
14
24
|
}
|
|
25
|
+
function transformSourceText(source) {
|
|
26
|
+
const updated = source.split(/(\r\n|\n|\r)/).map((part, index) => index % 2 === 0 ? replaceApply(part) : part).join("");
|
|
27
|
+
return updated === source ? null : updated;
|
|
28
|
+
}
|
|
15
29
|
function transformPackageJson(source) {
|
|
16
30
|
let parsed;
|
|
17
31
|
try {
|
|
@@ -44,7 +58,9 @@ function transformPackageJson(source) {
|
|
|
44
58
|
*/
|
|
45
59
|
function transform(source, filePath) {
|
|
46
60
|
if (!source.includes("tailor-sdk")) return null;
|
|
47
|
-
|
|
61
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
62
|
+
if (ext === ".json") return transformPackageJson(source);
|
|
63
|
+
if (SOURCE_EXTENSIONS.has(ext)) return transformSourceText(source);
|
|
48
64
|
return transformText(source);
|
|
49
65
|
}
|
|
50
66
|
//#endregion
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { Lang, parse } from "@ast-grep/napi";
|
|
2
2
|
//#region codemods/v2/auth-invoker-unwrap/scripts/transform.ts
|
|
3
|
-
const
|
|
3
|
+
const QUICK_FILTER_NEEDLES = ["auth.invoker", "authInvoker"];
|
|
4
4
|
function quickFilter(source) {
|
|
5
|
-
return source.includes(
|
|
5
|
+
return QUICK_FILTER_NEEDLES.some((needle) => source.includes(needle));
|
|
6
6
|
}
|
|
7
7
|
function isInsideImportStatement(node) {
|
|
8
8
|
let current = node.parent();
|
|
@@ -121,14 +121,101 @@ function findAuthImports(root) {
|
|
|
121
121
|
return false;
|
|
122
122
|
});
|
|
123
123
|
}
|
|
124
|
+
function sameRange(a, b) {
|
|
125
|
+
const ar = a.range();
|
|
126
|
+
const br = b.range();
|
|
127
|
+
return ar.start.index === br.start.index && ar.end.index === br.end.index;
|
|
128
|
+
}
|
|
129
|
+
function keyText(node) {
|
|
130
|
+
if (!node) return null;
|
|
131
|
+
return node.text().replace(/^['"]|['"]$/g, "");
|
|
132
|
+
}
|
|
133
|
+
function expressionArguments(args) {
|
|
134
|
+
return args.children().filter((child) => ![
|
|
135
|
+
"(",
|
|
136
|
+
")",
|
|
137
|
+
","
|
|
138
|
+
].includes(child.kind()));
|
|
139
|
+
}
|
|
140
|
+
function argumentCallForObject(objectNode) {
|
|
141
|
+
const args = objectNode.parent();
|
|
142
|
+
const call = args?.parent();
|
|
143
|
+
if (args?.kind() !== "arguments" || call?.kind() !== "call_expression") return null;
|
|
144
|
+
const index = expressionArguments(args).findIndex((arg) => sameRange(arg, objectNode));
|
|
145
|
+
return index === -1 ? null : {
|
|
146
|
+
call,
|
|
147
|
+
index
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
function calleeText(call) {
|
|
151
|
+
return call.field("function")?.text() ?? "";
|
|
152
|
+
}
|
|
153
|
+
function isCreateCallOptionObject(objectNode, functionName) {
|
|
154
|
+
const callInfo = argumentCallForObject(objectNode);
|
|
155
|
+
return callInfo?.index === 0 && callInfo.call.field("function")?.kind() === "identifier" && calleeText(callInfo.call) === functionName;
|
|
156
|
+
}
|
|
157
|
+
function isExecutorOperationObject(objectNode) {
|
|
158
|
+
const operationPair = objectNode.parent();
|
|
159
|
+
if (operationPair?.kind() !== "pair" || keyText(operationPair.field("key")) !== "operation") return false;
|
|
160
|
+
const configObject = operationPair.parent();
|
|
161
|
+
return configObject?.kind() === "object" && isCreateCallOptionObject(configObject, "createExecutor");
|
|
162
|
+
}
|
|
163
|
+
function isSupportedInvokerOptionObject(objectNode) {
|
|
164
|
+
return isCreateCallOptionObject(objectNode, "createResolver") || isCreateCallOptionObject(objectNode, "startWorkflow") || isExecutorOperationObject(objectNode);
|
|
165
|
+
}
|
|
166
|
+
function optionObjectForPairKey(node) {
|
|
167
|
+
const parent = node.parent();
|
|
168
|
+
if (!parent || parent.kind() !== "pair") return null;
|
|
169
|
+
const key = parent.field("key");
|
|
170
|
+
if (!key || !sameRange(key, node)) return null;
|
|
171
|
+
const objectNode = parent.parent();
|
|
172
|
+
return objectNode?.kind() === "object" ? objectNode : null;
|
|
173
|
+
}
|
|
174
|
+
function isSupportedInvokerOptionKey(node) {
|
|
175
|
+
const objectNode = optionObjectForPairKey(node) ?? node.parent();
|
|
176
|
+
return objectNode?.kind() === "object" && isSupportedInvokerOptionObject(objectNode);
|
|
177
|
+
}
|
|
178
|
+
function isSupportedInvokerValueCall(node) {
|
|
179
|
+
const pair = node.parent();
|
|
180
|
+
if (pair?.kind() !== "pair") return false;
|
|
181
|
+
const value = pair.field("value");
|
|
182
|
+
if (!value || !sameRange(value, node)) return false;
|
|
183
|
+
const key = keyText(pair.field("key"));
|
|
184
|
+
if (key !== "authInvoker" && key !== "invoker") return false;
|
|
185
|
+
const objectNode = pair.parent();
|
|
186
|
+
return objectNode?.kind() === "object" && isSupportedInvokerOptionObject(objectNode);
|
|
187
|
+
}
|
|
188
|
+
function findAuthInvokerShorthands(root) {
|
|
189
|
+
return root.findAll({ rule: {
|
|
190
|
+
kind: "shorthand_property_identifier",
|
|
191
|
+
regex: "^authInvoker$"
|
|
192
|
+
} }).filter(isSupportedInvokerOptionKey);
|
|
193
|
+
}
|
|
194
|
+
function findAuthInvokerPropertyKeys(root) {
|
|
195
|
+
return root.findAll({ rule: {
|
|
196
|
+
kind: "property_identifier",
|
|
197
|
+
regex: "^authInvoker$"
|
|
198
|
+
} }).filter(isSupportedInvokerOptionKey);
|
|
199
|
+
}
|
|
200
|
+
function findQuotedAuthInvokerPropertyKeys(root) {
|
|
201
|
+
return root.findAll({ rule: {
|
|
202
|
+
kind: "string",
|
|
203
|
+
regex: "^['\"]authInvoker['\"]$"
|
|
204
|
+
} }).filter(isSupportedInvokerOptionKey);
|
|
205
|
+
}
|
|
206
|
+
function renameQuotedKey(node) {
|
|
207
|
+
const quote = node.text().startsWith("'") ? "'" : "\"";
|
|
208
|
+
return `${quote}invoker${quote}`;
|
|
209
|
+
}
|
|
124
210
|
/**
|
|
125
|
-
* Replace `auth.invoker("name")` calls with the bare `"name"` string literal
|
|
211
|
+
* Replace `auth.invoker("name")` calls with the bare `"name"` string literal
|
|
212
|
+
* and rename `authInvoker:` option keys to `invoker:`.
|
|
126
213
|
* If no other `auth` references remain after the rewrite, drop the `auth`
|
|
127
214
|
* specifier (or the entire import line when `auth` was its sole specifier).
|
|
128
215
|
*
|
|
129
|
-
* `auth.invoker()` was
|
|
130
|
-
* directly
|
|
131
|
-
* pull config-layer
|
|
216
|
+
* `auth.invoker()` was removed in favor of passing the machine user name
|
|
217
|
+
* directly to `invoker`; carrying the `auth` import only for `.invoker()`
|
|
218
|
+
* would otherwise pull config-layer modules into runtime bundles.
|
|
132
219
|
* @param source - File contents
|
|
133
220
|
* @param filePath - Absolute path to the file (kept for the runner signature)
|
|
134
221
|
* @returns Transformed source or null when nothing matched.
|
|
@@ -136,15 +223,16 @@ function findAuthImports(root) {
|
|
|
136
223
|
function transform(source, _filePath) {
|
|
137
224
|
if (!quickFilter(source)) return null;
|
|
138
225
|
const root = parse(source.includes("</") || source.includes("/>") ? Lang.Tsx : Lang.TypeScript, source).root();
|
|
139
|
-
const calls = findInvokerCalls(root);
|
|
140
|
-
if (calls.length === 0) return null;
|
|
226
|
+
const calls = findInvokerCalls(root).filter((c) => isSupportedInvokerValueCall(c.callNode));
|
|
141
227
|
const edits = calls.map((c) => c.callNode.replace(c.argText));
|
|
142
|
-
|
|
228
|
+
edits.push(...findAuthInvokerPropertyKeys(root).map((node) => node.replace("invoker")));
|
|
229
|
+
edits.push(...findQuotedAuthInvokerPropertyKeys(root).map((node) => node.replace(renameQuotedKey(node))));
|
|
230
|
+
edits.push(...findAuthInvokerShorthands(root).map((node) => node.replace("invoker: authInvoker")));
|
|
231
|
+
if (calls.length > 0 && countRemainingAuthRefs(root, calls.map((c) => c.range)) === 0) for (const importStmt of findAuthImports(root)) {
|
|
143
232
|
const edit = buildAuthImportRemovalEdit(source, importStmt);
|
|
144
233
|
if (edit) edits.push(edit);
|
|
145
234
|
}
|
|
146
|
-
|
|
147
|
-
let result = root.commitEdits(edits);
|
|
235
|
+
let result = edits.length === 0 ? source : root.commitEdits(edits);
|
|
148
236
|
result = result.replace(/^[\t ]*\n+/, "").replace(/\n{3,}/g, "\n\n");
|
|
149
237
|
return result === source ? null : result;
|
|
150
238
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Lang, parse } from "@ast-grep/napi";
|
|
2
|
+
//#region codemods/v2/execute-script-arg/scripts/transform.ts
|
|
3
|
+
const NEEDLE = "executeScript";
|
|
4
|
+
function quickFilter(source) {
|
|
5
|
+
return source.includes(NEEDLE) && source.includes("JSON.stringify");
|
|
6
|
+
}
|
|
7
|
+
function pairKeyText(pair) {
|
|
8
|
+
const key = pair.children()[0];
|
|
9
|
+
if (!key) return null;
|
|
10
|
+
return key.text().replace(/^['"]|['"]$/g, "");
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* True when `stringifyCall` is the value of a top-level `arg:` property in the
|
|
14
|
+
* object literal passed directly to `executeScript(...)`. The chain checked is
|
|
15
|
+
* `JSON.stringify(...)` → pair (`arg:`) → object → arguments → `executeScript`
|
|
16
|
+
* call, so a nested `arg:` (e.g. `executeScript({ opts: { arg: ... } })`) or an
|
|
17
|
+
* unrelated `JSON.stringify` is left untouched.
|
|
18
|
+
*/
|
|
19
|
+
function isExecuteScriptArg(stringifyCall) {
|
|
20
|
+
const pair = stringifyCall.parent();
|
|
21
|
+
if (!pair || pair.kind() !== "pair") return false;
|
|
22
|
+
if (pairKeyText(pair) !== "arg") return false;
|
|
23
|
+
const obj = pair.parent();
|
|
24
|
+
if (!obj || obj.kind() !== "object") return false;
|
|
25
|
+
const args = obj.parent();
|
|
26
|
+
if (!args || args.kind() !== "arguments") return false;
|
|
27
|
+
const call = args.parent();
|
|
28
|
+
if (!call || call.kind() !== "call_expression") return false;
|
|
29
|
+
const callee = call.children()[0];
|
|
30
|
+
return !!callee && callee.text() === NEEDLE;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Rewrite `executeScript({ ..., arg: JSON.stringify(X), ... })` to
|
|
34
|
+
* `executeScript({ ..., arg: X, ... })`.
|
|
35
|
+
*
|
|
36
|
+
* In v2 the `executeScript` `arg` option takes a JSON-serializable value and
|
|
37
|
+
* serializes it internally, so a pre-stringified argument double-encodes. Only
|
|
38
|
+
* the literal `arg: JSON.stringify(<single expr>)` form is rewritten; indirect
|
|
39
|
+
* forms (a stringified value held in a variable, `JSON.stringify(x, null, 2)`,
|
|
40
|
+
* etc.) are left for manual migration.
|
|
41
|
+
* @param source - File contents
|
|
42
|
+
* @param _filePath - Absolute path to the file (kept for the runner signature)
|
|
43
|
+
* @returns Transformed source or null when nothing matched.
|
|
44
|
+
*/
|
|
45
|
+
function transform(source, _filePath) {
|
|
46
|
+
if (!quickFilter(source)) return null;
|
|
47
|
+
const root = parse(source.includes("</") || source.includes("/>") ? Lang.Tsx : Lang.TypeScript, source).root();
|
|
48
|
+
const edits = [];
|
|
49
|
+
for (const match of root.findAll({ rule: { pattern: "JSON.stringify($X)" } })) {
|
|
50
|
+
if (!isExecuteScriptArg(match)) continue;
|
|
51
|
+
const inner = match.getMatch("X");
|
|
52
|
+
if (!inner) continue;
|
|
53
|
+
edits.push(match.replace(inner.text()));
|
|
54
|
+
}
|
|
55
|
+
if (edits.length === 0) return null;
|
|
56
|
+
const result = root.commitEdits(edits);
|
|
57
|
+
return result === source ? null : result;
|
|
58
|
+
}
|
|
59
|
+
//#endregion
|
|
60
|
+
export { transform as default };
|