@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/dist/index.js CHANGED
@@ -7,11 +7,29 @@ import { arg, defineCommand, runMain } from "politty";
7
7
  import { z } from "zod";
8
8
  import { gte, lt, valid } from "semver";
9
9
  import * as fs from "node:fs";
10
+ import { Lang, parse } from "@ast-grep/napi";
10
11
  import chalk from "chalk";
11
12
  import { structuredPatch } from "diff";
12
13
  import picomatch from "picomatch";
14
+ //#region src/migration-doc.ts
15
+ /**
16
+ * Classify how much of a migration the codemod automates.
17
+ * - `Automatic`: a transform fully covers it, with no residual to flag.
18
+ * - `Partially automatic`: a transform covers the common cases but flags
19
+ * residuals (via `legacyPatterns`/`suspiciousPatterns`/`prompt`) to finish.
20
+ * - `Manual`: no transform; the change is migrated by hand (optionally guided
21
+ * by a `prompt`). Whether a person or an LLM does it does not matter here.
22
+ * @param codemod - The codemod registry entry
23
+ * @returns The automation level
24
+ */
25
+ function automationLevel(codemod) {
26
+ if (!codemod.scriptPath) return "Manual";
27
+ return (codemod.legacyPatterns?.length ?? 0) > 0 || (codemod.suspiciousPatterns?.length ?? 0) > 0 || codemod.prompt != null ? "Partially automatic" : "Automatic";
28
+ }
29
+ //#endregion
13
30
  //#region src/registry.ts
14
31
  const CODEMODS_ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), "codemods");
