lemon-devkit
Version:
Lemon Serverless Micro-Service Platform for local development
198 lines • 7.14 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.deriveName = exports.prefixWithPath = exports.baseName = exports.isSimpleIdentifier = void 0;
/**
* `field-derive-name.ts`
* - legacy `keys<T>()` 호출 위치에서 안정적인 registry 이름을 생성한다.
*
* **NOTE**
* - `field-migrate.ts`가 source rewrite 시 사용한다.
* - `field-gen.ts`에서는 `gen --allow-legacy`로 남은 legacy 호출을 처리할 때만 사용한다.
* - 이미 migrate 된 `fieldKeys.<name><T>()` 이름은 source의 값을 그대로 보존한다.
*
* @author Claire <claire@lemoncloud.io>
* @date 2026-04-17 added stable field key derivation.
* @copyright (C) lemoncloud.io 2026 - All Rights Reserved.
*/
const crypto_1 = require("crypto");
const RESERVED = new Set([
'break',
'case',
'catch',
'class',
'const',
'continue',
'debugger',
'default',
'delete',
'do',
'else',
'enum',
'export',
'extends',
'false',
'finally',
'for',
'function',
'if',
'import',
'in',
'instanceof',
'new',
'null',
'return',
'super',
'switch',
'this',
'throw',
'true',
'try',
'typeof',
'var',
'void',
'while',
'with',
'yield',
'let',
'static',
'await',
'implements',
'interface',
'package',
'private',
'protected',
'public',
]);
const IDENT = /^[a-z][A-Za-z0-9_]*$/;
const GENERIC_TYPE_NAMES = new Set([
'Model',
'Head',
'Body',
'Item',
'Entity',
'Record',
'Data',
'Param',
'Params',
'Result',
]);
/** 파일 경로에서 확장자를 제거한 basename만 추출 */
const fileStem = (relPath) => {
const base = relPath.split('/').pop() ?? relPath;
return base.replace(/\.[^.]+$/, '');
};
/**
* 문자열을 이름 생성용 token으로 분해함.
* - punctuation, camelCase, PascalCase 경계를 모두 고려한다.
* - ex) `UserModel` => ['User','Model']
* - ex) `HTTPRequest` => ['HTTP','Request']
* - ex) `primary-post key` => ['primary','post','key']
*/
const tokenize = (s) => s
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
.replace(/[^A-Za-z0-9]+/g, ' ')
.split(/\s+/)
.filter(Boolean);
/** token 목록을 camelCase로 변환 */
const camelize = (tokens) => {
if (tokens.length === 0)
return '';
const [first, ...rest] = tokens;
return first.toLowerCase() + rest.map(t => t.charAt(0).toUpperCase() + t.slice(1).toLowerCase()).join('');
};
/** 첫 글자만 대문자로 보정 */
const upperFirst = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
/** `Model`처럼 너무 넓은 타입명에는 파일 경로 context를 붙인다. */
const pathContext = (relPath) => {
const parts = relPath.split('/').filter(Boolean);
const stem = fileStem(parts.pop() ?? relPath).replace(/\.spec$/, '');
const dir = [...parts].reverse().find(p => p !== 'src' && p !== 'modules') ?? '';
const tokens = dir && dir !== stem ? [...tokenize(dir), ...tokenize(stem)] : tokenize(stem);
return camelize(tokens);
};
/** path context가 이미 generic type suffix로 끝나면 중복해서 붙이지 않는다. */
const genericPathName = (relPath, typed) => {
const context = pathContext(relPath);
const suffix = upperFirst(typed);
return context.toLowerCase().endsWith(suffix.toLowerCase()) ? context : `${context}${suffix}`;
};
/** `&`, `|`, `<`, `.` 등이 없는 단순 identifier 여부 */
const isSimpleIdentifier = (typeArgText) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(typeArgText.trim());
exports.isSimpleIdentifier = isSimpleIdentifier;
/** `(relPath, typeArgText)` 기준의 짧고 결정적인 hash */
const shortHash = (relPath, typeArgText) => (0, crypto_1.createHash)('sha1').update(`${relPath}\0${typeArgText}`).digest('hex').slice(0, 4);
/** identifier에 사용할 수 없는 문자를 제거하고 첫 글자를 보정 */
const stripInvalid = (name) => {
const out = name.replace(/[^A-Za-z0-9_]/g, '');
if (!out)
return '';
const headSafe = !/[a-z]/.test(out.charAt(0)) ? out.charAt(0).toLowerCase() + out.slice(1) : out;
return /^[a-z_]/.test(headSafe) ? headSafe : `_${headSafe}`;
};
/** 예약어/identifier 규칙을 만족하도록 최종 보정 */
const ensureValid = (name, input) => {
const stripped = stripInvalid(name);
const reservedSafe = RESERVED.has(stripped) ? `${stripped}_` : stripped;
return IDENT.test(reservedSafe) ? reservedSafe : stripInvalid(`fk${shortHash(input.relPath, input.typeArgText)}`);
};
/**
* 호출 위치의 기본 이름을 생성한다. (충돌 처리 전)
* - 단순 identifier type arg => camelCase(typeArg)
* - 복합 type arg(intersection/union 등) => camelCase(enclosingContext) + 'Mix'
* - enclosingContext 없음 => camelCase(fileStem) + 'Mix'
*/
const baseName = (input) => {
const { typeArgText, enclosingContext, relPath } = input;
const trimmed = typeArgText.trim();
if ((0, exports.isSimpleIdentifier)(trimmed)) {
const typed = camelize(tokenize(trimmed));
const base = GENERIC_TYPE_NAMES.has(trimmed) ? genericPathName(relPath, typed) : typed;
return ensureValid(base, input);
}
if (enclosingContext && enclosingContext.trim()) {
return ensureValid(`${camelize(tokenize(enclosingContext))}Mix`, input);
}
return ensureValid(`${camelize(tokenize(fileStem(relPath)))}Mix`, input);
};
exports.baseName = baseName;
/** 기본 이름이 충돌할 때, 경로 prefix를 붙여 한번 더 구분 */
const prefixWithPath = (base, relPath) => {
const segments = relPath.split('/').filter(Boolean);
const dirs = segments.slice(0, -1);
const prefix = dirs.length === 0 ? '' : camelize(tokenize(dirs[dirs.length - 1]));
if (!prefix)
return base;
const joined = prefix + base.charAt(0).toUpperCase() + base.slice(1);
const out = stripInvalid(joined);
return RESERVED.has(out) ? `${out}_` : out;
};
exports.prefixWithPath = prefixWithPath;
/**
* `taken` 집합 안에서 최종 unique 이름을 확정한다.
* - `taken`은 caller가 전체 호출 위치 기준으로 누적 관리한다.
* - 기본 이름 -> 경로 prefix -> hash 순서로 충돌을 줄인다.
*/
const deriveName = (input, taken) => {
const base = (0, exports.baseName)(input);
if (!taken.has(base)) {
taken.add(base);
return base;
}
const prefixed = (0, exports.prefixWithPath)(base, input.relPath);
if (prefixed !== base && !taken.has(prefixed)) {
taken.add(prefixed);
return prefixed;
}
const hash = shortHash(input.relPath, input.typeArgText);
const makeHashed = (attempt = 0) => {
const suffix = attempt === 0 ? '' : `_${attempt}`;
const hashed = ensureValid(`${base}_${hash}${suffix}`, input);
return taken.has(hashed) ? makeHashed(attempt + 1) : hashed;
};
const hashed = makeHashed();
taken.add(hashed);
return hashed;
};
exports.deriveName = deriveName;
//# sourceMappingURL=field-derive-name.js.map