lemon-devkit
Version:
Lemon Serverless Micro-Service Platform for local development
362 lines • 16.4 kB
JavaScript
;
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.runGuardCommon = void 0;
/**
* `field-guard-common.ts`
* - transformer spec의 `checkAllKeys`에 common field guard를 삽입/갱신한다.
*
* @author Claire <claire@lemoncloud.io>
* @date 2026-04-27 added common field guard 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 common_fields_1 = require("./common-fields");
const DEFAULT_PATHS = ['src/**/*.spec.ts'];
const CANDIDATE_VAR_RE = /^\$(node|mock|temp)$/;
const toPosix = (p) => p.split(path.sep).join('/');
const relPathOf = (cwd, abs) => toPosix(path.relative(cwd, abs));
const stripQuotes = (s) => s.replace(/^['"]|['"]$/g, '');
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');
};
const findTargetBlocks = (sf, targetName) => {
const blocks = [];
for (const fn of sf.getFunctions()) {
if (fn.getName() !== targetName)
continue;
const block = fn.getBody()?.asKind(ts_morph_1.SyntaxKind.Block);
if (block)
blocks.push(block);
}
for (const decl of sf.getDescendantsOfKind(ts_morph_1.SyntaxKind.VariableDeclaration)) {
if (decl.getNameNode().getText() !== targetName)
continue;
const init = decl.getInitializer();
if (!init || !ts_morph_1.Node.isArrowFunction(init))
continue;
const block = init.getBody().asKind(ts_morph_1.SyntaxKind.Block);
if (block)
blocks.push(block);
}
return blocks;
};
const findBaseVarFromCommons = (block) => {
for (const decl of block.getDescendantsOfKind(ts_morph_1.SyntaxKind.VariableDeclaration)) {
if (decl.getNameNode().getText() !== 'commons')
continue;
const init = decl.getInitializer();
const match = init?.getText().match(/(\$[A-Za-z0-9_]+)\.includes\s*\(/);
if (match)
return match[1];
}
return undefined;
};
const findBaseVarFromNotInModel = (sf) => {
for (const decl of sf.getDescendantsOfKind(ts_morph_1.SyntaxKind.VariableDeclaration)) {
if (decl.getNameNode().getText() !== 'notInModel')
continue;
const match = decl
.getInitializer()
?.getText()
.match(/!\s*(\$[A-Za-z0-9_]+)\.includes\s*\(/);
if (match)
return match[1];
}
return undefined;
};
const findBaseVarDeclaration = (sf, name) => {
const decls = sf.getDescendantsOfKind(ts_morph_1.SyntaxKind.VariableDeclaration);
if (name)
return decls.find(decl => decl.getNameNode().getText() === name);
return decls.find(decl => CANDIDATE_VAR_RE.test(decl.getNameNode().getText()));
};
const includesDollarFromArrayLiteral = (node) => node
.asKind(ts_morph_1.SyntaxKind.ArrayLiteralExpression)
?.getElements()
.some(el => el.getText().replace(/['"]/g, '') === '$') ?? false;
const includesDollarFromInitializer = (decl) => {
const init = decl?.getInitializer();
if (!init)
return false;
const text = init.getText();
if (/\bCORE_FIELDS\b/.test(text))
return true;
const call = init.asKind(ts_morph_1.SyntaxKind.CallExpression);
if (!call)
return false;
const args = call.getArguments();
if (args.length < 2)
return false;
return includesDollarFromArrayLiteral(args[1]);
};
const resolveRelativeModule = (fromFile, spec) => {
if (!spec.startsWith('.'))
return undefined;
const resolved = path.resolve(path.dirname(fromFile), spec);
return path.extname(resolved) ? resolved : `${resolved}.ts`;
};
const registryBindingsFor = (sf, outAbs) => {
const bindings = new Set();
for (const decl of sf.getImportDeclarations()) {
const resolved = resolveRelativeModule(sf.getFilePath(), decl.getModuleSpecifierValue());
if (!resolved || path.normalize(resolved) !== path.normalize(outAbs))
continue;
for (const spec of decl.getNamedImports()) {
if (spec.getName() === 'fieldKeys')
bindings.add(spec.getAliasNode()?.getText() ?? 'fieldKeys');
}
}
return bindings;
};
const readRegistryFields = (project, outAbs) => {
const result = new Map();
if (!fs.existsSync(outAbs))
return result;
const sf = project.addSourceFileAtPathIfExists(outAbs);
const fieldKeys = sf
?.getDescendantsOfKind(ts_morph_1.SyntaxKind.VariableDeclaration)
.find(decl => decl.getNameNode().getText() === 'fieldKeys');
const init = fieldKeys?.getInitializer();
const obj = init?.asKind(ts_morph_1.SyntaxKind.ObjectLiteralExpression) ??
init?.asKind(ts_morph_1.SyntaxKind.AsExpression)?.getExpression().asKind(ts_morph_1.SyntaxKind.ObjectLiteralExpression);
if (!obj)
return result;
for (const prop of obj.getProperties()) {
if (!ts_morph_1.Node.isPropertyAssignment(prop))
continue;
const name = stripQuotes(prop.getName());
const arr = prop.getInitializer()?.getDescendantsOfKind(ts_morph_1.SyntaxKind.ArrayLiteralExpression)[0];
if (!arr)
continue;
const fields = arr.getElements().map(el => stripQuotes(el.getText()));
result.set(name, fields);
}
return result;
};
const fieldKeyNamesFromInitializer = (decl, bindings) => {
const init = decl?.getInitializer();
if (!init || bindings.size === 0)
return [];
return init.getDescendantsOfKind(ts_morph_1.SyntaxKind.CallExpression).flatMap(call => {
const expr = call.getExpression();
if (!ts_morph_1.Node.isPropertyAccessExpression(expr))
return [];
if (!ts_morph_1.Node.isIdentifier(expr.getExpression()))
return [];
if (!bindings.has(expr.getExpression().getText()))
return [];
return [expr.getName()];
});
};
const literalFieldsFromInitializer = (decl) => {
const init = decl?.getInitializer();
if (!init)
return [];
return init.getDescendantsOfKind(ts_morph_1.SyntaxKind.ArrayLiteralExpression).flatMap(arr => arr.getElements().flatMap(el => {
const text = el.getText();
return /^['"].*['"]$/.test(text) ? [stripQuotes(text)] : [];
}));
};
const functionTextInSource = (sf, name) => {
const fn = sf.getFunctions().find(x => x.getName() === name);
if (fn)
return fn.getText();
const decl = sf.getDescendantsOfKind(ts_morph_1.SyntaxKind.VariableDeclaration).find(x => x.getNameNode().getText() === name);
return decl?.getText();
};
const importedFunctionText = (sf, project, name) => {
for (const decl of sf.getImportDeclarations()) {
const spec = decl.getNamedImports().find(x => (x.getAliasNode()?.getText() ?? x.getName()) === name);
if (!spec)
continue;
const resolved = resolveRelativeModule(sf.getFilePath(), decl.getModuleSpecifierValue());
if (!resolved || !fs.existsSync(resolved))
continue;
const target = project.getSourceFile(resolved) ?? project.addSourceFileAtPathIfExists(resolved);
const text = target ? functionTextInSource(target, spec.getName()) : undefined;
if (text)
return text;
}
return undefined;
};
const filterFunctionExcludesId = (text) => Boolean(text &&
/(?:field|[_a-zA-Z][_a-zA-Z0-9]*)\s*!={1,2}\s*['"]_id['"]|['"]_id['"]\s*!={1,2}\s*(?:field|[_a-zA-Z][_a-zA-Z0-9]*)/.test(text));
const baseInitializerExcludesId = (project, sf, decl) => {
const init = decl?.getInitializer();
if (!init)
return false;
const call = init.asKind(ts_morph_1.SyntaxKind.CallExpression);
const calls = call
? [call, ...init.getDescendantsOfKind(ts_morph_1.SyntaxKind.CallExpression)]
: init.getDescendantsOfKind(ts_morph_1.SyntaxKind.CallExpression);
return calls.some(call => {
const expr = call.getExpression();
if (!ts_morph_1.Node.isIdentifier(expr))
return false;
const name = expr.getText();
const text = functionTextInSource(sf, name) ?? importedFunctionText(sf, project, name);
return filterFunctionExcludesId(text);
});
};
const unique = (xs) => xs.reduce((L, x) => {
if (!L.includes(x))
L.push(x);
return L;
}, []);
const expectedFieldsForBaseVar = (project, sf, baseDecl, registryFields, outAbs) => {
const bindings = registryBindingsFor(sf, outAbs);
const fromRegistry = fieldKeyNamesFromInitializer(baseDecl, bindings).flatMap(name => registryFields.get(name) ?? []);
if (fromRegistry.length === 0)
return undefined;
const initText = baseDecl?.getInitializer()?.getText() ?? '';
const fromBase = literalFieldsFromInitializer(baseDecl);
const fromCore = /\bCORE_FIELDS\b/.test(initText) ? ['$'] : [];
const fields = unique([...fromRegistry, ...fromBase, ...fromCore]);
return baseInitializerExcludesId(project, sf, baseDecl) ? fields.filter(field => field !== '_id') : fields;
};
const renderGuard = (varName, expected) => [
`//* 최소한, 만일 \`keys()\`가 잘 작동했다면, 공통 필드를 가지고 있어야함.`,
`const commons = fields.reduce<string[]>((L, a) => {`,
` if (${varName}.includes(a)) L.push(a);`,
` return L;`,
`}, []);`,
`expect2(() => ${varName}?.sort((a, b) => a.length - b.length || a.localeCompare(b)).join(',')).toEqual(`,
` '${expected}',`,
`);`,
`expect2(() => commons?.sort((a, b) => a.length - b.length || a.localeCompare(b)).join(',')).toEqual(`,
` '${expected}',`,
`);`,
].join('\n');
const guardStatements = (block, varName) => block.getStatements().filter(stmt => {
const text = stmt.getText();
return (text.includes('const commons = fields.reduce') ||
(text.includes('expect2') && text.includes(`${varName}?.sort`) && text.includes('.toEqual(')) ||
(text.includes('expect2') && text.includes('commons?.sort') && text.includes('.toEqual(')));
});
const firstNonGuardInsertIndex = (block) => {
const statements = block.getStatements();
const idx = statements.findIndex(stmt => {
const text = stmt.getText();
return text.includes('const keys = Object.keys') || text.includes('const alls = notInModel');
});
return idx < 0 ? 0 : idx;
};
const upsertGuard = (block, varName, expected) => {
const next = renderGuard(varName, expected);
const existing = guardStatements(block, varName);
if (existing.length > 0) {
const before = existing.map(stmt => stmt.getText()).join('\n');
const expectedCount = before.split(`'${expected}'`).length - 1;
if (before.includes('const commons = fields.reduce') &&
before.includes(`${varName}?.sort`) &&
before.includes('commons?.sort') &&
expectedCount === 2) {
return undefined;
}
existing[0].replaceWithText(next);
for (const dup of existing.slice(1).reverse())
dup.remove();
return before === next && existing.length === 1 ? undefined : 'updated';
}
block.insertStatements(firstNonGuardInsertIndex(block), next);
return 'inserted';
};
/** `checkAllKeys`의 실제 base 변수 초기화 형태를 보고 `$` 포함 여부에 맞는 literal guard를 삽입/갱신한다. */
const runGuardCommon = (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 ?? 'src/generated/field-registry.ts');
const project = new ts_morph_1.Project({ tsConfigFilePath: tsconfigPath });
const registryFields = readRegistryFields(project, outAbs);
const paths = opts.paths && opts.paths.length > 0 ? opts.paths : DEFAULT_PATHS;
for (const pat of paths)
project.addSourceFilesAtPaths(path.isAbsolute(pat) ? pat : path.join(cwd, pat));
const scanFiles = paths.flatMap(pat => {
const files = project.getSourceFiles(pat);
if (files.length > 0 || path.isAbsolute(pat))
return files;
return project.getSourceFiles(path.join(cwd, pat));
});
const uniqueFiles = Array.from(new Map(scanFiles.map(sf => [sf.getFilePath(), sf])).values()).filter(sf => !sf.getFilePath().includes(`${path.sep}node_modules${path.sep}`));
const originalTextByPath = new Map(uniqueFiles.map(sf => [sf.getFilePath(), sf.getFullText()]));
const changedFiles = new Set();
const guards = [];
for (const sf of uniqueFiles) {
const relPath = relPathOf(cwd, sf.getFilePath());
const fallbackVar = findBaseVarFromNotInModel(sf);
for (const block of findTargetBlocks(sf, opts.targetName)) {
const varName = findBaseVarFromCommons(block) ?? fallbackVar;
if (!varName)
continue;
const baseDecl = findBaseVarDeclaration(sf, varName);
const includeDollar = includesDollarFromInitializer(baseDecl);
const expectedFields = expectedFieldsForBaseVar(project, sf, baseDecl, registryFields, outAbs) ?? common_fields_1.DEFAULT_COMMON_MODEL_FIELDS;
const expected = (0, common_fields_1.formatCommonFields)(expectedFields, includeDollar ? {} : { exclude: ['$'] });
const action = upsertGuard(block, varName, expected);
if (!action)
continue;
changedFiles.add(sf.getFilePath());
guards.push({ relPath, targetName: opts.targetName, varName, expected, action });
}
}
const diffs = opts.diff
? Array.from(changedFiles)
.map(filePath => renderDiff(relPathOf(cwd, filePath), originalTextByPath.get(filePath) ?? '', project.getSourceFileOrThrow(filePath).getFullText()))
.filter(Boolean)
: undefined;
if (!opts.dryRun) {
for (const filePath of changedFiles) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
project.getSourceFileOrThrow(filePath).saveSync();
}
}
return { guards, changedFiles: Array.from(changedFiles), diffs };
};
exports.runGuardCommon = runGuardCommon;
//# sourceMappingURL=field-guard-common.js.map