chore(문서): 문서 사이트 운영 안 함(사용자) — docs/tools · mkdocs.yml · 시험 478 둘 지움 · 생성물 무시 줄 걷음 · docs = guide · raw

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
This commit is contained in:
2026-10-19 20:02:53 +09:00
co-authored by Claude Opus 5.5
parent 47a63d3fa4
commit 482a443dce
17 changed files with 1 additions and 2463 deletions
+1 -5
View File
@@ -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/
~$*
-53
View File
@@ -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
-40
View File
@@ -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 는 페이지 · 묶음 화면과 링크만.
-495
View File
@@ -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']}",
"",
'<div class="grid cards" markdown>',
"",
*cards,
"</div>",
"",
]
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()
-50
View File
@@ -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()
-138
View File
@@ -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('.<locals>.', '.')}"
def load_routes() -> list[dict]:
os.chdir(ROOT)
sys.path.insert(0, str(ROOT))
logging.disable(logging.CRITICAL)
from fastapi.routing import APIRoute
import main
endpoints = {
(m, r.path): r.endpoint
for r in main.app.routes
if isinstance(r, APIRoute)
for m in r.methods
}
out = []
for path, ops in main.app.openapi()["paths"].items():
for method in ops:
m = method.upper()
ep = endpoints.get((m, path))
out.append({"method": m, "path": path, "handler": handler_id(ep) if ep else None})
return out
def segments(path: str) -> list[str]:
return [s for s in re.sub(r"^/api(?=/)", "", path).split("/") if s]
def score(ts_path: str, route_path: str) -> int | None:
"""맞으면 어긋남 수(작을수록 또렷) · 안 맞으면 None. TS `{}` = 아무 글자 · 서버 `{x}` = 칸."""
a, b = segments(ts_path), segments(route_path)
if len(a) != len(b):
return None
miss = 0
for t, r in zip(a, b):
param = r.startswith("{") and r.endswith("}")
if "{}" in t:
pat = "^" + ".*".join(re.escape(x) for x in t.split("{}")) + "$"
if param:
continue
if not re.match(pat, r):
return None
miss += t == "{}" # 통째 `{}` 가 글자 칸을 먹으면 어긋남 하나
elif param:
miss += 1 # 서버 칸이 TS 글자를 먹음
elif t != r:
return None
return miss
def match(call: dict, routes: list[dict]) -> list[dict]:
method = call["method"] # 글자 · "*"(변수 · 펼침 = 아무 메서드) · None(모름 = GET 먼저)
if method == "*":
pool = routes
elif method:
pool = [r for r in routes if r["method"] == method]
else:
pool = [r for r in routes if r["method"] == "GET"]
scored = [(s, r) for r in pool if (s := score(call["path"], r["path"])) is not None]
if not scored and not method:
scored = [(s, r) for r in routes if (s := score(call["path"], r["path"])) is not None]
if not scored:
return []
best = min(s for s, _ in scored)
return [r for s, r in scored if s == best]
def main() -> None:
routes = load_routes()
ts_calls = json.loads((DATA / "raw/ts_api_calls.json").read_text(encoding="utf-8"))
py_ids = {
f["id"] for f in json.loads((DATA / "raw/functions_py.json").read_text(encoding="utf-8"))
}
callers: dict[tuple[str, str], set[str]] = {}
unmatched = []
for call in ts_calls:
hits = match(call, routes)
if not hits:
unmatched.append(call)
for r in hits:
callers.setdefault((r["method"], r["path"]), set()).add(call["fn"])
api = [
{**r, "callers": sorted(callers.get((r["method"], r["path"]), ()))}
for r in routes
if r["handler"] in py_ids or (r["method"], r["path"]) in callers
]
api.sort(key=lambda r: (r["path"], r["method"]))
lonely = [f"{r['method']} {r['path']}" for r in api if not r["callers"]]
(DATA / "api.json").write_text(
json.dumps(api, ensure_ascii=False, indent=1) + "\n", encoding="utf-8"
)
(DATA / "api_unmatched.json").write_text(
json.dumps(
{"ts_calls": unmatched, "routes_without_caller": lonely}, ensure_ascii=False, indent=1
)
+ "\n",
encoding="utf-8",
)
print(
f"api: 서버 주소 전체 {len(routes)} · 범위 {len(api)} · 짝 없는 TS 글자 {len(unmatched)} · "
f"부르는 TS 없는 주소 {len(lonely)}"
)
if __name__ == "__main__":
main()
-307
View File
@@ -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 <x> import` 의 절대 모듈 이름(상대 import 풀기)."""
if not node.level:
return node.module or ""
parts = list(Path(rel(file)).parent.parts)
parts = parts[: len(parts) - (node.level - 1)] if node.level > 1 else parts
return ".".join([*parts, *([node.module] if node.module else [])])
def imports_of(file: Path, tree: ast.Module) -> dict[str, tuple[str, str | None]]:
"""이름 → (모듈, 그 모듈 안 이름 | None=모듈 자체)."""
out: dict[str, tuple[str, str | None]] = {}
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for a in node.names:
if a.asname:
out[a.asname] = (a.name, None)
else:
out[a.name] = (a.name, None)
elif isinstance(node, ast.ImportFrom):
mod = resolve_from(file, node)
for a in node.names:
sub = f"{mod}.{a.name}" if mod else a.name
out[a.asname or a.name] = (sub, None) if module_file(sub) else (mod, a.name)
return out
def scope_files() -> list[Path]:
seen: dict[str, Path] = {}
queue = [p for r in SCOPE["roots"] for p in (ROOT / r).rglob("*.py")]
# 계산 등록부(registry.json · extract_ts 가 먼저 씀)가 짚는 follow 폴더 파이썬도 범위
registry = ROOT / "docs/_data/registry.json"
named = json.loads(registry.read_text(encoding="utf-8")) if registry.is_file() else []
for ref in (x for e in named for x in (e.get("pair"), e.get("runner")) if x):
file = ref.removeprefix("py:").split("#")[0]
if (
file.endswith(".py")
and any(file.startswith(f) for f in SCOPE["follow"])
and (ROOT / file).is_file()
):
queue.append(ROOT / file)
while queue:
f = queue.pop()
r = rel(f)
if r in seen or skipped(r):
continue
seen[r] = f
tree = ast.parse(f.read_text(encoding="utf-8"), filename=r)
for mod, _ in imports_of(f, tree).values():
target = module_file(mod)
if target and any(rel(target).startswith(x) for x in SCOPE["follow"]):
queue.append(target)
return [seen[k] for k in sorted(seen)]
def first_line(node: ast.AST) -> str | None:
doc = (
ast.get_docstring(node)
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
else None
)
lines = [ln.strip() for ln in (doc or "").splitlines() if ln.strip()]
return lines[0] if lines else None
def params_of(fn: ast.FunctionDef | ast.AsyncFunctionDef) -> list[dict]:
a = fn.args
pos = [*a.posonlyargs, *a.args]
out = [
{"name": p.arg, "type": ast.unparse(p.annotation) if p.annotation else None} for p in pos
]
if a.vararg:
out.append(
{
"name": f"*{a.vararg.arg}",
"type": ast.unparse(a.vararg.annotation) if a.vararg.annotation else None,
}
)
out += [
{"name": p.arg, "type": ast.unparse(p.annotation) if p.annotation else None}
for p in a.kwonlyargs
]
if a.kwarg:
out.append(
{
"name": f"**{a.kwarg.arg}",
"type": ast.unparse(a.kwarg.annotation) if a.kwarg.annotation else None,
}
)
return out
def router_prefixes(tree: ast.Module) -> dict[str, str]:
"""모듈 머리 `router = APIRouter(prefix="...")` → {router: prefix}."""
out: dict[str, str] = {}
for node in tree.body:
if isinstance(node, ast.Assign) and isinstance(node.value, ast.Call):
callee = node.value.func
name = callee.id if isinstance(callee, ast.Name) else getattr(callee, "attr", "")
if name != "APIRouter":
continue
prefix = next(
(
k.value.value
for k in node.value.keywords
if k.arg == "prefix" and isinstance(k.value, ast.Constant)
),
"",
)
for t in node.targets:
if isinstance(t, ast.Name):
out[t.id] = prefix
return out
def routes_of(fn: ast.FunctionDef | ast.AsyncFunctionDef, prefixes: dict[str, str]) -> list[dict]:
out = []
for d in fn.decorator_list:
if not (isinstance(d, ast.Call) and isinstance(d.func, ast.Attribute)):
continue
verb = d.func.attr
owner = d.func.value.id if isinstance(d.func.value, ast.Name) else ""
path = d.args[0].value if d.args and isinstance(d.args[0], ast.Constant) else None
if path is None or (verb not in HTTP and verb != "api_route"):
continue
methods = [verb.upper()] if verb in HTTP else ["GET"]
for k in d.keywords:
if (
verb == "api_route"
and k.arg == "methods"
and isinstance(k.value, (ast.List, ast.Tuple))
):
methods = [e.value.upper() for e in k.value.elts if isinstance(e, ast.Constant)]
out += [{"method": m, "path": prefixes.get(owner, "") + path} for m in methods]
return out
def own_calls(fn: ast.AST):
"""fn 몸통의 부름 — 안쪽 def · class 몫은 빼고 lambda · 내포는 fn 몫."""
for child in ast.iter_child_nodes(fn):
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
continue
if isinstance(child, ast.Call):
yield child
yield from own_calls(child)
class Collector(ast.NodeVisitor):
def __init__(self, file: Path, tree: ast.Module, used: dict[str, int]):
self.file = rel(file)
self.imports = imports_of(file, tree)
self.prefixes = router_prefixes(tree)
self.used = used
self.stack: list[tuple[str, str]] = [] # (이름, "class" | "def")
self.records: list[dict] = []
self.raw_calls: dict[str, list[tuple[ast.expr, list[str], str | None]]] = {}
def _qual(self, name: str) -> str:
return ".".join([*(n for n, _ in self.stack), name])
def visit_ClassDef(self, node: ast.ClassDef) -> None:
self.stack.append((node.name, "class"))
self.generic_visit(node)
self.stack.pop()
def _fn(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
qual = self._qual(node.name)
rid = f"py:{self.file}#{qual}"
self.used[rid] = self.used.get(rid, 0) + 1
if self.used[rid] > 1:
rid = f"{rid}~{self.used[rid]}"
in_class = bool(self.stack) and self.stack[-1][1] == "class"
top = not self.stack or (in_class and len(self.stack) == 1)
rec = {
"id": rid,
"lang": "py",
"file": self.file,
"line": node.lineno,
"name": qual,
"kind": "method" if in_class else "function",
"exported": top and not node.name.startswith("_"),
"doc": first_line(node),
"params": params_of(node),
"returns": ast.unparse(node.returns) if node.returns else None,
"calls": [],
"called_by": [],
}
routes = routes_of(node, self.prefixes)
if routes:
rec["routes"] = routes
self.records.append(rec)
cls = self.stack[-1][0] if in_class else None
# 이름 찾는 차례 = 자기 안 → 감싼 def(안쪽부터) → 모듈(클래스 칸은 건너뜀 = 파이썬 규칙)
defs = [
".".join(n for n, _ in self.stack[: i + 1])
for i, (_, k) in enumerate(self.stack)
if k == "def"
]
scopes = [qual, *reversed(defs), ""]
self.raw_calls[rid] = [(c.func, scopes, cls) for c in own_calls(node)]
self.stack.append((node.name, "def"))
self.generic_visit(node)
self.stack.pop()
visit_FunctionDef = _fn
visit_AsyncFunctionDef = _fn
def resolve(
self, func: ast.expr, scopes: list[str], cls: str | None, ids: set[str]
) -> str | None:
def local(qual: str) -> str | None:
rid = f"py:{self.file}#{qual}"
return rid if rid in ids else None
def external(mod: str, name: str) -> str | None:
target = module_file(mod)
rid = f"py:{rel(target)}#{name}" if target else None
return rid if rid in ids else None
if isinstance(func, ast.Name):
for s in scopes:
hit = local(f"{s}.{func.id}" if s else func.id)
if hit:
return hit
if func.id in self.imports:
mod, name = self.imports[func.id]
return external(mod, name) if name else None
return None
if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
base = func.value.id
if base in ("self", "cls") and cls:
return local(f"{cls}.{func.attr}")
if local(f"{base}.{func.attr}"):
return local(f"{base}.{func.attr}")
if base in self.imports:
mod, name = self.imports[base]
return (
external(mod, func.attr)
if name is None
else external(mod, f"{name}.{func.attr}")
)
return None
def main() -> None:
used: dict[str, int] = {}
collectors = []
for f in scope_files():
tree = ast.parse(f.read_text(encoding="utf-8"), filename=rel(f))
c = Collector(f, tree, used)
c.visit(tree)
collectors.append(c)
records = [r for c in collectors for r in c.records]
ids = {r["id"] for r in records}
index = {r["id"]: r for r in records}
for c in collectors:
for rid, calls in c.raw_calls.items():
hits = {c.resolve(func, scopes, cls, ids) for func, scopes, cls in calls}
index[rid]["calls"] = sorted(h for h in hits if h and h != rid)
for r in records:
for target in r["calls"]:
index[target]["called_by"].append(r["id"])
for r in records:
r["called_by"] = sorted(set(r["called_by"]))
records.sort(key=lambda r: r["id"])
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(json.dumps(records, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
routes = sum(len(r.get("routes", [])) for r in records)
print(f"py: 파일 {len(collectors)} · 함수 {len(records)} · 라우트 데코레이터 {routes}")
if __name__ == "__main__":
main()
-464
View File
@@ -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}`,
);
-81
View File
@@ -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]
-227
View File
@@ -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_*
-11
View File
@@ -1,11 +0,0 @@
"""mkdocs 훅 — build.py 가 쓴 왼쪽 메뉴(<docs_dir>/.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
-343
View File
@@ -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"
}
}
}
}
-10
View File
@@ -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"
}
}
-4
View File
@@ -1,4 +0,0 @@
mkdocs>=1.6
mkdocs-material>=9.5
mkdocstrings[python]>=0.26
pyyaml>=6
-5
View File
@@ -1,5 +0,0 @@
{
"roots": ["B05_Profile/", "B06_Section/", "A00_Common/design/"],
"follow": ["common_util/"],
"skip": ["node_modules/", "__pycache__/"]
}
-134
View File
@@ -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)
-96
View File
@@ -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