/* ============================================================================= * docs/tools/extract_ts.mjs — TS 추출(PLAN 478-1 · ts-morph) * * 범위(`scope.json`)의 TS 를 읽어 `docs/_data/` 에 씀: * raw/functions_ts.json — 함수 · 화살표 · 메서드 · 다시 내보내기(꼴 = PLAN 478 「추출 꼴」) * raw/ts_api_calls.json — 함수 안 서버 주소 글자 [{fn, path, method}] → extract_api 가 짝지음 * session_keys.json — b_page_state 창구 · sessionStorage 직접 [{key, reads[], writes[]}] * registry.json — 계산 등록부(`design_calc_registry*.ts`) 항목 그대로 * 세는 규칙 = docs/tools/README.md. * ========================================================================== */ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { Node, Project, ts } from "ts-morph"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const SCOPE = JSON.parse(fs.readFileSync(path.join(ROOT, "docs/tools/scope.json"), "utf8")); const OUT = path.join(ROOT, "docs/_data"); const PAGE_STATE = "A00_Common/b_page_state.ts"; const STATE_READ = new Set(["readState", "readStateRaw", "readByKey"]); const STATE_WRITE = new Set(["writeState", "writeStateRaw", "writeByKey", "clearState"]); const STORAGE_READ = new Set(["getItem"]); const STORAGE_WRITE = new Set(["setItem", "removeItem"]); const rel = (p) => path.relative(ROOT, p).split(path.sep).join("/"); const skipped = (r) => SCOPE.skip.some((s) => r.includes(s)); const byId = (a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0); const uniqSorted = (xs) => [...new Set(xs)].sort(); function listTs(dir) { const out = []; for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, ent.name); if (skipped(rel(full) + (ent.isDirectory() ? "/" : ""))) continue; if (ent.isDirectory()) out.push(...listTs(full)); else if (ent.name.endsWith(".ts") && !ent.name.endsWith(".d.ts")) out.push(full); } return out; } const project = new Project({ tsConfigFilePath: path.join(ROOT, "tsconfig.json"), skipAddingFilesFromTsConfig: true, compilerOptions: { types: [] }, }); const options = project.getCompilerOptions(); /* ── 범위: roots 전부 + 거기서 import 로 닿는 follow 폴더 파일 ─────────────────── */ const inScope = new Map(); // 절대 경로 → SourceFile const queue = SCOPE.roots.flatMap((r) => listTs(path.join(ROOT, r))); while (queue.length) { const file = path.normalize(queue.pop()); if (inScope.has(file)) continue; const sf = project.addSourceFileAtPath(file); inScope.set(file, sf); for (const lit of sf.getImportStringLiterals()) { const hit = ts.resolveModuleName(lit.getLiteralValue(), file, options, ts.sys).resolvedModule; if (!hit || hit.extension !== ".ts") continue; const r = rel(hit.resolvedFileName); if (SCOPE.follow.some((f) => r.startsWith(f)) && !skipped(r)) queue.push(hit.resolvedFileName); } } /* ── 함수 모으기 ──────────────────────────────────────────────────────────────── */ const records = []; // 함수 꼴 + 숨은 칸(_calls · _strings · _state) const declToId = new Map(); // "<파일>:<시작>" → id const keyOf = (n) => `${n.getSourceFile().getFilePath()}:${n.getStart()}`; const usedIds = new Map(); function docLine(host) { const text = host.getSourceFile().getFullText(); const ranges = host.getLeadingCommentRanges(); for (let i = ranges.length - 1; i >= 0; i -= 1) { const raw = text.slice(ranges[i].getPos(), ranges[i].getEnd()); if (!raw.startsWith("/**")) continue; const lines = raw .replace(/^\/\*\*/, "") .replace(/\*\/$/, "") .split(/\r?\n/) .map((l) => l.replace(/^\s*\*\s?/, "").trim()) .filter((l) => l && !l.startsWith("@")); return lines[0] ?? null; } return null; } function typeText(node, typeNode) { if (typeNode) return typeNode.getText(); try { return node.getType().getText(node, ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope); } catch { return null; } } /** 이름 있는 함수면 {name, kind, fn, host, exported} · 아니면 null. */ function asFunction(node, inClass) { if (Node.isFunctionDeclaration(node) && node.hasBody()) { return { name: node.getName() ?? "default", kind: "function", fn: node, host: node, exported: node.isExported(), }; } if ( (Node.isMethodDeclaration(node) || Node.isGetAccessorDeclaration(node) || Node.isSetAccessorDeclaration(node)) && node.getBody() ) { const priv = Node.isModifierable(node) && (node.hasModifier(ts.SyntaxKind.PrivateKeyword) || node.getNameNode().getKind() === ts.SyntaxKind.PrivateIdentifier); return { name: node.getName(), kind: "method", fn: node, host: node, exported: inClass && !priv, }; } if (Node.isConstructorDeclaration(node) && node.getBody()) { return { name: "constructor", kind: "method", fn: node, host: node, exported: inClass }; } const init = Node.isVariableDeclaration(node) || Node.isPropertyAssignment(node) || Node.isPropertyDeclaration(node) ? node.getInitializer?.() : null; const nameKind = init ? node.getNameNode().getKind() : null; const named = nameKind === ts.SyntaxKind.Identifier || nameKind === ts.SyntaxKind.StringLiteral; if (init && (Node.isArrowFunction(init) || Node.isFunctionExpression(init)) && named) { const stmt = Node.isVariableDeclaration(node) ? node.getVariableStatement() : null; const top = stmt && stmt.getParent() && Node.isSourceFile(stmt.getParent()); return { name: node.getName().replace(/^["']|["']$/g, ""), kind: Node.isArrowFunction(init) ? "arrow" : "function", fn: init, host: stmt && stmt.getDeclarations().length === 1 ? stmt : node, exported: Node.isVariableDeclaration(node) ? Boolean(top && stmt.isExported()) : inClass, }; } if (Node.isExportAssignment(node) && !node.isExportEquals()) { const e = node.getExpression(); if (Node.isArrowFunction(e) || Node.isFunctionExpression(e)) return { name: "default", kind: Node.isArrowFunction(e) ? "arrow" : "function", fn: e, host: node, exported: true, }; } return null; } function addRecord(file, qual, f, extra = {}) { let id = `ts:${file}#${qual}`; const n = (usedIds.get(id) ?? 0) + 1; usedIds.set(id, n); if (n > 1) id = `${id}~${n}`; const params = f.fn && f.fn.getParameters ? f.fn.getParameters().map((p) => ({ name: p.getName(), type: typeText(p, p.getTypeNode()) })) : []; let returns = null; if ( f.fn && f.fn.getReturnType && !Node.isConstructorDeclaration(f.fn) && !Node.isSetAccessorDeclaration(f.fn) ) { returns = f.fn.getReturnTypeNode()?.getText() ?? f.fn.getReturnType().getText(f.fn, ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope); } const rec = { id, lang: "ts", file, line: (f.host ?? f.fn).getStartLineNumber(), name: qual, kind: f.kind, exported: f.exported, doc: f.host ? docLine(f.host) : null, params, returns, calls: [], called_by: [], ...extra, _calls: [], _strings: [], }; records.push(rec); return rec; } function collect(sf) { const file = rel(sf.getFilePath()); const visit = (node, prefix, owner, inClass) => { let nextPrefix = prefix; let nextOwner = owner; let nextInClass = false; const f = asFunction(node, inClass); if (f) { const rec = addRecord(file, prefix + f.name, f); declToId.set(keyOf(node), rec.id); if (f.fn !== node) declToId.set(keyOf(f.fn), rec.id); if (Node.isFunctionDeclaration(node)) node.getOverloads().forEach((o) => declToId.set(keyOf(o), rec.id)); nextPrefix = `${prefix}${f.name}.`; nextOwner = rec; } else if (Node.isClassDeclaration(node) || Node.isClassExpression(node)) { nextPrefix = `${prefix}${node.getName() ?? "default"}.`; nextInClass = Node.isClassDeclaration(node) && node.isExported() && prefix === ""; } else if ( (Node.isVariableDeclaration(node) || Node.isPropertyAssignment(node)) && Node.isIdentifier(node.getNameNode()) ) { nextPrefix = `${prefix}${node.getName()}.`; } else if (owner && Node.isCallExpression(node)) { owner._calls.push(node); } else if ( owner && (Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node) || Node.isTemplateExpression(node)) ) { owner._strings.push(node); } node.forEachChild((c) => visit(c, nextPrefix, nextOwner, nextInClass)); }; sf.forEachChild((c) => visit(c, "", null, false)); // 다시 내보내기(함수를 가리키는 것만) — `export { a as b } from "./x"` · `export default foo` // `export * from` 은 셈하지 않음(README) for (const ed of sf.getExportDeclarations()) { if (ed.isTypeOnly()) continue; for (const spec of ed.getNamedExports()) { if (spec.isTypeOnly() || !pointsToFunction(spec.getNameNode())) continue; const alias = spec.getAliasNode()?.getText() ?? spec.getName(); addRecord( file, alias, { kind: "reexport", fn: null, host: ed, exported: true }, { _target: spec.getNameNode() }, ); } } for (const ea of sf.getExportAssignments()) { const e = ea.getExpression(); if (!ea.isExportEquals() && Node.isIdentifier(e) && pointsToFunction(e)) { addRecord( file, "default", { kind: "reexport", fn: null, host: ea, exported: true }, { _target: e }, ); } } } function pointsToFunction(nameNode) { let sym = nameNode.getSymbol(); if (sym?.isAlias()) sym = sym.getAliasedSymbol() ?? sym; return (sym?.getDeclarations() ?? []).some((d) => { if (Node.isFunctionDeclaration(d)) return true; const init = Node.isVariableDeclaration(d) ? d.getInitializer() : null; return Boolean(init && (Node.isArrowFunction(init) || Node.isFunctionExpression(init))); }); } for (const sf of inScope.values()) collect(sf); /* ── 부르는 관계 ──────────────────────────────────────────────────────────────── */ function targetIds(nameNode) { let sym = nameNode?.getSymbol(); if (!sym) return []; if (sym.isAlias()) sym = sym.getAliasedSymbol() ?? sym; return sym .getDeclarations() .map((d) => declToId.get(keyOf(d))) .filter(Boolean); } const calleeName = (call) => { const e = call.getExpression(); if (Node.isIdentifier(e)) return e; if (Node.isPropertyAccessExpression(e)) return e.getNameNode(); return null; }; const callTarget = (call) => { const nameNode = calleeName(call); if (!nameNode) return { name: null, decl: null }; let sym = nameNode.getSymbol(); if (sym?.isAlias()) sym = sym.getAliasedSymbol() ?? sym; return { name: nameNode.getText(), decl: sym?.getDeclarations()[0] ?? null }; }; for (const rec of records) { if (rec._target) { rec.target = targetIds(rec._target)[0] ?? null; // 범위 밖 함수면 null continue; } rec.calls = uniqSorted( rec._calls.flatMap((c) => targetIds(calleeName(c))).filter((id) => id !== rec.id), ); } const index = new Map(records.map((r) => [r.id, r])); for (const rec of records) for (const id of rec.calls) index.get(id)?.called_by.push(rec.id); records.forEach((r) => (r.called_by = uniqSorted(r.called_by))); /* ── 글자 값 풀기(문자 · 템플릿 · 상수) ──────────────────────────────────────────── */ function stringOf(node, depth = 0) { if (!node || depth > 4) return null; if (Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node)) return node.getLiteralValue(); if (Node.isAsExpression(node) || Node.isParenthesizedExpression(node)) return stringOf(node.getExpression(), depth + 1); if (Node.isTemplateExpression(node)) { let s = node.getHead().getLiteralText(); for (const span of node.getTemplateSpans()) { const inner = stringOf(span.getExpression(), depth + 1); s += (inner ?? (/API_BASE_URL/.test(span.getExpression().getText()) && s === "" ? "" : "{}")) + span.getLiteral().getLiteralText(); } return s; } if (Node.isIdentifier(node) || Node.isPropertyAccessExpression(node)) { const nameNode = Node.isIdentifier(node) ? node : node.getNameNode(); let sym = nameNode.getSymbol(); if (sym?.isAlias()) sym = sym.getAliasedSymbol() ?? sym; const d = sym?.getDeclarations()[0]; if ( d && (Node.isVariableDeclaration(d) || Node.isPropertyAssignment(d)) && (!Node.isVariableDeclaration(d) || d.getVariableStatement()?.getDeclarationKind() === "const") ) { return stringOf(d.getInitializer(), depth + 1); } } return null; } const bare = (n) => (Node.isAsExpression(n) ? bare(n.getExpression()) : n); const keyText = (arg) => (arg ? (stringOf(arg) ?? `{${bare(arg).getText()}}`) : "{}"); /* ── 세션 칸 ──────────────────────────────────────────────────────────────────── */ const sessions = new Map(); // key → {reads:Set, writes:Set} const touch = (key, side, id) => { if (!sessions.has(key)) sessions.set(key, { reads: new Set(), writes: new Set() }); sessions.get(key)[side].add(id); }; for (const rec of records) { for (const call of rec._calls) { const { name, decl } = callTarget(call); if (!name) continue; const arg = call.getArguments()[0]; if (decl && rel(decl.getSourceFile().getFilePath()) === PAGE_STATE) { if (STATE_READ.has(name)) touch(keyText(arg), "reads", rec.id); if (STATE_WRITE.has(name)) touch(keyText(arg), "writes", rec.id); continue; } const e = call.getExpression(); if ( Node.isPropertyAccessExpression(e) && /(^|\.)sessionStorage$/.test(e.getExpression().getText()) ) { if (STORAGE_READ.has(name)) touch(keyText(arg), "reads", rec.id); if (STORAGE_WRITE.has(name)) touch(keyText(arg), "writes", rec.id); } } } /* ── 서버 주소 글자 ────────────────────────────────────────────────────────────── */ function methodNear(lit, owner) { // 글자 메서드 = 그것 · 변수 메서드 · 펼침(`...init`) = "*"(아무 메서드) · 옵션에 메서드 없음 = GET(fetch 기본) const fromCall = (call) => { for (const a of call.getArguments()) { if (!Node.isObjectLiteralExpression(a)) continue; const p = a.getProperty("method"); if (p) { const v = Node.isPropertyAssignment(p) ? stringOf(p.getInitializer()) : null; return v ? v.toUpperCase() : "*"; } return a.getProperties().some((x) => Node.isSpreadAssignment(x)) ? "*" : "GET"; } return null; }; let n = lit; while (Node.isTemplateExpression(n.getParent()) || Node.isTemplateSpan(n.getParent())) n = n.getParent(); const parent = n.getParent(); if (Node.isCallExpression(parent)) return fromCall(parent); if (Node.isVariableDeclaration(parent)) { const name = parent.getName(); for (const call of owner._calls) { if (call.getArguments().some((a) => a.getText() === name)) return fromCall(call); } } return null; } const apiCalls = []; for (const rec of records) { for (const lit of rec._strings) { if ( Node.isTemplateExpression(lit.getParent()?.getParent?.()) && !Node.isTemplateExpression(lit) ) continue; let p = stringOf(lit); if (!p) continue; p = p.replace(/^\/api(?=[/{])/, "").replace(/[?#].*$/, ""); if (!/^\/[A-Za-z{][^\s]*$/.test(p) || !/[A-Za-z]/.test(p)) continue; apiCalls.push({ fn: rec.id, path: p, method: methodNear(lit, rec) }); } } /* ── 등록부 ───────────────────────────────────────────────────────────────────── */ function loadRegistry() { const dir = path.join(ROOT, "A00_Common/design/logic"); const files = fs.readdirSync(dir).filter((f) => /^design_calc_registry.*\.ts$/.test(f)); const mods = new Map(); const load = (base) => { if (mods.has(base)) return mods.get(base).exports; const m = { exports: {} }; mods.set(base, m); if (!files.includes(`${base}.ts`)) return m.exports; // 타입만 쓰는 import const src = fs.readFileSync(path.join(dir, `${base}.ts`), "utf8"); const js = ts.transpileModule(src, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, }).outputText; new Function("exports", "require", "module", js)(m.exports, (s) => load(path.basename(s)), m); return m.exports; }; return load("design_calc_registry").CALC_REGISTRY; } /* ── 쓰기 ─────────────────────────────────────────────────────────────────────── */ const write = (name, data) => { const file = path.join(OUT, name); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, `${JSON.stringify(data, null, 1)}\n`, "utf8"); }; const functions = records.map(({ _calls, _strings, _target, ...r }) => r).sort(byId); write("raw/functions_ts.json", functions); write( "raw/ts_api_calls.json", apiCalls.sort((a, b) => (a.fn + a.path + a.method < b.fn + b.path + b.method ? -1 : 1)), ); write( "session_keys.json", [...sessions.entries()] .map(([key, v]) => ({ key, reads: uniqSorted(v.reads), writes: uniqSorted(v.writes) })) .sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)), ); write("registry.json", loadRegistry()); console.log( `ts: 파일 ${inScope.size} · 함수 ${functions.length} · 세션 칸 ${sessions.size} · 서버 주소 글자 ${apiCalls.length}`, );