diff --git a/.gitignore b/.gitignore
index 88834db29..122ae73b4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,7 +33,7 @@ dist/
# 저장소 및 백업 데이터
storage/
0_old/
-# docs/ 는 git 추적 대상 (요구 장부 · 분석 · 문서 사이트 손글 · 스크립트)
+# docs/ 는 git 추적 대상 (요구 장부 · 분석 · 가이드)
# 아래 둘만 제외 — 창끼리 시놀로지로 공유하는 장부
/PLAN.md
/OWNERS.md
@@ -73,8 +73,4 @@ config/b07_note_node/
# 계산 등록부 서버 Node 입구 번들 — `npm run build:design-calc` 산출물(472-3).
config/design_calc_node/
-# 문서 사이트 생성물 — 스크립트로 다시 만듦(손글 · 스크립트 · 설정만 git)
-docs/_data/
-docs/_build/
-docs/tools/node_modules/
~$*
diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml
deleted file mode 100644
index d3fe4d819..000000000
--- a/docs/mkdocs.yml
+++ /dev/null
@@ -1,53 +0,0 @@
-# 프로그램 문서 사이트(PLAN 478) — 원고 = docs/tools/build.py 가 _build/src 에 만듦
-site_name: Aislo 프로그램 문서
-docs_dir: _build/src
-site_dir: _build/site
-use_directory_urls: false
-
-theme:
- name: material
- language: ko
- features:
- - navigation.sections
- - navigation.indexes
- - toc.integrate
- - search.highlight
-
-plugins:
- - search:
- separator: '[\s\-_.]+'
- - autorefs
- - mkdocstrings:
- handlers:
- python:
- paths: [..]
- options:
- show_source: false
- show_root_heading: true
- heading_level: 2
- members_order: source
- filters: []
- show_if_no_docstring: true
- docstring_style: null
- show_signature_annotations: true
- separate_signature: true
-
-hooks:
- - tools/nav_hook.py
-
-markdown_extensions:
- - attr_list
- - md_in_html
- - admonition
- - tables
- - toc:
- permalink: true
- - pymdownx.superfences:
- custom_fences:
- - name: mermaid
- class: mermaid
- format: !!python/name:pymdownx.superfences.fence_code_format
-
-validation:
- links:
- anchors: warn
diff --git a/docs/tools/README.md b/docs/tools/README.md
deleted file mode 100644
index 17217cb71..000000000
--- a/docs/tools/README.md
+++ /dev/null
@@ -1,40 +0,0 @@
-# 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 가 부르는 주소.
-- 모든 목록은 정렬 고정 — 다시 돌려도 같은 결과.
-
-## 사이트 (478-2)
-
-- 처음 한 번 = `./venv/Scripts/python.exe -m pip install -r docs/tools/requirements.txt` (mkdocs · Material · mkdocstrings).
-- 원고 = `./venv/Scripts/python.exe docs/tools/build.py` → `docs/_build/src` · `--site` = `mkdocs build --strict` 까지 · `--stamp` = 손글 지문을 지금 함수 목록으로.
-- 띄우기 = `cd docs && ../venv/Scripts/python.exe -m mkdocs serve -a 127.0.0.1:8090`
-- 묶음 규칙 = `groups.yml`(계산 등록부 id · 파일 이름 무늬) · 손글 = `docs/guide/<페이지>/page.md` · `groups/<묶음>.md` · 손글 안 `[[함수]]` · `[[파일#함수]]` = 함수 칸 링크.
-- 함수 화면 = TS `TypeDoc`(HTML) · 파이썬 `mkdocstrings`(griffe) — build 는 페이지 · 묶음 화면과 링크만.
diff --git a/docs/tools/build.py b/docs/tools/build.py
deleted file mode 100644
index d9f94c5e8..000000000
--- a/docs/tools/build.py
+++ /dev/null
@@ -1,495 +0,0 @@
-"""문서 사이트 원고 만들기(PLAN 478-2).
-
-추출(docs/_data/*.json) + 손글(docs/guide/<페이지>/page.md · groups/<묶음>.md) → docs/_build/src
- index.md 처음
- <페이지>/index.md 개요 · 묶음 상자 · 묶음 사이 흐름 그림 · 손글(규칙 · 사용자 결정)
- <페이지>/<묶음>.md 손글 설명 · 묶음 안 흐름 그림 · 세션 칸 · 서버 주소 · 함수 칸
- <페이지>/none.md 묶음 없음
- ts/ · py/ 함수 화면 — TypeDoc(HTML) · mkdocstrings(griffe)가 만듦
- .nav.json 왼쪽 메뉴(nav_hook.py 가 읽음)
-함수 목록 지문이 손글 지문과 다른 묶음 = 「설명 확인 필요」 · 맞으면 --stamp.
-
- venv python docs/tools/build.py [--data D] [--out O] [--site] [--stamp]
-"""
-
-from __future__ import annotations
-
-import argparse
-import hashlib
-import json
-import re
-import shutil
-import subprocess
-import sys
-import time
-from collections import Counter
-from pathlib import Path
-
-import griffe
-import yaml
-
-sys.path.insert(0, str(Path(__file__).resolve().parent))
-import group as grouping # noqa: E402
-
-TOOLS = grouping.TOOLS
-DOCS = TOOLS.parent
-ROOT = DOCS.parent
-REF = re.compile(r"\[\[([^\]]+)\]\]")
-MAX_GRAPH = 40
-
-
-# ── 읽기 ────────────────────────────────────────────────────────────────────
-
-
-def load_data(data: Path) -> dict:
- out = {}
- for name in ("functions", "session_keys", "api", "registry"):
- path = data / f"{name}.json"
- out[name] = json.loads(path.read_text(encoding="utf-8")) if path.exists() else []
- if not out["functions"]:
- raise SystemExit(f"추출 없음: {data / 'functions.json'}")
- return out
-
-
-def read_hand(path: Path) -> tuple[dict, str]:
- if not path.exists():
- return {}, ""
- text = path.read_text(encoding="utf-8")
- if text.startswith("---\n"):
- head, _, body = text[4:].partition("\n---\n")
- return yaml.safe_load(head) or {}, body.strip()
- return {}, text.strip()
-
-
-def stamp(path: Path, print_: str) -> None:
- meta, body = read_hand(path)
- meta["지문"] = print_
- head = yaml.safe_dump(meta, allow_unicode=True, sort_keys=False).strip()
- path.write_text(f"---\n{head}\n---\n\n{body}\n", encoding="utf-8")
-
-
-# ── 함수 화면(기성 도구) ───────────────────────────────────────────────────
-
-
-def run_typedoc(ts_files: list[str], out: Path) -> dict[tuple[str, str], str]:
- """TypeDoc HTML 로 ts/ 함수 화면 · (파일, 이름) → 원고 안 경로."""
- if not ts_files:
- return {}
- opts = {
- "entryPoints": [(ROOT / f).as_posix() for f in ts_files],
- "tsconfig": (ROOT / "tsconfig.json").as_posix(),
- "skipErrorChecking": True,
- "router": "kind-dir",
- "out": (out / "src" / "ts").as_posix(),
- "json": (out / "typedoc.json").as_posix(),
- "readme": "none",
- "logLevel": "Error",
- }
- opt_file = out / "typedoc.options.json"
- opt_file.write_text(json.dumps(opts, ensure_ascii=False), encoding="utf-8")
- subprocess.run(
- ["node", str(TOOLS / "node_modules/typedoc/bin/typedoc"), "--options", str(opt_file)],
- cwd=ROOT,
- check=True,
- )
- return typedoc_pages(out)
-
-
-FN_KINDS = {64, 512, 2048, 262144} # TypeDoc 함수 · 생성자 · 메서드 · 접근자
-KIND_DIR = {64: "functions", 32: "variables", 128: "classes"} # router kind-dir 폴더
-
-
-def typedoc_entries(out: Path) -> list[tuple[str, str, int, str]]:
- """TypeDoc JSON → (파일, 이름, 종류, 화면 경로).
-
- 클래스 칸 = `Class.name` · 다시 내보내기 = 가리키는 것의 종류.
- """
- project = json.loads((out / "typedoc.json").read_text(encoding="utf-8"))
- modules = [m for m in project.get("children", []) if m["kind"] == 2]
- by_id: dict[int, dict] = {}
-
- def index(r: dict) -> None:
- by_id[r["id"]] = r
- for c in r.get("children", []):
- index(c)
-
- index(project)
- rows = []
- for mod in modules:
- file = mod["sources"][0]["fileName"] if mod.get("sources") else f"{mod['name']}.ts"
- slug = mod["name"].replace("/", "_")
- for c in mod.get("children", []):
- folder = KIND_DIR.get(c["kind"])
- page = f"ts/{folder}/{slug}.{c['name']}/index.html" if folder else ""
- kind = c["kind"]
- if kind == 4194304:
- kind = by_id.get(c.get("target"), {}).get("kind", kind)
- rows.append((file, c["name"], kind, page))
- if c["kind"] != 128:
- continue
- for m in c.get("children", []):
- if m.get("sources") and not m.get("flags", {}).get("isInherited"):
- rows.append(
- (file, f"{c['name']}.{m['name']}", m["kind"], f"{page}#{m['name'].lower()}")
- )
- return rows
-
-
-def typedoc_pages(out: Path) -> dict[tuple[str, str], str]:
- """(파일, 이름) → 화면 경로 · 대소문자만 다른 겹침(Windows 한 파일)은 링크 안 함."""
- rows = [r for r in typedoc_entries(out) if r[3]]
- seen = Counter(rel.split("#")[0].lower() for *_, rel in rows if "#" not in rel)
- return {
- (file, name): rel
- for file, name, _, rel in rows
- if seen[rel.split("#")[0].lower()] == 1 and (out / "src" / rel.split("#")[0]).exists()
- }
-
-
-def py_ident(file: str) -> str:
- return file.removesuffix(".py").replace("/", ".")
-
-
-def griffe_names(file: str) -> set[str]:
- """griffe 가 보는 함수 이름(모듈 함수 · 클래스 메서드 · 속성 `Class.name` · 자동 생성 뺌)."""
- mod = griffe.load(py_ident(file), search_paths=[str(ROOT)], allow_inspection=False)
- names: set[str] = set()
-
- def walk(obj, prefix: str) -> None:
- for name, m in obj.members.items():
- if m.is_alias:
- continue
- if (m.is_function or "property" in m.labels) and m.lineno:
- names.add(prefix + name)
- elif m.is_class:
- walk(m, f"{prefix}{name}.")
-
- walk(mod, "")
- return names
-
-
-def write_py_pages(py_files: list[str], src: Path) -> dict[tuple[str, str], str]:
- """mkdocstrings 원고 · (파일, 이름) → autorefs 식별자."""
- refs = {}
- lines = ["# 파이썬 함수 화면", ""]
- for file in py_files:
- ident = py_ident(file)
- (src / "py" / f"{ident}.md").write_text(f"# {ident}\n\n::: {ident}\n", encoding="utf-8")
- lines.append(f"- [{file}]({ident}.md)")
- refs.update({(file, n): f"{ident}.{n}" for n in griffe_names(file)})
- (src / "py" / "index.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
- return refs
-
-
-# ── 원고 ───────────────────────────────────────────────────────────────────
-
-
-def anchor(fid: str) -> str:
- return "f-" + hashlib.sha1(fid.encode("utf-8")).hexdigest()[:10]
-
-
-def first_line(doc: str) -> str:
- return next((s.strip() for s in (doc or "").splitlines() if s.strip()), "")
-
-
-def owner_of(item: str, fns_by_id: dict, by_file: dict) -> dict | None:
- """세션 칸 · 서버 주소의 `id` · `파일:줄` · `파일` → 함수."""
- if item in fns_by_id:
- return fns_by_id[item]
- if item[:3] in ("ts:", "py:"):
- item = item[3:]
- file, _, line = item.split("#")[0].partition(":")
- cands = by_file.get(file, [])
- if not cands:
- return None
- if line.isdigit():
- before = [f for f in cands if f.get("line", 0) <= int(line)]
- return before[-1] if before else cands[0]
- return cands[0]
-
-
-class Site:
- def __init__(self, data: dict, rules: dict, out: Path):
- self.data, self.rules, self.out = data, rules, out.resolve()
- self.src = self.out / "src"
- self.fns = data["functions"]
- self.by_id = {f["id"]: f for f in self.fns}
- self.by_file: dict[str, list] = {}
- for f in sorted(self.fns, key=lambda f: f.get("line", 0)):
- self.by_file.setdefault(f["file"], []).append(f)
- self.gid: dict[str, str] = {}
- self.missing_refs: list[str] = []
- self.stale: list[str] = []
- self.ts_pages: dict = {}
- self.py_refs: dict = {}
-
- # 링크
- def fn_link(self, fid: str, label: str | None = None) -> str:
- f = self.by_id.get(fid)
- if f is None:
- return f"`{label or fid.split('#')[-1]}`"
- return f"[`{label or f['name']}`]({self.gid[fid]}.md#{anchor(fid)})"
-
- def hand_refs(self, text: str, where: str) -> str:
- def sub(m: re.Match) -> str:
- ref = m.group(1).strip()
- hit = [
- f
- for f in self.fns
- if f["name"] == ref or f"{grouping.stem(f['file'])}#{f['name']}" == ref
- ]
- if not hit:
- self.missing_refs.append(f"{where}: {ref}")
- return f"`{ref}` ⚠"
- return self.fn_link(hit[0]["id"])
-
- return REF.sub(sub, text)
-
- def tool_link(self, f: dict) -> str:
- if f["lang"] == "ts":
- rel = self.ts_pages.get((f["file"], f["name"]))
- return f"[자세히](../{rel})" if rel else ""
- ref = self.py_refs.get((f["file"], f["name"]))
- return f"[자세히][{ref}]" if ref else ""
-
- # 함수 칸
- def fn_block(self, f: dict) -> str:
- params = ", ".join(
- p["name"] if isinstance(p, dict) else str(p) for p in f.get("params", [])
- )
- bits = [f"`{f['file']}:{f.get('line', 0)}`"]
- if params:
- bits.append(f"입력 `{params}`")
- if f.get("returns"):
- bits.append(f"출력 `{f['returns']}`")
- if link := self.tool_link(f):
- bits.append(link)
- lines = [
- f"### `{f['name']}` {{ #{anchor(f['id'])} }}",
- "",
- first_line(f.get("doc")) or "*설명 없음*",
- "",
- ]
- lines += [" · ".join(bits), ""]
- callers = f.get("called_by", [])
- if callers:
- shown = " · ".join(self.fn_link(c) for c in callers[:8])
- more = f" 외 {len(callers) - 8}" if len(callers) > 8 else ""
- lines += [f"부르는 곳: {shown}{more}", ""]
- return "\n".join(lines)
-
- def fn_graph(self, fns: list[dict]) -> str:
- ids = {f["id"] for f in fns}
- edges = [(f["id"], c) for f in fns for c in f.get("calls", []) if c in ids and c != f["id"]]
- if not edges:
- return ""
- degree = Counter(a for e in edges for a in e)
- keep = {i for i, _ in degree.most_common(MAX_GRAPH)}
- node = {i: f"n{k}" for k, i in enumerate(sorted(keep))}
- lines = ["```mermaid", "flowchart LR"]
- lines += [f' {node[i]}["{self.by_id[i]["name"]}"]' for i in sorted(keep)]
- lines += [
- f" {node[a]} --> {node[b]}" for a, b in sorted(set(edges)) if a in keep and b in keep
- ]
- lines.append("```")
- note = f"\n\n많이 이어진 함수 {MAX_GRAPH} 개만 그림." if len(degree) > MAX_GRAPH else ""
- return "\n".join(lines) + note
-
- def group_uses(self, gid: str) -> tuple[list[str], list[str]]:
- def mine(item: str) -> bool:
- o = owner_of(item, self.by_id, self.by_file)
- return o is not None and self.gid[o["id"]] == gid
-
- keys = []
- for k in self.data["session_keys"]:
- reads, writes = (
- [r for r in k.get("reads", []) if mine(r)],
- [w for w in k.get("writes", []) if mine(w)],
- )
- if reads or writes:
- keys.append(f"| `{k['key']}` | {len(reads)} | {len(writes)} |")
- apis = []
- for a in self.data["api"]:
- calls = [c for c in a.get("callers", []) if mine(c)]
- if mine(a.get("handler", "")) or calls:
- handler = (
- self.fn_link(a["handler"])
- if a.get("handler") in self.by_id
- else f"`{a.get('handler', '')}`"
- )
- apis.append(f"| `{a['method']} {a['path']}` | {handler} | {len(calls)} |")
- return keys, apis
-
- # 화면
- def write_group(self, page: dict, g: dict, fns: list[dict], hand_dir: Path) -> str:
- meta, body = read_hand(hand_dir / f"{g['id']}.md")
- now = grouping.fingerprint(fns)
- out = [f"# {g['title']}", ""]
- if meta.get("지문") != now:
- self.stale.append(g["id"])
- out += [
- '!!! warning "설명 확인 필요"',
- f" 함수 목록이 바뀜(손글 지문 `{meta.get('지문', '없음')}` · 지금 `{now}`).",
- "",
- ]
- out += [self.hand_refs(body, f"groups/{g['id']}.md") or "*설명 없음*", ""]
- if graph := self.fn_graph(fns):
- out += ["## 묶음 안 흐름", "", graph, ""]
- keys, apis = self.group_uses(g["id"])
- if keys:
- out += ["## 쓰는 세션 칸", "", "| 칸 | 읽는 곳 | 쓰는 곳 |", "|---|---|---|", *keys, ""]
- if apis:
- out += [
- "## 서버 주소",
- "",
- "| 주소 | 받는 함수 | 부르는 곳 |",
- "|---|---|---|",
- *apis,
- "",
- ]
- out += [f"## 함수 {len(fns)}", "", *(self.fn_block(f) for f in fns)]
- (self.src / page["id"] / f"{g['id']}.md").write_text("\n".join(out), encoding="utf-8")
- return first_line(body)
-
- def write_page(self, page: dict, groups: dict[str, list[dict]]) -> list:
- hand_dir = DOCS / page["guide"]
- (self.src / page["id"]).mkdir(parents=True, exist_ok=True)
- cards, nav = [], [{"개요": f"{page['id']}/index.md"}]
- for g in page["groups"]:
- fns = groups[g["id"]]
- lead = self.write_group(page, g, fns, hand_dir / "groups")
- warn = " · ⚠ 설명 확인 필요" if g["id"] in self.stale else ""
- cards += [
- f"- **[{g['title']}]({g['id']}.md)** · 함수 {len(fns)}{warn}",
- "",
- f" {lead}",
- "",
- ]
- nav.append({g["title"]: f"{page['id']}/{g['id']}.md"})
- none = groups[grouping.NONE]
- none_md = [f"# 묶음 없음 {len(none)}", "", "groups.yml 규칙에 안 맞는 함수.", ""]
- (self.src / page["id"] / "none.md").write_text(
- "\n".join(none_md + [self.fn_block(f) for f in none]), encoding="utf-8"
- )
- nav.append({"묶음 없음": f"{page['id']}/none.md"})
-
- _, body = read_hand(hand_dir / "page.md")
- md = [
- f"# {page['title']}",
- "",
- '
',
- "",
- *cards,
- "
",
- "",
- ]
- md += [f"묶음 없음 함수 {len(none)} — [목록](none.md)", ""]
- if graph := self.group_graph(page, groups):
- md += ["## 묶음 사이 흐름", "", graph, ""]
- md.append(self.hand_refs(body, "page.md"))
- (self.src / page["id"] / "index.md").write_text("\n".join(md) + "\n", encoding="utf-8")
- return nav
-
- def group_graph(self, page: dict, groups: dict[str, list[dict]]) -> str:
- edges = Counter(
- (self.gid[f["id"]], self.gid[c])
- for fns in groups.values()
- for f in fns
- for c in f.get("calls", [])
- if c in self.gid and self.gid[c] != self.gid[f["id"]]
- )
- if not edges:
- return ""
- titles = {g["id"]: g["title"] for g in page["groups"]} | {grouping.NONE: "묶음 없음"}
- used = sorted({a for e in edges for a in e})
- lines = ["```mermaid", "flowchart LR"] + [f' {g}["{titles[g]}"]' for g in used]
- lines += [f" {a} -->|{n}| {b}" for (a, b), n in sorted(edges.items())]
- return "\n".join(lines + ["```"])
-
- def build(self, typedoc: bool = True) -> dict:
- if self.src.exists():
- shutil.rmtree(self.src)
- (self.src / "py").mkdir(parents=True)
- files = sorted(self.by_file)
- if typedoc:
- self.ts_pages = run_typedoc([f for f in files if f.endswith(".ts")], self.out)
- self.py_refs = write_py_pages([f for f in files if f.endswith(".py")], self.src)
- nav = [{"처음": "index.md"}]
- home = ["# Aislo 프로그램 문서", "", "페이지 → 로직 묶음 → 함수 차례로 눌러 내려감.", ""]
- for page in self.rules["pages"]:
- groups = grouping.assign(self.fns, self.data["registry"], page["groups"])
- self.gid.update({f["id"]: gid for gid, fns in groups.items() for f in fns})
- nav.append({page["title"]: self.write_page(page, groups)})
- home.append(f"- [{page['title']}]({page['id']}/index.md)")
- ts_nav = []
- if (self.src / "ts" / "index.html").exists():
- (self.src / "ts.md").write_text(
- "# TypeScript 함수 화면\n\n[TypeDoc 열기](ts/index.html)\n", encoding="utf-8"
- )
- ts_nav = [{"TypeScript": "ts.md"}]
- nav.append({"함수 화면": [*ts_nav, {"파이썬": "py/index.md"}]})
- (self.src / "index.md").write_text("\n".join(home) + "\n", encoding="utf-8")
- (self.src / ".nav.json").write_text(json.dumps(nav, ensure_ascii=False), encoding="utf-8")
- return self.report()
-
- def report(self) -> dict:
- exported = [f for f in self.fns if f.get("exported")]
- return {
- "functions": len(self.fns),
- "groups": sum(len(p["groups"]) for p in self.rules["pages"]),
- "ungrouped": sum(1 for v in self.gid.values() if v == grouping.NONE),
- "session_keys": len(self.data["session_keys"]),
- "api": len(self.data["api"]),
- "undocumented_exports": sorted(
- f["id"] for f in exported if not first_line(f.get("doc"))
- ),
- "missing_refs": self.missing_refs,
- "stale_groups": self.stale,
- }
-
-
-def build_site(out: Path, strict: bool = True) -> None:
- """mkdocs build(--strict) — docs_dir = out/src · site_dir = out/site."""
- from mkdocs.commands.build import build
- from mkdocs.config import load_config
-
- cfg = load_config(
- str(DOCS / "mkdocs.yml"),
- docs_dir=str(out / "src"),
- site_dir=str(out / "site"),
- strict=strict,
- )
- build(cfg)
-
-
-def main() -> None:
- ap = argparse.ArgumentParser()
- ap.add_argument("--data", type=Path, default=DOCS / "_data")
- ap.add_argument("--out", type=Path, default=DOCS / "_build")
- ap.add_argument("--site", action="store_true", help="mkdocs build --strict 까지")
- ap.add_argument("--stamp", action="store_true", help="지금 함수 목록 지문을 손글에 적음")
- args = ap.parse_args()
- t0 = time.time()
- data, rules = load_data(args.data), grouping.load_rules()
- if args.stamp:
- for page in rules["pages"]:
- groups = grouping.assign(data["functions"], data["registry"], page["groups"])
- for g in page["groups"]:
- path = DOCS / page["guide"] / "groups" / f"{g['id']}.md"
- if path.exists():
- stamp(path, grouping.fingerprint(groups[g["id"]]))
- print("지문 적음")
- return
- args.out.mkdir(parents=True, exist_ok=True)
- out = args.out.resolve()
- rep = Site(data, rules, out).build()
- if args.site:
- build_site(out)
- summary = {k: (len(v) if isinstance(v, list) else v) for k, v in rep.items()}
- print(json.dumps(summary, ensure_ascii=False), f"{time.time() - t0:.1f}s")
- for ref in rep["missing_refs"]:
- print("손글 함수 없음:", ref)
-
-
-if __name__ == "__main__":
- main()
diff --git a/docs/tools/extract_all.py b/docs/tools/extract_all.py
deleted file mode 100644
index 18bef9039..000000000
--- a/docs/tools/extract_all.py
+++ /dev/null
@@ -1,50 +0,0 @@
-"""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
deleted file mode 100644
index 62860d0f5..000000000
--- a/docs/tools/extract_api.py
+++ /dev/null
@@ -1,138 +0,0 @@
-"""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
deleted file mode 100644
index 33038f82e..000000000
--- a/docs/tools/extract_py.py
+++ /dev/null
@@ -1,307 +0,0 @@
-"""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
deleted file mode 100644
index 7c37f2a95..000000000
--- a/docs/tools/extract_ts.mjs
+++ /dev/null
@@ -1,464 +0,0 @@
-/* =============================================================================
- * 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/group.py b/docs/tools/group.py
deleted file mode 100644
index b3a900cfe..000000000
--- a/docs/tools/group.py
+++ /dev/null
@@ -1,81 +0,0 @@
-"""함수 → 로직 묶음 자동 배정(PLAN 478-2).
-
-규칙 = docs/tools/groups.yml · 위에서부터 첫 맞음:
- registry — 계산 등록부 id 무늬 → 그 줄의 ts · pair 함수
- files — 파일 이름(확장자 뺀) 무늬
-어디에도 안 맞는 함수 = 「묶음 없음」(None).
-"""
-
-from __future__ import annotations
-
-import hashlib
-from fnmatch import fnmatchcase
-from pathlib import Path, PurePosixPath
-
-import yaml
-
-TOOLS = Path(__file__).resolve().parent
-NONE = "none"
-
-
-def load_rules(path: Path = TOOLS / "groups.yml") -> dict:
- return yaml.safe_load(path.read_text(encoding="utf-8"))
-
-
-def stem(file: str) -> str:
- return PurePosixPath(file.replace("\\", "/")).stem
-
-
-def registry_ref_id(ref: str | None) -> str | None:
- """등록부 칸 `<경로>#<함수>` → 함수 id."""
- if not ref or "#" not in ref:
- return None
- lang = "py" if ref.split("#", 1)[0].endswith(".py") else "ts"
- return f"{lang}:{ref}"
-
-
-def registry_owners(registry: list[dict], groups: list[dict]) -> dict[str, str]:
- """등록부로 정해지는 함수 id → 묶음 id."""
- owners: dict[str, str] = {}
- for row in registry:
- rid = row.get("id", "")
- group = next(
- (g["id"] for g in groups if any(fnmatchcase(rid, p) for p in g.get("registry", []))),
- None,
- )
- if group is None:
- continue
- for key in ("ts", "pair"):
- fid = registry_ref_id(row.get(key))
- if fid:
- owners.setdefault(fid, group)
- return owners
-
-
-def file_group(file: str, groups: list[dict]) -> str | None:
- name = stem(file)
- for g in groups:
- if any(fnmatchcase(name, p) for p in g.get("files", [])):
- return g["id"]
- return None
-
-
-def assign(
- functions: list[dict], registry: list[dict], groups: list[dict]
-) -> dict[str, list[dict]]:
- """묶음 id → 함수 목록(파일 · 줄 차례) · 「묶음 없음」 = NONE."""
- owners = registry_owners(registry, groups)
- out: dict[str, list[dict]] = {g["id"]: [] for g in groups}
- out[NONE] = []
- for fn in functions:
- gid = owners.get(fn["id"]) or file_group(fn["file"], groups) or NONE
- out[gid].append(fn)
- for fns in out.values():
- fns.sort(key=lambda f: (f["file"], f.get("line", 0)))
- return out
-
-
-def fingerprint(fns: list[dict]) -> str:
- """함수 목록 지문 — 손글에 적힌 지문과 다르면 「설명 확인 필요」."""
- text = "\n".join(sorted(f["id"] for f in fns))
- return hashlib.sha1(text.encode("utf-8")).hexdigest()[:10]
diff --git a/docs/tools/groups.yml b/docs/tools/groups.yml
deleted file mode 100644
index d44d6e474..000000000
--- a/docs/tools/groups.yml
+++ /dev/null
@@ -1,227 +0,0 @@
-# 문서 사이트 묶음 규칙 — 페이지 · 범위 · 로직 묶음
-# 함수 하나 = 묶음 하나 · 위에서부터 첫 맞음
-# registry — 계산 등록부 id 무늬(그 줄의 ts · pair 함수)
-# files — 파일 이름 무늬(확장자 뺀 이름 · fnmatch)
-# 어디에도 안 맞음 = 「묶음 없음」
-
-pages:
- - id: B05_B06
- title: B05 · B06 종단 · 횡단 설계
- guide: guide/B05_B06
- scope:
- - B05_Profile/
- - B06_Section/
- - A00_Common/design/
- - common_util/
- groups:
- - id: recommend
- title: 추천 노선
- files:
- - "*RouteInitial*"
- - B05_Profile_UI_RouteEdit_Initial*
- - B05_Profile_UI_RouteEdit_Recommend*
- - B05_Profile_UI_Panel_Recommend
- - B05_Profile_Engine_InitialProfile
- - B05_Profile_Engine_RidgeValley*
- - B05_Profile_Engine_Skeleton
- - B05_Profile_Engine_Solver
- - id: route_edit
- title: 노선 편집
- registry: ["route.*"]
- files:
- - B05_Profile_UI_RouteEdit*
- - B05_Profile_RouteEdit_*
- - B05_Profile_Route_NodeEdits
- - B05_Profile_ContourLock
- - design_plan_peek
- - common_util_route_polyline
- - common_util_route_geometry
- - id: route_save
- title: 노선 저장 · 시설 따라감
- registry: ["stations.carry", "facility.follow*", "facility.plus_follow"]
- files:
- - B05_Profile_Route_Save*
- - B05_Profile_Router_Replan
- - B05_Profile_Api_Replan
- - B06_Section_Router_DesignSave_Route
- - B06_Section_Route_Carry*
- - B05_Profile_Pipeline_Follow
- - B05_Profile_Structure_Follow
- - design_calc_node_carry
- - design_calc_node_follow
- - common_util_route_follow
- - id: stations
- title: 측점
- registry: ["stations.*"]
- files:
- - B05_Profile_Sections_Stations
- - B05_Profile_Engine_Sections*
- - B05_Profile_UI_Page_Stations
- - B05_Profile_UI_IrregularStations
- - B05_Profile_Util_Station
- - B05_Profile_Plus_Stations
- - B05_Profile_Api_PlusStations
- - B06_Section_Station_*
- - B06_Section_Swap_Bus
- - B06_Section_Router_Stations
- - B06_Section_UI_Missing_Stations
- - B06_Section_UI_Page_Station_Swap
- - design_pipeline_stations
- - design_pipeline_probe_station
- - common_util_station_key
- - common_util_station
- - id: ground
- title: 지반 표본
- registry: ["ground.*"]
- files:
- - B05_Profile_Api_TerrainGrid
- - B05_Profile_Sections_Ground
- - B05_Profile_Pipeline_RouteGround
- - B05_Profile_Router_Terrain
- - B06_Section_Engine_SampleExtend
- - B06_Section_Router_Widen
- - common_util_surface_sampler
- - common_util_surface_confirmation
- - id: earthwork
- title: 토량 · 유토 배분
- registry: ["earth.*", "haul.*"]
- files:
- - "*MassHaul*"
- - "*HaulPlan*"
- - B05_Profile_UI_Profile_Balance
- - common_util_mass_haul*
- - id: profile
- title: 종단 계획고
- registry: ["profile.*"]
- files:
- - B05_Profile_UI_Profile_*
- - B05_Profile_Profile_*
- - B05_Profile_Grade_*
- - B05_Profile_Engine_Grade*
- - B05_Profile_Pipeline_Grade
- - B05_Profile_EndOffsets
- - B05_Profile_UI_Panel*
- - B05_Profile_PageSettings
- - B05_Profile_Api_PageSettings
- - design_profile*
- - common_util_route_profile
- - id: cross
- title: 횡단 설계 계산
- registry: ["cross.*"]
- files:
- - B06_Section_Cross_*
- - B06_Section_Engine_*
- - B06_Section_Design_Keys
- - B06_Section_Router_Design
- - B06_Section_Router_Design_Ranges
- - B06_Section_Router_Regenerate_Options
- - B06_Section_Server_Calc*
- - common_util_cross_*
- - id: cross_view
- title: 횡단 화면 · 조정창
- files:
- - B06_Section_UI_Cross_*
- - B06_Section_UI_Standard*
- - B06_Section_UI_Section_*
- - B06_Section_UI_Longitudinal
- - B06_Section_UI_Adjust_Dock
- - B06_Section_Label_Tiers
- - B06_Section_Display_Width
- - B06_Section_Session_Cells
- - B06_Section_Section_Adjusts
- - B06_Section_UI_Page_Design_Sync
- - B06_Section_UI_Page_Station_Controls
- - B06_Section_UI_Page_Span_Control
- - B06_Section_UI_Page_Ford_Controls
- - B06_Section_UI_Page_Link_Session
- - B06_Section_UI_Page_Pipe_Options
- - B06_Section_UI_Page_Patches
- - id: drainage
- title: 관 · 배수
- registry: ["drainage.*", "facility.pipe*"]
- files:
- - "*Drainage*"
- - B05_Profile_Api_Pipes_Draft
- - B06_Section_Api_Culvert_Options
- - design_pipe_ids
- - design_structure_pipe*
- - common_util_drainage*
- - common_util_culvert*
- - id: structures
- title: 구조물
- registry: ["facility.*"]
- files:
- - "*Structures*"
- - "*Structure_*"
- - B05_Profile_UI_Structure_Pick_Session
- - B06_Section_Structure_Layouts
- - design_structure*
- - design_facility_lane
- - common_util_structure_*
- - common_util_berm*
- - common_util_excavation
- - id: corridor
- title: 3D 길 모형
- registry: ["corridor.*", "view3d.*"]
- files:
- - "*Corridor*"
- - B05_Profile_UI_Viewer*
- - design_view_profile_3d*
- - id: footprint
- title: 평면 발자국
- registry: ["plan.*", "footprint.*"]
- files:
- - "*Footprint*"
- - id: pipeline
- title: 계산 묶음 · 가게
- files:
- - design_pipeline*
- - design_calc*
- - design_store
- - B05_Profile_Pipeline_*
- - B06_Section_Section_*
- - B06_Section_UI_Page_Persist
- - common_util_calc_runner
- - common_util_derived_store
- - common_util_node_bundle
- - id: save
- title: 저장 · 확정 · 초기화 · 떠남
- files:
- - design_save*
- - design_reset
- - design_leave
- - design_shared
- - B06_Section_Router_DesignSave
- - B06_Section_Router_Confirm
- - B05_Profile_Router_Confirm
- - B05_Profile_Router_Lifecycle
- - common_util_initial_snapshot
- - common_util_save_guard
- - id: screen
- title: 페이지 틀 · 보기
- files:
- - design_page*
- - design_view*
- - design_panel
- - design_dock
- - design_pick*
- - design_selection
- - design_user_pick
- - design_sections
- - B05_Profile_UI*
- - B06_Section_UI*
- - id: server
- title: 서버 창구 · 저장소
- files:
- - "*_Router*"
- - "*_Repository*"
- - "*_Schema"
- - "*_Api_*"
- - B05_Profile_Engine*
- - B05_Profile
- - B06_Section
- - B05_Profile_Debug
- - id: common
- title: 공용 도구
- files:
- - common_util_*
diff --git a/docs/tools/nav_hook.py b/docs/tools/nav_hook.py
deleted file mode 100644
index ef8154ec2..000000000
--- a/docs/tools/nav_hook.py
+++ /dev/null
@@ -1,11 +0,0 @@
-"""mkdocs 훅 — build.py 가 쓴 왼쪽 메뉴(/.nav.json)를 얹음."""
-
-import json
-from pathlib import Path
-
-
-def on_config(config):
- nav = Path(config.docs_dir) / ".nav.json"
- if nav.exists():
- config.nav = json.loads(nav.read_text(encoding="utf-8"))
- return config
diff --git a/docs/tools/package-lock.json b/docs/tools/package-lock.json
deleted file mode 100644
index af15f7cd9..000000000
--- a/docs/tools/package-lock.json
+++ /dev/null
@@ -1,343 +0,0 @@
-{
- "name": "aislo-docs-tools",
- "lockfileVersion": 3,
- "requires": true,
- "packages": {
- "": {
- "name": "aislo-docs-tools",
- "dependencies": {
- "ts-morph": "^28.0.0",
- "typedoc": "^0.28.20",
- "typescript": "~6.0.3"
- }
- },
- "node_modules/@gerrit0/mini-shiki": {
- "version": "3.23.0",
- "resolved": "https://registry.npmjs.org/@gerrit0/mini-shiki/-/mini-shiki-3.23.0.tgz",
- "integrity": "sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==",
- "license": "MIT",
- "dependencies": {
- "@shikijs/engine-oniguruma": "^3.23.0",
- "@shikijs/langs": "^3.23.0",
- "@shikijs/themes": "^3.23.0",
- "@shikijs/types": "^3.23.0",
- "@shikijs/vscode-textmate": "^10.0.2"
- }
- },
- "node_modules/@shikijs/engine-oniguruma": {
- "version": "3.23.0",
- "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz",
- "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==",
- "license": "MIT",
- "dependencies": {
- "@shikijs/types": "3.23.0",
- "@shikijs/vscode-textmate": "^10.0.2"
- }
- },
- "node_modules/@shikijs/langs": {
- "version": "3.23.0",
- "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz",
- "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==",
- "license": "MIT",
- "dependencies": {
- "@shikijs/types": "3.23.0"
- }
- },
- "node_modules/@shikijs/themes": {
- "version": "3.23.0",
- "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz",
- "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==",
- "license": "MIT",
- "dependencies": {
- "@shikijs/types": "3.23.0"
- }
- },
- "node_modules/@shikijs/types": {
- "version": "3.23.0",
- "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz",
- "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==",
- "license": "MIT",
- "dependencies": {
- "@shikijs/vscode-textmate": "^10.0.2",
- "@types/hast": "^3.0.4"
- }
- },
- "node_modules/@shikijs/vscode-textmate": {
- "version": "10.0.2",
- "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz",
- "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
- "license": "MIT"
- },
- "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/@types/hast": {
- "version": "3.0.5",
- "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz",
- "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==",
- "license": "MIT",
- "dependencies": {
- "@types/unist": "*"
- }
- },
- "node_modules/@types/unist": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
- "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
- "license": "MIT"
- },
- "node_modules/argparse": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
- "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
- "license": "Python-2.0"
- },
- "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/entities": {
- "version": "4.5.0",
- "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
- "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">=0.12"
- },
- "funding": {
- "url": "https://github.com/fb55/entities?sponsor=1"
- }
- },
- "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/linkify-it": {
- "version": "5.0.2",
- "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz",
- "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/puzrin"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/markdown-it"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "uc.micro": "^2.0.0"
- }
- },
- "node_modules/lunr": {
- "version": "2.3.9",
- "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz",
- "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==",
- "license": "MIT"
- },
- "node_modules/markdown-it": {
- "version": "14.3.3",
- "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.3.tgz",
- "integrity": "sha512-L//tDAkjTjJCU6pBUSBTgs3Kg4InVaZuUZog/b6V12UKhcDQ7S78877ibbCanRExwPuYP1CZD7Gd2dxSy1GXRg==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/puzrin"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/markdown-it"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "argparse": "^2.0.1",
- "entities": "^4.5.0",
- "linkify-it": "^5.0.2",
- "mdurl": "^2.0.0",
- "punycode.js": "^2.3.1",
- "uc.micro": "^2.1.0"
- },
- "bin": {
- "markdown-it": "bin/markdown-it.mjs"
- }
- },
- "node_modules/mdurl": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz",
- "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==",
- "license": "MIT"
- },
- "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/punycode.js": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
- "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
- "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"
- }
- },
- "node_modules/typedoc": {
- "version": "0.28.20",
- "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.20.tgz",
- "integrity": "sha512-uSKqkh8Cr48vllnEy+jdaAgOeR6Y+QCBW7usgUsKj7gJEfR7stw9U/fE49LBnj2tPRKPY0c0EBJSWe9Appmplg==",
- "license": "Apache-2.0",
- "dependencies": {
- "@gerrit0/mini-shiki": "^3.23.0",
- "lunr": "^2.3.9",
- "markdown-it": "^14.3.0",
- "minimatch": "^10.2.5",
- "yaml": "^2.9.0"
- },
- "bin": {
- "typedoc": "bin/typedoc"
- },
- "engines": {
- "node": ">= 18",
- "pnpm": ">= 10"
- },
- "peerDependencies": {
- "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x"
- }
- },
- "node_modules/typescript": {
- "version": "6.0.3",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
- "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
- "license": "Apache-2.0",
- "bin": {
- "tsc": "bin/tsc",
- "tsserver": "bin/tsserver"
- },
- "engines": {
- "node": ">=14.17"
- }
- },
- "node_modules/uc.micro": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
- "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
- "license": "MIT"
- },
- "node_modules/yaml": {
- "version": "2.9.1",
- "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
- "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
- "license": "ISC",
- "bin": {
- "yaml": "bin.mjs"
- },
- "engines": {
- "node": ">= 14.6"
- },
- "funding": {
- "url": "https://github.com/sponsors/eemeli"
- }
- }
- }
-}
diff --git a/docs/tools/package.json b/docs/tools/package.json
deleted file mode 100644
index 486c098a3..000000000
--- a/docs/tools/package.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
- "name": "aislo-docs-tools",
- "private": true,
- "type": "module",
- "dependencies": {
- "ts-morph": "^28.0.0",
- "typedoc": "^0.28.20",
- "typescript": "~6.0.3"
- }
-}
diff --git a/docs/tools/requirements.txt b/docs/tools/requirements.txt
deleted file mode 100644
index 968820fc2..000000000
--- a/docs/tools/requirements.txt
+++ /dev/null
@@ -1,4 +0,0 @@
-mkdocs>=1.6
-mkdocs-material>=9.5
-mkdocstrings[python]>=0.26
-pyyaml>=6
diff --git a/docs/tools/scope.json b/docs/tools/scope.json
deleted file mode 100644
index dfcc7ab4c..000000000
--- a/docs/tools/scope.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "roots": ["B05_Profile/", "B06_Section/", "A00_Common/design/"],
- "follow": ["common_util/"],
- "skip": ["node_modules/", "__pycache__/"]
-}
diff --git a/resources/tester/test_478_docs.py b/resources/tester/test_478_docs.py
deleted file mode 100644
index 0f213ddbb..000000000
--- a/resources/tester/test_478_docs.py
+++ /dev/null
@@ -1,134 +0,0 @@
-"""478-2 문서 사이트 검사 — 묶음 배정 · 손글 함수 · 함수 화면 도구 대 추출 · mkdocs --strict.
-
-가짜 추출(fixtures/478_docs)은 늘 돎 · 진짜 추출(docs/_data)은 있을 때만.
-"""
-
-import re
-import sys
-import warnings
-from pathlib import Path
-
-import pytest
-
-ROOT = Path(__file__).resolve().parents[2]
-sys.path.insert(0, str(ROOT / "docs" / "tools"))
-
-import build # noqa: E402
-import group # noqa: E402
-
-FIXTURE = Path(__file__).parent / "fixtures" / "478_docs"
-REAL = ROOT / "docs" / "_data"
-
-
-def make_site(data_dir: Path, out: Path) -> tuple[build.Site, dict]:
- site = build.Site(build.load_data(data_dir), group.load_rules(), out)
- return site, site.build()
-
-
-def shown_anchors(site: build.Site) -> set[str]:
- text = "".join(p.read_text(encoding="utf-8") for p in (site.src).glob("*/*.md"))
- return set(re.findall(r"\{ #(f-[0-9a-f]{10}) \}", text))
-
-
-def ts_mismatch(site: build.Site) -> tuple[list, list]:
- """추출 export TS ↔ TypeDoc — (추출만, TypeDoc 함수만)."""
- rows = build.typedoc_entries(site.out)
- every = {(f, n) for f, n, _, _ in rows}
- ts = {(f["file"], f["name"]) for f in site.fns if f["lang"] == "ts" and f["exported"]}
- names = {n for _, n in ts}
- # 화면 없는 줄 = 다시 내보내기 — `export *` 는 추출이 원본 파일에서만 셈
- funcs = {(f, n) for f, n, k, rel in rows if k in build.FN_KINDS and (rel or n not in names)}
- return sorted(ts - every), sorted(funcs - ts)
-
-
-def py_mismatch(site: build.Site) -> tuple[list, list]:
- """추출 파이썬(안쪽 함수 뺌) ↔ griffe — (추출만, griffe 만)."""
- py = {
- (f["file"], f["name"])
- for f in site.fns
- if f["lang"] == "py" and (f["kind"] == "method" or "." not in f["name"])
- }
- got = {(file, n) for file in {f for f, _ in py} for n in build.griffe_names(file)}
- return sorted(py - got), sorted(got - py)
-
-
-@pytest.fixture(scope="module")
-def fake(tmp_path_factory):
- return make_site(FIXTURE, tmp_path_factory.mktemp("fake"))
-
-
-@pytest.fixture(scope="module")
-def real(tmp_path_factory):
- if not (REAL / "functions.json").exists():
- pytest.skip("추출 없음 — docs/_data")
- return make_site(REAL, tmp_path_factory.mktemp("real"))
-
-
-def test_assign_rules():
- data = build.load_data(FIXTURE)
- page = group.load_rules()["pages"][0]
- gid = {
- f["name"]: g
- for g, fns in group.assign(data["functions"], data["registry"], page["groups"]).items()
- for f in fns
- }
- assert gid["stationKey"] == "stations" # 등록부 stations.key
- assert gid["station_key"] == "stations" # 등록부 pair
- assert gid["seatBornNodes"] == "profile" # 등록부 profile.seat
- assert gid["median"] == "profile" # 파일 B05_Profile_Grade_*
- assert gid["node_edits"] == "route_edit" # 파일 B05_Profile_Route_NodeEdits
- assert gid["atomic_write_bytes"] == "common" # 파일 common_util_* 끝 규칙
- assert gid["get_project_storage_path"] == group.NONE
-
-
-def test_fake_site(fake):
- site, rep = fake
- assert shown_anchors(site) == {build.anchor(f["id"]) for f in site.fns}
- assert rep["ungrouped"] == 1
- assert rep["session_keys"] == 1 and rep["api"] == 1
- assert "stations" in rep["stale_groups"] # 손글 지문 ≠ 가짜 목록
- build.build_site(site.out, strict=True)
-
-
-def test_fake_tools_match_extract(fake):
- site, _ = fake
- assert ts_mismatch(site) == ([], [])
- assert py_mismatch(site) == ([], [])
-
-
-def test_real_hand_refs(real):
- _, rep = real
- assert rep["missing_refs"] == [], "손글이 가리키는 함수가 추출에 없음"
-
-
-def test_real_every_function_shown(real):
- site, _ = real
- assert shown_anchors(site) == {build.anchor(f["id"]) for f in site.fns}, "조용히 빠진 함수"
-
-
-def test_real_typedoc_matches_extract(real):
- only_extract, only_tool = ts_mismatch(real[0])
- assert not only_extract and not only_tool, (
- f"추출만 {only_extract[:10]} · TypeDoc 만 {only_tool[:10]}"
- )
-
-
-def test_real_griffe_matches_extract(real):
- only_extract, only_tool = py_mismatch(real[0])
- assert not only_extract and not only_tool, (
- f"추출만 {only_extract[:10]} · griffe 만 {only_tool[:10]}"
- )
-
-
-def test_real_counts(real):
- _, rep = real
- if rep["undocumented_exports"]:
- warnings.warn(f"설명 없는 export 함수 {len(rep['undocumented_exports'])}")
- if rep["ungrouped"]:
- warnings.warn(f"묶음 없는 함수 {rep['ungrouped']}")
- assert rep["ungrouped"] < rep["functions"]
-
-
-def test_real_strict_build(real):
- site, _ = real
- build.build_site(site.out, strict=True)
diff --git a/resources/tester/test_478_extract.py b/resources/tester/test_478_extract.py
deleted file mode 100644
index f50670aa3..000000000
--- a/resources/tester/test_478_extract.py
+++ /dev/null
@@ -1,96 +0,0 @@
-"""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