UNPKG

lemon-devkit

Version:

Lemon Serverless Micro-Service Platform for local development

476 lines 20.9 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.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