32
+ /** All registered codemods, in registration order. */
15
33
  const allCodemods = [
16
34
  {
17
35
  id: "v2/define-generators-to-plugins",
@@ -20,7 +38,29 @@ const allCodemods = [
20
38
  since: "1.0.0",
21
39
  until: "2.0.0",
22
40
  scriptPath: "v2/define-generators-to-plugins/scripts/transform.js",
23
- legacyPatterns: ["defineGenerators"]
41
+ legacyPatterns: ["defineGenerators"],
42
+ examples: [{
43
+ before: [
44
+ "import { defineGenerators } from \"@tailor-platform/sdk\";",
45
+ "",
46
+ "export const generators = defineGenerators(",
47
+ " [\"@tailor-platform/kysely-type\", { distPath: \"db.ts\" }],",
48
+ ");"
49
+ ].join("\n"),
50
+ after: [
51
+ "import { definePlugins } from \"@tailor-platform/sdk\";",
52
+ "import { kyselyTypePlugin } from \"@tailor-platform/sdk/plugin/kysely-type\";",
53
+ "",
54
+ "export const generators = definePlugins(kyselyTypePlugin({ distPath: \"db.ts\" }));"
55
+ ].join("\n")
56
+ }],
57
+ prompt: [
58
+ "defineGenerators() is replaced by definePlugins() in v2. The codemod rewrites the",
59
+ "known plugin tuples (kysely-type, enum-constants, file-utils, seed). For any",
60
+ "remaining defineGenerators([...]) the codemod left in place — a plugin it does not",
61
+ "know, or a non-tuple/spread form — convert it to definePlugins(pluginFn(config)),",
62
+ "importing the matching plugin from its @tailor-platform/sdk/plugin/<name> subpath."
63
+ ].join("\n")
24
64
  },
25
65
  {
26
66
  id: "v2/plugin-cli-import",
@@ -28,7 +68,11 @@ const allCodemods = [
28
68
  description: "Rewrite deprecated plugin re-export imports (kyselyTypePlugin, enumConstantsPlugin, fileUtilsPlugin, seedPlugin) from `@tailor-platform/sdk/cli` to their dedicated plugin subpaths",
29
69
  since: "1.0.0",
30
70
  until: "2.0.0",
31
- scriptPath: "v2/plugin-cli-import/scripts/transform.js"
71
+ scriptPath: "v2/plugin-cli-import/scripts/transform.js",
72
+ examples: [{
73
+ before: "import { kyselyTypePlugin } from \"@tailor-platform/sdk/cli\";",
74
+ after: "import { kyselyTypePlugin } from \"@tailor-platform/sdk/plugin/kysely-type\";"
75
+ }]
32
76
  },
33
77
  {
34
78
  id: "v2/test-run-arg-input",
@@ -41,7 +85,12 @@ const allCodemods = [
41
85
  "**/package.json",
42
86
  "**/*.{sh,bash,zsh}",
43
87
  "**/*.md"
44
- ]
88
+ ],
89
+ examples: [{
90
+ lang: "sh",
91
+ before: "tailor-sdk function test-run resolvers/add.ts --arg '{\"input\":{\"a\":1}}'",
92
+ after: "tailor-sdk function test-run resolvers/add.ts --arg '{\"a\":1}'"
93
+ }]
45
94
  },
46
95
  {
47
96
  id: "v2/sdk-skills-shim",
@@ -55,21 +104,63 @@ const allCodemods = [
55
104
  "**/*.{sh,bash,zsh,yml,yaml}",
56
105
  "**/*.md"
57
106
  ],
58
- legacyPatterns: ["tailor-sdk-skills"]
107
+ legacyPatterns: ["tailor-sdk-skills"],
108
+ examples: [{
109
+ lang: "sh",
110
+ before: "npx tailor-sdk-skills",
111
+ after: "tailor-sdk skills install"
112
+ }],
113
+ prompt: [
114
+ "The standalone tailor-sdk-skills binary is removed in v2; call the skills install",
115
+ "subcommand on the main tailor-sdk CLI instead. Replace any remaining",
116
+ "tailor-sdk-skills invocations the codemod did not rewrite with",
117
+ "`tailor-sdk skills install`."
118
+ ].join("\n")
59
119
  },
60
120
  {
61
121
  id: "v2/principal-unify",
62
- name: "Unify TailorUser/TailorActor/TailorInvoker → TailorPrincipal",
63
- description: "Rename TailorUser/TailorActor/TailorInvoker to TailorPrincipal, drop unauthenticatedTailorUser, and rename resolver body `user` to `caller`",
122
+ name: "Unify TailorUser/TailorActor/TailorActorType/TailorInvoker → TailorPrincipal",
123
+ description: "Rename TailorUser/TailorActor/TailorActorType/TailorInvoker to TailorPrincipal, drop unauthenticatedTailorUser, rename resolver body `user` to `caller`, and rename TailorDB callback `user` to `invoker`",
64
124
  since: "1.0.0",
65
125
  until: "2.0.0",
66
126
  scriptPath: "v2/principal-unify/scripts/transform.js",
67
127
  legacyPatterns: [
68
128
  "TailorUser",
69
129
  "TailorActor",
130
+ "TailorActorType",
70
131
  "TailorInvoker",
71
132
  "unauthenticatedTailorUser"
72
- ]
133
+ ],
134
+ suspiciousPatterns: [
135
+ "caller?.",
136
+ "context.user",
137
+ "context.invoker ?? context.user",
138
+ "ResolverContext"
139
+ ],
140
+ examples: [{
141
+ caption: "Type references unify under `TailorPrincipal`:",
142
+ before: "import type { TailorUser } from \"@tailor-platform/sdk\";",
143
+ after: "import type { TailorPrincipal } from \"@tailor-platform/sdk\";"
144
+ }, {
145
+ caption: "The resolver body `user` becomes `caller`:",
146
+ before: "body: ({ input, user }) => user.id,",
147
+ after: "body: ({ input, caller }) => caller.id,"
148
+ }],
149
+ prompt: [
150
+ "Finish the cases the codemod left for manual migration:",
151
+ "- Rename user -> caller in resolver bodies the codemod skipped because a `caller`",
152
+ " binding already exists or renaming would shadow/collide with another value.",
153
+ "- Replace member-access on the removed unauthenticatedTailorUser (e.g.",
154
+ " unauthenticatedTailorUser.id); the codemod only replaced standalone references",
155
+ " with null and left member access to surface a type error.",
156
+ "- Review helper adapters that still accept or read `context.user`; v2 resolver",
157
+ " context uses nullable `caller` and `invoker`, so project-specific helper",
158
+ " semantics for anonymous callers and command invokers must be chosen explicitly.",
159
+ "- Review `caller?.` values passed to APIs that require non-null values. If the",
160
+ " resolver requires authentication, throw or otherwise narrow before the call;",
161
+ " if anonymous callers are allowed, keep the nullable flow explicit.",
162
+ "Use TailorPrincipal for the unified user/actor/invoker type."
163
+ ].join("\n")
73
164
  },
74
165
  {
75
166
  id: "v2/apply-to-deploy",
@@ -81,8 +172,14 @@ const allCodemods = [
81
172
  filePatterns: [
82
173
  "**/package.json",
83
174
  "**/*.{sh,bash,zsh,yml,yaml}",
175
+ "**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}",
84
176
  "**/*.md"
85
- ]
177
+ ],
178
+ examples: [{
179
+ lang: "sh",
180
+ before: "tailor-sdk apply --profile prod",
181
+ after: "tailor-sdk deploy --profile prod"
182
+ }]
86
183
  },
87
184
  {
88
185
  id: "v2/cli-rename",
@@ -96,25 +193,235 @@ const allCodemods = [
96
193
  "**/*.{sh,bash,zsh,yml,yaml}",
97
194
  "**/*.md"
98
195
  ],
99
- legacyPatterns: ["tailor-sdk crash-report", "--machineuser"]
196
+ legacyPatterns: ["tailor-sdk crash-report", "--machineuser"],
197
+ examples: [{
198
+ lang: "sh",
199
+ before: "tailor-sdk crash-report list\ntailor-sdk login --machineuser",
200
+ after: "tailor-sdk crashreport list\ntailor-sdk login --machine-user"
201
+ }],
202
+ prompt: [
203
+ "Apply the v2 CLI renames the codemod did not reach (only `tailor-sdk`-prefixed",
204
+ "invocations are rewritten): `tailor-sdk crash-report` -> `tailor-sdk crashreport`",
205
+ "and the `--machineuser` option -> `--machine-user`. Leave unrelated commands that",
206
+ "happen to use `--machineuser` alone."
207
+ ].join("\n")
100
208
  },
101
209
  {
102
210
  id: "v2/auth-invoker-unwrap",
103
- name: "auth.invoker(\"name\") → \"name\"",
104
- description: "Replace `auth.invoker(\"name\")` calls with the bare `\"name\"` string and drop the `auth` import when no other reference remains. The `auth.invoker()` helper is deprecated in v2 because importing `auth` from `tailor.config.ts` into runtime files pulls Node-only modules into the bundle.",
211
+ name: "auth.invoker(\"name\") → invoker: \"name\"",
212
+ description: "Rename statically identified SDK `authInvoker` options to `invoker`, replace `auth.invoker(\"name\")` there with the bare `\"name\"` string, and drop the `auth` import when no other reference remains. Ambiguous workflow `.trigger()` calls are left for manual review. The `auth.invoker()` helper is removed in v2 because importing `auth` from `tailor.config.ts` into runtime files pulls Node-only modules into the bundle.",
105
213
  since: "1.0.0",
106
214
  until: "2.0.0",
107
215
  scriptPath: "v2/auth-invoker-unwrap/scripts/transform.js",
108
- legacyPatterns: ["auth.invoker"]
216
+ suspiciousPatterns: [
217
+ "auth.invoker",
218
+ "authInvoker:",
219
+ "authInvoker :",
220
+ "authInvoker?",
221
+ "{ authInvoker",
222
+ ", authInvoker",
223
+ "\n authInvoker",
224
+ "\n authInvoker",
225
+ "\n authInvoker",
226
+ "\"authInvoker\":",
227
+ "\"authInvoker\" :",
228
+ "\"authInvoker\"?",
229
+ "'authInvoker':",
230
+ "'authInvoker' :",
231
+ "'authInvoker'?"
232
+ ],
233
+ prompt: [
234
+ "In Tailor SDK v2 the auth.invoker() helper is removed; an invoker is now the",
235
+ "machine user name passed directly as a string. The codemod already rewrote the",
236
+ "statically identified SDK option form authInvoker: auth.invoker(\"name\") to invoker: \"name\" and renamed supported authInvoker option keys. These files still contain",
237
+ "auth.invoker(...) calls or authInvoker keys that need manual review.",
238
+ "",
239
+ "For each remaining auth.invoker(<expr>) call:",
240
+ "1. Replace the whole call with <expr> only where the target option expects a",
241
+ " machine user name string; platform/runtime authInvoker payloads still expect",
242
+ " the object form.",
243
+ "2. Rename remaining authInvoker option keys to invoker only for SDK resolver,",
244
+ " executor, workflow.trigger(), or startWorkflow() options. Keep platform/runtime",
245
+ " payload keys such as tailor.workflow.triggerWorkflow(..., { authInvoker: ... }).",
246
+ "3. After removing every auth.invoker usage in a file, delete the now-unused auth",
247
+ " import (keeping it pulls Node-only config modules into runtime bundles); leave",
248
+ " the import if auth is still referenced elsewhere.",
249
+ "",
250
+ "Do not change behavior beyond the SDK option rename and auth.invoker() removal."
251
+ ].join("\n"),
252
+ examples: [{
253
+ before: "createResolver({ invoker: auth.invoker(\"manager\") });",
254
+ after: "createResolver({ invoker: \"manager\" });"
255
+ }]
109
256
  },
110
257
  {
111
258
  id: "v2/tailordb-namespace",
112
259
  name: "Tailordb → tailordb (lowercase ambient namespace)",
113
- description: "Rewrite references to the removed capital-cased `Tailordb` ambient namespace (`Tailordb.QueryResult`, `Tailordb.CommandType`, `Tailordb.Client`, `typeof Tailordb.Client`) to the lowercase `tailordb.*` namespace exposed by `@tailor-platform/sdk/runtime/globals`.",
260
+ description: "Rewrite references to the removed capital-cased `Tailordb` ambient namespace (`Tailordb.QueryResult`, `Tailordb.CommandType`, `Tailordb.Client`, `typeof Tailordb.Client`) to the lowercase `tailordb.*` namespace exposed by `@tailor-platform/sdk/runtime/globals`. Because v2 no longer activates ambient declarations automatically, each file that contains `tailordb.*` references after the rewrite must also add `import \"@tailor-platform/sdk/runtime/globals\"`.",
114
261
  since: "1.0.0",
115
262
  until: "2.0.0",
116
263
  scriptPath: "v2/tailordb-namespace/scripts/transform.js",
117
- legacyPatterns: ["Tailordb."]
264
+ legacyPatterns: ["Tailordb."],
265
+ examples: [{
266
+ before: "const command: Tailordb.CommandType = \"SELECT\";",
267
+ after: "import \"@tailor-platform/sdk/runtime/globals\";\nconst command: tailordb.CommandType = \"SELECT\";"
268
+ }],
269
+ prompt: [
270
+ "The capital-cased Tailordb ambient namespace is removed in v2; use the lowercase",
271
+ "tailordb.* namespace from @tailor-platform/sdk/runtime/globals. The codemod rewrites",
272
+ "the known members (QueryResult, CommandType, Client). Rewrite any other remaining",
273
+ "Tailordb.* reference to its tailordb.* equivalent (and confirm the member still",
274
+ "exists on the lowercase namespace).",
275
+ "Also add `import \"@tailor-platform/sdk/runtime/globals\"` at the top of each file",
276
+ "that contains any tailordb.* type reference — v2 no longer activates ambient",
277
+ "declarations automatically on SDK import."
278
+ ].join("\n")
279
+ },
280
+ {
281
+ id: "v2/execute-script-arg",
282
+ name: "executeScript arg JSON.stringify → value",
283
+ description: "Unwrap `JSON.stringify(...)` passed as the `executeScript` `arg` option. In v2 `arg` takes a JSON-serializable value and is serialized internally, so a pre-stringified argument double-encodes.",
284
+ since: "1.0.0",
285
+ until: "2.0.0",
286
+ scriptPath: "v2/execute-script-arg/scripts/transform.js",
287
+ filePatterns: ["**/*.{ts,tsx,mts,cts,mjs,cjs,js}"],
288
+ suspiciousPatterns: [[
289
+ "executeScript",
290
+ "JSON.stringify",
291
+ /\barg\s*[:=]|["']arg["']\s*(?::|\]\s*[:=])/
292
+ ]],
293
+ prompt: [
294
+ "In Tailor SDK v2 the executeScript() arg option takes a JSON-serializable value",
295
+ "and is serialized internally, so a pre-stringified argument double-encodes. The",
296
+ "codemod already rewrote the direct form arg: JSON.stringify(X) to arg: X. Review",
297
+ "the executeScript calls in these files for cases it could not rewrite — where the",
298
+ "arg value is reached indirectly, for example:",
299
+ "- a variable holding a JSON.stringify(...) result (const s = JSON.stringify(x); ... arg: s)",
300
+ "- JSON.stringify(x, null, 2) or another multi-argument form",
301
+ "- an options object built or spread dynamically",
302
+ "",
303
+ "For each such call, pass the underlying value directly as arg (drop the",
304
+ "JSON.stringify wrapper) so executeScript serializes it once. Leave calls that",
305
+ "already pass a plain value unchanged."
306
+ ].join("\n"),
307
+ examples: [{
308
+ before: "await executeScript({ ...opts, arg: JSON.stringify({ a: 1 }) });",
309
+ after: "await executeScript({ ...opts, arg: { a: 1 } });"
310
+ }]
311
+ },
312
+ {
313
+ id: "v2/open-download-stream",
314
+ name: "openDownloadStream → downloadStream",
315
+ description: "The deprecated `openDownloadStream` file-streaming API is removed in v2. Use `downloadStream` for streamed file downloads. The generated file utilities now emit `downloadFileStream` (which calls `downloadStream` and returns `FileDownloadStreamResponse`) instead of the removed `openFileDownloadStream` helper.",
316
+ since: "1.0.0",
317
+ until: "2.0.0",
318
+ filePatterns: ["**/*.{ts,tsx,mts,cts,mjs,cjs,js}"],
319
+ suspiciousPatterns: ["openDownloadStream", "openFileDownloadStream"],
320
+ examples: [{
321
+ before: "const res = await openDownloadStream(namespace, typeName, fieldName, recordId);",
322
+ after: "const res = await downloadStream(namespace, typeName, fieldName, recordId);"
323
+ }],
324
+ prompt: [
325
+ "The openDownloadStream file-streaming API is removed in v2. Replace every call to",
326
+ "openDownloadStream with downloadStream (same arguments). If you used the generated",
327
+ "openFileDownloadStream helper, switch to downloadFileStream, which calls",
328
+ "downloadStream and returns FileDownloadStreamResponse."
329
+ ].join("\n")
330
+ },
331
+ {
332
+ id: "v2/runtime-globals-opt-in",
333
+ name: "Ambient runtime globals are opt-in",
334
+ description: "Importing `@tailor-platform/sdk` no longer activates the ambient `tailor.*` / `tailordb.*` global declarations. Normal SDK development does not need them — use the SDK APIs and the typed wrappers from `@tailor-platform/sdk/runtime`. Only if you relied on the ambient globals directly, add `import \"@tailor-platform/sdk/runtime/globals\"`. (The capital-cased `Tailordb.*` namespace is removed separately — see the `Tailordb → tailordb` codemod.)",
335
+ since: "1.0.0",
336
+ until: "2.0.0",
337
+ filePatterns: ["**/*.{ts,tsx,mts,cts}"],
338
+ suspiciousPatterns: [
339
+ "tailor.context",
340
+ "tailor.iconv",
341
+ "tailor.idp",
342
+ "tailor.workflow",
343
+ "tailor[",
344
+ "tailordb.Client",
345
+ "tailordb.CommandType",
346
+ "tailordb.QueryResult",
347
+ "tailordb.file",
348
+ "tailordb[",
349
+ "TailorDBFileError",
350
+ "TailorErrorItem",
351
+ "TailorErrorMessage",
352
+ "TailorErrors"
353
+ ],
354
+ examples: [{
355
+ caption: "Preferred: switch to the typed wrappers from `@tailor-platform/sdk/runtime` and drop the ambient globals:",
356
+ before: "const client = new tailor.idp.Client();",
357
+ after: "import { idp } from \"@tailor-platform/sdk/runtime\";\nconst client = new idp.Client({ namespace: \"my-namespace\" });"
358
+ }, {
359
+ caption: "Fallback: only if you must keep referencing the bare `tailor.*` names, opt into the global declarations:",
360
+ before: "const client = new tailor.idp.Client();",
361
+ after: "import \"@tailor-platform/sdk/runtime/globals\";\nconst client = new tailor.idp.Client();"
362
+ }],
363
+ prompt: [
364
+ "The v2 SDK no longer enables ambient Tailor runtime globals from",
365
+ "`@tailor-platform/sdk`. For each flagged file that uses `tailor.*`,",
366
+ "`tailordb.*`, or Tailor runtime error globals, prefer migrating to the",
367
+ "typed wrappers from `@tailor-platform/sdk/runtime` (e.g. replace",
368
+ "`new tailor.idp.Client()` with `import { idp } from \"@tailor-platform/sdk/runtime\"`",
369
+ "and `new idp.Client({ namespace })`). The wrappers are self-contained, so the",
370
+ "ambient globals are no longer needed.",
371
+ "",
372
+ "Only when the file must keep referencing the bare `tailor.*` names directly,",
373
+ "opt into the global declarations instead by adding one of these:",
374
+ "- per-file: `import \"@tailor-platform/sdk/runtime/globals\";`",
375
+ "- project-wide: `\"types\": [\"@tailor-platform/sdk/runtime/globals\"]` in",
376
+ " the relevant tsconfig compilerOptions",
377
+ "",
378
+ "Leave files unchanged when the matching name is local, imported from another",
379
+ "module, or appears only in comments or strings."
380
+ ].join("\n")
381
+ },
382
+ {
383
+ id: "v2/workflow-trigger-dispatch",
384
+ name: "Workflow .trigger() and trigger tests",
385
+ description: "Workflow job `.trigger()` now aligns with the platform runtime: it returns the job result directly instead of a Promise wrapper, and tests no longer run job bodies locally. Mock trigger responses with `mockWorkflow()` (`setJobHandler` / `enqueueResult`, assert via `triggeredJobs`), or use `runWorkflowLocally()` for a full-chain local run.",
386
+ since: "1.0.0",
387
+ until: "2.0.0",
388
+ suspiciousPatterns: [".trigger("],
389
+ examples: [{
390
+ caption: "Tests must mock the workflow runtime instead of running bodies locally:",
391
+ before: "const result = await orderJob.trigger({ id });\nexpect(result.status).toBe(\"done\");",
392
+ after: "using wf = mockWorkflow();\nwf.setJobHandler((jobName) => (jobName === \"order-job\" ? { status: \"done\" } : null));\nconst result = await orderJob.trigger({ id });\nexpect(result.status).toBe(\"done\");"
393
+ }],
394
+ prompt: [
395
+ "Workflow job .trigger() now uses the platform workflow runtime instead of running",
396
+ "the job body locally. In tests, acquire `using wf = mockWorkflow()` and provide",
397
+ "trigger responses (setJobHandler / enqueueResult), or use runWorkflowLocally() for a",
398
+ "full-chain local run; an unmocked trigger now throws. Outside tests, treat the",
399
+ "trigger result as the job output directly (no Promise wrapper to unwrap)."
400
+ ].join("\n")
401
+ },
402
+ {
403
+ id: "v2/cli-token-keyring-storage",
404
+ name: "CLI tokens stored in the OS keyring",
405
+ description: "CLI login tokens are stored in the OS keyring by default when available, falling back to the platform config file when it is not. No source change is required; re-login if you need tokens moved into the keyring.",
406
+ since: "1.0.0",
407
+ until: "2.0.0",
408
+ notice: true
409
+ },
410
+ {
411
+ id: "v2/cli-users-by-subject",
412
+ name: "CLI users keyed by subject ID",
413
+ description: "The CLI stores human users by their stable subject ID instead of email (email is kept for display). Legacy email-keyed entries are migrated automatically on the next login or token refresh. No source change is required.",
414
+ since: "1.0.0",
415
+ until: "2.0.0",
416
+ notice: true
417
+ },
418
+ {
419
+ id: "v2/function-logs-content-hash",
420
+ name: "function logs require a content hash for source mapping",
421
+ description: "`tailor-sdk function logs` maps stack traces against the function bundle only when the execution recorded a `contentHash`. Executions without one now show raw stack traces instead of mapped frames. No source change is required.",
422
+ since: "1.0.0",
423
+ until: "2.0.0",
424
+ notice: true
118
425
  }
119
426
  ];
120
427
  /**
@@ -148,6 +455,22 @@ const EXCLUDE_DIRS = new Set([
148
455
  ".git"
149
456
  ]);
150
457
  const ALLOWED_DOT_DIRS = new Set([".github", ".circleci"]);
458
+ const SOURCE_EXTENSIONS = new Set([
459
+ ".ts",
460
+ ".tsx",
461
+ ".mts",
462
+ ".cts",
463
+ ".js",
464
+ ".jsx",
465
+ ".mjs",
466
+ ".cjs"
467
+ ]);
468
+ const MASKED_SOURCE_NODE_KINDS = new Set([
469
+ "comment",
470
+ "string",
471
+ "regex",
472
+ "string_fragment"
473
+ ]);
151
474
  function shouldSkipDirectory(name) {
152
475
  return EXCLUDE_DIRS.has(name) || name.startsWith(".") && !ALLOWED_DOT_DIRS.has(name);
153
476
  }
@@ -198,9 +521,69 @@ async function loadTransform(scriptPath) {
198
521
  if (typeof mod.default !== "function") throw new Error(`Transform at ${scriptPath} does not have a default export function`);
199
522
  return mod.default;
200
523
  }
524
+ function contentForResidualMatching(relative, content) {
525
+ const ext = path.extname(relative).toLowerCase();
526
+ return SOURCE_EXTENSIONS.has(ext) ? maskSourceNonCode(relative, content) : content;
527
+ }
528
+ function sourceLang(relative) {
529
+ const ext = path.extname(relative).toLowerCase();
530
+ return ext === ".tsx" || ext === ".jsx" ? Lang.Tsx : Lang.TypeScript;
531
+ }
532
+ function collectMaskedRanges(root) {
533
+ const ranges = [];
534
+ const visit = (node) => {
535
+ if (MASKED_SOURCE_NODE_KINDS.has(node.kind())) {
536
+ const range = node.range();
537
+ ranges.push([range.start.index, range.end.index]);
538
+ return;
539
+ }
540
+ for (const child of node.children()) visit(child);
541
+ };
542
+ visit(root);
543
+ return ranges;
544
+ }
545
+ function maskSourceNonCode(relative, content) {
546
+ let ranges;
547
+ try {
548
+ ranges = collectMaskedRanges(parse(sourceLang(relative), content).root());
549
+ } catch {
550
+ return content;
551
+ }
552
+ ranges = ranges.toSorted(([a], [b]) => a - b);
553
+ const chars = content.split("");
554
+ for (const [start, end] of ranges) for (let i = start; i < end && i < chars.length; i++) if (chars[i] !== "\n" && chars[i] !== "\r") chars[i] = " ";
555
+ return chars.join("");
556
+ }
557
+ function isIdentifierChar(char) {
558
+ return char != null && /^[A-Za-z0-9_$]$/.test(char);
559
+ }
560
+ function matchesPattern(content, pattern) {
561
+ if (typeof pattern === "string") {
562
+ const checkLeft = isIdentifierChar(pattern[0]);
563
+ const checkRight = isIdentifierChar(pattern.at(-1));
564
+ let index = content.indexOf(pattern);
565
+ while (index !== -1) {
566
+ const before = index > 0 ? content[index - 1] : void 0;
567
+ const after = content[index + pattern.length];
568
+ if ((!checkLeft || !isIdentifierChar(before)) && (!checkRight || !isIdentifierChar(after))) return true;
569
+ index = content.indexOf(pattern, index + 1);
570
+ }
571
+ return false;
572
+ }
573
+ pattern.lastIndex = 0;
574
+ return pattern.test(content);
575
+ }
576
+ function patternLabel(pattern) {
577
+ return typeof pattern === "string" ? pattern : pattern.toString();
578
+ }
579
+ /** Resolve a residual pattern against content, returning its label when matched. */
580
+ function matchResidualPattern(content, pattern) {
581
+ if (!Array.isArray(pattern)) return matchesPattern(content, pattern) ? patternLabel(pattern) : null;
582
+ return pattern.every((p) => matchesPattern(content, p)) ? pattern.map((p) => patternLabel(p)).join(" + ") : null;
583
+ }
201
584
  function legacyPatternWarnings(relative, content, transforms) {
202
585
  return transforms.flatMap((lt) => {
203
- const found = lt.legacyPatterns.filter((p) => content.includes(p));
586
+ const found = lt.legacyPatterns.map((p) => matchResidualPattern(content, p)).filter((label) => label !== null);
204
587
  if (found.length === 0) return [];
205
588
  return [`${relative}: contains ${found.join(", ")} but was not migrated automatically (rule: ${lt.id}). Manual migration may be needed.`];
206
589
  });
@@ -222,15 +605,18 @@ async function runCodemods(codemods, targetPath, dryRun) {
222
605
  const patterns = codemod.filePatterns ?? DEFAULT_FILE_PATTERNS;
223
606
  loaded.push({
224
607
  id: codemod.id,
225
- transform: await loadTransform(scriptPath),
608
+ transform: scriptPath ? await loadTransform(scriptPath) : void 0,
226
609
  matches: picomatch(patterns, { dot: true }),
227
- legacyPatterns: codemod.legacyPatterns ?? []
610
+ legacyPatterns: codemod.legacyPatterns ?? [],
611
+ suspiciousPatterns: codemod.suspiciousPatterns ?? [],
612
+ prompt: codemod.prompt
228
613
  });
229
614
  }
230
615
  const filesModified = [];
231
616
  const warnings = [];
232
617
  const appliedCodemodIds = /* @__PURE__ */ new Set();
233
618
  const seen = /* @__PURE__ */ new Set();
619
+ const suspiciousByCodemod = /* @__PURE__ */ new Map();
234
620
  for await (const relative of walkFiles(targetPath)) {
235
621
  const absolute = path.resolve(targetPath, relative);
236
622
  if (seen.has(absolute)) continue;
@@ -245,6 +631,7 @@ async function runCodemods(codemods, targetPath, dryRun) {
245
631
  }
246
632
  let current = original;
247
633
  for (const lt of matchedTransforms) {
634
+ if (!lt.transform) continue;
248
635
  const result = await lt.transform(current, absolute);
249
636
  if (result != null) {
250
637
  current = result;
@@ -256,21 +643,94 @@ async function runCodemods(codemods, targetPath, dryRun) {
256
643
  if (dryRun) printDiff(absolute, original, current);
257
644
  else await fs.promises.writeFile(absolute, current, "utf-8");
258
645
  }
259
- warnings.push(...legacyPatternWarnings(relative, current, matchedTransforms));
646
+ const residualContent = contentForResidualMatching(relative, current);
647
+ warnings.push(...legacyPatternWarnings(relative, residualContent, matchedTransforms));
648
+ for (const lt of matchedTransforms) {
649
+ if (!lt.prompt || lt.suspiciousPatterns.length === 0) continue;
650
+ if (lt.suspiciousPatterns.some((p) => matchResidualPattern(residualContent, p) !== null)) {
651
+ const files = suspiciousByCodemod.get(lt.id) ?? [];
652
+ files.push(relative);
653
+ suspiciousByCodemod.set(lt.id, files);
654
+ }
655
+ }
656
+ }
657
+ const llmReviews = [];
658
+ for (const lt of loaded) {
659
+ if (!lt.prompt) continue;
660
+ if (lt.suspiciousPatterns.length > 0) {
661
+ const files = suspiciousByCodemod.get(lt.id);
662
+ if (files) llmReviews.push({
663
+ codemodId: lt.id,
664
+ prompt: lt.prompt,
665
+ files: files.toSorted()
666
+ });
667
+ } else if (lt.legacyPatterns.length === 0) llmReviews.push({
668
+ codemodId: lt.id,
669
+ prompt: lt.prompt,
670
+ files: []
671
+ });
260
672
  }
261
673
  return {
262
674
  changed: filesModified.length > 0,
263
675
  filesModified,
264
676
  warnings,
265
- appliedCodemodIds
677
+ appliedCodemodIds,
678
+ llmReviews
266
679
  };
267
680
  }
268
681
  //#endregion
269
682
  //#region src/index.ts
270
683
  const packageJson = await readPackageJSON(path.dirname(fileURLToPath(import.meta.url)) + "/..");
684
+ const listCommand = defineCommand({
685
+ name: "list",
686
+ description: "List the available codemod rules (id, name, kind, version range).",
687
+ args: z.object({}).strict(),
688
+ run: () => {
689
+ const rules = allCodemods.map((codemod) => ({
690
+ id: codemod.id,
691
+ name: codemod.name,
692
+ kind: codemod.notice ? "Notice" : automationLevel(codemod),
693
+ since: codemod.since,
694
+ until: codemod.until
695
+ }));
696
+ for (const rule of rules) process.stderr.write(` ${rule.id} [${rule.kind}] ${rule.name}\n`);
697
+ process.stdout.write(JSON.stringify(rules) + "\n");
698
+ }
699
+ });
700
+ /**
701
+ * Print an LLM-assisted review task to stderr: the flagged files plus the
702
+ * codemod's migration prompt, ready to hand to an LLM for the cases the
703
+ * deterministic transform could not complete on its own.
704
+ * @param review - The review task (codemod id, prompt, files)
705
+ */
706
+ function printLlmReview(review) {
707
+ const scope = review.files.length > 0 ? "the codemod cannot safely migrate these automatically" : "review the project for this manual change";
708
+ process.stderr.write(`\n🤖 LLM-assisted review suggested (${review.codemodId}) — ${scope}:\n`);
709
+ for (const file of review.files) process.stderr.write(` - ${file}\n`);
710
+ process.stderr.write(`\nPrompt for an LLM:\n${review.prompt.trim()}\n`);
711
+ }
271
712
  runMain(defineCommand({
272
713
  name: packageJson.name ?? "sdk-codemod",
273
714
  description: packageJson.description ?? "Codemod runner for Tailor Platform SDK upgrades",
715
+ subCommands: { list: listCommand },
716
+ notes: `Applies the codemods matching the \`--from\`/\`--to\` version range to the
717
+ \`--target\` directory, then writes a JSON summary to \`stdout\`:
718
+
719
+ - \`filesModified\`: files a codemod changed
720
+ - \`warnings\`: files that may still need manual migration
721
+ - \`llmReviews\`: changes the codemods could not fully migrate on their own. Each
722
+ entry has the affected \`files\` and a \`prompt\` — hand the prompt and files to
723
+ an LLM (or follow it yourself) to finish those cases.
724
+
725
+ Progress, warnings, and the LLM-review prompts are also printed to \`stderr\` in
726
+ human-readable form, so \`stdout\` stays pure JSON for piping.`,
727
+ examples: [{
728
+ cmd: "--from 1.64.0 --to 2.0.0",
729
+ desc: "Apply every codemod for the 1.64.0 -> 2.0.0 upgrade to the current project"
730
+ }, {
731
+ cmd: "--from 1.64.0 --to 2.0.0 --dry-run",
732
+ desc: "Preview the changes and any LLM-review prompts without writing files"
733
+ }],
274
734
  args: z.object({
275
735
  from: arg(z.string(), { description: "Source SDK version (the version before upgrade)" }),
276
736
  to: arg(z.string(), { description: "Target SDK version (the version after upgrade)" }),
@@ -289,7 +749,8 @@ runMain(defineCommand({
289
749
  codemodsSkipped: 0,
290
750
  filesModified: [],
291
751
  warnings: [],
292
- errors: []
752
+ errors: [],
753
+ llmReviews: []
293
754
  };
294
755
  if (codemods.length === 0) {
295
756
  process.stdout.write(JSON.stringify(output) + "\n");
@@ -297,7 +758,7 @@ runMain(defineCommand({
297
758
  }
298
759
  const codemodEntries = codemods.map((codemod) => ({
299
760
  codemod,
300
- scriptPath: resolveCodemodScript(codemod.scriptPath)
761
+ scriptPath: codemod.scriptPath ? resolveCodemodScript(codemod.scriptPath) : void 0
301
762
  }));
302
763
  for (const { codemod } of codemodEntries) process.stderr.write(`Running: ${codemod.name} - ${codemod.description}\n`);
303
764
  try {
@@ -306,8 +767,10 @@ runMain(defineCommand({
306
767
  output.codemodsSkipped = codemods.length - result.appliedCodemodIds.size;
307
768
  output.filesModified = result.filesModified;
308
769
  output.warnings = result.warnings;
770
+ output.llmReviews = result.llmReviews;
309
771
  if (result.changed) process.stderr.write(` ${result.filesModified.length} file(s) modified\n`);
310
772
  else process.stderr.write(" No changes needed\n");
773
+ for (const review of output.llmReviews) printLlmReview(review);
311
774
  } catch (error) {
312
775
  const message = error instanceof Error ? error.message : String(error);
313
776
  output.errors.push({