UNPKG

lemon-devkit

Version:

Lemon Serverless Micro-Service Platform for local development

400 lines 18.7 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.runMigrate = void 0; /** * `field-migrate.ts` * - `ts-transformer-keys`의 `keys<T>()`를 registry 기반 호출로 바꾸는 one-shot codemod. * * **NOTE** * - `CallExpression`의 callee만 AST rewrite 한다. `.filter(...)`, `as` cast, * `filterFields(...)`, indentation은 최대한 유지한다. * - 최초 `gen` 전에도 compile 되도록 `--out`에 bootstrap stub을 쓴다. * - registry 이름은 여기서 생성하고 source에 고정한다. `gen`은 그 이름을 그대로 보존한다. * - 파일 안에 `fieldKeys` 충돌이 있으면 `fieldKeys as lemonFieldKeys`로 alias 한다. * * @author Claire <claire@lemoncloud.io> * @date 2026-04-17 added legacy field migration codemod. * @copyright (C) lemoncloud.io 2026 - All Rights Reserved. */ const fs = __importStar(require("fs")); const path = __importStar(require("path")); const ts_morph_1 = require("ts-morph"); const field_derive_name_1 = require("./field-derive-name"); const field_gen_1 = require("./field-gen"); const toPosix = (p) => p.split(path.sep).join('/'); const relPathOf = (cwd, abs) => toPosix(path.relative(cwd, abs)); const isSpec = (relPath) => /\.spec\.(t|j)sx?$/.test(relPath); /** `--paths` glob을 project source file 절대 경로 set으로 변환 */ const sourceFilesForPaths = (project, cwd, paths) => new Set(paths.flatMap(pat => { const files = project.getSourceFiles(pat); if (files.length > 0 || path.isAbsolute(pat)) return files.map(sf => sf.getFilePath()); return project.getSourceFiles(path.join(cwd, pat)).map(sf => sf.getFilePath()); })); /** * 호출식을 감싸는 가장 가까운 declaration 이름을 찾는다. * * - 단순 타입명은 `<T>`에서 바로 이름을 만들 수 있음. * - `keys<A & B>()` 같은 복합 타입은 주변 변수/프로퍼티명이 있어야 `postMix`처럼 읽히는 이름을 만들 수 있음. */ const enclosingContextOf = (call) => { const walk = (node) => { if (!node) return undefined; switch (node.getKind()) { case ts_morph_1.SyntaxKind.PropertyAssignment: { return node.asKindOrThrow(ts_morph_1.SyntaxKind.PropertyAssignment).getNameNode().getText(); } case ts_morph_1.SyntaxKind.VariableDeclaration: { return node.asKindOrThrow(ts_morph_1.SyntaxKind.VariableDeclaration).getNameNode().getText(); } case ts_morph_1.SyntaxKind.MethodDeclaration: { return node.asKindOrThrow(ts_morph_1.SyntaxKind.MethodDeclaration).getNameNode().getText(); } case ts_morph_1.SyntaxKind.FunctionDeclaration: { return node.asKindOrThrow(ts_morph_1.SyntaxKind.FunctionDeclaration).getNameNode()?.getText(); } case ts_morph_1.SyntaxKind.PropertyDeclaration: { return node.asKindOrThrow(ts_morph_1.SyntaxKind.PropertyDeclaration).getNameNode().getText(); } } return walk(node.getParent()); }; return walk(call.getParent()); }; /** * `ts-transformer-keys`의 `keys` import를 찾는다. * * 반환되는 `local` 값은 alias를 반영한다. * - ex) `keys as typeKeys`도 plain `keys`와 같은 경로로 rewrite 가능함. */ const findLegacyImport = (sf) => { for (const imp of sf.getImportDeclarations()) { if (imp.getModuleSpecifierValue() !== 'ts-transformer-keys') continue; for (const named of imp.getNamedImports()) { if (named.getName() !== 'keys') continue; const alias = named.getAliasNode(); return { local: alias ? alias.getText() : 'keys', decl: imp }; } } return undefined; }; /** * 원하는 registry binding 이름이 현재 파일 scope에서 충돌하는지 확인한다. * * generated import는 top-level binding이므로 import binding과 top-level 선언만 확인한다. */ const conflictsWithFieldKeys = (sf, name) => { for (const imp of sf.getImportDeclarations()) { for (const named of imp.getNamedImports()) { const local = named.getAliasNode()?.getText() ?? named.getName(); if (local === name) return true; } const def = imp.getDefaultImport(); if (def && def.getText() === name) return true; const ns = imp.getNamespaceImport(); if (ns && ns.getText() === name) return true; } for (const stmt of sf.getStatements()) { if (stmt.getKind() === ts_morph_1.SyntaxKind.VariableStatement) { const vs = stmt.asKindOrThrow(ts_morph_1.SyntaxKind.VariableStatement); for (const d of vs.getDeclarationList().getDeclarations()) { if (d.getNameNode().getText() === name) return true; } } if (stmt.getKind() === ts_morph_1.SyntaxKind.FunctionDeclaration) { const fd = stmt.asKindOrThrow(ts_morph_1.SyntaxKind.FunctionDeclaration); if (fd.getNameNode()?.getText() === name) return true; } if (stmt.getKind() === ts_morph_1.SyntaxKind.ClassDeclaration) { const cd = stmt.asKindOrThrow(ts_morph_1.SyntaxKind.ClassDeclaration); if (cd.getNameNode()?.getText() === name) return true; } } return false; }; /** * source file 기준의 registry import module specifier를 계산한다. * - `.ts` 확장자는 제거한다. */ const registrySpecifier = (sfPath, outAbs) => { const spec = toPosix(path.relative(path.dirname(sfPath), outAbs)).replace(/\.ts$/, ''); return spec.startsWith('.') ? spec : `./${spec}`; }; /** 기존 파일의 import quote style을 새 registry import에도 맞춘다. */ const quoteKindOf = (sf) => sf.getImportDeclarations()[0]?.getModuleSpecifier().getQuoteKind() ?? ts_morph_1.QuoteKind.Single; /** * registry를 가리키는 `fieldKeys` import를 추가하거나 기존 import에 병합한다. * - 반환값은 해당 파일에서 실제 사용할 local binding 이름. * * 파일에 이미 `fieldKeys`가 있으면 `lemonFieldKeys`로 alias 하고, * 해당 파일의 모든 rewrite는 alias를 사용한다. */ const ensureRegistryImport = (sf, outAbs) => { const spec = registrySpecifier(sf.getFilePath(), outAbs); sf.getProject().manipulationSettings.set({ quoteKind: quoteKindOf(sf) }); //* 이미 `out`을 가리키는 registry import가 있으면 재사용한다. for (const imp of sf.getImportDeclarations()) { const t = imp.getModuleSpecifierSourceFile(); if (!t && imp.getModuleSpecifierValue() !== spec) continue; const matchesByResolve = t && path.resolve(t.getFilePath()) === outAbs; const matchesBySpec = imp.getModuleSpecifierValue() === spec; if (!matchesByResolve && !matchesBySpec) continue; for (const named of imp.getNamedImports()) { if (named.getName() !== 'fieldKeys') continue; const alias = named.getAliasNode(); return alias ? alias.getText() : 'fieldKeys'; } //* registry import는 있지만 named list에 `fieldKeys`가 없으면 추가한다. const desiredLocal = conflictsWithFieldKeys(sf, 'fieldKeys') ? 'lemonFieldKeys' : 'fieldKeys'; imp.addNamedImport(desiredLocal === 'fieldKeys' ? { name: 'fieldKeys' } : { name: 'fieldKeys', alias: desiredLocal }); return desiredLocal; } //* registry import가 없으면 안전한 local binding으로 새 import를 추가한다. const desiredLocal = conflictsWithFieldKeys(sf, 'fieldKeys') ? 'lemonFieldKeys' : 'fieldKeys'; sf.addImportDeclaration({ moduleSpecifier: spec, namedImports: [ desiredLocal === 'fieldKeys' ? { name: 'fieldKeys' } : { name: 'fieldKeys', alias: desiredLocal }, ], }); return desiredLocal; }; /** 변경 전/후 source text를 사람이 확인하기 쉬운 짧은 diff로 만든다. */ const renderDiff = (relPath, before, after) => { const beforeLines = before.split('\n'); const afterLines = after.split('\n'); const prefix = beforeLines.findIndex((line, i) => line !== afterLines[i]); if (prefix < 0) return ''; const commonTail = [...beforeLines] .reverse() .findIndex((line, i) => line !== afterLines[afterLines.length - 1 - i]); const tail = commonTail < 0 ? 0 : commonTail; const removed = beforeLines.slice(prefix, beforeLines.length - tail).map(line => `-${line}`); const added = afterLines.slice(prefix, afterLines.length - tail).map(line => `+${line}`); return [`--- ${relPath}`, `+++ ${relPath}`, `@@`, ...removed, ...added].join('\n'); }; /** `--update-tsconfig`가 명시된 경우 legacy transformer plugin을 제거한다. */ const updateTsconfig = (tsconfigPath, dryRun) => { const raw = fs.readFileSync(tsconfigPath, 'utf8'); const json = JSON.parse(raw); const plugins = json?.compilerOptions?.plugins; if (!Array.isArray(plugins)) return false; const nextPlugins = plugins.filter((p) => p?.transform !== 'ts-transformer-keys/transformer'); if (nextPlugins.length === plugins.length) return false; const compilerOptions = { ...json.compilerOptions, plugins: nextPlugins }; const nextJson = { ...json, compilerOptions }; if (nextPlugins.length === 0) delete nextJson.compilerOptions.plugins; if (!dryRun) fs.writeFileSync(tsconfigPath, `${JSON.stringify(nextJson, null, 4)}\n`, 'utf8'); return true; }; /** * legacy import에서 `keys`만 제거한다. * * - mixed import는 유지한다. ex) `import { keys, other }` => `import { other }` * - `keys`만 import하던 선언은 통째로 제거한다. */ const stripLegacyKeysImport = (decl) => { const named = decl.getNamedImports(); const toRemove = named.find(n => n.getName() === 'keys'); if (!toRemove) return; toRemove.remove(); if (decl.getNamedImports().length === 0 && !decl.getDefaultImport() && !decl.getNamespaceImport()) { decl.remove(); } }; /** * `<T>`가 enclosing generic parameter라면 true. * * generated registry는 concrete property 이름이 필요하다. * generic parameter는 gen 시점에 field list가 고정되지 않으므로 수동 처리 또는 `--allow-skips`가 필요함. */ const isGenericParameter = (call, typeArgText) => { //* 호출 위치에서 위로 올라가며 enclosing declaration만 확인한다. //* `A & B` 같은 복합 타입은 여기서 match 되지 않고 통과한다. if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(typeArgText.trim())) return false; const walk = (node) => { if (!node) return false; const tps = node.getTypeParameters?.(); if (tps && tps.some(tp => tp.getName() === typeArgText)) return true; return walk(node.getParent()); }; return walk(call.getParent()); }; /** * legacy `keys<T>()` 호출을 named registry 호출로 rewrite 한다. * * **처리 순서** * 1. `tsconfig`로 consumer TypeScript project를 로드한다. * 2. `ts-transformer-keys`에서 `keys`를 import한 파일을 찾는다. * 3. 안정적인 이름을 생성하고 call expression target만 바꾼다. * 4. registry import를 추가하고, 가능하면 legacy import를 제거한다. * 5. migrated source가 compile 되도록 bootstrap registry stub을 쓴다. */ const runMigrate = (opts) => { const cwd = opts.cwd ? path.resolve(opts.cwd) : path.resolve(path.dirname(opts.tsconfig)); const tsconfigPath = path.resolve(opts.tsconfig); const outAbs = path.resolve(cwd, opts.out); const project = new ts_morph_1.Project({ tsConfigFilePath: tsconfigPath }); if (opts.includeSpec) { project.addSourceFilesAtPaths(path.join(cwd, 'src/**/*.spec.ts')); } if (fs.existsSync(outAbs)) { project.addSourceFileAtPathIfExists(outAbs); } const shouldWriteStub = !fs.existsSync(outAbs); const wroteStub = shouldWriteStub && !opts.dryRun; const allowedPaths = opts.paths && opts.paths.length > 0 ? sourceFilesForPaths(project, cwd, opts.paths) : undefined; //* STEP.1 전체 TypeScript project는 유지하고, rewrite scan 대상만 좁힌다. const scanFiles = project.getSourceFiles().filter(sf => { const p = sf.getFilePath(); if (p.includes(`${path.sep}node_modules${path.sep}`)) return false; if (path.resolve(p) === outAbs) return false; if (!opts.includeSpec && isSpec(relPathOf(cwd, p))) return false; if (allowedPaths && !allowedPaths.has(p)) return false; return true; }); const rewrites = []; const skipped = []; const changedFiles = new Set(); const takenNames = new Set(); const originalTextByPath = new Map(scanFiles.map(sf => [String(sf.getFilePath()), sf.getFullText()])); const sourceFileByPath = new Map(scanFiles.map(sf => [String(sf.getFilePath()), sf])); for (const sf of scanFiles) { const legacy = findLegacyImport(sf); if (!legacy) continue; const relPath = relPathOf(cwd, sf.getFilePath()); const pending = []; //* STEP.2 먼저 수집하고 나중에 수정한다. traversal 중 AST를 바꾸지 않기 위함. for (const call of sf.getDescendantsOfKind(ts_morph_1.SyntaxKind.CallExpression)) { const expr = call.getExpression(); if (expr.getKind() !== ts_morph_1.SyntaxKind.Identifier) continue; if (expr.getText() !== legacy.local) continue; const typeArgs = call.getTypeArguments(); if (typeArgs.length === 0) { skipped.push({ relPath, typeArgText: '', reason: 'no type argument' }); continue; } const typeArgText = typeArgs[0].getText(); if (isGenericParameter(call, typeArgText)) { skipped.push({ relPath, typeArgText, reason: 'type argument is an enclosing generic parameter' }); continue; } pending.push({ call, typeArgText, context: enclosingContextOf(call) }); } const fileHasSkips = skipped.some(s => s.relPath === relPath); if (pending.length === 0) { //* 호출 없이 legacy import만 있으면 제거한다. //* skipped 호출이 있던 파일은 source 유지를 위해 import를 남긴다. if (!fileHasSkips) { stripLegacyKeysImport(legacy.decl); changedFiles.add(sf.getFilePath()); } continue; } const local = ensureRegistryImport(sf, outAbs); for (const p of pending) { const input = { relPath, typeArgText: p.typeArgText, enclosingContext: p.context }; const name = (0, field_derive_name_1.deriveName)(input, takenNames); //* rewrite: keys<X>() -> <local>.<name><X>() //* callee만 바꾸고 type arg, argument, cast/filter tail은 유지한다. p.call.asKindOrThrow(ts_morph_1.SyntaxKind.CallExpression).getExpression().replaceWithText(`${local}.${name}`); rewrites.push({ relPath, name, typeArgText: p.typeArgText }); } if (!fileHasSkips) { stripLegacyKeysImport(legacy.decl); } changedFiles.add(sf.getFilePath()); } if (!opts.allowSkips && skipped.length > 0) { const lines = skipped.map(s => ` - ${s.relPath} :: keys<${s.typeArgText}>() — ${s.reason}`).join('\n'); throw new Error(`migrate failed on ${skipped.length} site(s); re-run with --allow-skips after hand-fixing:\n${lines}`); } if (opts.updateTsconfig && opts.paths && opts.paths.length > 0) { throw new Error(`--update-tsconfig cannot be used with --paths; run a full migration first.`); } if (opts.updateTsconfig && skipped.length > 0) { throw new Error(`--update-tsconfig cannot run while skipped legacy sites remain.`); } const updatedTsconfig = opts.updateTsconfig ? updateTsconfig(tsconfigPath, opts.dryRun) : false; const diffs = opts.diff ? Array.from(changedFiles) .map(filePath => renderDiff(relPathOf(cwd, filePath), originalTextByPath.get(filePath) ?? '', sourceFileByPath.get(filePath)?.getFullText() ?? '')) .filter(Boolean) : undefined; //* STEP.3 validation 통과 후 bootstrap stub을 쓴다. 실패한 run은 orphan stub을 남기지 않는다. if (wroteStub) { fs.mkdirSync(path.dirname(outAbs), { recursive: true }); fs.writeFileSync(outAbs, (0, field_gen_1.bootstrapStub)(), 'utf8'); project.addSourceFileAtPath(outAbs); } if (!opts.dryRun) { for (const sf of project.getSourceFiles()) { if (changedFiles.has(sf.getFilePath())) sf.saveSync(); } } return { rewrites, skipped, changedFiles: Array.from(changedFiles), wroteStub, diffs, updatedTsconfig }; }; exports.runMigrate = runMigrate; //# sourceMappingURL=field-migrate.js.map