@tailor-platform/sdk-codemod 0.3.0-next.0 → 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,12 +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 { glob } from "node:fs/promises";
10
+ import { Lang, parse } from "@ast-grep/napi";
11
11
  import chalk from "chalk";
12
12
  import { structuredPatch } from "diff";
13
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
14
30
  //#region src/registry.ts
15
31
  const CODEMODS_ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), "codemods");
32
+ /** All registered codemods, in registration order. */
16
33
  const allCodemods = [
17
34
  {
18
35
  id: "v2/define-generators-to-plugins",
@@ -21,7 +38,29 @@ const allCodemods = [
21
38
  since: "1.0.0",
22
39
  until: "2.0.0",
23
40
  scriptPath: "v2/define-generators-to-plugins/scripts/transform.js",
24
- 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")
25
64
  },
26
65
  {
27
66
  id: "v2/plugin-cli-import",
@@ -29,7 +68,11 @@ const allCodemods = [
29
68
  description: "Rewrite deprecated plugin re-export imports (kyselyTypePlugin, enumConstantsPlugin, fileUtilsPlugin, seedPlugin) from `@tailor-platform/sdk/cli` to their dedicated plugin subpaths",
30
69
  since: "1.0.0",
31
70
  until: "2.0.0",
32
- 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
+ }]
33
76
  },
34
77
  {
35
78
  id: "v2/test-run-arg-input",
@@ -42,7 +85,12 @@ const allCodemods = [
42
85
  "**/package.json",
43
86
  "**/*.{sh,bash,zsh}",
44
87
  "**/*.md"
45
- ]
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
+ }]
46
94
  },
47
95
  {
48
96
  id: "v2/sdk-skills-shim",
@@ -56,39 +104,87 @@ const allCodemods = [
56
104
  "**/*.{sh,bash,zsh,yml,yaml}",
57
105
  "**/*.md"
58
106
  ],
59
- 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")
60
119
  },
