From 9e315a8abb9c8e63b0ec2d5081351bac7d806f8f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 19 Oct 2026 18:53:44 +0900 Subject: [PATCH] =?UTF-8?q?feat(478-1=20=EB=AC=B8=EC=84=9C=20=EC=B6=94?= =?UTF-8?q?=EC=B6=9C):=20=EC=B6=94=EC=B6=9C=20=EC=8A=A4=ED=81=AC=EB=A6=BD?= =?UTF-8?q?=ED=8A=B8=20=EC=85=8B=20+=20JSON=20=E2=80=94=20ts-morph=20?= =?UTF-8?q?=C2=B7=20ast=20=C2=B7=20app.openapi()=20=C2=B7=20=ED=95=9C=20?= =?UTF-8?q?=EB=AA=85=EB=A0=B9=20extract=5Fall?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/tools/README.md | 32 ++ docs/tools/extract_all.py | 50 +++ docs/tools/extract_api.py | 138 ++++++++ docs/tools/extract_py.py | 307 ++++++++++++++++++ docs/tools/extract_ts.mjs | 464 +++++++++++++++++++++++++++ docs/tools/package-lock.json | 127 ++++++++ docs/tools/package.json | 8 + docs/tools/scope.json | 5 + resources/tester/test_478_extract.py | 96 ++++++ 9 files changed, 1227 insertions(+) create mode 100644 docs/tools/README.md create mode 100644 docs/tools/extract_all.py create mode 100644 docs/tools/extract_api.py create mode 100644 docs/tools/extract_py.py create mode 100644 docs/tools/extract_ts.mjs create mode 100644 docs/tools/package-lock.json create mode 100644 docs/tools/package.json create mode 100644 docs/tools/scope.json create mode 100644 resources/tester/test_478_extract.py diff --git a/docs/tools/README.md b/docs/tools/README.md new file mode 100644 index 000000000..1fda05fc9 --- /dev/null +++ b/docs/tools/README.md @@ -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 가 부르는 주소. +- 모든 목록은 정렬 고정 — 다시 돌려도 같은 결과. diff --git a/docs/tools/extract_all.py b/docs/tools/extract_all.py new file mode 100644 index 000000000..18bef9039 --- /dev/null +++ b/docs/tools/extract_all.py @@ -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() diff --git a/docs/tools/extract_api.py b/docs/tools/extract_api.py new file mode 100644 index 000000000..62860d0f5 --- /dev/null +++ b/docs/tools/extract_api.py @@ -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('..', '.')}" + + +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() diff --git a/docs/tools/extract_py.py b/docs/tools/extract_py.py new file mode 100644 index 000000000..33038f82e --- /dev/null +++ b/docs/tools/extract_py.py @@ -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 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() diff --git a/docs/tools/extract_ts.mjs b/docs/tools/extract_ts.mjs new file mode 100644 index 000000000..7c37f2a95 --- /dev/null +++ b/docs/tools/extract_ts.mjs @@ -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}`, +); diff --git a/docs/tools/package-lock.json b/docs/tools/package-lock.json new file mode 100644 index 000000000..890d4e3dc --- /dev/null +++ b/docs/tools/package-lock.json @@ -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" + } + } + } +} diff --git a/docs/tools/package.json b/docs/tools/package.json new file mode 100644 index 000000000..d50717d3d --- /dev/null +++ b/docs/tools/package.json @@ -0,0 +1,8 @@ +{ + "name": "aislo-docs-tools", + "private": true, + "type": "module", + "dependencies": { + "ts-morph": "^28.0.0" + } +} diff --git a/docs/tools/scope.json b/docs/tools/scope.json new file mode 100644 index 000000000..dfcc7ab4c --- /dev/null +++ b/docs/tools/scope.json @@ -0,0 +1,5 @@ +{ + "roots": ["B05_Profile/", "B06_Section/", "A00_Common/design/"], + "follow": ["common_util/"], + "skip": ["node_modules/", "__pycache__/"] +} diff --git a/resources/tester/test_478_extract.py b/resources/tester/test_478_extract.py new file mode 100644 index 000000000..f50670aa3 --- /dev/null +++ b/resources/tester/test_478_extract.py @@ -0,0 +1,96 @@ +"""PLAN 478-1 (요구 278) — 문서 추출 스크립트 셋(`docs/tools/extract_all.py`). + +- 알려진 것 있음: computeMassHaul · 계산 등록부 항목 · POST /design/save · 세션 칸 몇 개. +- 함수 id 중복 0 · calls · called_by 가 가리키는 id 모두 있음 · 등록부 TS 자리 모두 함수에 있음. +- 다시 돌려도 같은 결과(정렬 고정). +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +DATA = ROOT / "docs" / "_data" +OUTPUTS = ["functions.json", "session_keys.json", "api.json", "api_unmatched.json", "registry.json"] + +pytestmark = pytest.mark.skipif( + shutil.which("node") is None or not (ROOT / "docs/tools/node_modules/ts-morph").is_dir(), + reason="node · ts-morph 없음(npm --prefix docs/tools install)", +) + + +def _run() -> dict[str, str]: + subprocess.run( # noqa: S603 — 고정 명령 + [sys.executable, str(ROOT / "docs/tools/extract_all.py")], + cwd=ROOT, + check=True, + capture_output=True, + timeout=300, + ) + return {name: (DATA / name).read_text(encoding="utf-8") for name in OUTPUTS} + + +@pytest.fixture(scope="module") +def first() -> dict[str, str]: + return _run() + + +@pytest.fixture(scope="module") +def data(first) -> dict: + return {name.removesuffix(".json"): json.loads(text) for name, text in first.items()} + + +def test_known_function(data): + f = {x["id"]: x for x in data["functions"]} + mh = f["ts:common_util/common_util_mass_haul.ts#computeMassHaul"] + assert mh["kind"] == "function" and mh["exported"] and mh["doc"] + assert [p["name"] for p in mh["params"]][:2] == ["crossSections", "conversion"] + assert mh["calls"] and mh["called_by"] + assert any(x["lang"] == "py" for x in data["functions"]) + + +def test_ids_unique_and_links_exist(data): + ids = [x["id"] for x in data["functions"]] + assert len(ids) == len(set(ids)) + known = set(ids) + for x in data["functions"]: + assert set(x["calls"]) <= known, x["id"] + assert set(x["called_by"]) <= known, x["id"] + assert x.get("target") is None or x["target"] in known + assert ids == sorted(ids) + + +def test_registry(data): + reg = {e["id"]: e for e in data["registry"]} + assert {"route.polyline", "facility.follow"} <= set(reg) + known = {x["id"] for x in data["functions"]} + assert [e["ts"] for e in data["registry"] if e["ts"] and f"ts:{e['ts']}" not in known] == [] + + +def test_design_save_api(data): + hit = [ + a + for a in data["api"] + if a["method"] == "POST" and a["path"] == "/api/projects/{project_id}/design/save" + ] + assert len(hit) == 1 + assert hit[0]["handler"] == "py:B06_Section/B06_Section_Router_DesignSave.py#save_design" + assert "ts:A00_Common/design/design_save_api.ts#postDesignSave" in hit[0]["callers"] + assert set(data["api_unmatched"]) == {"ts_calls", "routes_without_caller"} + + +def test_session_keys(data): + keys = {k["key"]: k for k in data["session_keys"]} + for key in ("section-context", "route-draft-hold", "masshaul-open"): + assert keys[key]["reads"] or keys[key]["writes"], key + assert list(keys) == sorted(keys) + + +def test_rerun_same(first): + assert _run() == first