Files
Aislo/docs/tools/extract_ts.mjs
T
eomsangdonandClaude Opus 5.5 9e315a8abb feat(478-1 문서 추출): 추출 스크립트 셋 + JSON — ts-morph · ast · app.openapi() · 한 명령 extract_all
- extract_ts.mjs — 함수(kind · exported · doc · params · returns · calls · called_by · 다시 내보내기) · 세션 칸 · 서버 주소 글자 · 계산 등록부 그대로
- extract_py.py — def 전부 · docstring 첫 줄 · 부르는 함수 · FastAPI 라우트 데코레이터
- extract_api.py — 서버 안 켜고 openapi 주소 → handler · TS 부르는 곳 짝 · 짝 없는 것 따로
- 범위 = scope.json 한 곳 · 세는 규칙 = README · 시험 test_478_extract(6)
- 수치 = 함수 5746 · 세션 칸 45 · 서버 주소 41 · 등록부 45 · 14.3 s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:53:44 +09:00

465 lines
18 KiB
JavaScript

/* =============================================================================
* 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}`,
);