@tailor-platform/sdk-codemod 0.1.0

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.
@@ -0,0 +1,120 @@
1
+ import { Lang, parse } from "@ast-grep/napi";
2
+ //#region codemods/v2/define-generators-to-plugins/scripts/transform.ts
3
+ /**
4
+ * Known plugin mappings from package name to plugin function/import.
5
+ */
6
+ const PLUGIN_MAP = {
7
+ "@tailor-platform/kysely-type": {
8
+ functionName: "kyselyTypePlugin",
9
+ importPath: "@tailor-platform/sdk/plugin/kysely-type"
10
+ },
11
+ "@tailor-platform/seed": {
12
+ functionName: "seedPlugin",
13
+ importPath: "@tailor-platform/sdk/plugin/seed"
14
+ },
15
+ "@tailor-platform/enum-constants": {
16
+ functionName: "enumConstantsPlugin",
17
+ importPath: "@tailor-platform/sdk/plugin/enum-constants"
18
+ },
19
+ "@tailor-platform/file-utils": {
20
+ functionName: "fileUtilsPlugin",
21
+ importPath: "@tailor-platform/sdk/plugin/file-utils"
22
+ }
23
+ };
24
+ /**
25
+ * Transform defineGenerators() to definePlugins():
26
+ *
27
+ * 1. Rename `defineGenerators` → `definePlugins` in import and call
28
+ * 2. Transform tuple arguments `["pkg-name", config]` → `pluginFn(config)`
29
+ * 3. Add plugin imports from their respective SDK paths
30
+ * @param source - Source code to transform
31
+ * @returns Transformed source or null if no changes needed
32
+ */
33
+ function transform(source) {
34
+ const tree = parse(Lang.TypeScript, source).root();
35
+ if (!source.includes("defineGenerators")) return null;
36
+ if (!source.includes("@tailor-platform/sdk")) return null;
37
+ const edits = [];
38
+ const importsToAdd = /* @__PURE__ */ new Map();
39
+ const callNodes = tree.findAll({ rule: { pattern: "defineGenerators($$$ARGS)" } });
40
+ let totalArgs = 0;
41
+ let migratedArgs = 0;
42
+ for (const callNode of callNodes) {
43
+ const args = callNode.getMultipleMatches("ARGS");
44
+ for (const arg of args) {
45
+ if (!arg.isNamed() || arg.kind() === "comment") continue;
46
+ totalArgs++;
47
+ if (arg.kind() === "array") {
48
+ const children = arg.children().filter((c) => c.isNamed() && c.kind() !== "comment");
49
+ if (children.length >= 1) {
50
+ const mapping = PLUGIN_MAP[children[0].text().replace(/^["']|["']$/g, "")];
51
+ if (mapping) {
52
+ migratedArgs++;
53
+ importsToAdd.set(mapping.importPath, mapping.functionName);
54
+ const configNodes = children.slice(1);
55
+ const configText = configNodes.length > 0 ? configNodes.map((c) => c.text()).join(", ") : "";
56
+ const replacement = `${mapping.functionName}(${configText})`;
57
+ edits.push(arg.replace(replacement));
58
+ }
59
+ }
60
+ }
61
+ }
62
+ }
63
+ if (totalArgs > 0 && migratedArgs < totalArgs) return null;
64
+ const callIdentifiers = tree.findAll({ rule: {
65
+ pattern: "defineGenerators",
66
+ kind: "identifier",
67
+ inside: { kind: "call_expression" }
68
+ } });
69
+ for (const id of callIdentifiers) edits.push(id.replace("definePlugins"));
70
+ const sdkImportStatements = tree.findAll({ rule: {
71
+ kind: "import_statement",
72
+ has: {
73
+ kind: "string",
74
+ regex: "^[\"']@tailor-platform/sdk[\"']$"
75
+ }
76
+ } });
77
+ const hasDefinePlugins = sdkImportStatements.some((stmt) => stmt.findAll({ rule: { kind: "import_specifier" } }).some((s) => s.children().some((c) => c.kind() === "identifier" && c.text() === "definePlugins")));
78
+ for (const importStmt of sdkImportStatements) {
79
+ const specifiers = importStmt.findAll({ rule: { kind: "import_specifier" } });
80
+ for (const spec of specifiers) {
81
+ const identNode = spec.children().find((c) => c.kind() === "identifier" && c.text() === "defineGenerators");
82
+ if (!identNode) continue;
83
+ if (hasDefinePlugins) {
84
+ const specText = spec.text();
85
+ const importText = importStmt.text();
86
+ const idx = importText.indexOf(specText);
87
+ if (idx !== -1) {
88
+ const afterSpec = importText.slice(idx + specText.length);
89
+ const beforeSpec = importText.slice(0, idx);
90
+ let removeFrom = idx;
91
+ let removeTo = idx + specText.length;
92
+ if (afterSpec.match(/^\s*,/)) removeTo = idx + specText.length + (afterSpec.match(/^\s*,\s*/)[0]?.length ?? 0);
93
+ else if (beforeSpec.match(/,\s*$/)) removeFrom = idx - (beforeSpec.match(/,\s*$/)[0]?.length ?? 0);
94
+ const cleaned = importText.slice(0, removeFrom) + importText.slice(removeTo);
95
+ edits.push(importStmt.replace(cleaned));
96
+ }
97
+ } else edits.push(identNode.replace("definePlugins"));
98
+ }
99
+ }
100
+ if (edits.length === 0) return null;
101
+ let result = tree.commitEdits(edits);
102
+ if (importsToAdd.size > 0) {
103
+ const importLines = [];
104
+ for (const [importPath, functionName] of importsToAdd) {
105
+ const line = `import { ${functionName} } from "${importPath}";`;
106
+ if (new RegExp(`import\\s*\\{[^}]*\\b${functionName}\\b[^}]*\\}`, "m").test(result)) continue;
107
+ importLines.push(line);
108
+ }
109
+ importLines.sort();
110
+ if (importLines.length === 0) return result;
111
+ const match = /^(import\s+.*from\s+["']@tailor-platform\/sdk["'];?)$/m.exec(result);
112
+ if (match) {
113
+ const insertPos = (match.index ?? 0) + match[0].length;
114
+ result = result.slice(0, insertPos) + "\n" + importLines.join("\n") + result.slice(insertPos);
115
+ } else result = importLines.join("\n") + "\n" + result;
116
+ }
117
+ return result;
118
+ }
119
+ //#endregion
120
+ export { transform as default };
package/dist/index.js ADDED
@@ -0,0 +1,198 @@
1
+ #!/usr/bin/env node
2
+ import * as url from "node:url";
3
+ import { fileURLToPath } from "node:url";
4
+ import * as path from "pathe";
5
+ import { arg, defineCommand, runMain } from "politty";
6
+ import { readPackageJSON } from "pkg-types";
7
+ import { z } from "zod";
8
+ import { gte, lt, valid } from "semver";
9
+ import * as fs from "node:fs";
10
+ import { glob } from "node:fs/promises";
11
+ import chalk from "chalk";
12
+ import { structuredPatch } from "diff";
13
+ import picomatch from "picomatch";
14
+ //#region src/registry.ts
15
+ const CODEMODS_ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), "codemods");
16
+ const allCodemods = [{
17
+ id: "v2/define-generators-to-plugins",
18
+ name: "defineGenerators → definePlugins",
19
+ description: "Migrate defineGenerators() tuple syntax to definePlugins() with explicit plugin imports",
20
+ since: "1.0.0",
21
+ until: "2.0.0",
22
+ scriptPath: "v2/define-generators-to-plugins/scripts/transform.js"
23
+ }];
24
+ /**
25
+ * Resolve the absolute path to a codemod script.
26
+ * @param scriptPath - Relative path from the codemods root
27
+ * @returns Absolute path to the script file
28
+ */
29
+ function resolveCodemodScript(scriptPath) {
30
+ return path.resolve(CODEMODS_ROOT, scriptPath);
31
+ }
32
+ /**
33
+ * Get codemod packages applicable for a version range.
34
+ * A codemod applies when: since <= fromVersion < until <= toVersion
35
+ * @param fromVersion - Current SDK version (semver)
36
+ * @param toVersion - Target SDK version (semver)
37
+ * @returns Array of applicable codemod packages in registration order
38
+ */
39
+ function getApplicableCodemods(fromVersion, toVersion) {
40
+ if (!valid(fromVersion)) throw new Error(`Invalid fromVersion: ${fromVersion}`);
41
+ if (!valid(toVersion)) throw new Error(`Invalid toVersion: ${toVersion}`);
42
+ return allCodemods.filter((codemod) => gte(fromVersion, codemod.since) && lt(fromVersion, codemod.until) && gte(toVersion, codemod.until));
43
+ }
44
+ //#endregion
45
+ //#region src/runner.ts
46
+ /** Default file patterns for TypeScript files. */
47
+ const DEFAULT_FILE_PATTERNS = ["**/*.{ts,tsx,mts,cts}"];
48
+ /** Directory names always excluded from file scanning. */
49
+ const EXCLUDE_DIRS = new Set([
50
+ "node_modules",
51
+ "dist",
52
+ ".git"
53
+ ]);
54
+ /**
55
+ * Print a colorized unified diff for a single file to stderr.
56
+ * @param filePath - Absolute path to the file
57
+ * @param before - Original content
58
+ * @param after - Transformed content
59
+ */
60
+ function printDiff(filePath, before, after) {
61
+ const patch = structuredPatch(filePath, filePath, before, after, "", "", { context: 3 });
62
+ if (patch.hunks.length === 0) return;
63
+ process.stderr.write(`\n${chalk.bold(`--- ${filePath}`)}\n`);
64
+ process.stderr.write(`${chalk.bold(`+++ ${filePath}`)}\n`);
65
+ for (const hunk of patch.hunks) {
66
+ process.stderr.write(chalk.cyan(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@\n`));
67
+ for (const line of hunk.lines) if (line.startsWith("+")) process.stderr.write(`${chalk.green(line)}\n`);
68
+ else if (line.startsWith("-")) process.stderr.write(`${chalk.red(line)}\n`);
69
+ else process.stderr.write(`${line}\n`);
70
+ }
71
+ }
72
+ /**
73
+ * Load a transform module from a TypeScript file path.
74
+ * Expects the module to have a default export that is a TransformFn.
75
+ * @param scriptPath - Absolute path to the transform script
76
+ * @returns The transform function
77
+ */
78
+ async function loadTransform(scriptPath) {
79
+ const mod = await import(url.pathToFileURL(scriptPath).href);
80
+ if (typeof mod.default !== "function") throw new Error(`Transform at ${scriptPath} does not have a default export function`);
81
+ return mod.default;
82
+ }
83
+ /**
84
+ * Run multiple codemods on a project directory using in-memory chaining.
85
+ * Each file is processed through all transforms whose filePatterns match it.
86
+ * Later transforms see earlier transforms' output — even in dry-run mode.
87
+ *
88
+ * In dry-run mode, colorized diffs are printed to stderr.
89
+ * @param codemods - Codemod packages to run (with resolved script paths)
90
+ * @param targetPath - Project directory to transform
91
+ * @param dryRun - Whether to preview changes without writing
92
+ * @returns Combined result of all codemod executions
93
+ */
94
+ async function runCodemods(codemods, targetPath, dryRun) {
95
+ const loaded = [];
96
+ for (const { codemod, scriptPath } of codemods) {
97
+ const patterns = codemod.filePatterns ?? DEFAULT_FILE_PATTERNS;
98
+ loaded.push({
99
+ id: codemod.id,
100
+ transform: await loadTransform(scriptPath),
101
+ matches: picomatch(patterns)
102
+ });
103
+ }
104
+ const allPatterns = /* @__PURE__ */ new Set();
105
+ for (const { codemod } of codemods) for (const p of codemod.filePatterns ?? DEFAULT_FILE_PATTERNS) allPatterns.add(p);
106
+ const filesModified = [];
107
+ const warnings = [];
108
+ const seen = /* @__PURE__ */ new Set();
109
+ for (const pattern of allPatterns) for await (const relative of glob(pattern, {
110
+ cwd: targetPath,
111
+ exclude: (name) => EXCLUDE_DIRS.has(name)
112
+ })) {
113
+ const absolute = path.resolve(targetPath, relative);
114
+ if (seen.has(absolute)) continue;
115
+ seen.add(absolute);
116
+ let original;
117
+ try {
118
+ original = await fs.promises.readFile(absolute, "utf-8");
119
+ } catch {
120
+ continue;
121
+ }
122
+ let current = original;
123
+ const matchedRules = [];
124
+ for (const { id, transform, matches } of loaded) {
125
+ if (!matches(relative)) continue;
126
+ matchedRules.push(id);
127
+ const result = await transform(current, absolute);
128
+ if (result != null) current = result;
129
+ }
130
+ if (current !== original) {
131
+ filesModified.push(absolute);
132
+ if (dryRun) printDiff(absolute, original, current);
133
+ else await fs.promises.writeFile(absolute, current, "utf-8");
134
+ } else if (matchedRules.length > 0 && original.includes("defineGenerators")) warnings.push(`${relative}: contains defineGenerators but was not migrated automatically (matched rules: ${matchedRules.join(", ")}). Manual migration may be needed.`);
135
+ }
136
+ return {
137
+ changed: filesModified.length > 0,
138
+ filesModified,
139
+ warnings
140
+ };
141
+ }
142
+ //#endregion
143
+ //#region src/index.ts
144
+ const packageJson = await readPackageJSON(path.dirname(fileURLToPath(import.meta.url)) + "/..");
145
+ runMain(defineCommand({
146
+ name: packageJson.name ?? "sdk-codemod",
147
+ description: packageJson.description ?? "Codemod runner for Tailor Platform SDK upgrades",
148
+ args: z.object({
149
+ from: arg(z.string(), { description: "Source SDK version (the version before upgrade)" }),
150
+ to: arg(z.string(), { description: "Target SDK version (the version after upgrade)" }),
151
+ target: arg(z.string().default("."), { description: "Project directory to transform" }),
152
+ "dry-run": arg(z.boolean().default(false), {
153
+ alias: "d",
154
+ description: "Preview changes without modifying files"
155
+ })
156
+ }).strict(),
157
+ run: async (args) => {
158
+ const targetPath = path.resolve(args.target);
159
+ const dryRun = args["dry-run"];
160
+ const codemods = getApplicableCodemods(args.from, args.to);
161
+ const output = {
162
+ codemodsApplied: 0,
163
+ codemodsSkipped: 0,
164
+ filesModified: [],
165
+ warnings: [],
166
+ errors: []
167
+ };
168
+ if (codemods.length === 0) {
169
+ process.stdout.write(JSON.stringify(output) + "\n");
170
+ return;
171
+ }
172
+ const codemodEntries = codemods.map((codemod) => ({
173
+ codemod,
174
+ scriptPath: resolveCodemodScript(codemod.scriptPath)
175
+ }));
176
+ for (const { codemod } of codemodEntries) process.stderr.write(`Running: ${codemod.name} - ${codemod.description}\n`);
177
+ try {
178
+ const result = await runCodemods(codemodEntries, targetPath, dryRun);
179
+ output.codemodsApplied = result.changed ? codemods.length : 0;
180
+ output.codemodsSkipped = result.changed ? 0 : codemods.length;
181
+ output.filesModified = result.filesModified;
182
+ output.warnings = result.warnings;
183
+ if (result.changed) process.stderr.write(` ${result.filesModified.length} file(s) modified\n`);
184
+ else process.stderr.write(" No changes needed\n");
185
+ } catch (error) {
186
+ const message = error instanceof Error ? error.message : String(error);
187
+ output.errors.push({
188
+ codemodId: "pipeline",
189
+ message
190
+ });
191
+ process.stderr.write(` Failed: ${message}\n`);
192
+ }
193
+ process.stdout.write(JSON.stringify(output) + "\n");
194
+ if (output.errors.length > 0) process.exit(1);
195
+ }
196
+ }), { version: packageJson.version });
197
+ //#endregion
198
+ export {};
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@tailor-platform/sdk-codemod",
3
+ "version": "0.1.0",
4
+ "description": "Codemod runner for Tailor Platform SDK upgrades",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/tailor-platform/sdk.git",
9
+ "directory": "packages/sdk-codemod"
10
+ },
11
+ "bin": "dist/index.js",
12
+ "files": [
13
+ "CHANGELOG.md",
14
+ "dist",
15
+ "LICENSE",
16
+ "README.md"
17
+ ],
18
+ "type": "module",
19
+ "scripts": {
20
+ "build": "tsdown",
21
+ "lint": "oxlint . && eslint --cache .",
22
+ "lint:fix": "oxlint --fix . && eslint --cache --fix .",
23
+ "typecheck": "tsc --noEmit",
24
+ "test": "vitest",
25
+ "prepublish": "pnpm run build",
26
+ "publint": "publint --strict"
27
+ },
28
+ "dependencies": {
29
+ "@ast-grep/napi": "^0.42.0",
30
+ "chalk": "5.6.2",
31
+ "diff": "8.0.4",
32
+ "pathe": "2.0.3",
33
+ "picomatch": "4.0.4",
34
+ "pkg-types": "2.3.0",
35
+ "politty": "0.4.12",
36
+ "semver": "7.7.4",
37
+ "zod": "4.3.6"
38
+ },
39
+ "devDependencies": {
40
+ "@eslint/js": "10.0.1",
41
+ "@types/node": "24.12.0",
42
+ "@types/picomatch": "4.0.2",
43
+ "@types/semver": "7.7.1",
44
+ "eslint": "10.1.0",
45
+ "eslint-plugin-oxlint": "1.57.0",
46
+ "oxlint": "1.57.0",
47
+ "tsdown": "0.21.7",
48
+ "typescript": "5.9.3",
49
+ "typescript-eslint": "8.57.2",
50
+ "vitest": "4.1.2"
51
+ }
52
+ }