- 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>
465 lines
18 KiB
JavaScript
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}`,
|
|
);
|