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>
This commit is contained in:
2026-10-19 18:53:44 +09:00
co-authored by Claude Opus 5.5
parent bbc0f53f50
commit 9e315a8abb
9 changed files with 1227 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
# docs/tools — 프로그램 문서 추출 (PLAN 478)
- 한 명령 = `./venv/Scripts/python.exe docs/tools/extract_all.py` → `docs/_data/` (git 밖).
- 처음 한 번 = `npm --prefix docs/tools install` (ts-morph · 루트 npm install 금지).
- 범위 = `scope.json` 한 곳 — `roots` 폴더 전부 + 거기서 import 로 닿는 `follow` 폴더 파일(파이썬은 계산 등록부가 짚는 `follow` 파일도).
## 만드는 것
| 파일 | 내용 |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `functions.json` | 함수 [{id, lang, file, line, name, kind, exported, doc, params, returns, calls, called_by}] · 파이썬 라우트 함수 = `routes` · 다시 내보내기 = `target` |
| `session_keys.json` | 세션 칸 [{key, reads, writes}] |
| `api.json` | 서버 주소 [{method, path, handler, callers}] |
| `api_unmatched.json` | 짝 없는 TS 주소 글자 · 부르는 TS 없는 범위 주소 |
| `registry.json` | 계산 등록부(`design_calc_registry*.ts`) 항목 그대로 |
| `raw/` | 중간 결과(TS 함수 · 파이썬 함수 · TS 주소 글자) |
## 세는 규칙
- id = `ts:<파일>#<이름>` · `py:<파일>#<이름>` · 이름 = 감싼 클래스 · 함수 · 변수 이름을 점으로 이음(`Class.method` · `outer.inner` · `obj.prop`) · 같은 id 가 또 나오면 소스 차례대로 `~2` · `~3`.
- TS 에서 세는 것: 몸통 있는 `function`(이름 없는 기본 export = `default`) · 변수 · 속성 · 클래스 칸에 담긴 화살표 · 함수 식 · 클래스 메서드 · 접근자 · 생성자(`constructor`) · 함수를 가리키는 다시 내보내기(`export { a as b } from` · `export default foo` → kind `reexport`).
- TS 에서 안 세는 것: 이름 없는 콜백(그 안의 부름은 감싼 함수 몫) · 오버로드 선언 · 타입 · `export * from`.
- 파이썬에서 세는 것: `def` · `async def` 전부(메서드 · 안쪽 함수 포함). 안 세는 것: `lambda`(그 안의 부름은 감싼 함수 몫).
- kind = `function` · `arrow` · `method` · `reexport`.
- exported = TS 맨 위 `export` · 내보낸 클래스의 private 아닌 메서드 · 파이썬 맨 위(또는 맨 위 클래스) 의 `_` 로 시작하지 않는 이름.
- 기성 도구(TypeDoc · griffe)와 맞댈 때 = `exported` 가 참인 것만 · 파일 · 이름 · 줄 · kind 로.
- calls = 범위 안 함수만(범위 밖 · 이름 못 푸는 부름은 뺌).
- doc = JSDoc · docstring 의 첫 줄(`@` 태그 줄 뺌) · 없으면 null.
- 세션 칸 = `b_page_state` 의 `readState` · `readStateRaw` · `readByKey`(읽기) · `writeState` · `writeStateRaw` · `writeByKey` · `clearState`(쓰기) · `sessionStorage.getItem`(읽기) · `setItem` · `removeItem`(쓰기) · 열쇠를 글자로 못 풀면 `{식}`.
- 서버 주소 = 함수 안 `/` 로 시작하는 글자 · 템플릿(`${…}` = `{}` · 앞 `/api` 뗌 · `?` 뒤 뗌) → 경로 칸끼리 짝(서버 `{x}` · TS `{}` = 아무 칸 · 어긋남 적은 것) · 메서드 = 옆 옵션의 `method`(변수 · 펼침 = 아무 메서드 · 옵션에 없으면 GET).
- api.json 범위 = 범위 파이썬이 받거나 범위 TS 가 부르는 주소.
- 모든 목록은 정렬 고정 — 다시 돌려도 같은 결과.
+50
View File
@@ -0,0 +1,50 @@
"""docs/tools/extract_all.py — 추출 한 명령(PLAN 478-1).
TS → 파이썬 → 서버 주소 차례로 돌리고 함수 둘을 `docs/_data/functions.json` 한 벌로 합침 ·
걸린 시간 출력. 부르기: `./venv/Scripts/python.exe docs/tools/extract_all.py`
(ts-morph = `npm --prefix docs/tools install`).
"""
from __future__ import annotations
import json
import subprocess
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
TOOLS = ROOT / "docs/tools"
DATA = ROOT / "docs/_data"
STEPS = [
("ts", ["node", str(TOOLS / "extract_ts.mjs")]),
("py", [sys.executable, str(TOOLS / "extract_py.py")]),
("api", [sys.executable, "-W", "ignore", str(TOOLS / "extract_api.py")]),
]
def load(name: str):
return json.loads((DATA / name).read_text(encoding="utf-8"))
def main() -> None:
start = time.perf_counter()
for name, cmd in STEPS:
t = time.perf_counter()
subprocess.run(cmd, cwd=ROOT, check=True) # noqa: S603 — 고정 명령
print(f" {name} {time.perf_counter() - t:.1f} s")
functions = sorted(
load("raw/functions_ts.json") + load("raw/functions_py.json"), key=lambda f: f["id"]
)
(DATA / "functions.json").write_text(
json.dumps(functions, ensure_ascii=False, indent=1) + "\n", encoding="utf-8"
)
print(
f"함수 {len(functions)} · 세션 칸 {len(load('session_keys.json'))} · "
f"서버 주소 {len(load('api.json'))} · 등록부 {len(load('registry.json'))} · "
f"모두 {time.perf_counter() - start:.1f} s"
)
if __name__ == "__main__":
main()
+138
View File
@@ -0,0 +1,138 @@
"""docs/tools/extract_api.py — 서버 주소 추출(PLAN 478-1 · FastAPI app.openapi()).
서버를 켜지 않고 `main.app` 을 불러 주소 목록을 얻고, TS 추출의 서버 주소 글자
(`raw/ts_api_calls.json`)와 경로 패턴으로 짝지어 씀:
api.json [{method, path, handler, callers[]}]
— 범위 파이썬이 받거나 범위 TS 가 부르는 주소
api_unmatched.json {ts_calls: 짝 없는 TS 글자,
routes_without_caller: 범위 주소 중 부르는 TS 없음}
"""
from __future__ import annotations
import inspect
import json
import logging
import os
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
DATA = ROOT / "docs/_data"
def handler_id(endpoint) -> str | None:
fn = inspect.unwrap(endpoint)
try:
file = Path(inspect.getsourcefile(fn)).resolve().relative_to(ROOT).as_posix()
except (TypeError, ValueError):
return None
return f"py:{file}#{fn.__qualname__.replace('.<locals>.', '.')}"
def load_routes() -> list[dict]:
os.chdir(ROOT)
sys.path.insert(0, str(ROOT))
logging.disable(logging.CRITICAL)
from fastapi.routing import APIRoute
import main
endpoints = {
(m, r.path): r.endpoint
for r in main.app.routes
if isinstance(r, APIRoute)
for m in r.methods
}
out = []
for path, ops in main.app.openapi()["paths"].items():
for method in ops:
m = method.upper()
ep = endpoints.get((m, path))
out.append({"method": m, "path": path, "handler": handler_id(ep) if ep else None})
return out
def segments(path: str) -> list[str]:
return [s for s in re.sub(r"^/api(?=/)", "", path).split("/") if s]
def score(ts_path: str, route_path: str) -> int | None:
"""맞으면 어긋남 수(작을수록 또렷) · 안 맞으면 None. TS `{}` = 아무 글자 · 서버 `{x}` = 칸."""
a, b = segments(ts_path), segments(route_path)
if len(a) != len(b):
return None
miss = 0
for t, r in zip(a, b):
param = r.startswith("{") and r.endswith("}")
if "{}" in t:
pat = "^" + ".*".join(re.escape(x) for x in t.split("{}")) + "$"
if param:
continue
if not re.match(pat, r):
return None
miss += t == "{}" # 통째 `{}` 가 글자 칸을 먹으면 어긋남 하나
elif param:
miss += 1 # 서버 칸이 TS 글자를 먹음
elif t != r:
return None
return miss
def match(call: dict, routes: list[dict]) -> list[dict]:
method = call["method"] # 글자 · "*"(변수 · 펼침 = 아무 메서드) · None(모름 = GET 먼저)
if method == "*":
pool = routes
elif method:
pool = [r for r in routes if r["method"] == method]
else:
pool = [r for r in routes if r["method"] == "GET"]
scored = [(s, r) for r in pool if (s := score(call["path"], r["path"])) is not None]
if not scored and not method:
scored = [(s, r) for r in routes if (s := score(call["path"], r["path"])) is not None]
if not scored:
return []
best = min(s for s, _ in scored)
return [r for s, r in scored if s == best]
def main() -> None:
routes = load_routes()
ts_calls = json.loads((DATA / "raw/ts_api_calls.json").read_text(encoding="utf-8"))
py_ids = {
f["id"] for f in json.loads((DATA / "raw/functions_py.json").read_text(encoding="utf-8"))
}
callers: dict[tuple[str, str], set[str]] = {}
unmatched = []
for call in ts_calls:
hits = match(call, routes)
if not hits:
unmatched.append(call)
for r in hits:
callers.setdefault((r["method"], r["path"]), set()).add(call["fn"])
api = [
{**r, "callers": sorted(callers.get((r["method"], r["path"]), ()))}
for r in routes
if r["handler"] in py_ids or (r["method"], r["path"]) in callers
]
api.sort(key=lambda r: (r["path"], r["method"]))
lonely = [f"{r['method']} {r['path']}" for r in api if not r["callers"]]
(DATA / "api.json").write_text(
json.dumps(api, ensure_ascii=False, indent=1) + "\n", encoding="utf-8"
)
(DATA / "api_unmatched.json").write_text(
json.dumps(
{"ts_calls": unmatched, "routes_without_caller": lonely}, ensure_ascii=False, indent=1
)
+ "\n",
encoding="utf-8",
)
print(
f"api: 서버 주소 전체 {len(routes)} · 범위 {len(api)} · 짝 없는 TS 글자 {len(unmatched)} · "
f"부르는 TS 없는 주소 {len(lonely)}"
)
if __name__ == "__main__":
main()
+307
View File
@@ -0,0 +1,307 @@
"""docs/tools/extract_py.py — 파이썬 추출(PLAN 478-1 · ast).
범위(`scope.json`)의 .py 를 읽어 `docs/_data/raw/functions_py.json` 에 씀(꼴 = PLAN 478 「추출 꼴」
+ FastAPI 라우트 데코레이터 `routes` [{method, path}]). 세는 규칙 = docs/tools/README.md.
"""
from __future__ import annotations
import ast
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
SCOPE = json.loads((ROOT / "docs/tools/scope.json").read_text(encoding="utf-8"))
OUT = ROOT / "docs/_data/raw/functions_py.json"
HTTP = {"get", "post", "put", "delete", "patch", "head", "options"}
def rel(p: Path) -> str:
return p.resolve().relative_to(ROOT).as_posix()
def skipped(r: str) -> bool:
return any(s in r for s in SCOPE["skip"])
def module_file(mod: str) -> Path | None:
base = ROOT.joinpath(*mod.split("."))
for cand in (base.with_suffix(".py"), base / "__init__.py"):
if cand.is_file():
return cand
return None
def resolve_from(file: Path, node: ast.ImportFrom) -> str:
"""`from <x> import` 의 절대 모듈 이름(상대 import 풀기)."""
if not node.level:
return node.module or ""
parts = list(Path(rel(file)).parent.parts)
parts = parts[: len(parts) - (node.level - 1)] if node.level > 1 else parts
return ".".join([*parts, *([node.module] if node.module else [])])
def imports_of(file: Path, tree: ast.Module) -> dict[str, tuple[str, str | None]]:
"""이름 → (모듈, 그 모듈 안 이름 | None=모듈 자체)."""
out: dict[str, tuple[str, str | None]] = {}
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for a in node.names:
if a.asname:
out[a.asname] = (a.name, None)
else:
out[a.name] = (a.name, None)
elif isinstance(node, ast.ImportFrom):
mod = resolve_from(file, node)
for a in node.names:
sub = f"{mod}.{a.name}" if mod else a.name
out[a.asname or a.name] = (sub, None) if module_file(sub) else (mod, a.name)
return out
def scope_files() -> list[Path]:
seen: dict[str, Path] = {}
queue = [p for r in SCOPE["roots"] for p in (ROOT / r).rglob("*.py")]
# 계산 등록부(registry.json · extract_ts 가 먼저 씀)가 짚는 follow 폴더 파이썬도 범위
registry = ROOT / "docs/_data/registry.json"
named = json.loads(registry.read_text(encoding="utf-8")) if registry.is_file() else []
for ref in (x for e in named for x in (e.get("pair"), e.get("runner")) if x):
file = ref.removeprefix("py:").split("#")[0]
if (
file.endswith(".py")
and any(file.startswith(f) for f in SCOPE["follow"])
and (ROOT / file).is_file()
):
queue.append(ROOT / file)
while queue:
f = queue.pop()
r = rel(f)
if r in seen or skipped(r):
continue
seen[r] = f
tree = ast.parse(f.read_text(encoding="utf-8"), filename=r)
for mod, _ in imports_of(f, tree).values():
target = module_file(mod)
if target and any(rel(target).startswith(x) for x in SCOPE["follow"]):
queue.append(target)
return [seen[k] for k in sorted(seen)]
def first_line(node: ast.AST) -> str | None:
doc = (
ast.get_docstring(node)
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
else None
)
lines = [ln.strip() for ln in (doc or "").splitlines() if ln.strip()]
return lines[0] if lines else None
def params_of(fn: ast.FunctionDef | ast.AsyncFunctionDef) -> list[dict]:
a = fn.args
pos = [*a.posonlyargs, *a.args]
out = [
{"name": p.arg, "type": ast.unparse(p.annotation) if p.annotation else None} for p in pos
]
if a.vararg:
out.append(
{
"name": f"*{a.vararg.arg}",
"type": ast.unparse(a.vararg.annotation) if a.vararg.annotation else None,
}
)
out += [
{"name": p.arg, "type": ast.unparse(p.annotation) if p.annotation else None}
for p in a.kwonlyargs
]
if a.kwarg:
out.append(
{
"name": f"**{a.kwarg.arg}",
"type": ast.unparse(a.kwarg.annotation) if a.kwarg.annotation else None,
}
)
return out
def router_prefixes(tree: ast.Module) -> dict[str, str]:
"""모듈 머리 `router = APIRouter(prefix="...")` → {router: prefix}."""
out: dict[str, str] = {}
for node in tree.body:
if isinstance(node, ast.Assign) and isinstance(node.value, ast.Call):
callee = node.value.func
name = callee.id if isinstance(callee, ast.Name) else getattr(callee, "attr", "")
if name != "APIRouter":
continue
prefix = next(
(
k.value.value
for k in node.value.keywords
if k.arg == "prefix" and isinstance(k.value, ast.Constant)
),
"",
)
for t in node.targets:
if isinstance(t, ast.Name):
out[t.id] = prefix
return out
def routes_of(fn: ast.FunctionDef | ast.AsyncFunctionDef, prefixes: dict[str, str]) -> list[dict]:
out = []
for d in fn.decorator_list:
if not (isinstance(d, ast.Call) and isinstance(d.func, ast.Attribute)):
continue
verb = d.func.attr
owner = d.func.value.id if isinstance(d.func.value, ast.Name) else ""
path = d.args[0].value if d.args and isinstance(d.args[0], ast.Constant) else None
if path is None or (verb not in HTTP and verb != "api_route"):
continue
methods = [verb.upper()] if verb in HTTP else ["GET"]
for k in d.keywords:
if (
verb == "api_route"
and k.arg == "methods"
and isinstance(k.value, (ast.List, ast.Tuple))
):
methods = [e.value.upper() for e in k.value.elts if isinstance(e, ast.Constant)]
out += [{"method": m, "path": prefixes.get(owner, "") + path} for m in methods]
return out
def own_calls(fn: ast.AST):
"""fn 몸통의 부름 — 안쪽 def · class 몫은 빼고 lambda · 내포는 fn 몫."""
for child in ast.iter_child_nodes(fn):
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
continue
if isinstance(child, ast.Call):
yield child
yield from own_calls(child)
class Collector(ast.NodeVisitor):
def __init__(self, file: Path, tree: ast.Module, used: dict[str, int]):
self.file = rel(file)
self.imports = imports_of(file, tree)
self.prefixes = router_prefixes(tree)
self.used = used
self.stack: list[tuple[str, str]] = [] # (이름, "class" | "def")
self.records: list[dict] = []
self.raw_calls: dict[str, list[tuple[ast.expr, list[str], str | None]]] = {}
def _qual(self, name: str) -> str:
return ".".join([*(n for n, _ in self.stack), name])
def visit_ClassDef(self, node: ast.ClassDef) -> None:
self.stack.append((node.name, "class"))
self.generic_visit(node)
self.stack.pop()
def _fn(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
qual = self._qual(node.name)
rid = f"py:{self.file}#{qual}"
self.used[rid] = self.used.get(rid, 0) + 1
if self.used[rid] > 1:
rid = f"{rid}~{self.used[rid]}"
in_class = bool(self.stack) and self.stack[-1][1] == "class"
top = not self.stack or (in_class and len(self.stack) == 1)
rec = {
"id": rid,
"lang": "py",
"file": self.file,
"line": node.lineno,
"name": qual,
"kind": "method" if in_class else "function",
"exported": top and not node.name.startswith("_"),
"doc": first_line(node),
"params": params_of(node),
"returns": ast.unparse(node.returns) if node.returns else None,
"calls": [],
"called_by": [],
}
routes = routes_of(node, self.prefixes)
if routes:
rec["routes"] = routes
self.records.append(rec)
cls = self.stack[-1][0] if in_class else None
# 이름 찾는 차례 = 자기 안 → 감싼 def(안쪽부터) → 모듈(클래스 칸은 건너뜀 = 파이썬 규칙)
defs = [
".".join(n for n, _ in self.stack[: i + 1])
for i, (_, k) in enumerate(self.stack)
if k == "def"
]
scopes = [qual, *reversed(defs), ""]
self.raw_calls[rid] = [(c.func, scopes, cls) for c in own_calls(node)]
self.stack.append((node.name, "def"))
self.generic_visit(node)
self.stack.pop()
visit_FunctionDef = _fn
visit_AsyncFunctionDef = _fn
def resolve(
self, func: ast.expr, scopes: list[str], cls: str | None, ids: set[str]
) -> str | None:
def local(qual: str) -> str | None:
rid = f"py:{self.file}#{qual}"
return rid if rid in ids else None
def external(mod: str, name: str) -> str | None:
target = module_file(mod)
rid = f"py:{rel(target)}#{name}" if target else None
return rid if rid in ids else None
if isinstance(func, ast.Name):
for s in scopes:
hit = local(f"{s}.{func.id}" if s else func.id)
if hit:
return hit
if func.id in self.imports:
mod, name = self.imports[func.id]
return external(mod, name) if name else None
return None
if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
base = func.value.id
if base in ("self", "cls") and cls:
return local(f"{cls}.{func.attr}")
if local(f"{base}.{func.attr}"):
return local(f"{base}.{func.attr}")
if base in self.imports:
mod, name = self.imports[base]
return (
external(mod, func.attr)
if name is None
else external(mod, f"{name}.{func.attr}")
)
return None
def main() -> None:
used: dict[str, int] = {}
collectors = []
for f in scope_files():
tree = ast.parse(f.read_text(encoding="utf-8"), filename=rel(f))
c = Collector(f, tree, used)
c.visit(tree)
collectors.append(c)
records = [r for c in collectors for r in c.records]
ids = {r["id"] for r in records}
index = {r["id"]: r for r in records}
for c in collectors:
for rid, calls in c.raw_calls.items():
hits = {c.resolve(func, scopes, cls, ids) for func, scopes, cls in calls}
index[rid]["calls"] = sorted(h for h in hits if h and h != rid)
for r in records:
for target in r["calls"]:
index[target]["called_by"].append(r["id"])
for r in records:
r["called_by"] = sorted(set(r["called_by"]))
records.sort(key=lambda r: r["id"])
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(json.dumps(records, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
routes = sum(len(r.get("routes", [])) for r in records)
print(f"py: 파일 {len(collectors)} · 함수 {len(records)} · 라우트 데코레이터 {routes}")
if __name__ == "__main__":
main()
+464
View File
@@ -0,0 +1,464 @@
/* =============================================================================
* 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}`,
);
+127
View File
@@ -0,0 +1,127 @@
{
"name": "aislo-docs-tools",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aislo-docs-tools",
"dependencies": {
"ts-morph": "^28.0.0"
}
},
"node_modules/@ts-morph/common": {
"version": "0.29.0",
"resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.29.0.tgz",
"integrity": "sha512-35oUmphHbJvQ/+UTwFNme/t2p3FoKiGJ5auTjjpNTop2dyREspirjMy82PLSC1pnDJ8ah1GU98hwpVt64YXQsg==",
"license": "MIT",
"dependencies": {
"minimatch": "^10.0.1",
"path-browserify": "^1.0.1",
"tinyglobby": "^0.2.14"
}
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/brace-expansion": {
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/code-block-writer": {
"version": "13.0.3",
"resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz",
"integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==",
"license": "MIT"
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/minimatch": {
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.8"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
"license": "MIT"
},
"node_modules/picomatch": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/ts-morph": {
"version": "28.0.0",
"resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-28.0.0.tgz",
"integrity": "sha512-Wp3tnZ2bzwxyTZMtgWVzXDfm7lB1Drz+y9DmmYH/L702PQhPyVrp3pkou3yIz4qjS14GY9kcpmLiOOMvl8oG1g==",
"license": "MIT",
"dependencies": {
"@ts-morph/common": "~0.29.0",
"code-block-writer": "^13.0.3"
}
}
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"name": "aislo-docs-tools",
"private": true,
"type": "module",
"dependencies": {
"ts-morph": "^28.0.0"
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"roots": ["B05_Profile/", "B06_Section/", "A00_Common/design/"],
"follow": ["common_util/"],
"skip": ["node_modules/", "__pycache__/"]
}