lemon-devkit
Version:
Lemon Serverless Micro-Service Platform for local development
476 lines • 20.9 kB
JavaScript
"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.writeRegistry = exports.runGen = exports.renderRegistry = exports.bootstrapStub = exports.canonicalEntries = void 0;
/**
* `field-gen.ts`
* - `fieldKeys.<name><T>()` 호출 위치를 스캔해서 field registry 파일을 생성한다.
*
* **NOTE**
* - literal `fieldKeys` 이름이 아니라, generated-registry import binding을 추적한다.
* - `fieldKeys.<property>`의 property 이름은 registry key로 그대로 보존한다.
* - migrate 된 호출의 이름은 다시 만들지 않는다. legacy `keys<T>()`는 `--allow-legacy`에서만 임시 처리한다.
* - output 파일이 없으면 concrete registry를 생성해서 self-heal 한다.
*
* @author Claire <claire@lemoncloud.io>
* @date 2026-04-17 added field registry generator.
* @copyright (C) lemoncloud.io 2026 - All Rights Reserved.
*/
const path = __importStar(require("path"));
const fs = __importStar(require("fs"));
const crypto = __importStar(require("crypto"));
const ts_morph_1 = require("ts-morph");
const field_derive_name_1 = require("./field-derive-name");
const HEADER = [
`/* eslint-disable prettier/prettier */`,
`// AUTO-GENERATED by lemon-devkit \`lemon-fields\` — do not edit manually.`,
`// Regenerate: lemon-fields gen`,
``,
].join('\n');
/**
* entry 목록에서 checksum 입력용 canonical string을 만든다.
*
* JSON 직렬화로 구분자 충돌(field 이름에 `,`·`|` 포함 시)을 방지한다.
* runtime validator의 `canonicalFromLiveRegistry`와 동일한 규칙이어야 한다.
*/
const canonicalEntries = (entries) => [...entries]
.sort((a, b) => a.name.localeCompare(b.name))
.map(e => JSON.stringify([e.name, e.fields]))
.join('\n');
exports.canonicalEntries = canonicalEntries;
const sha256First16 = (input) => crypto.createHash('sha256').update(input).digest('hex').slice(0, 16);
/** bootstrap stub 파일 내용을 생성 */
const bootstrapStub = () => {
const meta = {
kind: 'bootstrap',
schemaVersion: 1,
entryCount: 0,
checksum: '',
generatedBy: 'lemon-fields',
};
return (HEADER +
`export const fieldKeys = {} as Record<\n` +
` string,\n` +
` <T extends object>() => Array<Extract<keyof T, string>>\n` +
`>;\n` +
`\n` +
`export const fieldRegistryMeta = ${JSON.stringify(meta, null, 4)} as const;\n`);
};
exports.bootstrapStub = bootstrapStub;
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);
/**
* 호출식을 감싸는 가장 가까운 context 이름을 찾는다.
*
* legacy fallback naming 전용.
* - `keys<UserModel>()` 같은 단순 타입은 type 이름에서 만들 수 있음.
* - `keys<A & B>()` 같은 복합 타입은 변수/프로퍼티 이름을 기준점으로 삼는다.
*/
const enclosingContextOf = (call) => {
const walk = (node) => {
if (!node)
return undefined;
switch (node.getKind()) {
case ts_morph_1.SyntaxKind.PropertyAssignment: {
const key = node.asKindOrThrow(ts_morph_1.SyntaxKind.PropertyAssignment).getNameNode();
return key.getText();
}
case ts_morph_1.SyntaxKind.VariableDeclaration: {
const id = node.asKindOrThrow(ts_morph_1.SyntaxKind.VariableDeclaration).getNameNode();
return id.getText();
}
case ts_morph_1.SyntaxKind.MethodDeclaration: {
const n = node.asKindOrThrow(ts_morph_1.SyntaxKind.MethodDeclaration).getNameNode();
return n?.getText();
}
case ts_morph_1.SyntaxKind.FunctionDeclaration: {
const n = node.asKindOrThrow(ts_morph_1.SyntaxKind.FunctionDeclaration).getNameNode();
return n?.getText();
}
case ts_morph_1.SyntaxKind.PropertyDeclaration: {
const n = node.asKindOrThrow(ts_morph_1.SyntaxKind.PropertyDeclaration).getNameNode();
return n.getText();
}
}
return walk(node.getParent());
};
return walk(call.getParent());
};
/**
* source file 안에서 generated registry의 local binding을 찾는다.
*
* - output 파일이 있으면 ts-morph resolve 결과로 비교한다.
* - output 파일이 아직 없으면 상대 module specifier 문자열로 비교한다.
*/
const findRegistryBinding = (sf, outAbs) => {
const sfPath = sf.getFilePath();
const spec = registrySpecifier(sfPath, outAbs);
for (const imp of sf.getImportDeclarations()) {
const target = imp.getModuleSpecifierSourceFile();
const matchesByResolve = target && path.resolve(target.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 { localName: alias ? alias.getText() : 'fieldKeys' };
}
}
return undefined;
};
/**
* 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}`;
};
/**
* `ts-transformer-keys`에서 import 된 `keys`의 local binding을 찾는다.
*
* alias import도 지원한다.
* - ex) `import { keys as typeKeys }`도 `gen --allow-legacy`에서 처리 가능함.
*/
const findLegacyKeysBinding = (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 alias ? alias.getText() : 'keys';
}
}
return undefined;
};
/**
* source file 하나에서 registry 관련 호출식을 모두 찾는다.
*
* generated output 파일은 반드시 제외한다.
* - 그렇지 않으면 이전 생성물을 다시 읽어서 stale entry가 계속 살아남을 수 있음.
*/
const scanFile = (sf, cwd, outAbs, outputRelPath) => {
const hits = [];
const filePath = sf.getFilePath();
const relPath = relPathOf(cwd, filePath);
if (toPosix(path.normalize(relPath)) === outputRelPath)
return hits;
const regBinding = findRegistryBinding(sf, outAbs);
const legacyBinding = findLegacyKeysBinding(sf);
for (const call of sf.getDescendantsOfKind(ts_morph_1.SyntaxKind.CallExpression)) {
const typeArgs = call.getTypeArguments();
if (typeArgs.length === 0)
continue;
const typeArg = typeArgs[0];
const typeArgText = typeArg.getText();
const expr = call.getExpression();
//* migrated-site: `<localName>.<property>(...)`
if (regBinding && expr.getKind() === ts_morph_1.SyntaxKind.PropertyAccessExpression) {
const pa = expr.asKindOrThrow(ts_morph_1.SyntaxKind.PropertyAccessExpression);
const object = pa.getExpression();
if (object.getKind() === ts_morph_1.SyntaxKind.Identifier && object.getText() === regBinding.localName) {
const name = pa.getName();
const fields = resolveFields(typeArg);
hits.push({
kind: 'migrated',
name,
typeArgText,
fields,
relPath,
call,
context: enclosingContextOf(call),
reason: fields === null ? 'type checker failed to materialise property names' : undefined,
});
continue;
}
}
//* legacy-site: `ts-transformer-keys`의 `keys` local binding 호출
if (legacyBinding && expr.getKind() === ts_morph_1.SyntaxKind.Identifier && expr.getText() === legacyBinding) {
const fields = resolveFields(typeArg);
const context = enclosingContextOf(call);
hits.push({
kind: 'legacy',
typeArgText,
fields,
relPath,
call,
context,
reason: fields === null ? 'type checker failed to materialise property names' : undefined,
});
}
}
return hits;
};
/**
* generic argument에서 property 이름 목록을 해석한다.
*
* ts-morph가 TypeScript checker에 위임하므로, imported interface/intersection/
* declared type 모두 consumer project의 `tsconfig` 기준으로 해석된다.
* property가 0개인 타입도 `ts-transformer-keys`와 맞추기 위해 빈 배열로 유지한다.
*/
const resolveFields = (typeArgNode) => {
try {
const t = typeArgNode.getType?.();
if (!t)
return null;
const props = t.getProperties?.();
if (!props)
return null;
const names = props.map((p) => p.getName?.()).filter((x) => typeof x === 'string');
return names.length === props.length ? names : null;
}
catch {
return null;
}
};
/**
* registry entry 목록에서 생성 파일 내용을 만든다.
*
* 파일 시스템 scan 순서가 바뀌어도 diff가 흔들리지 않도록 registry key 기준으로 정렬한다.
*/
const renderRegistry = (entries) => {
const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name));
const body = sorted
.map(e => {
const arr = e.fields.map(f => JSON.stringify(f)).join(', ');
return [
` // source: ${e.relPath}#${e.typeArgText}`,
` ${e.name}: <T extends object>() =>`,
` [${arr}] as Array<Extract<keyof T, string>>,`,
].join('\n');
})
.join('\n');
const checksum = sha256First16((0, exports.canonicalEntries)(entries));
const meta = {
kind: 'concrete',
schemaVersion: 1,
entryCount: entries.length,
checksum,
generatedBy: 'lemon-fields',
};
return (`${HEADER}export const fieldKeys = {\n${body}\n} as const;\n` +
`\n` +
`export const fieldRegistryMeta = ${JSON.stringify(meta, null, 4)} as const;\n`);
};
exports.renderRegistry = renderRegistry;
/** CLI 옵션을 절대 경로와 normalize 된 output metadata로 변환 */
const resolveOpts = (opts) => {
const cwd = opts.cwd ? path.resolve(opts.cwd) : path.resolve(path.dirname(opts.tsconfig));
const outAbs = path.resolve(cwd, opts.out);
return { cwd, outAbs, outRelPosix: toPosix(path.relative(cwd, outAbs)) };
};
/** `--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());
}));
/**
* consumer project의 concrete field registry를 생성한다.
*
* **처리 순서**
* 1. `tsconfig`로 consumer TypeScript project를 로드한다.
* 2. migrate 된 `fieldKeys.<name><T>()`와 optional legacy 호출을 스캔한다.
* 3. TypeScript checker로 `<T>`의 field를 해석한다.
* 4. 결정적인 registry 내용을 render하고 변경 여부를 반환한다.
*/
const runGen = (opts) => {
const { cwd, outAbs, outRelPosix } = resolveOpts(opts);
const tsconfigPath = path.resolve(opts.tsconfig);
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);
}
//* STEP.1 전체 project를 먼저 로드해서 `tsc`와 같은 context로 type을 해석한다.
const allScanFiles = project.getSourceFiles().filter(sf => {
const p = sf.getFilePath();
if (p.includes(`${path.sep}node_modules${path.sep}`))
return false;
if (!opts.includeSpec && isSpec(relPathOf(cwd, p)))
return false;
return true;
});
//* STEP.2 `--paths`는 scan 대상만 줄인다. type checking 대상 project는 유지한다.
const allowedPaths = opts.paths && opts.paths.length > 0 ? sourceFilesForPaths(project, cwd, opts.paths) : undefined;
const scanFiles = allowedPaths ? allScanFiles.filter(sf => allowedPaths.has(sf.getFilePath())) : allScanFiles;
const hits = [];
for (const sf of scanFiles)
hits.push(...scanFile(sf, cwd, outAbs, outRelPosix));
const skipped = [];
const legacyLeftovers = [];
const repairs = [];
const changedFiles = new Set();
const migratedByName = new Map();
const takenNames = new Set();
const repairDuplicateNames = opts.repairDuplicateNames !== false;
for (const hit of hits) {
if (hit.kind === 'migrated') {
if (!hit.fields) {
skipped.push({ relPath: hit.relPath, typeArgText: hit.typeArgText, reason: hit.reason ?? 'unknown' });
continue;
}
if (!hit.name) {
skipped.push({ relPath: hit.relPath, typeArgText: hit.typeArgText, reason: 'missing-name' });
continue;
}
const prior = migratedByName.get(hit.name);
//* 같은 key + 같은 fields는 중복 호출로 허용 가능.
//* 같은 key + 다른 fields는 생성 결과가 모호하므로 실패 처리.
if (prior && !sameFields(prior.fields, hit.fields)) {
if (!repairDuplicateNames) {
throw new Error(`duplicate registry name \`${hit.name}\` with divergent field sets:\n` +
` - ${prior.relPath} :: ${prior.typeArgText} -> [${prior.fields.join(', ')}]\n` +
` - ${hit.relPath} :: ${hit.typeArgText} -> [${hit.fields.join(', ')}]\n` +
`Hand-edit one of the call sites to a distinct name.`);
}
const input = {
relPath: hit.relPath,
typeArgText: hit.typeArgText,
enclosingContext: hit.context,
};
const prefixed = (0, field_derive_name_1.prefixWithPath)(hit.name, hit.relPath);
const repairedName = prefixed !== hit.name && !takenNames.has(prefixed) ? prefixed : (0, field_derive_name_1.deriveName)(input, takenNames);
takenNames.add(repairedName);
const regBinding = findRegistryBinding(hit.call.getSourceFile(), outAbs);
hit.call
.asKindOrThrow(ts_morph_1.SyntaxKind.CallExpression)
.getExpression()
.replaceWithText(`${regBinding?.localName ?? 'fieldKeys'}.${repairedName}`);
changedFiles.add(hit.call.getSourceFile().getFilePath());
repairs.push({ relPath: hit.relPath, name: repairedName, typeArgText: hit.typeArgText });
migratedByName.set(repairedName, {
name: repairedName,
fields: hit.fields,
relPath: hit.relPath,
typeArgText: hit.typeArgText,
legacy: false,
});
continue;
}
if (!prior) {
takenNames.add(hit.name);
migratedByName.set(hit.name, {
name: hit.name,
fields: hit.fields,
relPath: hit.relPath,
typeArgText: hit.typeArgText,
legacy: false,
});
}
}
}
if (!opts.allowLegacy) {
//* STEP.3 기본 모드는 strict. source-of-truth는 committed `fieldKeys.<name>()` 호출이다.
for (const hit of hits) {
if (hit.kind === 'legacy')
legacyLeftovers.push({ relPath: hit.relPath, typeArgText: hit.typeArgText });
}
if (legacyLeftovers.length > 0) {
const lines = legacyLeftovers.map(l => ` - ${l.relPath} :: keys<${l.typeArgText}>()`).join('\n');
throw new Error(`found ${legacyLeftovers.length} legacy \`keys<T>()\` call site(s); run \`lemon-fields migrate\` first or pass \`--allow-legacy\`:\n${lines}`);
}
}
else {
//* STEP.3B migration 진행 중 호환 모드. 여기서 만든 이름은 임시값으로 본다.
const taken = new Set(takenNames);
for (const hit of hits) {
if (hit.kind !== 'legacy')
continue;
if (!hit.fields) {
skipped.push({ relPath: hit.relPath, typeArgText: hit.typeArgText, reason: hit.reason ?? 'unknown' });
continue;
}
const input = {
relPath: hit.relPath,
typeArgText: hit.typeArgText,
enclosingContext: hit.context,
};
const name = (0, field_derive_name_1.deriveName)(input, taken);
migratedByName.set(name, {
name,
fields: hit.fields,
relPath: hit.relPath,
typeArgText: hit.typeArgText,
legacy: true,
});
legacyLeftovers.push({ relPath: hit.relPath, typeArgText: hit.typeArgText });
}
}
if (skipped.length > 0) {
const lines = skipped.map(s => ` - ${s.relPath} :: ${s.typeArgText} (${s.reason})`).join('\n');
throw new Error(`failed to materialise ${skipped.length} call site(s):\n${lines}`);
}
const entries = Array.from(migratedByName.values());
if (entries.length === 0 && !opts.allowEmpty) {
throw new Error(`no \`fieldKeys.<name><T>()\` call sites found. If this is expected, pass \`--allow-empty\`; otherwise check \`--include-spec\` and \`--paths\`.`);
}
const content = entries.length > 0 ? (0, exports.renderRegistry)(entries) : (0, exports.bootstrapStub)();
const existing = fs.existsSync(outAbs) ? fs.readFileSync(outAbs, 'utf8') : undefined;
const changed = existing !== content;
if (changedFiles.size > 0) {
for (const sf of project.getSourceFiles()) {
if (changedFiles.has(sf.getFilePath()))
sf.saveSync();
}
}
return {
entries,
content,
existing,
changed,
skipped,
legacyLeftovers,
repairs,
changedFiles: Array.from(changedFiles),
};
};
exports.runGen = runGen;
const sameFields = (a, b) => a.length === b.length && a.every((v, i) => v === b[i]);
/** 생성 파일과 parent directory를 함께 기록 */
const writeRegistry = (outAbs, content) => {
fs.mkdirSync(path.dirname(outAbs), { recursive: true });
fs.writeFileSync(outAbs, content, 'utf8');
};
exports.writeRegistry = writeRegistry;
//# sourceMappingURL=field-gen.js.map