61
120
  {
62
121
  id: "v2/principal-unify",
63
- name: "Unify TailorUser/TailorActor/TailorInvoker → TailorPrincipal",
64
- 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`",
65
124
  since: "1.0.0",
66
125
  until: "2.0.0",
67
126
  scriptPath: "v2/principal-unify/scripts/transform.js",
68
127
  legacyPatterns: [
69
128
  "TailorUser",
70
129
  "TailorActor",
130
+ "TailorActorType",
71
131
  "TailorInvoker",
72
132
  "unauthenticatedTailorUser"
73
- ]
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")
74
164
  },
75
165
  {
76
166
  id: "v2/apply-to-deploy",
77
167
  name: "tailor-sdk apply → tailor-sdk deploy",
78
- description: "Rewrite `tailor-sdk apply` invocations in package.json scripts, shell scripts, CI configs, and docs to the v2-recommended `tailor-sdk deploy` alias",
168
+ description: "Rewrite `tailor-sdk apply` invocations in package.json scripts, shell scripts, CI configs, and docs to the canonical v2 `tailor-sdk deploy` command",
79
169
  since: "1.0.0",
80
170
  until: "2.0.0",
81
171
  scriptPath: "v2/apply-to-deploy/scripts/transform.js",
82
172
  filePatterns: [
83
173
  "**/package.json",
84
174
  "**/*.{sh,bash,zsh,yml,yaml}",
175
+ "**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}",
85
176
  "**/*.md"
86
- ]
177
+ ],
178
+ examples: [{
179
+ lang: "sh",
180
+ before: "tailor-sdk apply --profile prod",
181
+ after: "tailor-sdk deploy --profile prod"
182
+ }]
87
183
  },
88
184
  {
89
185
  id: "v2/cli-rename",
90
- name: "v2 CLI rename (single-word commands)",
91
- description: "Rewrite `tailor-sdk crash-report` invocations to the v2 single-word `tailor-sdk crashreport` form across package.json scripts, shell scripts, CI configs, and docs",
186
+ name: "v2 CLI rename",
187
+ description: "Rewrite `tailor-sdk crash-report` to `tailor-sdk crashreport` and `--machineuser` to `--machine-user` across package.json scripts, shell scripts, CI configs, and docs",
92
188
  since: "1.0.0",
93
189
  until: "2.0.0",
94
190
  scriptPath: "v2/cli-rename/scripts/transform.js",
@@ -96,25 +192,236 @@ const allCodemods = [
96
192
  "**/package.json",
97
193
  "**/*.{sh,bash,zsh,yml,yaml}",
98
194
  "**/*.md"
99
- ]
195
+ ],
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 deprecated capital-cased `Tailordb` ambient namespace (`Tailordb.QueryResult`, `Tailordb.CommandType`, `Tailordb.Client`, `typeof Tailordb.Client`) to the new lowercase `tailordb.*` namespace re-published by the SDK in place of `@tailor-platform/function-types`.",
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
  /**
@@ -147,6 +454,44 @@ const EXCLUDE_DIRS = new Set([
147
454
  "dist",
148
455
  ".git"
149
456
  ]);
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
+ ]);
474
+ function shouldSkipDirectory(name) {
475
+ return EXCLUDE_DIRS.has(name) || name.startsWith(".") && !ALLOWED_DOT_DIRS.has(name);
476
+ }
477
+ async function* walkFiles(root, relativeDir = "") {
478
+ const absoluteDir = path.join(root, relativeDir);
479
+ let entries;
480
+ try {
481
+ entries = await fs.promises.readdir(absoluteDir, { withFileTypes: true });
482
+ } catch {
483
+ return;
484
+ }
485
+ for (const entry of entries) {
486
+ const relative = relativeDir ? path.join(relativeDir, entry.name) : entry.name;
487
+ if (entry.isDirectory()) {
488
+ if (shouldSkipDirectory(entry.name)) continue;
489
+ yield* walkFiles(root, relative);
490
+ continue;
491
+ }
492
+ if (entry.isFile()) yield relative;
493
+ }
494
+ }
150
495
  /**
151
496
  * Print a colorized unified diff for a single file to stderr.
152
497
  * @param filePath - Absolute path to the file
@@ -176,6 +521,73 @@ async function loadTransform(scriptPath) {
176
521
  if (typeof mod.default !== "function") throw new Error(`Transform at ${scriptPath} does not have a default export function`);
177
522
  return mod.default;
178
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
+ }
584
+ function legacyPatternWarnings(relative, content, transforms) {
585
+ return transforms.flatMap((lt) => {
586
+ const found = lt.legacyPatterns.map((p) => matchResidualPattern(content, p)).filter((label) => label !== null);
587
+ if (found.length === 0) return [];
588
+ return [`${relative}: contains ${found.join(", ")} but was not migrated automatically (rule: ${lt.id}). Manual migration may be needed.`];
589
+ });
590
+ }
179
591
  /**
180
592
  * Run multiple codemods on a project directory using in-memory chaining.
181
593
  * Each file is processed through all transforms whose filePatterns match it.
@@ -193,24 +605,24 @@ async function runCodemods(codemods, targetPath, dryRun) {
193
605
  const patterns = codemod.filePatterns ?? DEFAULT_FILE_PATTERNS;
194
606
  loaded.push({
195
607
  id: codemod.id,
196
- transform: await loadTransform(scriptPath),
197
- matches: picomatch(patterns),
198
- legacyPatterns: codemod.legacyPatterns ?? []
608
+ transform: scriptPath ? await loadTransform(scriptPath) : void 0,
609
+ matches: picomatch(patterns, { dot: true }),
610
+ legacyPatterns: codemod.legacyPatterns ?? [],
611
+ suspiciousPatterns: codemod.suspiciousPatterns ?? [],
612
+ prompt: codemod.prompt
199
613
  });
200
614
  }
201
- const allPatterns = /* @__PURE__ */ new Set();
202
- for (const { codemod } of codemods) for (const p of codemod.filePatterns ?? DEFAULT_FILE_PATTERNS) allPatterns.add(p);
203
615
  const filesModified = [];
204
616
  const warnings = [];
205
617
  const appliedCodemodIds = /* @__PURE__ */ new Set();
206
618
  const seen = /* @__PURE__ */ new Set();
207
- for (const pattern of allPatterns) for await (const relative of glob(pattern, {
208
- cwd: targetPath,
209
- exclude: (name) => EXCLUDE_DIRS.has(name)
210
- })) {
619
+ const suspiciousByCodemod = /* @__PURE__ */ new Map();
620
+ for await (const relative of walkFiles(targetPath)) {
211
621
  const absolute = path.resolve(targetPath, relative);
212
622
  if (seen.has(absolute)) continue;
213
623
  seen.add(absolute);
624
+ const matchedTransforms = loaded.filter((lt) => lt.matches(relative));
625
+ if (matchedTransforms.length === 0) continue;
214
626
  let original;
215
627
  try {
216
628
  original = await fs.promises.readFile(absolute, "utf-8");
@@ -218,10 +630,8 @@ async function runCodemods(codemods, targetPath, dryRun) {
218
630
  continue;
219
631
  }
220
632
  let current = original;
221
- const matchedTransforms = [];
222
- for (const lt of loaded) {
223
- if (!lt.matches(relative)) continue;
224
- matchedTransforms.push(lt);
633
+ for (const lt of matchedTransforms) {
634
+ if (!lt.transform) continue;
225
635
  const result = await lt.transform(current, absolute);
226
636
  if (result != null) {
227
637
  current = result;
@@ -232,24 +642,95 @@ async function runCodemods(codemods, targetPath, dryRun) {
232
642
  filesModified.push(absolute);
233
643
  if (dryRun) printDiff(absolute, original, current);
234
644
  else await fs.promises.writeFile(absolute, current, "utf-8");
235
- } else for (const lt of matchedTransforms) {
236
- const found = lt.legacyPatterns.filter((p) => original.includes(p));
237
- if (found.length > 0) warnings.push(`${relative}: contains ${found.join(", ")} but was not migrated automatically (rule: ${lt.id}). Manual migration may be needed.`);
645
+ }
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
+ }
238
655
  }
239
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
+ });
672
+ }
240
673
  return {
241
674
  changed: filesModified.length > 0,
242
675
  filesModified,
243
676
  warnings,
244
- appliedCodemodIds
677
+ appliedCodemodIds,
678
+ llmReviews
245
679
  };
246
680
  }
247
681
  //#endregion
248
682
  //#region src/index.ts
249
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
+ }
250
712
  runMain(defineCommand({
251
713
  name: packageJson.name ?? "sdk-codemod",
252
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
+ }],
253
734
  args: z.object({
254
735
  from: arg(z.string(), { description: "Source SDK version (the version before upgrade)" }),
255
736
  to: arg(z.string(), { description: "Target SDK version (the version after upgrade)" }),
@@ -268,7 +749,8 @@ runMain(defineCommand({
268
749
  codemodsSkipped: 0,
269
750
  filesModified: [],
270
751
  warnings: [],
271
- errors: []
752
+ errors: [],
753
+ llmReviews: []
272
754
  };
273
755
  if (codemods.length === 0) {
274
756
  process.stdout.write(JSON.stringify(output) + "\n");
@@ -276,7 +758,7 @@ runMain(defineCommand({
276
758
  }
277
759
  const codemodEntries = codemods.map((codemod) => ({
278
760
  codemod,
279
- scriptPath: resolveCodemodScript(codemod.scriptPath)
761
+ scriptPath: codemod.scriptPath ? resolveCodemodScript(codemod.scriptPath) : void 0
280
762
  }));
281
763
  for (const { codemod } of codemodEntries) process.stderr.write(`Running: ${codemod.name} - ${codemod.description}\n`);
282
764
  try {
@@ -285,8 +767,10 @@ runMain(defineCommand({
285
767
  output.codemodsSkipped = codemods.length - result.appliedCodemodIds.size;
286
768
  output.filesModified = result.filesModified;
287
769
  output.warnings = result.warnings;
770
+ output.llmReviews = result.llmReviews;
288
771
  if (result.changed) process.stderr.write(` ${result.filesModified.length} file(s) modified\n`);
289
772
  else process.stderr.write(" No changes needed\n");
773
+ for (const review of output.llmReviews) printLlmReview(review);
290
774
  } catch (error) {
291
775
  const message = error instanceof Error ? error.message : String(error);
292
776
  output.errors.push({