diff --git a/.claude/hooks/docs-lint.py b/.claude/hooks/docs-lint.py index 24b7723..687bef9 100755 --- a/.claude/hooks/docs-lint.py +++ b/.claude/hooks/docs-lint.py @@ -1,29 +1,24 @@ #!/usr/bin/env python3 -"""docs/*.md 편집 후 자동 검증 — CONVENTIONS.md 정책 enforcement. +"""docs/*.md PostToolUse lint — single file mode. -PostToolUse hook 으로 Edit / Write / MultiEdit 후 실행. stdin 으로 받은 hook -event 의 ``tool_input.file_path`` 가 ``docs/*.md`` 면 검증, 그 외는 즉시 종료. +stdin 으로 받은 hook event 의 ``tool_input.file_path`` 가 ``docs/*.md`` 면 +공통 lib (``scripts/_doc_lint.py``) 의 룰 일괄 적용. 그 외 즉시 종료. -검증 항목 (CONVENTIONS.md 의 hard rule 4 종): +위반 시 exit 2 + stderr — Claude Code 가 stderr 를 LLM 컨텍스트에 주입하여 +모델이 위반 사항을 인지. exit 1 은 non-blocking 이라 사용 금지. -1. **Status 헤더** — Living 외 모든 spec 은 ``**Status**: ...`` 메타 라인 보유 -2. **업스트림 monorepo 잔재 키워드** — 분사 리포 컨벤션 위배 (``사용자 Fork`` / - ``rhwp 본체`` / ``pyo3-sandbox`` 등). v0.1.0 historical Frozen 본문은 예외 -3. **같은 vX.Y.Z 디렉토리 내 spec ↔ spec 직접 link** — pair 페어 - (``.md`` ↔ ``-research.md``) 만 예외 -4. **깨진 .md 링크** — relative path 가 실제 파일을 가리키는지 - -위반 발견 시 exit 2 + stderr — Claude Code 가 stderr 를 LLM 컨텍스트에 -주입하여 모델이 위반 사항을 인지하고 후속 조치 결정. exit 1 은 non-blocking -이라 LLM 에 노출되지 않으므로 사용 금지 (hooks 명세). +룰 일람 / 정책 SSOT: ``scripts/_doc_lint.py`` docstring + ``docs/CONVENTIONS.md``. """ import json -import re import sys from pathlib import Path -# * stdin 에서 hook event 파싱 +REPO = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO / "scripts")) + +from _doc_lint import lint_file # noqa: E402 + try: event = json.loads(sys.stdin.read() or "{}") except json.JSONDecodeError: @@ -34,9 +29,8 @@ if not file_path: sys.exit(0) -repo = Path(__file__).resolve().parents[2] try: - rel = Path(file_path).resolve().relative_to(repo) + rel = Path(file_path).resolve().relative_to(REPO) except ValueError: sys.exit(0) @@ -44,84 +38,9 @@ if not (rel_str.startswith("docs/") and rel.suffix == ".md"): sys.exit(0) -target = repo / rel -if not target.is_file(): - sys.exit(0) - -text = target.read_text(encoding="utf-8") -errors: list[str] = [] - - -# * 1. Status header (required outside Living docs) -LIVING_FILES = {"docs/CONVENTIONS.md", "docs/roadmap/README.md"} -if rel_str not in LIVING_FILES: - if not re.search(r"^\*\*Status\*\*:", text, re.MULTILINE): - errors.append( - "missing Status header — add '**Status**: " - " · " - "**GA|Target**: vX.Y.Z · **Last updated**: YYYY-MM-DD' " - "(CONVENTIONS § Status header format)" - ) - - -# * 2. Upstream monorepo residue keywords (v0.1.0 Frozen historical exempted) -HISTORICAL_FROZEN = ("docs/implementation/v0.1.0/",) -if not any(rel_str.startswith(p) for p in HISTORICAL_FROZEN): - forbidden = [ - "사용자 Fork", - "rhwp 본체", - "pyo3-sandbox", - "/Cargo.toml (루트)", - "pyo3-bindings.md", - ] - for kw in forbidden: - if kw in text: - errors.append( - f"upstream monorepo residue keyword {kw!r} — " - "this is a spinoff binding repo, not the source-of-truth repo" - ) - - -# * 3. Same-version spec ↔ spec direct link (pair files exempted) -# ^ SemVer 정확 매칭 (vMAJOR.MINOR.PATCH) — 이전의 [\d.]+ 기반은 catastrophic -# backtracking 위험 (CodeQL py/redos). v0.3.0 / v0.3.1 등 모두 cover. -m = re.match(r"docs/(roadmap|design)/(v\d+\.\d+\.\d+)/(.+)\.md$", rel_str) -if m: - base = m.group(3) - pair_topic = base.removesuffix("-research") - # ^ pair: .md ↔ -research.md (the only allowed direct link) - if base.endswith("-research"): - allowed_link = f"{pair_topic}.md" - else: - allowed_link = f"{base}-research.md" - self_link = f"{base}.md" - for link in re.findall(r"\]\(([^)]+\.md)[^)]*\)", text): - link_target = link.split("#")[0] - # only same-directory .md candidates qualify - if "/" in link_target: - continue - if link_target in (allowed_link, self_link): - continue - errors.append( - f"same-version spec direct link {link!r} — " - "route through phase-N.md or roadmap/README.md " - "(CONVENTIONS § Cross-link direction rule)" - ) - - -# * 4. Broken .md link -dir_path = target.parent -for link in re.findall(r"\]\(([^)]+\.md)[^)]*\)", text): - link_target = link.split("#")[0].split("?")[0] - if not link_target or link_target.startswith("http"): - continue - resolved = (dir_path / link_target).resolve() - if not resolved.exists(): - errors.append(f"broken .md link {link!r} (resolved: {resolved})") - - +errors = lint_file(rel_str, REPO) if errors: - sys.stderr.write(f"\ndocs-lint: {rel_str} — {len(errors)} violation(s)\n") + sys.stderr.write(f"\ndocs-lint: {len(errors)} violation(s)\n") for i, e in enumerate(errors, 1): sys.stderr.write(f" {i}. {e}\n") sys.stderr.write("policy: docs/CONVENTIONS.md\n") diff --git a/.claude/hooks/update-last-updated.py b/.claude/hooks/update-last-updated.py new file mode 100644 index 0000000..419f86d --- /dev/null +++ b/.claude/hooks/update-last-updated.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""docs/*.md 편집 시 frontmatter 의 last_updated 를 오늘 날짜로 자동 갱신. + +PostToolUse hook 으로 Edit / Write / MultiEdit 후 실행. stdin 으로 받은 hook +event 의 ``tool_input.file_path`` 가 ``docs/*.md`` 면서 frontmatter 가 있으면 +``last_updated:`` 라인을 오늘 날짜로 in-place 교체. + +skip 조건: +- ``docs/`` 외부 파일 +- frontmatter 없는 파일 (Living: CONVENTIONS / roadmap/README / traces/coverage) +- last_updated 가 이미 오늘 날짜 +- frontmatter 의 status 가 Frozen 또는 Superseded — 본문 의미 변경 금지가 + 원칙. 이런 파일을 편집한 경우는 (a) Frozen 면제 조항 활용 일괄 마이그 + (PR 단위 수기 처리) 또는 (b) 오타·링크 fix (last_updated 갱신 적절) 둘 + 다 가능 → 자동 처리는 위험하니 hook 은 skip, 사용자가 명시 결정. + +본 hook 은 silent (exit 0) — 갱신 결과는 git diff 로 확인. +""" + +import json +import re +import sys +from datetime import date +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] + +try: + event = json.loads(sys.stdin.read() or "{}") +except json.JSONDecodeError: + sys.exit(0) + +tool_input = event.get("tool_input") or {} +file_path = tool_input.get("file_path") or "" +if not file_path: + sys.exit(0) + +try: + rel = Path(file_path).resolve().relative_to(REPO) +except ValueError: + sys.exit(0) + +rel_str = str(rel).replace("\\", "/") +if not (rel_str.startswith("docs/") and rel.suffix == ".md"): + sys.exit(0) + +target = REPO / rel +if not target.is_file(): + sys.exit(0) + +text = target.read_text(encoding="utf-8") +if not text.startswith("---\n"): + sys.exit(0) +end = text.find("\n---\n", 4) +if end < 0: + sys.exit(0) + +block = text[4:end] +status_match = re.search(r"^status:\s*(\S+)", block, re.MULTILINE) +if status_match and status_match.group(1) in ("Frozen", "Superseded"): + # ^ Frozen / Superseded 자동 갱신 금지 — 사용자 명시 결정 필요 + sys.exit(0) + +today = date.today().isoformat() +new_block, n = re.subn( + r"^last_updated:\s*\S+", + f"last_updated: {today}", + block, + count=1, + flags=re.MULTILINE, +) +if n == 0 or new_block == block: + sys.exit(0) + +new_text = "---\n" + new_block + text[end:] +target.write_text(new_text, encoding="utf-8") +sys.exit(0) diff --git a/.claude/settings.json b/.claude/settings.json index e6115dc..15106e9 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -4,6 +4,10 @@ { "matcher": "Edit|Write|MultiEdit", "hooks": [ + { + "type": "command", + "command": "python3 ${CLAUDE_PROJECT_DIR}/.claude/hooks/update-last-updated.py" + }, { "type": "command", "command": "python3 ${CLAUDE_PROJECT_DIR}/.claude/hooks/docs-lint.py" diff --git a/.claude/skills/new-spec/SKILL.md b/.claude/skills/new-spec/SKILL.md new file mode 100644 index 0000000..d7b6e81 --- /dev/null +++ b/.claude/skills/new-spec/SKILL.md @@ -0,0 +1,125 @@ +--- +name: new-spec +description: Scaffold a new version spec and paired ADR following docs/CONVENTIONS.md. Invoke when the user wants to start a new version spec (e.g. "v0.4.0 view 렌더러 시작", "phase 3 첫 spec"). Idempotent — aborts if spec already exists. State the version + topic before invoking and wait for user confirmation. +argument-hint: +arguments: + - version + - topic +--- + +# /new-spec — scaffold a new version spec + +Given `` (e.g. `v0.4.0`) and `` (e.g. `view-renderer`), create a new per-version spec, its paired ADR (design research), and the index entry in one shot. + +## Outputs + +1. `docs/roadmap//.md` — the spec body (frontmatter `status: Draft`, `target: `) +2. `docs/design//-research.md` — the paired ADR (same frontmatter) +3. `docs/roadmap/README.md` — append a row to the active spec index table +4. EARS notation placeholder section inside the spec body + +## Procedure + +When this skill is invoked, execute the following steps in order: + +1. **Validate arguments** + - `version` must match the SemVer pattern `vX.Y.Z` + - `topic` must be kebab-case (`[a-z0-9]+(-[a-z0-9]+)*`) + - Abort if `docs/roadmap//.md` already exists — never overwrite an existing spec + +2. **Re-read `docs/CONVENTIONS.md`** before writing — apply the current frontmatter schema, naming rules, cross-link direction, and EARS notation section. Conventions may have evolved since the last skill invocation. + +3. **Create directories** if missing: + - `docs/roadmap//` + - `docs/design//` + +4. **Write `docs/roadmap//.md`** using this template (placeholders in `<...>` must be filled by Claude based on user intent; section headers and Korean prose stay as-is — they become Korean docs): + + ```markdown + --- + status: Draft + target: + last_updated: + --- + + # + + . + + 주요 결정의 근거·대안·실패 시나리오는 짝 페어: [-research.md](../../design//-research.md). + + ## 결정 사항 + + | 항목 | 값 | 근거 | + |---|---|---| + | 1 | (placeholder) | (placeholder) | + + ## 인수조건 + + + + - **AC-1** — + - **AC-2** — + + ## 영구 비목표 + + - + + ## 참조 + + - 짝 페어 (ADR): [-research.md](../../design//-research.md) + ``` + +5. **Write `docs/design//-research.md`** using this template: + + ```markdown + --- + status: Draft + target: + last_updated: + --- + + # — 설계 의사결정 리서치 요약 + + [/.md](../../roadmap//.md) §결정 사항 중 외부 독자가 "왜?" 를 던질 만한 N건의 업계 선례·대안·실패 시나리오를 기록한다. .md 본문이 최종 결정을 기술하고, 본 문서는 그 결정의 근거를 담는다. + + ## 결정 매트릭스 + + | # | 항목 | 옵션 비교 | 채택 | 1차 근거 | + |---|---|---|---|---| + | 1 | (placeholder) | A: ... / B: ... / C: ... | (?) | (?) | + + ## 1. + + ### 팩트 + ### 검증자 반박 + ### 최종 결정 + ### 1차 소스 + + ## 참조 + + - [roadmap//.md](../../roadmap//.md) — 본 리서치의 결정 요약 + ``` + +6. **Append a row to `docs/roadmap/README.md`** in the active spec index table (find `## 활성 spec 인덱스` section, add at the end of the table): + + ```markdown + | () | Draft | [/.md](/.md) | [design//-research.md](../design//-research.md) | + ``` + +7. **Run integrity check**: `uv run --no-project --with "typer>=0.12" python scripts/lint_docs.py docs/`. If violations are reported, surface them to the user and propose corrections — do not silently fix. + +## Rules (must comply with `docs/CONVENTIONS.md`) + +- Frontmatter schema: `status` enum, `target` SemVer, `last_updated` `YYYY-MM-DD` +- Spec ↔ research pair files may link directly; **all other spec ↔ spec direct links are forbidden** — route through index pages +- Filenames must be kebab-case; directories use `vX.Y.Z` SemVer +- Relative paths are implicit (`foo.md`, `subdir/foo.md`); no `./` prefix; external resources use fully-qualified URLs + +## Limits + +- Spec body and decision matrix are placeholders — actual decisions / acceptance criteria / non-goals must be filled by the user +- This skill automates **structural consistency** only (frontmatter / pair / index / EARS placeholder). Content judgment is the user's. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d5d0b7..07e4dd8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,22 +1,14 @@ name: CI # 트리거: PR / main 푸시 — test 계열만 (wheel 빌드 + 배포는 publish.yml) -# 문서·라이선스·gitignore 전용 변경은 paths-ignore 로 스킵 (런타임·빌드 영향 없음) +# 문서·라이선스·gitignore 전용 변경은 changes job + job-level if 패턴으로 if-skip +# (status: success). paths-ignore 안 씀 — required check "All tests passed" 가 +# expected 로 stuck 되는 GitHub 함정 회피. on: push: branches: [main] - paths-ignore: - - '**.md' - - 'docs/**' - - 'LICENSE*' - - '.gitignore' pull_request: branches: [main] - paths-ignore: - - '**.md' - - 'docs/**' - - 'LICENSE*' - - '.gitignore' workflow_dispatch: {} permissions: @@ -27,11 +19,34 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + # * 코드 변경 감지 — docs/*.md / LICENSE / .gitignore 만 변경되면 test job 들이 + # if-skip (status: success). predicate-quantifier: every 라 모든 변경 파일이 + # exclusion 패턴에 다 해당해야 code:false (= docs-only commit). + changes: + name: Detect code changes + runs-on: ubuntu-latest + outputs: + code: ${{ steps.filter.outputs.code }} + steps: + - uses: actions/checkout@v6 + - uses: dorny/paths-filter@v4 + id: filter + with: + predicate-quantifier: every + filters: | + code: + - '!**.md' + - '!docs/**' + - '!LICENSE*' + - '!.gitignore' + # * Linux abi3 wheel 1회 빌드 → 모든 Linux 잡(test×4 / slow / core-only)이 공유 # abi3-py310 이라 py3.10/3.11/3.12/3.13 가 동일 wheel 재사용 가능. # macOS/Windows 는 단일 잡이라 빌드/테스트 분리 이득이 없어 그대로 매번 빌드. build-linux-wheel: name: Build Linux abi3 wheel + needs: changes + if: needs.changes.outputs.code == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -55,7 +70,8 @@ jobs: # * 메인 테스트 + 린트 + 타입체크 (Linux × 전 Python 버전 — wheel 공유) test: name: Test (Linux / py${{ matrix.python }}) - needs: build-linux-wheel + needs: [build-linux-wheel, changes] + if: needs.changes.outputs.code == 'true' runs-on: ubuntu-latest strategy: fail-fast: false @@ -113,6 +129,8 @@ jobs: # * macOS / Windows 스모크 — 단일 잡이라 wheel 분리 이득 없음 → 직접 maturin develop test-other-os: name: Test (${{ matrix.os }} / py3.12) + needs: changes + if: needs.changes.outputs.code == 'true' runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -139,7 +157,8 @@ jobs: # * PDF 렌더링 — 느려서 별도 잡, Linux wheel 재사용 test-slow: name: Test slow (Linux / py3.12 — PDF) - needs: build-linux-wheel + needs: [build-linux-wheel, changes] + if: needs.changes.outputs.code == 'true' runs-on: ubuntu-latest defaults: run: @@ -162,7 +181,8 @@ jobs: # * extras 미설치 시 langchain 테스트가 importorskip 로 auto-skip 되는지 검증 test-core-only: name: Test without extras (importorskip auto-skip) - needs: build-linux-wheel + needs: [build-linux-wheel, changes] + if: needs.changes.outputs.code == 'true' runs-on: ubuntu-latest defaults: run: @@ -199,8 +219,9 @@ jobs: name: All tests passed if: always() runs-on: ubuntu-latest - needs: [build-linux-wheel, test, test-other-os, test-slow, test-core-only] + needs: [changes, build-linux-wheel, test, test-other-os, test-slow, test-core-only] steps: - uses: re-actors/alls-green@release/v1 with: jobs: ${{ toJSON(needs) }} + allowed-skips: build-linux-wheel, test, test-other-os, test-slow, test-core-only diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c61fd2c..1fcaa8c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -3,24 +3,40 @@ name: CodeQL on: push: branches: [main] - paths-ignore: - - '**.md' - - 'docs/**' - - 'LICENSE*' - - '.gitignore' pull_request: branches: [main] - paths-ignore: - - '**.md' - - 'docs/**' - - 'LICENSE*' - - '.gitignore' schedule: - cron: "0 6 * * 1" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: + # * 코드 변경 감지 — docs-only PR 은 analyze job 을 if-skip (status: success). + # schedule 트리거 (주 1회 cron) 은 코드 변경 무관하게 항상 분석. + changes: + name: Detect code changes + runs-on: ubuntu-latest + outputs: + code: ${{ steps.filter.outputs.code }} + steps: + - uses: actions/checkout@v6 + - uses: dorny/paths-filter@v4 + id: filter + with: + predicate-quantifier: every + filters: | + code: + - '!**.md' + - '!docs/**' + - '!LICENSE*' + - '!.gitignore' + analyze: name: Analyze + needs: changes + if: github.event_name == 'schedule' || needs.changes.outputs.code == 'true' runs-on: ubuntu-latest permissions: security-events: write diff --git a/.github/workflows/dependabot-uv-sync.yml b/.github/workflows/dependabot-uv-sync.yml index 59a24ea..828630d 100644 --- a/.github/workflows/dependabot-uv-sync.yml +++ b/.github/workflows/dependabot-uv-sync.yml @@ -2,6 +2,10 @@ name: Dependabot uv sync on: pull_request +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + permissions: contents: write diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..404ddd3 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,51 @@ +name: Docs lint + +# 트리거: docs/*.md / scripts/_doc_lint.py / hook 변경 시. 빌드/테스트와 분리. +on: + push: + branches: [main] + # ^ 'docs/**' 가 docs 안 모든 .md 포괄. README/CHANGELOG 등 lint 무관 .md + # 변경에 fire 안 하려고 '**.md' 제외. tests/** 는 spec-trace 검증 때문에 유지. + paths: + - 'docs/**' + - 'tests/**' + - 'scripts/_doc_lint.py' + - 'scripts/lint_docs.py' + - 'scripts/generate_spec_trace.py' + - '.claude/hooks/docs-lint.py' + - '.github/workflows/docs.yml' + pull_request: + branches: [main] + paths: + - 'docs/**' + - 'tests/**' + - 'scripts/_doc_lint.py' + - 'scripts/lint_docs.py' + - 'scripts/generate_spec_trace.py' + - '.claude/hooks/docs-lint.py' + - '.github/workflows/docs.yml' + workflow_dispatch: {} + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + lint: + name: Lint docs (frontmatter / pair / supersede / links) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v8.1.0 + with: + python-version: "3.12" + # ^ --no-project: pyproject.toml 의 maturin build (Rust + submodule 필요) skip. + # --with: typer 만 ad-hoc 설치 (lint 는 rhwp import 안 함, 가벼움). + - name: Lint docs + run: uv run --no-project --with "typer>=0.12" python scripts/lint_docs.py docs/ + - name: Verify spec trace report up to date + # ^ tests/ 의 @pytest.mark.spec 변경 시 docs/traces/coverage.md 동기화 필수. + run: uv run --no-project --with "typer>=0.12" python scripts/generate_spec_trace.py --check diff --git a/.gitignore b/.gitignore index c189367..9c5df84 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,8 @@ coverage.xml # * Claude Code — settings.json 은 팀 공유 (hooks 등록), local override 만 ignore .claude/settings.local.json +# ^ ScheduleWakeup / 백그라운드 task lock (런타임 PID/세션ID, 머신-로컬) +.claude/scheduled_tasks.lock # * Examples 산출물 render_output/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b3771ac --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,67 @@ +# AGENTS.md — rhwp-python + +Project-specific instructions. Inherits all rules from `~/.claude/CLAUDE.md` (global). + +## Project context + +- **What it is**: PyO3 Python bindings for the [edwardkim/rhwp](https://github.com/edwardkim/rhwp) Rust HWP/HWPX parser & renderer +- **Names**: PyPI `rhwp-python` / `import rhwp` / extension `rhwp._rhwp` +- **Core delivery**: Rust core consumed via git submodule at `external/rhwp`, pinned to a specific upstream commit (tracked in `CHANGELOG.md` + `.gitmodules`) +- **License**: MIT — dual copyright (Edward Kim for rhwp core, DanMeon for bindings). Both LICENSE files are bundled in the wheel (`license-files = ["LICENSE", "external/rhwp/LICENSE"]`) +- **Status**: unofficial community package. The `rhwp` name on PyPI is intentionally left for the upstream maintainer + +## Project-specific rules + +### Rust + Python hybrid build +- After any Rust change (`src/*.rs`): `uv run maturin develop --release` before `pytest` (without it, tests run against the stale binary), and `cargo clippy --all-targets -- -D warnings` for lint +- `external/rhwp/` is upstream-owned. Never edit it locally — file an issue / PR against [edwardkim/rhwp](https://github.com/edwardkim/rhwp) instead +- PyO3 `#[pyclass(unsendable)]`: `_Document` is bound to its creation thread (upstream `DocumentCore` holds `RefCell` fields — `!Sync`). Same-thread worker pattern (`parse + consume + return primitives` inside one thread) works; `asyncio.to_thread(rhwp.parse, path)` does NOT — the Future resolves on the main thread and first attribute access panics with `_rhwp::document::PyDocument is unsendable, but sent to another thread` +- GIL release via `py.detach` — apply selectively, not blanket: + - **Release** for ≥1 ms CPU/IO-bound work that touches only Rust-side data (parse, render, decode, compress, file read). Current sites: `_Document::from_bytes` / `render_pdf()` / `export_pdf()`. When adding new methods of this shape, follow the same pattern + - **Don't release** for trivial getters, short attribute access, or hot paths that frequently call back into Python — the `detach`/`attach` round-trip cost exceeds the gain, and may slow things down + - **When unsure**, measure with the `benches/bench_gil.py` pattern (with vs without `py.detach` wall-clock comparison) before committing +- `abi3-py310` feature: **one wheel covers 3.10–3.13+**. Don't bind to Python version-specific C API + +### Async direction +- Python-surface APIs for I/O and integrations are **async-first**: when adding LangChain / LlamaIndex / Haystack loaders, implement `aload` / `alazy_load` / async counterparts alongside sync versions +- **Forbidden pattern**: `asyncio.to_thread(rhwp.parse, path)` — `_Document` is unsendable (see Rust+Python hybrid build note above), the returned Document panics on main-thread access. `async fn` in `#[pymethods]` is also incompatible (PyO3 requires `Send + 'static` futures) +- **Supported async pattern**: `aparse(path)` uses stdlib `asyncio.to_thread` to offload the file read to a thread pool, then calls `Document.from_bytes(data)` on the event-loop thread. Document never crosses a thread boundary. No external dependency — Python `asyncio` lacks native async file I/O so all async file libs (aiofiles etc.) wrap thread pools anyway; stdlib achieves the same effect with zero install footprint +- **Document instance-level async methods (`doc.ato_ir()` etc.) are NOT provided** — they would require thread offload which unsendable forbids. For async code, `await rhwp.aparse(path)` once, then call sync methods on the Document directly (these are fast, in-memory, GIL-holding operations) +- If upstream rhwp ever replaces its `RefCell` caches with thread-safe synchronization, revisit this — `unsendable` could then be dropped, enabling true `async fn pymethods` + +### Tests +- Real HWP fixtures live in the submodule: `external/rhwp/samples/aift.hwp` (HWP5), `table-vpos-01.hwpx` (HWPX). `tests/conftest.py` + `benches/bench_gil.py` reference this path +- When changing one path, change both +- Markers: `slow` (PDF render), `langchain` (extras required). Default run: `pytest -m "not slow"` +- Extras-gated test files use module-level `pytest.importorskip` so the whole file counts as **1 skip** when the extra is missing. Current gated files: `test_langchain_loader.py` + `test_langchain_loader_ir.py` (langchain-core), `test_ir_schema_export.py` (jsonschema), `test_cli.py` (typer) → CI's `test-without-extras` job validates **exactly 4 skipped** (see `.github/workflows/ci.yml`). When adding a new extras-gated file, bump the count in both AGENTS.md and ci.yml +- `tests/type_check_errors.py` holds **exactly 4 intentional pyright errors** — CI validates that too. When editing, preserve count; don't fix them + +### Git workflow +- Single-branch trunk model: feature branches off `main` → PR to `main`. No `develop` / `staging` +- Branch naming: **MINOR** = `feature/vX.Y.0` (long-lived, isolates external contract changes across stages). **PATCH** = `/` (short-lived, merges directly to main, tag only `vX.Y.Z`) where `` follows [Conventional Commits](https://www.conventionalcommits.org/) (`fix` / `chore` / `refactor` / `docs` / `build` / `ci` / `perf` / `test` / `revert`) +- Commit subject: lowercase `type: description` (seed commit: `init: 프로젝트 초기화`) +- PR body follows [.github/pull_request_template.md](.github/pull_request_template.md) — Summary / Why / Related Issues +- Full contributor flow (fork, pre-submit checks, rhwp-core changes): [CONTRIBUTING_EN.md](CONTRIBUTING_EN.md) (Korean: [CONTRIBUTING.md](CONTRIBUTING.md)) + +### Versioning / release +- Git tags `vX.Y.Z`, SemVer, MINOR-sized increments +- **Cargo.toml is the version source of truth** via `dynamic = ["version"]` in pyproject.toml. Always bump Cargo.toml before tagging — `publish.yml`'s `verify-version` aborts on mismatch +- **No breaking changes across Phase boundaries** (Phase 1 → 2 must keep existing APIs) +- Release trigger: GitHub Release `published` event fires `publish.yml`. Draft releases don't trigger +- Every release records the `external/rhwp` submodule commit hash in CHANGELOG. The git submodule itself (visible via `git ls-tree external/rhwp`) is the authoritative pin per release +- Integration-only runtime deps (LangChain, typer, jsonschema) belong in `[project.optional-dependencies]`, never `[project] dependencies` — keeps the core wheel dependency-free + +### Documentation +Authoritative policy is `docs/CONVENTIONS.md` — read it before any docs work. Active spec index SSOT is `docs/roadmap/README.md`. + +Hard rules (auto-applied without further instruction): +- Every per-version spec / ADR / impl-log / verification report carries a YAML frontmatter block as the first lines: `status: ` + `ga: vX.Y.Z` *or* `target: vX.Y.Z` + `last_updated: YYYY-MM-DD`. Living docs (README, CHANGELOG, AGENTS.md, CLAUDE.md, CONVENTIONS itself) skip the frontmatter. +- **Frozen spec body is immutable** — typo / broken-link fixes only. Decision changes go to a *new* spec; the old one's frontmatter flips to `status: Superseded`, `superseded_by: ` (single-block edit). Exception: Living-policy schema migration (see CONVENTIONS § Frozen 면제 조항). +- **Spec ↔ spec direct cross-links are forbidden** even within the same `vX.Y.Z/` directory. Use `phase-N.md` § two-axis-integration sections (or `roadmap/README.md`) as the bridge. **Exception**: pair files `.md` ↔ `-research.md` (the spec ↔ ADR pair) link directly. +- **`phase-N.md` carries no concrete decisions / open issues** — those belong in `vX.Y.Z/*.md`. Phase docs hold intent, scope, and two-axis integration only. +- New version `vX.Y.Z`: invoke `/new-spec ` Claude Code skill (auto-scaffolds spec + paired ADR + README index row). Manual: create `docs/roadmap/vX.Y.Z/.md` + `docs/design/vX.Y.Z/-research.md` (frontmatter `status: Draft`, `target: vX.Y.Z`), then add a row to the active-spec index in `roadmap/README.md`. On GA: flip `status: Draft → Frozen`, swap `target` → `ga`, write `implementation/vX.Y.Z/...` (Frozen on creation), refresh README index. + +### CI / secrets +- No secrets required. PyPI publish uses Trusted Publisher (OIDC) — no API token to manage +- `secrets.GITHUB_TOKEN` is injected automatically; don't try to "register" it +- Workflow permissions stay minimal. `publish.yml` declares `id-token: write` at the job level only diff --git a/CHANGELOG.md b/CHANGELOG.md index b06788c..a763be3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed — 문서 시스템 대규모 개편 + +본 변경은 메타 — 사용자 facing API / wheel 영향 0. 내부 문서 운영 체계 정비. + +- spec 메타데이터를 inline `**Status**: ...` 라인 → YAML frontmatter 로 전면 마이그 (24개 spec 일괄, Frozen 19 / Draft 2 / Active 3). Living-policy schema migration — CONVENTIONS § Frozen 면제 조항 신설 (non-semantic 형식 갱신은 in-place 허용). +- `AGENTS.md` 를 정본 agent context 파일로 도입 (`CLAUDE.md` 는 1줄 stub). Codex / Factory / Cursor / Kilo 등 비-Claude 도구 호환. +- `scripts/lint_docs.py` + `scripts/_doc_lint.py` (공통 lib) 신설 — frontmatter schema / supersede chain / kebab-case / 페어 / cross-link 방향성 / 깨진 링크 8 룰 일괄 검증. `.claude/hooks/docs-lint.py` 가 동일 lib 재사용. CI `docs.yml` workflow 분리 (paths-filter — build/test 와 독립). +- `pytest.mark.spec("vX.Y.Z/topic#AC-N")` marker + `scripts/generate_spec_trace.py` (AST 정적 분석) → `docs/traces/coverage.md` (Living) 자동 매핑. v0.4.0+ 신규 spec 부터 적용, 기존 v0.1.0 ~ v0.3.0 Frozen 미변경. +- `/new-spec ` Claude Code skill 신설 — 새 version spec + 짝 페어 ADR + README 인덱스 row + EARS placeholder 일괄 생성, lint 자동 검증. +- `last_updated` 자동 갱신 hook (`.claude/hooks/update-last-updated.py`, PostToolUse) — Frozen / Superseded / Living 은 skip. +- CONVENTIONS.md 갱신: EARS notation (v0.4.0+) / CHANGELOG ↔ implementation log 역할 분리 / 상대경로 implicit 표준 / Frozen 외부 의존성 부패 정책 / Trace report / verification 약화 / meta-level implementation 슬롯. +- 부수 정리: 상류 `edwardkim/rhwp#390` (find_control_text_positions) 옵션 A 채택 → cherry-pick 머지 → 본 spec in-place Frozen 전환 + RESOLVED notice. design 파일 `-design-research.md` → `-research.md` 명명 통일 (v0.2.0/ir, v0.3.0/cli rename + 24 cross-link 일괄 정정). +- 본 PR 의 a/b/c 결정 비교 + 14개 결정 historical record 는 [docs/implementation/spec-system-overhaul.md](docs/implementation/spec-system-overhaul.md) (Frozen) 가 보유. + ## [0.3.0] — 2026-04-28 ### Changed — async API 의존성 정리 @@ -62,7 +76,7 @@ v0.2.0 에서 폐기됐던 CLI 를 별도 이름 (`rhwp-py`) 으로 재도입. - `docs/roadmap/v0.3.0/ir-expansion.md` — IR 확장 spec (8 결정 사항 + research 인용). - `docs/roadmap/v0.3.0/cli.md` — `rhwp-py` 재도입 spec (이름 선정 + overlap=0 + extras 정책). -- `docs/design/v0.3.0/ir-expansion-research.md` / `cli-design-research.md` — 결정 증거. +- `docs/design/v0.3.0/ir-expansion-research.md` / `cli-research.md` — 결정 증거. - `docs/implementation/v0.3.0/stages/stage-{1..4}.md` — 단계별 구현 로그 (S1: Picture+Furniture, S2: Formula+Footnote/Endnote, S3: ListItem+Caption+Toc+Field, S4: Schema GA + CLI + LangChain include_furniture + 문서). - `README.md` — v0.3.0 신규 블록 + `rhwp-py` CLI 섹션 추가, content-addressed alias 안내. @@ -104,7 +118,7 @@ MINOR release — Phase 2 착수. RAG / LLM 파이프라인이 직접 소비하 - `.github/workflows/publish-schema.yml` — GitHub Pages 배포 파이프라인, 불변 경로 정책 (v1 URL 영구) 자동화. - Provenance 단위는 **Unicode codepoint** — Python `str[i]` 슬라이싱과 직접 호환 (이모지/SMP CJK 혼용에서도 off-by-one 없음). - 신규 런타임 의존성: `pydantic>=2.5,<3`. 테스트 의존성: `jsonschema>=4`. -- 문서: `docs/roadmap/v0.2.0/ir.md` (사양), `docs/design/v0.2.0/ir-design-research.md` (7개 결정 증거), `docs/implementation/v0.2.0/stages/stage-{1..5}.md`. +- 문서: `docs/roadmap/v0.2.0/ir.md` (사양), `docs/design/v0.2.0/ir-research.md` (7개 결정 증거), `docs/implementation/v0.2.0/stages/stage-{1..5}.md`. - 테스트: **165 passed** — IR schema/roundtrip/tables/iter/export + LangChain ir-blocks + Rust unit tests (`cargo test` 5 passed). ### Added — Binding 구조 개선 (Python wrapper class + async 진입점) diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 787afd0..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,72 +0,0 @@ -# CLAUDE.md — rhwp-python - -Project-specific instructions. Inherits all rules from `~/.claude/CLAUDE.md` (global). - -## Project context - -- **What it is**: PyO3 Python bindings for the [edwardkim/rhwp](https://github.com/edwardkim/rhwp) Rust HWP/HWPX parser & renderer -- **Names**: PyPI `rhwp-python` / `import rhwp` / extension `rhwp._rhwp` -- **Core delivery**: Rust core consumed via git submodule at `external/rhwp`, pinned to a specific upstream commit (tracked in `CHANGELOG.md` + `.gitmodules`) -- **License**: MIT — dual copyright (Edward Kim for rhwp core, DanMeon for bindings). Both LICENSE files are bundled in the wheel (`license-files = ["LICENSE", "external/rhwp/LICENSE"]`) -- **Status**: unofficial community package. The `rhwp` name on PyPI is intentionally left for the upstream maintainer - -## Global rules inherited - -All rules from `~/.claude/CLAUDE.md` apply. This file adds only project-specific details — do not restate global rules here. - -## Project-specific rules - -### Rust + Python hybrid build -- After any Rust change (`src/*.rs`): `uv run maturin develop --release` before `pytest` (without it, tests run against the stale binary), and `cargo clippy --all-targets -- -D warnings` for lint -- `external/rhwp/` is upstream-owned. Never edit it locally — file an issue / PR against [edwardkim/rhwp](https://github.com/edwardkim/rhwp) instead -- PyO3 `#[pyclass(unsendable)]`: `_Document` is bound to its creation thread (upstream `DocumentCore` holds `RefCell` fields — `!Sync`). Same-thread worker pattern (`parse + consume + return primitives` inside one thread) works; `asyncio.to_thread(rhwp.parse, path)` does NOT — the Future resolves on the main thread and first attribute access panics with `_rhwp::document::PyDocument is unsendable, but sent to another thread` -- GIL release via `py.detach` — apply selectively, not blanket: - - **Release** for ≥1 ms CPU/IO-bound work that touches only Rust-side data (parse, render, decode, compress, file read). Current sites: `_Document::from_bytes` / `render_pdf()` / `export_pdf()`. When adding new methods of this shape, follow the same pattern - - **Don't release** for trivial getters, short attribute access, or hot paths that frequently call back into Python — the `detach`/`attach` round-trip cost exceeds the gain, and may slow things down - - **When unsure**, measure with the `benches/bench_gil.py` pattern (with vs without `py.detach` wall-clock comparison) before committing -- `abi3-py310` feature: **one wheel covers 3.10–3.13+**. Don't bind to Python version-specific C API - -### Async direction -- Python-surface APIs for I/O and integrations are **async-first**: when adding LangChain / LlamaIndex / Haystack loaders, implement `aload` / `alazy_load` / async counterparts alongside sync versions -- **Forbidden pattern**: `asyncio.to_thread(rhwp.parse, path)` — `_Document` is unsendable (see Rust+Python hybrid build note above), the returned Document panics on main-thread access. `async fn` in `#[pymethods]` is also incompatible (PyO3 requires `Send + 'static` futures) -- **Supported async pattern**: `aparse(path)` uses stdlib `asyncio.to_thread` to offload the file read to a thread pool, then calls `Document.from_bytes(data)` on the event-loop thread. Document never crosses a thread boundary. No external dependency — Python `asyncio` lacks native async file I/O so all async file libs (aiofiles etc.) wrap thread pools anyway; stdlib achieves the same effect with zero install footprint -- **Document instance-level async methods (`doc.ato_ir()` etc.) are NOT provided** — they would require thread offload which unsendable forbids. For async code, `await rhwp.aparse(path)` once, then call sync methods on the Document directly (these are fast, in-memory, GIL-holding operations) -- If upstream rhwp ever replaces its `RefCell` caches with thread-safe synchronization, revisit this — `unsendable` could then be dropped, enabling true `async fn pymethods` - -### Tests -- Real HWP fixtures live in the submodule: `external/rhwp/samples/aift.hwp` (HWP5), `table-vpos-01.hwpx` (HWPX). `tests/conftest.py` + `benches/bench_gil.py` reference this path -- When changing one path, change both -- Markers: `slow` (PDF render), `langchain` (extras required). Default run: `pytest -m "not slow"` -- Extras-gated test files use module-level `pytest.importorskip` so the whole file counts as **1 skip** when the extra is missing. Current gated files: `test_langchain_loader.py` + `test_langchain_loader_ir.py` (langchain-core), `test_ir_schema_export.py` (jsonschema), `test_cli.py` (typer) → CI's `test-without-extras` job validates **exactly 4 skipped** (see `.github/workflows/ci.yml`). When adding a new extras-gated file, bump the count in both CLAUDE.md and ci.yml -- `tests/type_check_errors.py` holds **exactly 4 intentional pyright errors** — CI validates that too. When editing, preserve count; don't fix them - -### Git workflow -- Single-branch trunk model: feature branches off `main` → PR to `main`. No `develop` / `staging` -- Branch naming: **MINOR** = `feature/vX.Y.0` (long-lived, isolates external contract changes across stages). **PATCH** = `/` (short-lived, merges directly to main, tag only `vX.Y.Z`) where `` follows [Conventional Commits](https://www.conventionalcommits.org/) (`fix` / `chore` / `refactor` / `docs` / `build` / `ci` / `perf` / `test` / `revert`) -- Commit subject: lowercase `type: description` (seed commit: `init: 프로젝트 초기화`) -- PR body follows [.github/pull_request_template.md](.github/pull_request_template.md) — Summary / Why / Related Issues -- Full contributor flow (fork, pre-submit checks, rhwp-core changes): [CONTRIBUTING.md](CONTRIBUTING.md) - -### Versioning / release -- Git tags `vX.Y.Z`, SemVer, MINOR-sized increments -- **Cargo.toml is the version source of truth** via `dynamic = ["version"]` in pyproject.toml. Always bump Cargo.toml before tagging — `publish.yml`'s `verify-version` aborts on mismatch -- **No breaking changes across Phase boundaries** (Phase 1 → 2 must keep existing APIs) -- Release trigger: GitHub Release `published` event fires `publish.yml`. Draft releases don't trigger -- Every release records the `external/rhwp` submodule commit hash in CHANGELOG -- Integration-only runtime deps (LangChain, typer, jsonschema) belong in `[project.optional-dependencies]`, never `[project] dependencies` — keeps the core wheel dependency-free - -### Documentation -Authoritative policy is `docs/CONVENTIONS.md` — read it before any docs work. Active spec index SSOT is `docs/roadmap/README.md`. - -Hard rules (auto-applied without further instruction): -- Every per-version spec / ADR / impl-log / verification report carries a Status metadata line right after its first heading: `**Status**: · **GA**: vX.Y.Z` *or* `**Target**: vX.Y.Z` · `**Last updated**: YYYY-MM-DD`. Living docs (README, CHANGELOG, CLAUDE.md, CONVENTIONS itself) skip the Status line. -- **Frozen spec body is immutable** — typo / broken-link fixes only. Decision changes go to a *new* spec; the old one's Status flips to `Superseded by [link]` (single-line edit). -- **Spec ↔ spec direct cross-links are forbidden** even within the same `vX.Y.Z/` directory. Use `phase-N.md` § two-axis-integration sections (or `roadmap/README.md`) as the bridge. **Exception**: pair files `.md` ↔ `-research.md` (the spec ↔ ADR pair) link directly. -- **`phase-N.md` carries no concrete decisions / open issues** — those belong in `vX.Y.Z/*.md`. Phase docs hold intent, scope, and two-axis integration only. -- New version `vX.Y.Z`: create `docs/roadmap/vX.Y.Z/.md` + `docs/design/vX.Y.Z/-research.md` (Status: Draft, Target: vX.Y.Z), then add a row to the active-spec index in `roadmap/README.md`. On GA: flip Status Draft → Frozen, write `implementation/vX.Y.Z/...` (Frozen on creation), refresh README index. - -### CI / secrets -- No secrets required. PyPI publish uses Trusted Publisher (OIDC) — no API token to manage -- `secrets.GITHUB_TOKEN` is injected automatically; don't try to "register" it -- Workflow permissions stay minimal. `publish.yml` declares `id-token: write` at the job level only - diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3284299..bbf5f65 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,25 +1,61 @@ -# Contributing to rhwp-python +# rhwp-python 기여 가이드 -Thanks for your interest in contributing! AI-assisted contributions (issue creation, coding, reviews) are welcome. +**한국어** | [**English**](CONTRIBUTING_EN.md) -This repository consumes the rhwp Rust core via a git submodule at `external/rhwp`. -Clone with `--recurse-submodules`, or run `git submodule update --init --recursive` after cloning. +기여에 관심 가져주셔서 감사합니다. AI 기반 기여 (이슈 작성 / 코딩 / 리뷰) 도 환영합니다. -## Before You Submit +## TL;DR — 빠른 시작 -- `uv sync --no-install-project --group all` to install dev + testing + linting deps -- `uv run maturin develop --release` to build the extension -- `uv run pytest tests/ -m "not slow" -v` must pass (run `pytest -m slow` for PDF tests) -- `uv run ruff check python/ tests/ benches/` and `uv run pyright python/ tests/` must pass -- Pre-commit hooks run these automatically +```bash +git clone --recurse-submodules https://github.com/DanMeon/rhwp-python.git +cd rhwp-python +uv sync --no-install-project --group all +uv run maturin develop --release +uv run pytest tests/ -m "not slow" +``` -## Code Style +위 4 명령이 모두 통과하면 작업 시작 준비 완료. `main` 에서 분기 → Conventional Commits 으로 commit → push → PR. -- Python 3.10+, `T | None` (not `Optional[T]`), PEP 561 typed -- Rust 1.83+ (PyO3 0.28 MSRV). No new `unsafe` in the bindings layer +submodule 없이 클론한 경우: `git submodule update --init --recursive`. -## Pull Requests +## 기여 유형 선택 -1. Fork → feature branch → make changes with tests → PR against `main` -2. Keep PRs focused — one feature or fix per PR -3. For changes touching rhwp core, open an issue on [edwardkim/rhwp](https://github.com/edwardkim/rhwp) first; this repo only adds bindings +| 작업 종류 | 추가로 읽을 문서 | 비고 | +|---|---|---| +| 버그 수정 / 테스트 추가 | — | 가장 흔한 경로 — `main` 으로 PR | +| Python API 또는 LangChain 기능 추가 | [AGENTS.md](AGENTS.md) (프로젝트 규칙) | 큰 기능은 spec 문서 필요할 수 있음 — 먼저 issue 로 논의 | +| Rust 코어 / 파서 변경 | — | [edwardkim/rhwp](https://github.com/edwardkim/rhwp) 에 issue 등록 — 본 리포는 바인딩만 다룸 | +| 기존 문서 수정 | [docs/CONVENTIONS.md](docs/CONVENTIONS.md) | `docs/*.md` 편집 시 Claude Code hook + CI 가 자동 lint. frontmatter `last_updated` 는 hook 이 갱신하므로 수기 변경 금지 | +| 새 version spec 작성 | [docs/CONVENTIONS.md](docs/CONVENTIONS.md) | `/new-spec ` Claude Code skill 사용, 또는 CONVENTIONS § 새 spec 추가 절차 따라 수기 작성 | + +PR 의 90% 이상은 첫 행 (버그 수정 / 테스트) — spec 시스템 정책을 읽을 필요 없음. + +## 제출 전 체크리스트 + +모두 CI 에서 실행되지만, 로컬 선실행이 round-trip 절약: + +- `uv run pytest tests/ -m "not slow"` — 통과 필수 (PDF 테스트는 `-m slow`) +- `uv run ruff check python/ tests/ benches/` — 통과 필수 +- `uv run pyright python/ tests/` — 통과 필수 +- Rust 변경 (`src/*.rs`) 후엔 `uv run maturin develop --release` 재실행 + `cargo clippy --all-targets -- -D warnings` +- 문서 변경 시: `uv run --no-project --with "typer>=0.12" python scripts/lint_docs.py docs/` (CI 의 `Docs lint` job 과 동일) + +## 코드 스타일 + +- Python 3.10+, `T | None` (`Optional[T]` 금지), PEP 561 typed +- Rust 1.83+ (PyO3 0.28 MSRV). 바인딩 레이어에 새 `unsafe` 추가 금지 + +## Pull Request + +1. 브랜치 명명: **PATCH** = `/` (단명, `main` 직머지, 예: `fix/empty-paragraph`). **MINOR** = `feature/vX.Y.0` (장명, stage 간 외부 contract 변경 격리). `` 는 [Conventional Commits](https://www.conventionalcommits.org/) 따름 (`fix` / `chore` / `refactor` / `docs` / `build` / `ci` / `perf` / `test` / `revert`) +2. Commit subject: lowercase `: ` +3. PR body 는 [.github/pull_request_template.md](.github/pull_request_template.md) 양식 — Summary / Why / Related Issues +4. PR 단위는 한 가지 기능 / 한 가지 수정으로 집중 +5. rhwp Rust 코어 변경이 필요하면 [edwardkim/rhwp](https://github.com/edwardkim/rhwp) 에 먼저 issue — 본 리포는 바인딩만 추가 + +## 더 읽을 자료 + +- **프로젝트 규칙 + 아키텍처**: [AGENTS.md](AGENTS.md) — `CLAUDE.md` 와 동일 내용 (symlink). async 패턴, GIL release 규칙, extras-gated 테스트 카운트 등 +- **문서 운영 정책**: [docs/CONVENTIONS.md](docs/CONVENTIONS.md) — 수명 (Living/Active/Draft/Frozen/Superseded), frontmatter schema, EARS notation, supersede chain +- **활성 spec 인덱스**: [docs/roadmap/README.md](docs/roadmap/README.md) — 어떤 spec 이 GA 됐고 어떤 게 진행 중인지 +- **상류 commit pin**: 각 릴리스 tag 의 git submodule 이 SSOT — `git ls-tree v0.3.0 external/rhwp` / CHANGELOG 의 산문 노트 diff --git a/CONTRIBUTING_EN.md b/CONTRIBUTING_EN.md new file mode 100644 index 0000000..21a02df --- /dev/null +++ b/CONTRIBUTING_EN.md @@ -0,0 +1,61 @@ +# Contributing to rhwp-python + +[**한국어**](CONTRIBUTING.md) | **English** + +Thanks for your interest! AI-assisted contributions (issue creation, coding, reviews) are welcome. + +## TL;DR — quick start + +```bash +git clone --recurse-submodules https://github.com/DanMeon/rhwp-python.git +cd rhwp-python +uv sync --no-install-project --group all +uv run maturin develop --release +uv run pytest tests/ -m "not slow" +``` + +If those four commands succeed, you're ready to make changes. Branch off `main`, commit (Conventional Commits), push, open a PR. + +Already cloned without submodules? Run `git submodule update --init --recursive`. + +## Pick your contribution path + +| You want to... | Also read | Notes | +|---|---|---| +| Fix a bug / add a test | — | Most common path — open PR against `main` | +| Add a Python API or LangChain feature | [AGENTS.md](AGENTS.md) (project rules) | Larger features may need a spec doc — ask in an issue first | +| Change the Rust core / parser | — | File an issue at [edwardkim/rhwp](https://github.com/edwardkim/rhwp); this repo only wraps | +| Edit existing documentation | [docs/CONVENTIONS.md](docs/CONVENTIONS.md) | A `docs/*.md` edit triggers lint auto via Claude Code hook (and CI). Don't touch frontmatter `last_updated` by hand — the hook does that | +| Add a new version spec | [docs/CONVENTIONS.md](docs/CONVENTIONS.md) | Use `/new-spec ` Claude Code skill, or scaffold manually following CONVENTIONS § 새 spec 추가 절차 | + +90%+ of PRs are the first row — you don't need to read the spec system policy. + +## Pre-submit checklist + +All of these run in CI; running locally first saves a round trip: + +- `uv run pytest tests/ -m "not slow"` — must pass (use `-m slow` for PDF tests) +- `uv run ruff check python/ tests/ benches/` — must pass +- `uv run pyright python/ tests/` — must pass +- After Rust changes (`src/*.rs`): re-run `uv run maturin develop --release` before pytest, plus `cargo clippy --all-targets -- -D warnings` +- Docs touched? `uv run --no-project --with "typer>=0.12" python scripts/lint_docs.py docs/` (also runs in CI as `Docs lint`) + +## Code style + +- Python 3.10+, `T | None` (not `Optional[T]`), PEP 561 typed +- Rust 1.83+ (PyO3 0.28 MSRV). No new `unsafe` in the bindings layer + +## Pull requests + +1. Branch naming: **PATCH** = `/` (short-lived, merges to `main`, e.g. `fix/empty-paragraph`). **MINOR** = `feature/vX.Y.0` (long-lived, isolates external contract changes across stages). `` follows [Conventional Commits](https://www.conventionalcommits.org/) (`fix` / `chore` / `refactor` / `docs` / `build` / `ci` / `perf` / `test` / `revert`) +2. Commit subject: lowercase `: ` +3. PR body follows [.github/pull_request_template.md](.github/pull_request_template.md) — Summary / Why / Related Issues +4. Keep PRs focused — one feature or fix per PR +5. For changes touching rhwp Rust core, open an issue on [edwardkim/rhwp](https://github.com/edwardkim/rhwp) first; this repo only adds bindings + +## Where to learn more + +- **Project rules + architecture**: [AGENTS.md](AGENTS.md) — same content as `CLAUDE.md` (symlink). Async patterns, GIL release rules, extras-gated test counting, etc. +- **Documentation policy**: [docs/CONVENTIONS.md](docs/CONVENTIONS.md) — lifecycle (Living/Active/Draft/Frozen/Superseded), frontmatter schema, EARS notation, supersede chain +- **Active spec index**: [docs/roadmap/README.md](docs/roadmap/README.md) — what's GA, what's in progress, what's planned +- **Upstream commit pin per release**: the git submodule itself is authoritative — `git ls-tree external/rhwp`, plus the prose note in CHANGELOG diff --git a/README_EN.md b/README_EN.md index ca1cc64..1f95d02 100644 --- a/README_EN.md +++ b/README_EN.md @@ -149,6 +149,8 @@ git submodule update --init --recursive Test fixtures live in the submodule at `external/rhwp/samples/`; `tests/conftest.py` reads from that path. +For the full build / test / contribute flow, see [CONTRIBUTING_EN.md](CONTRIBUTING_EN.md). + ## Versioning This Python package and the `rhwp` Rust core are versioned **independently**. diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md index 096833d..a576511 100644 --- a/docs/CONVENTIONS.md +++ b/docs/CONVENTIONS.md @@ -6,31 +6,121 @@ | 분류 | 의미 | 갱신 정책 | 예시 | |---|---|---|---| -| **Living** | 항상 최신 — 다른 문서의 위치 포인터 + 시간선 + 규칙 | 자유 갱신, 매 변경 시 손봐도 무방 | `docs/CONVENTIONS.md` (자체), `docs/roadmap/README.md`, `CHANGELOG.md`, `CLAUDE.md`, `README.md` | -| **Active** | 현재 진행 중 — 의도/스코프 수준의 진화하는 문서 | 큰 변경만, in-place 갱신 OK | `docs/roadmap/phase-N.md` | -| **Draft** | 작성 중인 spec — 해당 버전 GA 전까지 활발 갱신 | 버전 GA 전까지 자유 갱신, GA 후 Frozen 으로 전환 | `docs/roadmap/v0.3.0/*.md` (현재 v0.3.0 GA 전) | +| **Living** | 항상 최신 — 다른 문서의 위치 포인터 + 시간선 + 규칙 | 자유 갱신, 매 변경 시 손봐도 무방 | `docs/CONVENTIONS.md` (자체), `docs/roadmap/README.md`, `CHANGELOG.md`, `CLAUDE.md`, `AGENTS.md`, `README.md` | +| **Active** | 외부 시스템으로 흘러가기 전 staging | 큰 변경만, in-place 갱신 OK | `docs/upstream/.md` | +| **Draft** | 작성 중인 spec — 해당 버전 GA 전까지 활발 갱신 | 버전 GA 전까지 자유 갱신, GA 후 Frozen 으로 전환 | `docs/roadmap/v0.7.0/mcp.md` (현재 v0.7.0 GA 전) | | **Frozen** | GA 완료된 spec / 완료된 stage / 완료된 검증 | **변경 금지** — 오타·링크 수정만 in-place 허용. 큰 변경은 새 spec + supersede | `docs/roadmap/v0.2.0/ir.md` (v0.2.0 GA 완료), `docs/implementation/v0.2.0/stages/*.md` | `Frozen` 은 [Rust RFC](https://rust-lang.github.io/rfcs/) / [Python PEP](https://peps.python.org/) 의 운영 모델. 결정의 historical record 가 보존되어 "왜 그렇게 설계됐는지" 가 명확해진다. -## Status 헤더 형식 +### Frozen 면제 조항 — Living-policy schema migration -`Living` 을 제외한 모든 spec 의 첫 헤더 직후에 metadata block 을 둔다: +Frozen 본문 변경 금지의 예외. **결정·인용·본문 의미를 보존하고 메타데이터의 표현 형식만 갱신하는** non-semantic 변경은 in-place 허용 — 예: inline `**Status**:` 라인 → YAML frontmatter 일괄 마이그. + +조건: + +- **non-semantic** — 결정 변경 / 새 인용 / 본문 의미 추가 모두 없음. 메타 형식만 갱신 +- **전체 spec 일괄** — 단일 PR 로 모든 영향 파일 동시 적용. 개별 파일 단발 갱신 금지 +- 면제 조항 활용 commit 은 PR description 에 *어떤 schema migration 인지* 명시 + +semantic 변경 (결정 / 인용 / 본문 의미) 은 그대로 supersede 절차 적용. + +### Frozen 외부 의존성 부패 + +Frozen 본문은 historical record. 시간 흐르며 외부 의존성 (라이브러리 버전 / API 시그니처 / 외부 URL) 이 deprecated 되어도 본문 변경하지 않는다 — 결정 시점의 정확성을 보존하는 것이 immutability 의 목적. 현재 진실은 *코드* 와 *최신 spec* 이 가짐. + +## Status 메타데이터 — YAML frontmatter + +`Living` 을 제외한 모든 spec 의 첫 행에 YAML frontmatter 블록을 둔다: ```markdown -# <문서 제목> +--- +status: Frozen +ga: v0.3.0 +last_updated: 2026-04-28 +--- -**Status**: · **GA**: 또는 **Target**: · **Last updated**: YYYY-MM-DD +# <문서 제목> <본문 시작> ``` -- **Status**: 현재 분류 -- **GA** (Frozen, 부모 버전 이미 GA): 어느 버전에서 GA 됐는지. **Target** (Draft, 또는 implementation stage 가 부모 GA 전에 Frozen 처리된 경우): 어느 버전을 향한 작업인지. Active 면 둘 다 생략 가능 -- **Last updated**: 본문에 의미 있는 변경이 있었던 날짜 (오타·링크 수정 제외) -- 모든 spec 변경 시 `Last updated` 만큼은 갱신 +### 필드 schema + +| 필드 | 타입 | 규칙 | +|---|---|---| +| `status` | enum: `Active` / `Draft` / `Frozen` / `Superseded` | 필수 | +| `ga` | `vX.Y.Z` SemVer | `status: Frozen` 또는 `Superseded` 일 때 필수. `target` 과 mutex | +| `target` | `vX.Y.Z` SemVer | `status: Draft` 일 때 필수. `ga` 와 mutex | +| `supersedes` | `/.md` 또는 생략 | 새 spec 이 무엇을 대체하는지 | +| `superseded_by` | `/.md` | `status: Superseded` 일 때 필수 | +| `last_updated` | `YYYY-MM-DD` | 필수. 의미 변경 commit 시 자동 갱신 ([D3 hook](#last_updated-자동-갱신)) | + +`Active` (예: `upstream/.md`) 는 `ga` / `target` 둘 다 생략. + +`Living` 은 frontmatter 없음 — 정의상 항상 최신. 대신 README 같은 인덱스가 다른 문서들의 Status 를 노출. -`Living` 문서는 정의상 항상 최신이므로 Status 헤더 없음. 대신 README 같은 인덱스가 다른 문서들의 Status 를 노출. +### 예시 + +**Frozen** (GA 완료 spec): + +```markdown +--- +status: Frozen +ga: v0.3.0 +last_updated: 2026-04-28 +--- + +# v0.3.0 — `rhwp-py` 얇은 CLI + +v0.2.0 에서 폐기했던 CLI 를... +``` + +**Draft** (GA 전 작업 중): + +```markdown +--- +status: Draft +target: v0.7.0 +last_updated: 2026-04-28 +--- + +# v0.7.0 — MCP server (`rhwp-mcp`) + +[Model Context Protocol](https://modelcontextprotocol.io/)... +``` + +**Active** (외부 시스템 staging): + +```markdown +--- +status: Active +last_updated: 2026-04-26 +--- + +# upstream issue 초안 — find_control_text_positions 누락 + +[edwardkim/rhwp](https://github.com/edwardkim/rhwp) 의 `Document::find_control_text_positions` ... +``` + +**Superseded** (새 spec 으로 대체된 Frozen): + +```markdown +--- +status: Superseded +ga: v0.2.0 +superseded_by: v0.4.0/ir-correction.md +last_updated: 2026-04-25 +--- + +# v0.2.0 — Document IR v1 +``` + +### last_updated 자동 갱신 + +Claude Code PostToolUse hook (`.claude/hooks/update-last-updated.py`) 이 docs/*.md 의 frontmatter `last_updated` 를 편집 시점 오늘 날짜로 자동 갱신. CI lint 가 frontmatter `last_updated` 와 git history 일치 여부 검증. **수기 갱신 절차는 폐기** — frontmatter 에 직접 손대지 않는다. + +면제 조항 활용 마이그 commit 은 hook 실행을 건너뛴다 (non-semantic — 본문 의미 변경 없음). 이 경우 `last_updated` 는 기존 값 그대로 frontmatter 로 이전. ## 디렉토리별 정책 @@ -38,23 +128,24 @@ docs/ ├── CONVENTIONS.md Living — 본 문서. 정책 SSOT ├── roadmap/ -│ ├── README.md Living — 활성 spec 인덱스 -│ ├── phase-{2,3,4}.md Active — Phase 의도/스코프 (구체 결정 미포함) +│ ├── README.md Living — 활성 spec 인덱스 + 미착수 narrative │ └── v/.md Draft → Frozen on GA — per-version spec ├── design/ │ └── v/-research.md Draft → Frozen on GA — ADR-style 결정 증거 ├── implementation/ -│ └── v/... Frozen — 완료된 stage 작업 로그 +│ ├── v/... Frozen — 완료된 stage 작업 로그 +│ └── .md Frozen — meta-level / cross-version 작업 +├── traces/ +│ └── coverage.md Living — spec ↔ test 자동 매핑 ├── upstream/ │ └── .md Active — 외부 (rhwp Rust 코어) 이슈 초안. 업스트림 머지 시 archive └── verification/ - └── v/... Frozen — 완료된 검증 리포트 + └── v/... Frozen — 큰 단위 작업 검증 리포트 (한정) ``` ### roadmap/ -- `README.md` (Living) — 활성 spec 인덱스. 어느 spec 이 어느 버전을 향하는지의 SSOT -- `phase-N.md` (Active) — Phase 의 의도/스코프만. **구체 결정/미결 이슈는 두지 않음** — 그것들은 `vX.Y.Z/*.md` 의 영역. Phase 의 대상 버전이 바뀌거나 phase boundary 가 이동할 때만 갱신 +- `README.md` (Living) — 활성 spec 인덱스 + 미착수 작업 계획 narrative. 어느 spec 이 어느 버전을 향하는지의 SSOT, 미착수 minor 의 의도/스코프도 본 문서가 보유 - `vX.Y.Z/.md` (Draft → Frozen) — per-version spec. v0.2.0 의 `ir.md` 처럼 한 릴리스의 한 큰 주제 = 한 파일 ### design/ @@ -65,16 +156,20 @@ docs/ - `vX.Y.Z/migration.md` 또는 `vX.Y.Z/stages/stage-N.md` (Frozen) — 작업 로그. 완료 즉시 Frozen. 산출물 / 검증 결과 / 이월 사항 기록 - 작은 작업 (단일 세션·수일 규모) 은 단일 `migration.md`. 큰 작업 (여러 주, 의존성 추적 필요) 은 `stages/stage-N.md` 분할 -- **stage 작성 시점이 부모 버전 GA 전이면** Status 는 `Frozen + Target: vX.Y.Z` 로 표기 (작성 즉시 immutable, GA 라벨은 미부여). 부모 버전 GA 시 `Target` → `GA` 로 일괄 전환 +- **stage 작성 시점이 부모 버전 GA 전이면** frontmatter 는 `status: Frozen`, `target: vX.Y.Z` 로 표기 (작성 즉시 immutable, GA 라벨은 미부여). 부모 버전 GA 시 `target` → `ga` 로 일괄 전환 ### upstream/ -- `.md` (Active) — 업스트림 (`edwardkim/rhwp` 등) 에 제출 검토 중인 이슈/제안 초안. 머지·해결 시 archive 또는 삭제. per-version 매핑 없음 +- `.md` (Active) — 업스트림 (`edwardkim/rhwp` 등) 에 제출 검토 중인 이슈/제안 초안. per-version 매핑 없음 - 본 디렉토리는 외부 시스템 (GitHub Issues) 으로 흘러가기 전 단계의 staging — 정식 spec 의 일부가 아님 +- **해결 시** — 두 가지 옵션: + - **삭제** — 다른 spec 이 본 파일을 참조하지 않을 때. 정보는 GitHub permalink + 본 PR commit history 가 보존 + - **in-place Frozen 전환** — 다른 Frozen spec 이 본 파일을 참조할 때. frontmatter `status: Frozen` (`ga` 생략 — 특정 버전 미귀속), 본문 첫 헤더 위에 `> **RESOLVED** — 상류 PR/commit 참조 …` 한 줄 인용 블록 추가. 기존 body 보존 (historical record) ### verification/ -- `vX.Y.Z/-review.md` (Frozen) — 독립 검증 리포트. 검증 시점·검증자·판정 기록 +- `vX.Y.Z/-review.md` (Frozen) — verifier subagent (code-reviewer / test-automator) 산출물. **큰 단위 작업** (다단계 stage / 의심 영역 / cross-cutting refactor) 한정 +- 작은 작업 (단일 세션 PR / typo / dep bump) 은 작성 생략 — git log + PR description 이 SSOT ## Cross-link 방향성 규칙 @@ -95,57 +190,83 @@ Living ───→ Active ───→ Draft ───→ Frozen ### Spec ↔ spec 직접 link 금지 (예외 1 종) - **금지**: 같은 디렉토리 안의 spec 끼리 직접 link (예: `v0.3.0/cli.md` ↔ `v0.3.0/ir-expansion.md`). 새 spec 추가 시 기존 spec 도 손봐야 하는 연쇄 발생 -- **대신**: `roadmap/README.md` 또는 `phase-N.md` 가 묶어서 노출 +- **대신**: `roadmap/README.md` 가 묶어서 노출 - **예외**: **짝 페어** — `roadmap/vX.Y.Z/.md` 와 `design/vX.Y.Z/-research.md` 는 1:1 짝 (spec ↔ ADR). 짝끼리는 직접 link 유지 (두 문서가 사실상 한 결정의 두 면) ## 새 spec 추가 절차 +`/new-spec ` Claude Code skill 이 본 절차를 자동화 (`.claude/skills/new-spec/SKILL.md`). + ### v 신설 시 1. 디렉토리 생성: `docs/roadmap/v/`, `docs/design/v/` -2. spec 파일 작성 (Status: Draft, Target: vX.Y.Z, Last updated: 오늘) -3. 짝이 되는 design research 파일 작성 (Status: Draft, Target: vX.Y.Z) -4. `docs/roadmap/README.md` 의 인덱스 표에 행 추가 -5. 해당 phase 가 있다면 `phase-N.md` 의 § 대상 버전 / § 산하 spec 갱신 (Active 갱신은 자유) +2. spec 파일 작성 — frontmatter `status: Draft`, `target: vX.Y.Z` +3. 짝이 되는 design research 파일 작성 — 같은 frontmatter +4. `docs/roadmap/README.md` 의 인덱스 표에 행 추가 (해당 minor 가 미착수 narrative 에 있었다면 거기서 promote) ### 버전 GA 후 -1. 해당 vX.Y.Z 디렉토리 안의 spec 들 Status: Draft → Frozen, GA: vX.Y.Z 로 전환 -2. `Last updated` 를 GA 일자로 -3. `docs/roadmap/README.md` 인덱스 갱신 (Status 컬럼) -4. `CHANGELOG.md` 의 해당 버전 섹션 마무리 -5. 구현 로그 작성 — `docs/implementation/v/...` (작성 즉시 Frozen) - -### Phase 완료 후 - -`phase-N.md` 의 모든 대상 vX.Y.Z 가 GA 되면 해당 phase 문서를 삭제한다. - -1. cross-link 정리 — Frozen spec 본문의 `phase-N.md` 참조: link 만 제거 (인용된 결정 텍스트는 본문에 흡수). 진행 중 spec / Active 문서의 참조: README 인덱스로 redirect 또는 단순 제거 -2. `docs/roadmap/README.md` § Phase 인덱스 표에서 해당 행 제거 -3. `docs/roadmap/phase-N.md` 파일 삭제 -4. CHANGELOG 에 phase 정리 기록 - -근거: phase 문서는 진행 중 phase 의 의도/스코프 SSOT — 모두 GA 되면 historical record 는 `v/*.md` spec 들이 보유. phase 문서를 보존하면 활성 인덱스가 비대해지고 "phase 가 살아있는 것처럼" 오해 유발. +1. 해당 vX.Y.Z 디렉토리 안의 spec 들 frontmatter `status: Draft → Frozen`, `target: vX.Y.Z` → `ga: vX.Y.Z` 로 전환 +2. `docs/roadmap/README.md` 인덱스 갱신 (Status 컬럼) +3. `CHANGELOG.md` 의 해당 버전 섹션 마무리 +4. 구현 로그 작성 — `docs/implementation/v/...` (작성 즉시 Frozen) ### Frozen 후 결정 변경이 필요한 경우 1. **새 spec 작성** — 기존 파일 수정 금지. 새 파일 (예: `docs/roadmap/v0.4.0/ir-correction.md`) -2. **기존 Frozen spec 의 Status** 만 단일 라인 갱신: `Status: Superseded by [link to new spec]` -3. 새 spec 본문에 § Supersedes 섹션 추가하여 무엇을 어떻게 바꾸는지 명시 -4. CHANGELOG 에 변경 사유 기록 +2. **기존 Frozen spec 의 frontmatter** 만 갱신: `status: Superseded`, `superseded_by: v0.4.0/ir-correction.md`. `ga` 보존 +3. 새 spec frontmatter 의 `supersedes` 에 역참조 — 양방향 chain 형성 +4. 새 spec 본문에 § Supersedes 섹션 추가하여 무엇을 어떻게 바꾸는지 명시 +5. CHANGELOG 에 변경 사유 기록 -오타·깨진 링크·외부 URL 변경 같은 비-의미 변경은 in-place 가능 (Last updated 갱신). +오타·깨진 링크·외부 URL 변경 같은 비-의미 변경은 in-place 가능 (last_updated 자동 갱신). ## Implementation log 구조 -`docs/implementation/vX.Y.Z/` 안의 두 종류 분류: +`docs/implementation/` 안의 세 종류 분류: -- `stages/stage-N.md` — 해당 release 의 spec § 구현 스테이지 분할 에 명시된 작업 (대규모, 다단계). spec 의 stage 표와 1:1 mapping -- `.md` (직속 평면) — spec 없는 작은 작업 (refactor / chore / perf / dep bump 등). 결정 비교 (a/b/c 옵션) 가치 있어 CHANGELOG 한 줄로 부족할 때만 작성. branch prefix `/` 와 1:1 mapping (예: branch `refactor/aparse-stdlib` → log `aparse-cleanup.md`) +- `vX.Y.Z/stages/stage-N.md` — 해당 release 의 spec § 구현 스테이지 분할 에 명시된 작업 (대규모, 다단계). spec 의 stage 표와 1:1 mapping +- `vX.Y.Z/.md` (vX.Y.Z 직속 평면) — spec 없는 작은 작업 (refactor / chore / perf / dep bump 등). 결정 비교 (a/b/c 옵션) 가치 있어 CHANGELOG 한 줄로 부족할 때만 작성. branch prefix `/` 와 1:1 mapping (예: branch `refactor/aparse-stdlib` → log `aparse-cleanup.md`) +- `.md` (vX.Y.Z 외부 직속 평면) — meta-level / cross-version 작업 (예: 문서 시스템 개편). 작성 즉시 Frozen, frontmatter `ga` 필드 생략 가능 (해당 사항 없음 — 특정 버전 귀속 안 됨) CHANGELOG 한 줄로 충분한 변경 (typo 정리, 단순 dep bump, 작은 docstring 갱신 등) 은 파일 작성 안 함 — git log + CHANGELOG 가 SSOT. -미래 ad-hoc note 가 5개 이상 모이면 그때 `chores/` 디렉토리화 검토 (YAGNI). 본 패턴 = spec-driven AI coding 진영의 "결정 보존 — 미래 세션 reconstruct 회피" trend (MADR / Cline `decisions.md` / OpenSpec) 와 정합. +미래 ad-hoc note 가 5개 이상 모이면 그때 `chores/` 디렉토리화 검토 (YAGNI). + +## CHANGELOG ↔ implementation log 역할 분리 + +| 문서 | 관점 | 내용 | +|---|---|---| +| `CHANGELOG.md` | 사용자 (외부) | *what* — 추가/변경/제거된 API, extras, 호환성 영향, 마이그레이션 안내 | +| `docs/implementation/.../*.md` | 개발자 (내부) | *why/how* — a/b/c 옵션 비교, 시행착오, 결정 근거, Stage별 작업 흐름 | + +같은 사실 중복 기록 금지 — CHANGELOG 가 *what*, log 가 *why/how*. 결정 비교 (a/b/c) 가치가 없는 변경 (단순 dep bump, typo) 은 CHANGELOG 한 줄로 충분 — implementation log 작성 안 함. + +## 인수조건 형식 (v0.4.0+ 신규 spec) + +v0.4.0+ 신규 spec 의 § 인수조건 섹션은 각 항목에 `AC-N` ID 를 부여한다 (테스트 marker 와 1:1 매핑용). 형식은 자유 — testable 하고 명확하면 plain prose 도 OK. 모호성이 우려되면 [EARS notation](https://alistairmavin.com/ears/) (`THE ... SHALL`, `WHEN ..., THE ... SHALL` 등) 같은 구조화 패턴을 참고 가능 (강제 아님). + +```markdown +## 인수조건 + +- **AC-1** — typer 미설치 시 CLI 가 친절 에러 + exit 2 +- **AC-2** — `rhwp-py parse ` 출력은 사람 가독 텍스트 +- **AC-3** — `rhwp-py ir ` 출력은 valid JSON (stdout) +- **AC-4** — 입력 파일 없으면 exit 2 + stderr 에 에러 +``` + +기존 v0.1.0 ~ v0.3.0 Frozen spec 은 본 형식 미적용 — historical record 보존. 단 트레이스 매핑은 spec 단위 (AC-N 생략) 로 retrofit 적용 (Trace report 섹션 참조). + +## Trace report — pytest spec markers + +테스트는 `pytest.mark.spec(spec_id)` marker 로 spec 과 매핑. `spec_id` 형식: + +- **v0.4.0+ spec**: `"vX.Y.Z/topic#AC-N"` — AC 단위 매핑 (full) +- **v0.1.0 ~ v0.3.0 spec**: `"vX.Y.Z/topic"` — spec 단위 매핑 (soft, AC 생략) + +파일 단위 적용 (모든 테스트가 같은 spec 검증): module top 에 `pytestmark = pytest.mark.spec("vX.Y.Z/topic")` 한 줄. 개별 테스트가 추가 spec 검증 시 `@pytest.mark.spec(...)` 데코레이터 추가 (양쪽 누적). + +CI 에서 `scripts/generate_spec_trace.py` 가 AST 정적 분석으로 marker 추출 → `docs/traces/coverage.md` (Living) 자동 갱신. ## Archive 정책 (v1.0+) @@ -164,12 +285,13 @@ v1.0 GA 전까지 아무것도 안 함 — 본 정책은 v1.0 GA 직전 작업 - 디렉토리: `v` prefix + SemVer (`v0.3.0/`, not `0.3.0/`) - ADR 파일: `-research.md` (roadmap spec `.md` 와 stem 일치) - stage 파일: `stage-.md` (1-indexed) +- **상대경로**: 같은 / 하위 디렉토리는 implicit (`foo.md`, `subdir/foo.md`). 상위는 `../foo.md`. `./` prefix 금지 (redundant). 외부 자원만 fully-qualified URL ## 본 문서의 갱신 CONVENTIONS.md 자체는 Living. 정책 변경 시 in-place 갱신. **단** 본 문서의 변경은 모든 spec 작성에 영향을 주므로: -- 큰 변경 (예: Status 분류 추가/삭제) 시 PR 에 영향 받는 기존 문서 일괄 마이그레이션 포함 +- 큰 변경 (예: Status 분류 추가/삭제, frontmatter schema 변경) 시 PR 에 영향 받는 기존 문서 일괄 마이그레이션 포함 (Frozen 면제 조항 활용 가능) - 작은 변경 (예: 명명 규칙 추가) 는 in-place + 신규 문서부터 적용, 기존은 점진 정리 ## 참조 @@ -179,3 +301,4 @@ CONVENTIONS.md 자체는 Living. 정책 변경 시 in-place 갱신. **단** 본 - ADR 패턴 (Architecture Decision Records): - Diátaxis 4-axis: - GitHub Spec Kit: +- EARS notation: diff --git a/docs/design/v0.2.0/ir-design-research.md b/docs/design/v0.2.0/ir-research.md similarity index 99% rename from docs/design/v0.2.0/ir-design-research.md rename to docs/design/v0.2.0/ir-research.md index 5442fc0..1b5fb5b 100644 --- a/docs/design/v0.2.0/ir-design-research.md +++ b/docs/design/v0.2.0/ir-research.md @@ -1,6 +1,10 @@ -# v0.2.0 Document IR — 설계 의사결정 리서치 요약 +--- +status: Frozen +ga: v0.2.0 +last_updated: 2026-04-25 +--- -**Status**: Frozen · **GA**: v0.2.0 · **Last updated**: 2026-04-25 +# v0.2.0 Document IR — 설계 의사결정 리서치 요약 [v0.2.0/ir.md](../../roadmap/v0.2.0/ir.md) 초안에 남아 있던 8개 미결 결정 사항 중 7개 (#6 "중첩 테이블 깊이 제한" 은 사용자 결정으로 스킵) 를 **수행자 + 검증자 2인 1조 × 7 팀 = 14 에이전트** 병렬 조사로 해결. 본 문서는 각 팀의 핵심 증거·수렴 지점·최종 결정을 기록한다. ir.md 본문의 결정 배경 참조용. diff --git a/docs/design/v0.3.0/cli-design-research.md b/docs/design/v0.3.0/cli-research.md similarity index 97% rename from docs/design/v0.3.0/cli-design-research.md rename to docs/design/v0.3.0/cli-research.md index c96fec2..ec4d48a 100644 --- a/docs/design/v0.3.0/cli-design-research.md +++ b/docs/design/v0.3.0/cli-research.md @@ -1,6 +1,10 @@ -# v0.3.0 `rhwp-py` CLI — 설계 의사결정 리서치 요약 +--- +status: Frozen +ga: v0.3.0 +last_updated: 2026-04-28 +--- -**Status**: Frozen · **GA**: v0.3.0 · **Last updated**: 2026-04-28 +# v0.3.0 `rhwp-py` CLI — 설계 의사결정 리서치 요약 [v0.3.0/cli.md](../../roadmap/v0.3.0/cli.md) §결정 사항 중 외부 독자가 "왜?" 를 던질 만한 3건 (이름 선정 · 업스트림 overlap=0 정책 · 기본 출력 포맷) 의 업계 선례·대안·실패 시나리오를 기록한다. cli.md 본문이 최종 결정을 기술하고, 본 문서는 그 결정의 근거를 담는다. @@ -165,10 +169,10 @@ 3. **§서브커맨드 스펙** — `blocks` 의 기본 포맷이 NDJSON 인 이유를 한 줄 추가 (streaming pipeline 친화) 4. **§결정 사항 테이블 #3** — "shell pipeline 과 CI 친화" 표현을 "kubectl/aws/gh 의 스크립팅-primary 관행 준수" 로 구체화 -본 문서를 cli.md 에서 `상세 증거: [cli-design-research.md](../../design/v0.3.0/cli-design-research.md)` 로 cross-link — v0.2.0 ir.md ↔ ir-design-research.md 와 동일 패턴. +본 문서를 cli.md 에서 `상세 증거: [cli-research.md](../../design/v0.3.0/cli-research.md)` 로 cross-link — v0.2.0 ir.md ↔ ir-research.md 와 동일 패턴. ## 참조 - [roadmap/v0.3.0/cli.md](../../roadmap/v0.3.0/cli.md) — 본 리서치의 결정 요약 - [roadmap/v0.2.0/ir.md](../../roadmap/v0.2.0/ir.md) §방향 전환 배경 — CLI 폐기→재도입 맥락 -- [design/v0.2.0/ir-design-research.md](../v0.2.0/ir-design-research.md) — 리서치 문서 포맷 선례 +- [design/v0.2.0/ir-research.md](../v0.2.0/ir-research.md) — 리서치 문서 포맷 선례 diff --git a/docs/design/v0.3.0/ir-expansion-research.md b/docs/design/v0.3.0/ir-expansion-research.md index 18b68c7..7a10c3b 100644 --- a/docs/design/v0.3.0/ir-expansion-research.md +++ b/docs/design/v0.3.0/ir-expansion-research.md @@ -1,10 +1,14 @@ -# v0.3.0 IR 확장 — 설계 의사결정 리서치 요약 +--- +status: Frozen +ga: v0.3.0 +last_updated: 2026-04-28 +--- -**Status**: Frozen · **GA**: v0.3.0 · **Last updated**: 2026-04-28 +# v0.3.0 IR 확장 — 설계 의사결정 리서치 요약 [v0.3.0/ir-expansion.md](../../roadmap/v0.3.0/ir-expansion.md) 의 § 결정 사항 8 건의 업계 선례·대안·실패 시나리오·1차 소스를 기록한다. ir-expansion.md 본문이 최종 결정을 기술하고, 본 문서는 그 결정의 근거를 담는다. -조사는 v0.2.0 ir-design-research.md 와 동일 형식: 라이브러리별 패턴 비교 → 검증자 반박 (실패 모드) → 최종 결정 → 1차 소스 인용. 1 차 소스 검증 없이 search-result 제목만 인용하지 않는다. RevisionMark 항목은 상류 zero-support 가 명백하여 본 리서치에서 다루지 않고 ir-expansion.md § 영구 비목표 한 줄로 처리. +조사는 v0.2.0 ir-research.md 와 동일 형식: 라이브러리별 패턴 비교 → 검증자 반박 (실패 모드) → 최종 결정 → 1차 소스 인용. 1 차 소스 검증 없이 search-result 제목만 인용하지 않는다. RevisionMark 항목은 상류 zero-support 가 명백하여 본 리서치에서 다루지 않고 ir-expansion.md § 영구 비목표 한 줄로 처리. ## 결정 매트릭스 @@ -538,7 +542,7 @@ CI workflow `.github/workflows/publish-schema.yml` 은 `keep_files: true` 정책 - v0.2.0 § JSON Schema 공개 (불변 경로): [v0.2.0/ir.md](../../roadmap/v0.2.0/ir.md#json-schema-공개) - v0.2.0 § 스키마 버저닝 (버전 증가 규칙 표): [v0.2.0/ir.md](../../roadmap/v0.2.0/ir.md#스키마-버저닝) -- v0.2.0 frozen IR 결정 증거: [design/v0.2.0/ir-design-research.md § 7](../v0.2.0/ir-design-research.md#7-to_ir-캐싱--rust-oncecellpyobject--frozen-ir) +- v0.2.0 frozen IR 결정 증거: [design/v0.2.0/ir-research.md § 7](../v0.2.0/ir-research.md#7-to_ir-캐싱--rust-oncecellpyobject--frozen-ir) --- @@ -563,5 +567,5 @@ CI workflow `.github/workflows/publish-schema.yml` 은 `keep_files: true` 정책 - 본 리서치의 결정 요약: [roadmap/v0.3.0/ir-expansion.md](../../roadmap/v0.3.0/ir-expansion.md) - v0.2.0 IR 본문: [roadmap/v0.2.0/ir.md](../../roadmap/v0.2.0/ir.md) -- v0.2.0 결정 증거 (리서치 문서 형식 선례): [design/v0.2.0/ir-design-research.md](../v0.2.0/ir-design-research.md) +- v0.2.0 결정 증거 (리서치 문서 형식 선례): [design/v0.2.0/ir-research.md](../v0.2.0/ir-research.md) - 활성 spec 인덱스: [roadmap/README.md](../../roadmap/README.md) diff --git a/docs/design/v0.7.0/mcp-research.md b/docs/design/v0.7.0/mcp-research.md index 02cb26e..7563b9c 100644 --- a/docs/design/v0.7.0/mcp-research.md +++ b/docs/design/v0.7.0/mcp-research.md @@ -1,6 +1,10 @@ -# v0.7.0 MCP server — 설계 의사결정 리서치 요약 +--- +status: Draft +target: v0.7.0 +last_updated: 2026-04-28 +--- -**Status**: Draft · **Target**: v0.7.0 · **Last updated**: 2026-04-28 +# v0.7.0 MCP server — 설계 의사결정 리서치 요약 [v0.7.0/mcp.md](../../roadmap/v0.7.0/mcp.md) §결정 사항 중 외부 독자가 "왜?" 를 던질 만한 4건 (SDK 채택 · transport 우선순위 · handler 동시성 모델 · 도구 분할 정책) 의 업계 선례·대안·실패 시나리오를 기록한다. mcp.md 본문이 최종 결정을 기술하고, 본 문서는 그 결정의 근거를 담는다. diff --git a/docs/implementation/spec-system-overhaul.md b/docs/implementation/spec-system-overhaul.md new file mode 100644 index 0000000..e5d588c --- /dev/null +++ b/docs/implementation/spec-system-overhaul.md @@ -0,0 +1,804 @@ +--- +status: Frozen +last_updated: 2026-04-29 +--- + +# Spec System Overhaul — 작업 로그 (Frozen) + +본 문서는 PR `docs/spec-system-overhaul` 의 historical record. 14개 결정 + 9 commit 단계 + a/b/c 옵션 비교 + invariant 정의를 보존한다. meta-level / cross-version 작업이라 특정 vX.Y.Z 에 귀속되지 않음 — `ga` 필드 생략 (CONVENTIONS § Implementation log 구조). + +PR 진행 중에 사용된 임시 명세서로, 머지 후 `docs/implementation/` 직속 평면으로 이주됨. 작업 진행 중 발생한 일부 사후 결정 (예: `-design-research` → `-research` rename, 상류 issue #390 RESOLVED in-place Frozen 처리, lint_docs.py typer 전환) 은 본 문서가 아닌 git log + commit message + CHANGELOG 가 보유. + +--- + +## TL;DR + +- **브랜치**: `docs/spec-system-overhaul` +- **PR 단위**: 단일 PR (모든 결정 일괄 적용, commit으로 분리) +- **영향 범위**: [docs/CONVENTIONS.md](../CONVENTIONS.md) 대폭 갱신 + 22개 spec 파일 frontmatter 마이그 + 새 도구 5개 + AGENTS.md 도입 +- **작업 시간 예상**: 4-6시간 (신중성 우선, 의심 케이스 사용자 확인) +- **invariant**: 22개 spec 파일의 *현재 메타데이터 값* (Status / GA / Target / Last updated) 정확 보존 + +--- + +## 결정 14개 (확정) + +이전 대화에서 사용자 확정된 결정. 수정 금지. + +| # | 항목 | 한 줄 | +|---|---|---| +| D1 | YAML frontmatter 정식 도입 (`status` / `ga` 또는 `target` / `supersedes` / `superseded_by` / `last_updated`). 기존 `**Status**: ...` inline 라인 제거 | +| D2 | AGENTS.md 정본화 + CLAUDE.md = 1줄 stub (symlink 아님, 별도 파일) | +| D3 | `last_updated` 자동 갱신 (Claude Code PostToolUse hook + CI 검증). 매 의미 변경 commit 마다 갱신 | +| D4 | EARS notation 인수조건 형식 — v0.4.0+ 신규 spec 만 적용. 기존 Frozen 미변경 | +| D6 | `pytest.mark.spec("vX.Y.Z/topic#AC-N")` marker + 자동 trace report (Living, [docs/traces/coverage.md](../traces/coverage.md)) | +| D7 | Claude Code skill `.claude/skills/new-spec/SKILL.md` (`/new-spec v0.4.0 view-renderer` 호출 시 spec + 페어 ADR + README 인덱스 row 일괄 생성) | +| D8 | Cross-link 방향성 lint (Living/Active/Draft/Frozen 4-tier 위반 검출) — 기존 [.claude/hooks/docs-lint.py](.claude/hooks/docs-lint.py) 확장 | +| D9 | 종합 spec lint (frontmatter schema / 파일명 kebab-case / supersede chain integrity) — 기존 lint 확장 | +| D10 | `docs/verification/` 정책 약화 — 큰 단위 작업 / 의심 영역의 verifier subagent 산출물 한정. 작은 작업 작성 안 함 | +| D11 | CHANGELOG (사용자 *what*) ↔ implementation log (개발자 *why/how*) 역할 분리 명문화 | +| D12 | 상대경로 implicit 표준 명문화 (`foo.md` / `subdir/foo.md` / `../foo.md`. `./` 금지). 기존 파일은 이미 표준 따름 — 마이그레이션 없음 | +| D13 | Frozen 외부 의존성 부패 → 무시 (옵션 A). CONVENTIONS.md 한 줄 추가 | +| D14 | `docs/upstream-pins.yaml` SSOT + 자동 갱신 스크립트 | +| D-extra | **Frozen 면제 조항** — "Living-policy schema migration is non-semantic, allowed in-place on Frozen" — CONVENTIONS.md L12 면제 조항 추가 (본 PR 자체가 이를 활용) | + +기각된 결정 2개: +- ❌ Spec 본문에 코드 경로 hardcode (사용자 직관 정확 — 대신 D6 trace로 대체) +- ❌ Phase Active이지만 spec 0개 우려 (정책상 정상) +- ❌ upstream/ 디렉토리 폐기 (사용자 유지 결정) + +--- + +## Frontmatter Schema (D1 — Source of Truth) + +본 schema 가 D6/D7/D8/D9 모두의 입력. **PR 첫 commit 에서 동결**. + +### 필드 정의 + +```yaml +--- +status: # required, enum +ga: vX.Y.Z # status=Frozen 일 때만 (mutex with target) +target: vX.Y.Z # status=Draft 일 때만 (mutex with ga) +supersedes: null | "/.md" # 새 spec이 무엇을 superseded 하는지 +superseded_by: null | "/.md" # Frozen 이 새 spec 으로 대체된 경우 +last_updated: YYYY-MM-DD # 자동 갱신 (D3) +--- +``` + +### 분류별 적용 + +| 분류 | 적용 | 예시 | +|---|---|---| +| **Living** | frontmatter **없음** (정의상 항상 최신) | [docs/CONVENTIONS.md](../CONVENTIONS.md), [docs/roadmap/README.md](../roadmap/README.md) | +| **Active** | `status: Active`, ga/target 둘 다 생략 | phase-3.md, phase-4.md (이후 폐기 — [roadmap/README.md](../roadmap/README.md) § 미착수 작업 계획 으로 흡수), [docs/upstream/issue-find-control-text-positions.md](../upstream/issue-find-control-text-positions.md) | +| **Draft** | `status: Draft`, `target: vX.Y.Z` 필수 | [v0.7.0/mcp.md](../roadmap/v0.7.0/mcp.md) | +| **Frozen** | `status: Frozen`, `ga: vX.Y.Z` 필수 | 나머지 17개 | +| **Superseded** | `status: Superseded`, `superseded_by` 필수, ga 보존 | (현재 0건) | + +### 예시 + +```markdown +--- +status: Frozen +ga: v0.3.0 +last_updated: 2026-04-28 +--- + +# v0.3.0 — `rhwp-py` 얇은 CLI + +v0.2.0 에서 폐기했던 CLI 를... +``` + +```markdown +--- +status: Draft +target: v0.7.0 +last_updated: 2026-04-28 +--- + +# v0.7.0 — MCP server (`rhwp-mcp`) + +[Model Context Protocol](https://modelcontextprotocol.io/)... +``` + +### 갱신 규칙 + +- 본문 의미 있는 변경 → `last_updated: YYYY-MM-DD` 자동 갱신 (Claude Code hook) +- Status 전환 (Draft → Frozen 등) → `status` + 적절 필드 갱신 +- Frozen 본문 변경 금지는 *body* 한정 — frontmatter 갱신은 본 PR 의 면제 조항 외엔 금지 + +--- + +## 마이그레이션 대상 22개 파일 (D1) + +각 파일의 *현재 inline 메타데이터 값* 정확 보존. 작업 시 한 파일씩 read → 메타 추출 → frontmatter 작성 → inline 라인 제거. + +### Frozen (17개) + +| 파일 | status | ga | last_updated | +|---|---|---|---| +| `docs/roadmap/v0.1.0/rhwp-python.md` | Frozen | v0.1.0 | (현재 값 보존) | +| `docs/roadmap/v0.2.0/ir.md` | Frozen | v0.2.0 | 2026-04-25 | +| `docs/roadmap/v0.3.0/cli.md` | Frozen | v0.3.0 | 2026-04-28 | +| `docs/roadmap/v0.3.0/ir-expansion.md` | Frozen | v0.3.0 | (현재 값 보존) | +| `docs/design/v0.2.0/ir-design-research.md` | Frozen | v0.2.0 | (현재 값 보존) | +| `docs/design/v0.3.0/cli-design-research.md` | Frozen | v0.3.0 | 2026-04-28 | +| `docs/design/v0.3.0/ir-expansion-research.md` | Frozen | v0.3.0 | (현재 값 보존) | +| `docs/implementation/v0.1.0/migration.md` | Frozen | v0.1.0 | (현재 값 보존) | +| `docs/implementation/v0.2.0/stages/stage-1.md` ~ `stage-5.md` (5개) | Frozen | v0.2.0 | (각 현재 값) | +| `docs/implementation/v0.3.0/stages/stage-1.md` ~ `stage-4.md` (4개) | Frozen | v0.3.0 | (각 현재 값) | +| `docs/implementation/v0.3.0/aparse-cleanup.md` | Frozen | v0.3.0 | (현재 값 보존) | +| `docs/verification/v0.1.0/spinoff-review.md` | Frozen | v0.1.0 | (현재 값 보존) | + +### Draft (1개) + +| 파일 | status | target | last_updated | +|---|---|---|---| +| `docs/roadmap/v0.7.0/mcp.md` | Draft | v0.7.0 | 2026-04-28 | +| `docs/design/v0.7.0/mcp-research.md` | Draft | v0.7.0 | (현재 값 보존) | + +### Active (3개) + +| 파일 | status | (ga/target 없음) | last_updated | +|---|---|---|---| +| `docs/roadmap/phase-3.md` | Active | — | 2026-04-26 | +| `docs/roadmap/phase-4.md` | Active | — | (현재 값 보존) | +| `docs/upstream/issue-find-control-text-positions.md` | Active | — | (현재 값 보존) | + +### Living (frontmatter 없음, 미마이그) + +- `docs/CONVENTIONS.md` +- `docs/roadmap/README.md` + +### 합계 + +- Frozen: 17개 파일 +- Draft: 2개 파일 (mcp.md + mcp-research.md) +- Active: 3개 파일 +- Living: 2개 (frontmatter 없음, 미변경) + += **마이그 대상 22개**, **미변경 2개** (Living) + +--- + +## CONVENTIONS.md 갱신 상세 (D1 / D10 / D11 / D12 / D13 / D-extra) + +핵심: 본 PR 의 정책 SSOT. 첫 commit 으로 처리 (다른 모든 변경의 전제). + +### 변경 매핑 + +| Before (line) | After | +|---|---| +| L9 (Living 정의) — 변경 없음 | (그대로) | +| L12 Frozen 정의 — "변경 금지 — 오타·링크 수정만 in-place 허용" | **+ Frozen 면제 조항 추가**: "*예외*: Living-policy schema migration (예: Status header 형식 일괄 갱신, frontmatter 도입) 은 non-semantic 변경으로 간주, in-place 허용. 본 변경은 *전체 spec 일괄* 형태여야 하며 *개별 파일 결정 변경* 이면 supersede 절차를 따른다." | +| L18-31 Status 헤더 형식 (inline `**Status**:` 라인) | **YAML frontmatter 형식으로 전면 갱신** — 본 명세서 § Frontmatter Schema 의 schema 그대로 복사 | +| L77 verification/ 정의 | **약화**: "verification 디렉토리는 **큰 단위 작업** (다단계 stage, 의심 영역) 의 verifier subagent (code-reviewer / test-automator) 산출물 한정. 작은 작업은 git log + PR description 이 SSOT — 작성 생략." | +| L106 "Last updated: 오늘" | **삭제** (자동화 — D3) | +| L114 "Last updated 를 GA 일자로" | **갱신**: "GA 시점 자동 갱신 (D3 hook 이 PR merge commit 날짜로 처리)" | +| L137 "Last updated 갱신" | **삭제** (자동) | +| L161-166 명명 규칙 | **+ 상대경로 형식 한 줄 추가**: "**상대경로**: 같은/하위 디렉토리는 implicit (`foo.md`, `subdir/foo.md`). 상위는 `../foo.md`. `./` prefix 금지 (redundant). 외부 자원만 fully-qualified URL." | +| L177-181 참조 | (그대로 + 본 작업 영감) | + +### 새로 추가하는 섹션 + +#### `## 인수조건 형식 — EARS notation (v0.4.0+ 신규 spec)` + +```markdown +v0.4.0+ 신규 spec 의 § 인수조건 섹션은 [EARS notation](https://alistairmavin.com/ears/) (Easy Approach to Requirements Syntax, Rolls-Royce) 5종 키워드로 작성한다. 각 항목에 `AC-N` ID 부여 — 테스트 `pytest.mark.spec("vX.Y.Z/topic#AC-N")` 와 1:1 매핑. + +| 패턴 | 형식 | 용도 | +|---|---|---| +| Ubiquitous | `THE {system} SHALL {response}` | 항상 성립 | +| Event-Driven | `WHEN {trigger}, THE {system} SHALL {response}` | 이벤트 시 | +| State-Driven | `WHILE {state}, THE {system} SHALL {response}` | 상태 지속 중 | +| Optional | `WHERE {feature}, THE {system} SHALL {response}` | 옵션 켜진 경우 | +| Unwanted | `IF {condition}, THEN THE {system} SHALL {response}` | 예외/실패 | + +기존 v0.1.0 ~ v0.3.0 Frozen spec 은 미변경 — historical record 보존. +``` + +#### `## 역할 분리 — CHANGELOG ↔ implementation log` (D11) + +```markdown +| 문서 | 관점 | 내용 | +|---|---|---| +| CHANGELOG.md | 사용자 (외부) | *what* — 추가/변경/제거된 API, extras, 호환성 영향, 마이그레이션 | +| docs/implementation/.../*.md | 개발자 (내부) | *why/how* — a/b/c 옵션 비교, 시행착오, 결정 근거, Stage별 작업 흐름 | + +같은 사실 중복 기록 금지 — CHANGELOG 가 *what*, log 가 *why/how*. 결정 비교 (a/b/c) 가치가 없는 변경 (단순 dep bump, typo) 은 CHANGELOG 한 줄로 충분 — implementation log 작성 안 함. +``` + +#### `## Frozen 외부 의존성 부패` (D13) + +```markdown +Frozen 본문은 historical record. 시간 흐르며 외부 의존성이 deprecated 되어도 본문 변경하지 않는다 — 결정 시점의 정확성을 보존하는 것이 immutability 의 목적. 현재 진실은 *코드* 와 *최신 spec* 이 가짐. +``` + +#### `## Trace report — pytest spec markers` (D6) + +```markdown +v0.4.0+ 부터 테스트는 `pytest.mark.spec("vX.Y.Z/topic#AC-N")` marker 로 spec 인수조건과 1:1 매핑. CI 에서 [scripts/generate_spec_trace.py](scripts/generate_spec_trace.py) 가 매 빌드 시 [docs/traces/coverage.md](../traces/coverage.md) (Living) 자동 갱신 — spec 별 인수조건 ↔ 테스트 mapping 표. + +기존 v0.1.0 ~ v0.3.0 Frozen spec 은 AC ID 부여 안 함 — marker 없는 테스트 허용. +``` + +--- + +## 새로 추가하는 파일 6종 (D2 / D6 / D7 / D8 / D14) + +### 1. `AGENTS.md` (repo 루트, D2) + +기존 [CLAUDE.md](../../CLAUDE.md) 본문을 그대로 복사. 본문 변경 없음 — 단순 rename. 본 PR 머지 후 모든 외부 도구 (Codex / Factory / Cursor / Kilo) 가 인식. + +### 2. `CLAUDE.md` (1줄 stub, D2) + +```markdown + +This project's agent context lives in [AGENTS.md](../../AGENTS.md). Claude Code reads both — keeping this stub for backward compatibility. +``` + +이유: symlink 는 Windows / 일부 git 클라이언트에서 깨짐. 1줄 stub 이 가장 robust. CLAUDE.md 자체가 더 이상 본문 안 가짐 — Single Source of Truth 는 AGENTS.md. + +### 3. `docs/upstream-pins.yaml` (D14) + +```yaml +# Source of truth for external/rhwp upstream commit pin per release. +# Auto-updated by scripts/update_upstream_pin.py. +# CHANGELOG.md references this file's values in prose. + +pins: + v0.1.0: + upstream_commit: <기존 값 — git log 에서 추출> + bumped_at: + v0.2.0: + upstream_commit: bea635b + bumped_at: 2026-04-25 + v0.3.0: + upstream_commit: 033617e + previous_commit: bea635b + commits_integrated: 380 + bumped_at: 2026-04-28 +``` + +### 4. `scripts/update_upstream_pin.py` (D14) + +```python +"""external/rhwp 의 현재 commit hash 를 docs/upstream-pins.yaml 에 기록. +릴리스 직전 작업자가 수기 호출 (또는 release workflow 가 호출). + +사용: + uv run python scripts/update_upstream_pin.py vX.Y.Z + +동작: + 1. external/rhwp 디렉토리에서 git rev-parse HEAD 추출 + 2. 직전 entry 와 commits_integrated 계산 (git log --oneline prev..curr | wc -l) + 3. docs/upstream-pins.yaml 의 pins[vX.Y.Z] 갱신 (또는 신규 추가) +""" +``` + +### 5. `scripts/lint_docs.py` (D8 / D9) + +기존 [.claude/hooks/docs-lint.py](.claude/hooks/docs-lint.py) 의 4개 룰 + 새 룰 5종. CI step 으로 *전체 repo scan*, hook 은 *편집 파일만* 검증. + +새 룰: +- frontmatter schema 검증 (status enum / ga ↔ target mutex / superseded_by ↔ status:Superseded mutex) +- 파일명 kebab-case (`ir-expansion.md` ✅, `ir_expansion.md` ❌) +- 디렉토리명 `vX.Y.Z` (SemVer) 형식 +- `.md` ↔ `-research.md` stem 매칭 (roadmap/vX.Y.Z/foo.md 있으면 design/vX.Y.Z/foo-research.md 도 있어야) +- supersede chain integrity (`superseded_by` 가 가리키는 파일이 존재 + 그 파일의 `supersedes` 가 역참조) + +**구현 가이드**: +- `pyyaml` 의존성 추가 (`pyproject.toml [project.optional-dependencies] dev`) +- 기존 `docs-lint.py` 의 hook 진입점은 유지 (편집 시점 즉시 알림) +- `scripts/lint_docs.py` 는 두 entry point: hook (단일 파일) / CLI (전체 scan) +- 공통 로직은 `scripts/_doc_lint.py` 모듈로 분리 (둘이 import) + +### 6. `.claude/skills/new-spec/SKILL.md` (D7) + +```yaml +--- +name: new-spec +description: Scaffold a new version spec and paired ADR following CONVENTIONS.md +argument-hint: +arguments: [version, topic] +disable-model-invocation: true +--- + +# /new-spec — 새 spec 스캐폴드 + +목적: `` (예: `v0.4.0`) + `` (예: `view-renderer`) 인자로 다음을 일괄 생성. + +산출물: +1. `docs/roadmap//.md` (frontmatter: status=Draft, target=) +2. `docs/design//-research.md` (페어 ADR) +3. `docs/roadmap/README.md` 인덱스 표에 row 추가 +4. § 인수조건 (EARS) placeholder 섹션 + +규칙 (CONVENTIONS.md 정독 후 준수): +- frontmatter schema 정확 적용 +- spec ↔ research 페어 외 다른 spec 파일 직접 link 금지 +- kebab-case 파일명, vX.Y.Z 디렉토리명 + +작업 후 `python3 scripts/lint_docs.py` 실행하여 무결성 검증. +``` + +### 7. `scripts/generate_spec_trace.py` (D6) + +```python +"""pytest 의 spec marker 를 collect 하여 docs/traces/coverage.md 갱신. + +사용: + uv run python scripts/generate_spec_trace.py + +동작: + 1. pytest --collect-only -q 실행 (테스트 파일 수집) + 2. 각 테스트의 spec marker 추출 + 3. spec 별로 그룹화 → 표 생성 + 4. docs/traces/coverage.md 작성 + +출력 형식: + | Spec | AC | Tests | + |---|---|---| + | v0.4.0/view-renderer | AC-1 | tests/test_view.py::test_basic | +""" +``` + +CI step 추가 (`.github/workflows/ci.yml`): + +```yaml +- name: Generate spec trace report + run: uv run python scripts/generate_spec_trace.py +- name: Verify trace report up to date + run: git diff --exit-code docs/traces/coverage.md +``` + +### 8. `docs/traces/coverage.md` placeholder + +초기엔 빈 표. CI 가 첫 PR 부터 갱신. + +```markdown +# Spec ↔ Test Trace + +자동 생성 — `scripts/generate_spec_trace.py`. Living. + +(아직 매핑 없음. v0.4.0+ 부터 채워짐.) +``` + +--- + +## 변경하는 기존 파일 + +### `pyproject.toml` (D6) + +```toml +[tool.pytest.ini_options] +markers = [ + "spec(spec_id): link this test to a spec acceptance criterion (e.g., 'v0.4.0/cli#AC-3')", + # ... 기존 markers 보존 +] + +[project.optional-dependencies] +dev = [ + # ... 기존 + "pyyaml>=6.0", # spec lint frontmatter 파싱 +] +``` + +### `.claude/hooks/docs-lint.py` (D8 / D9) + +기존 4개 룰은 그대로. 변경 사항: +- Status header 검증 (룰 #1) → frontmatter 검증으로 교체 +- Living 파일 정의 보존 (`docs/CONVENTIONS.md`, `docs/roadmap/README.md`) +- 새 룰 추가: frontmatter schema, kebab-case, supersede chain +- 공통 로직 `scripts/_doc_lint.py` 로 분리 (CLI 와 hook 둘이 import) + +### `.claude/settings.json` (D3) + +`last_updated` 자동 갱신 hook 추가: + +```json +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "python3 ${CLAUDE_PROJECT_DIR}/.claude/hooks/docs-lint.py" + }, + { + "type": "command", + "command": "python3 ${CLAUDE_PROJECT_DIR}/.claude/hooks/update-last-updated.py" + } + ] + } + ] + } +} +``` + +### `.claude/hooks/update-last-updated.py` (신규, D3) + +```python +"""편집된 docs/*.md 의 frontmatter 의 last_updated 를 오늘 날짜로 갱신. + +PostToolUse hook. Living 파일 (CONVENTIONS / roadmap/README) 은 skip. +frontmatter 가 없는 파일도 skip (Living 또는 비-spec). +""" +``` + +### `.github/workflows/ci.yml` (D6 / D8 / D9) + +```yaml +- name: Lint docs + run: uv run python scripts/lint_docs.py docs/ + +- name: Verify last_updated freshness + run: uv run python scripts/lint_docs.py --check-last-updated docs/ + +- name: Generate spec trace report + run: uv run python scripts/generate_spec_trace.py +``` + +### `CHANGELOG.md` ([Unreleased] 섹션) + +본 PR 자체를 기록: + +```markdown +## [Unreleased] + +### Changed — 문서 시스템 대규모 개편 + +- spec 메타데이터를 inline `**Status**:` 라인에서 YAML frontmatter 로 전면 마이그. 22개 spec 일괄 적용 (Living-policy schema migration — Frozen 면제 조항) +- `AGENTS.md` 를 정본 agent context 파일로 도입 (CLAUDE.md 는 1줄 stub 으로 유지). Codex / Factory / Cursor / Kilo 등 비-Claude 도구 호환 +- Spec lint 도구 (`scripts/lint_docs.py`) 신설 — frontmatter schema, 파일명, supersede chain, cross-link 방향성 자동 검증. CI step 추가 +- `pytest.mark.spec("vX.Y.Z/topic#AC-N")` marker + 자동 trace report (`docs/traces/coverage.md`) 도입 — v0.4.0+ 신규 spec 부터 적용 +- `/new-spec` Claude Code skill 신설 — 새 version spec 스캐폴드 자동화 +- `docs/upstream-pins.yaml` SSOT 도입 — external/rhwp 커밋 핀 자동 추출 +- CONVENTIONS.md 갱신: EARS notation 인수조건 형식 (v0.4.0+), CHANGELOG ↔ implementation log 역할 분리, 상대경로 명명 규칙, Frozen 외부 의존성 부패 정책 +- `last_updated` 자동 갱신 hook (Claude Code PostToolUse + CI 검증) + +본 변경은 메타 — 사용자 facing API 영향 0. 내부 문서 운영 체계 정비. +``` + +--- + +## 작업 순서 (commit 단위) + +단일 PR 안에서 의미 있는 commit 으로 분리. 각 commit 후 사용자 confirmation 받고 다음 진행 (신중성). + +### Commit 1: CONVENTIONS.md 정책 갱신 (정책 SSOT 우선) + +**파일**: `docs/CONVENTIONS.md` (단독) + +**변경**: +- Frozen 면제 조항 추가 (D-extra) +- Status 헤더 형식 → frontmatter 형식 전면 갱신 (D1) +- verification/ 정책 약화 (D10) +- last_updated 자동화 반영 (D3) +- 명명 규칙에 상대경로 형식 추가 (D12) +- 새 섹션: 인수조건 형식 EARS (D4) / CHANGELOG ↔ log 분리 (D11) / Frozen 외부 의존성 부패 (D13) / Trace report (D6) + +**검증**: +- 본 commit 자체로 `docs-lint.py` 통과 (Living 파일이라 frontmatter 없어도 OK) +- 본문 self-consistency (예시들이 새 schema 사용) + +**Commit message**: +``` +docs: CONVENTIONS.md 갱신 — frontmatter schema + 정책 정비 + +- Status 인라인 형식 → YAML frontmatter (status / ga | target / supersedes / superseded_by / last_updated) +- Frozen 면제 조항 추가 (Living-policy schema migration) +- verification/ 정책 약화 (큰 단위 작업 한정) +- last_updated 자동화로 전환 (수기 갱신 절차 삭제) +- 명명 규칙에 상대경로 implicit 표준 추가 +- 신규 섹션: EARS 인수조건 (v0.4.0+) / CHANGELOG ↔ log 분리 / Frozen 외부 의존성 부패 / Trace report + +후속 commit 들이 본 정책 따름. +``` + +--- + +### Commit 2: AGENTS.md 도입 + CLAUDE.md stub + +**파일**: +- 신규 `AGENTS.md` (기존 CLAUDE.md 본문 복사) +- 변경 `CLAUDE.md` (1줄 stub) + +**검증**: +- `git mv` 가 아니라 *복사 후 stub 으로 변경* (history 보존). Claude Code 가 양쪽 다 읽으므로 양립 + +**Commit message**: +``` +docs: AGENTS.md 정본화 + CLAUDE.md stub + +AGENTS.md 가 2025-12 Linux Foundation/AAIF 표준이 되어 Codex / Factory / +Cursor / Kilo 등이 인식. 본문은 AGENTS.md 가 SSOT, CLAUDE.md 는 1줄 stub +으로 backward compat 유지. +``` + +--- + +### Commit 3: 22개 spec 파일 frontmatter 마이그 + +**파일**: 22개 spec (본 명세서 § 마이그 대상 22개 표 그대로) + +**작업**: +- 한 파일씩 read → 기존 inline 메타 정확 추출 → frontmatter 작성 → inline 라인 제거 +- *기존 메타 값 정확 보존* (Status / GA / Target / Last updated 모두) +- Frozen 면제 조항 (D-extra) 적용 — 본 commit 이 그 사례 + +**검증**: +- 22개 파일 각각 `docs-lint.py` 통과 +- frontmatter schema 일관 (mutex 위반 없음) +- 기존 본문 의미 변경 0 + +**Commit message**: +``` +docs: 22개 spec frontmatter 마이그 (Frozen 면제 조항 적용) + +inline `**Status**:` 라인 → YAML frontmatter. 본문 변경 0. +Living-policy schema migration — Frozen 17개 포함 (CONVENTIONS § Frozen +면제 조항 적용). +``` + +--- + +### Commit 4: lint script 확장 + frontmatter 검증 룰 + +**파일**: +- 신규 `scripts/lint_docs.py` (CLI entry) +- 신규 `scripts/_doc_lint.py` (공통 lib) +- 변경 `.claude/hooks/docs-lint.py` (frontmatter 검증으로 교체 + 새 룰) +- 변경 `pyproject.toml` (`pyyaml` dev 의존성) + +**검증**: +- `python3 scripts/lint_docs.py docs/` 전체 repo scan 0 violation +- 기존 hook 룰 (cross-link 방향성, broken link, monorepo 잔재) 동작 보존 + +**Commit message**: +``` +chore: spec lint 확장 — frontmatter / supersede chain / kebab-case 검증 + +기존 .claude/hooks/docs-lint.py 의 4개 룰을 scripts/_doc_lint.py 공통 +lib 로 분리. CLI entry (scripts/lint_docs.py) 신설 — CI 에서 전체 repo +scan. hook 은 편집 파일 단위 즉시 알림 유지. + +추가 룰: +- frontmatter schema (status enum / ga ↔ target mutex) +- kebab-case 파일명 +- vX.Y.Z 디렉토리 SemVer +- .md ↔ -research.md stem 매칭 +- supersede chain integrity +``` + +--- + +### Commit 5: upstream-pins.yaml + 자동 추출 스크립트 + +**파일**: +- 신규 `docs/upstream-pins.yaml` +- 신규 `scripts/update_upstream_pin.py` + +**검증**: +- 현재 external/rhwp HEAD (`033617e`) 정확 +- 과거 v0.1.0 / v0.2.0 commit 도 git log 에서 추출하여 채움 (기존 CHANGELOG 참조) + +**Commit message**: +``` +chore: upstream-pins.yaml SSOT + 자동 추출 스크립트 + +external/rhwp 커밋 핀의 SSOT 를 yaml 로 분리. CHANGELOG 는 본 파일을 +참조하며 산문 작성 (수기 유지 — 한국어 산문 형식상 완전 자동화 부적절). +``` + +--- + +### Commit 6: pytest.mark.spec marker + trace 스크립트 + +**파일**: +- 변경 `pyproject.toml` (`markers` 등록) +- 신규 `scripts/generate_spec_trace.py` +- 신규 `docs/traces/coverage.md` (placeholder) +- 변경 `.github/workflows/ci.yml` (CI step 추가) + +**검증**: +- `pytest --markers` 출력에 `spec` marker 등장 +- `python3 scripts/generate_spec_trace.py` 동작 (현재 marker 0건이므로 빈 표) +- CI 통과 + +**Commit message**: +``` +test: pytest.mark.spec marker + 자동 trace report + +v0.4.0+ 신규 spec 의 인수조건 ↔ 테스트 매핑 자동화 인프라. +기존 v0.1.0 ~ v0.3.0 Frozen spec 은 marker 없는 테스트 허용. +``` + +--- + +### Commit 7: /new-spec slash command (skill) + +**파일**: +- 신규 `.claude/skills/new-spec/SKILL.md` + +**검증**: +- skill 본문이 CONVENTIONS.md 새 정책 (frontmatter / EARS / 명명) 정확 참조 +- placeholder 인수조건 섹션이 EARS 5종 키워드 예시 포함 + +**Commit message**: +``` +feat: /new-spec Claude Code skill — 새 version spec 스캐폴드 자동화 + +호출: /new-spec +산출: roadmap//.md + design//-research.md ++ README 인덱스 row. CONVENTIONS § 새 spec 추가 절차 자동화. +``` + +--- + +### Commit 8: last_updated 자동 갱신 hook + +**파일**: +- 신규 `.claude/hooks/update-last-updated.py` +- 변경 `.claude/settings.json` (hook 등록) + +**검증**: +- 더미 .md 파일 편집 시 frontmatter `last_updated` 가 오늘 날짜로 갱신 +- Living 파일 (CONVENTIONS / roadmap README) 은 skip + +**Commit message**: +``` +chore: last_updated 자동 갱신 hook (Claude Code PostToolUse) + +수기 갱신 절차 폐기. CI lint 가 frontmatter `last_updated` 가 git diff +의 마지막 commit 날짜와 일치하는지 검증 — 외부 (수기 git commit) 케이스도 +catch. +``` + +--- + +### Commit 9: CHANGELOG 갱신 + 명세서 이주 + +**파일**: +- 변경 `CHANGELOG.md` ([Unreleased] 추가) +- 이동: 본 `OVERHAUL_PLAN.md` → `docs/implementation/spec-system-overhaul.md` (Frozen) +- 삭제: 루트 `OVERHAUL_PLAN.md` + +**이주 시 frontmatter 추가**: + +```yaml +--- +status: Frozen +ga: +last_updated: <오늘> +--- +``` + +**문제**: meta 작업은 어떤 vX.Y.Z 에 속하는가? + +**해결**: 본 작업을 **`docs/implementation/` 직속 평면** 으로 두되 (vX.Y.Z 외부), CONVENTIONS.md 의 implementation log 구조 정의에 *meta-level / cross-version 작업 슬롯* 한 줄 추가: + +```markdown +- `docs/implementation/.md` (직속 평면, vX.Y.Z 외부) — meta-level + / cross-version 작업 (예: 문서 시스템 개편). 작성 즉시 Frozen, ga 필드 + 생략 가능 (해당 사항 없음). +``` + +이건 **Commit 1** (CONVENTIONS 갱신) 에 포함하는 게 깔끔. Commit 9 은 그 슬롯 활용. + +**Commit message**: +``` +docs: CHANGELOG [Unreleased] + spec-system-overhaul 명세서 이주 + +OVERHAUL_PLAN.md 를 docs/implementation/spec-system-overhaul.md (Frozen) +로 이주. 결정 14개 + a/b/c 비교 historical record 보존. +``` + +--- + +## 검증 체크리스트 (PR 머지 전 필수) + +- [ ] `python3 scripts/lint_docs.py docs/` — 0 violations +- [ ] `python3 .claude/hooks/docs-lint.py` 가 22개 마이그 파일 모두 통과 +- [ ] `uv run pytest -m "not slow"` — 기존 테스트 통과 (regression 없음) +- [ ] `cargo clippy --all-targets -- -D warnings` — Rust 변경 없으나 검증 +- [ ] frontmatter schema 일관 (mutex 위반 0 / 모든 파일 status / ga|target / last_updated 보유) +- [ ] AGENTS.md 와 CLAUDE.md 둘 다 존재, CLAUDE.md 가 stub 형태 +- [ ] 기존 cross-link 모두 동작 (broken link 0) +- [ ] CONVENTIONS.md 자체가 새 정책 따름 (self-consistency) +- [ ] CHANGELOG [Unreleased] 갱신 +- [ ] `OVERHAUL_PLAN.md` 루트에서 삭제, `docs/implementation/spec-system-overhaul.md` 이주 +- [ ] CI 모든 step 통과 + +--- + +## PR 메시지 초안 + +**제목**: `docs: spec system overhaul — frontmatter + lint + AGENTS.md + EARS infra` + +**Body**: + +```markdown +## Summary +- spec 메타데이터를 inline `**Status**:` 라인 → YAML frontmatter 전면 마이그 (22개 파일) +- AGENTS.md 정본화 (CLAUDE.md 는 1줄 stub) — Codex / Factory / Cursor 등 호환 +- spec lint 종합 도구 (`scripts/lint_docs.py`) + 기존 `.claude/hooks/docs-lint.py` 확장 +- `pytest.mark.spec()` + 자동 trace report 인프라 (v0.4.0+ 부터 사용) +- `/new-spec` Claude Code skill — 새 version spec 스캐폴드 자동화 +- `docs/upstream-pins.yaml` SSOT — external/rhwp 커밋 핀 +- CONVENTIONS.md 대폭 갱신 (Frozen 면제 조항 / EARS / CHANGELOG ↔ log 분리 / 명명 규칙 / Frozen 외부 의존성 부패) + +## Why +2026-04 시점 SDD 트렌드 조사 결과 본 프로젝트는 "Spec-driven + immutable per-version" 디자인은 외부 도구 (Spec-Kit / Kiro / OpenSpec / BMAD) 대비 한 단계 깊으나, *집행* 이 인간 검열 + LLM 협조에만 의존. frontmatter 표준화 + lint 자동화로 같은 디자인을 자동 보장으로 격상. + +## Scope +- 사용자 facing API 영향: **0** (메타 작업) +- 외부 의존성 변경: `pyyaml` (dev only) +- breaking 변경: 없음 + +## Test plan +- [ ] `python3 scripts/lint_docs.py docs/` 통과 +- [ ] `uv run pytest -m "not slow"` regression 없음 +- [ ] AGENTS.md / CLAUDE.md 양립 (Claude Code 가 양쪽 read) +- [ ] 22개 spec frontmatter schema 일관 +- [ ] CI 모든 step 통과 (lint / trace / pytest) + +## Related +- 본 PR 의 결정 historical record: `docs/implementation/spec-system-overhaul.md` (Frozen) +- 영감: GitHub Spec-Kit / Kiro EARS / OpenSpec / MADR / Cline Memory Bank +``` + +--- + +## 신중성 가이드 (Clean session 작업 시) + +본 작업은 **문서 관리 시스템의 터닝포인트**. 매 commit 마다: + +1. 변경 파일 list + 핵심 변경 요약 사용자에게 보여주고 +2. 다음 step 진행 OK 받고 진행 +3. 의심 케이스 (아래) 는 **멈추고 질문**: + - 22개 마이그 대상 파일 중 *현재 메타 값* 이 명세서와 다르면 (예: 실제 last_updated 값이 다름) + - frontmatter schema 가 명세서와 안 맞는 케이스 발견 시 (예: status 가 enum 외) + - 기존 cross-link 이 frontmatter 마이그 후 깨지는 케이스 + - CONVENTIONS.md 자체에 본 작업의 면제 조항이 *제대로 들어갔는지* (자기 모순 점검) + +**추측 금지**. 사용자 확인 후 진행. + +본 작업의 가장 중요한 **invariant 5종**: +1. 22개 spec 파일의 *현재 메타데이터 값* (Status / GA / Target / Last updated) 정확 보존 — 본 PR 이 결정 변경이 아니라 형식 마이그 +2. Frozen 분류 정책 위반 없음 (Living-policy schema migration 면제 조항이 *commit 1 에서* 명문화 — 그 이후 commit 들이 면제 활용) +3. 기존 cross-link 무결성 (broken link 0) +4. 사용자 facing API 영향 0 (메타 작업 — pyproject.toml 도 dev 의존성만 변경) +5. AGENTS.md / CLAUDE.md 양립 — symlink 안 씀, 둘 다 별도 파일 + +--- + +## 작업 후 정리 (PR 머지 후) + +본 명세서 자체: +- Commit 9 에서 `docs/implementation/spec-system-overhaul.md` (Frozen) 로 이주 완료 +- 루트 `OVERHAUL_PLAN.md` 삭제 완료 +- 본 작업의 a/b/c 비교 historical record 는 implementation log 가 보유 + +후속 작업 (별도 PR): +- v0.4.0 신규 spec 작성 시 `/new-spec v0.4.0 view-renderer` 활용 — EARS 인수조건 도입 첫 사례 +- archive sed 스크립트 작성 (v1.0 GA 가까울 때) +- CHANGELOG 정리 — 기존 v0.3.0 섹션의 *why/how* 부분을 implementation log 로 이주 (선택, 큰 작업이라 별도) + +--- + +## 메타 — 본 명세서의 의의 + +본 작업은 **single PR로 14개 결정 일괄 적용**하는 의도적 큰 변화. 분할 PR (10개) 은 review burden 분산이 가능하나: +- 각 PR 의 정합성 검증이 어려움 (D1 schema 가 D6/D7/D8/D9 의 입력인데 PR 분리 시 race) +- 머지 순서 관리 부담 +- 본 작업의 *terminal point* (PR 9 까지 다 머지된 후에야 새 시스템 동작) 가 시간 분산되면 그 사이 어색한 상태 + +→ 단일 PR 로 atomic 하게. 각 commit 단위로 review 는 가능 (`git log -p` 또는 PR 의 commit-by-commit view). + +--- + +본 명세서 끝. diff --git a/docs/implementation/v0.1.0/migration.md b/docs/implementation/v0.1.0/migration.md index a427439..fb92b55 100644 --- a/docs/implementation/v0.1.0/migration.md +++ b/docs/implementation/v0.1.0/migration.md @@ -1,6 +1,10 @@ -# 분사·이관 작업 로그 +--- +status: Frozen +ga: v0.1.0 +last_updated: 2026-04-23 +--- -**Status**: Frozen · **GA**: v0.1.0 · **Last updated**: 2026-04-23 +# 분사·이관 작업 로그 **작업일**: 2026-04-23 **작업자**: Claude (Opus 4.7) + DanMeon diff --git a/docs/implementation/v0.2.0/stages/stage-1.md b/docs/implementation/v0.2.0/stages/stage-1.md index 546d8a6..6276583 100644 --- a/docs/implementation/v0.2.0/stages/stage-1.md +++ b/docs/implementation/v0.2.0/stages/stage-1.md @@ -1,10 +1,14 @@ -# Stage S1 — Pydantic 모델 초안 (완료) +--- +status: Frozen +ga: v0.2.0 +last_updated: 2026-04-24 +--- -**Status**: Frozen · **GA**: v0.2.0 · **Last updated**: 2026-04-24 +# Stage S1 — Pydantic 모델 초안 (완료) **작업일**: 2026-04-24 **계획 문서**: [roadmap/v0.2.0/ir.md](../../../roadmap/v0.2.0/ir.md) §구현 스테이지 분할 -**설계 근거**: [design/v0.2.0/ir-design-research.md](../../../design/v0.2.0/ir-design-research.md) +**설계 근거**: [design/v0.2.0/ir-research.md](../../../design/v0.2.0/ir-research.md) ## 스코프 @@ -83,5 +87,5 @@ S2 는 "Rust → Python dict → Pydantic `model_validate`" 매핑을 `src/docum ## 참조 - 상위 설계: [roadmap/v0.2.0/ir.md](../../../roadmap/v0.2.0/ir.md) -- 결정 사항 증거: [design/v0.2.0/ir-design-research.md](../../../design/v0.2.0/ir-design-research.md) +- 결정 사항 증거: [design/v0.2.0/ir-research.md](../../../design/v0.2.0/ir-research.md) - 상류 타입 (S2 에서 매핑): `external/rhwp/src/model/{document,paragraph,table}.rs` diff --git a/docs/implementation/v0.2.0/stages/stage-2.md b/docs/implementation/v0.2.0/stages/stage-2.md index 746b282..fc54cb3 100644 --- a/docs/implementation/v0.2.0/stages/stage-2.md +++ b/docs/implementation/v0.2.0/stages/stage-2.md @@ -1,10 +1,14 @@ -# Stage S2 — Rust → dict 매퍼 + `Document.to_ir()` 바인딩 (완료) +--- +status: Frozen +ga: v0.2.0 +last_updated: 2026-04-24 +--- -**Status**: Frozen · **GA**: v0.2.0 · **Last updated**: 2026-04-24 +# Stage S2 — Rust → dict 매퍼 + `Document.to_ir()` 바인딩 (완료) **작업일**: 2026-04-24 **계획 문서**: [roadmap/v0.2.0/ir.md](../../../roadmap/v0.2.0/ir.md) §구현 스테이지 분할 -**설계 근거**: [design/v0.2.0/ir-design-research.md](../../../design/v0.2.0/ir-design-research.md) §7 (캐싱 전략) +**설계 근거**: [design/v0.2.0/ir-research.md](../../../design/v0.2.0/ir-research.md) §7 (캐싱 전략) ## 스코프 diff --git a/docs/implementation/v0.2.0/stages/stage-3.md b/docs/implementation/v0.2.0/stages/stage-3.md index e9520e8..3c7dd52 100644 --- a/docs/implementation/v0.2.0/stages/stage-3.md +++ b/docs/implementation/v0.2.0/stages/stage-3.md @@ -1,10 +1,14 @@ -# Stage S3 — Table 통합 (완료) +--- +status: Frozen +ga: v0.2.0 +last_updated: 2026-04-24 +--- -**Status**: Frozen · **GA**: v0.2.0 · **Last updated**: 2026-04-24 +# Stage S3 — Table 통합 (완료) **작업일**: 2026-04-24 **계획 문서**: [roadmap/v0.2.0/ir.md](../../../roadmap/v0.2.0/ir.md) §테이블 표현 + §구현 스테이지 분할 -**설계 근거**: [design/v0.2.0/ir-design-research.md](../../../design/v0.2.0/ir-design-research.md) §2 (HTML 직렬화 위치) +**설계 근거**: [design/v0.2.0/ir-research.md](../../../design/v0.2.0/ir-research.md) §2 (HTML 직렬화 위치) ## 스코프 @@ -35,7 +39,7 @@ Provenance 는 같은 `(section_idx, para_idx)` 공유 — 같은 Paragraph 에 ### 2. HTML 직렬화 (Rust 측) -설계 근거: ir-design-research.md §2 — Unstructured / Docling 모두 Python layer. 그러나 우리는 Rust 가 이미 dict 를 만드는 경로라 **Rust 에서 직접 생성**. "잠정, 상류 PR 동시 추진" 원칙은 동일. +설계 근거: ir-research.md §2 — Unstructured / Docling 모두 Python layer. 그러나 우리는 Rust 가 이미 dict 를 만드는 경로라 **Rust 에서 직접 생성**. "잠정, 상류 PR 동시 추진" 원칙은 동일. ```rust // attribute 순서 고정 (rowspan → colspan, 1 생략) — dedup hash 안정성 diff --git a/docs/implementation/v0.2.0/stages/stage-4.md b/docs/implementation/v0.2.0/stages/stage-4.md index e17d0c4..1636ddf 100644 --- a/docs/implementation/v0.2.0/stages/stage-4.md +++ b/docs/implementation/v0.2.0/stages/stage-4.md @@ -1,10 +1,14 @@ -# Stage S4 — JSON Schema 공개 (완료) +--- +status: Frozen +ga: v0.2.0 +last_updated: 2026-04-24 +--- -**Status**: Frozen · **GA**: v0.2.0 · **Last updated**: 2026-04-24 +# Stage S4 — JSON Schema 공개 (완료) **작업일**: 2026-04-24 **계획 문서**: [roadmap/v0.2.0/ir.md](../../../roadmap/v0.2.0/ir.md) §JSON Schema 공개 -**설계 근거**: [design/v0.2.0/ir-design-research.md](../../../design/v0.2.0/ir-design-research.md) §8 ($id 호스팅) +**설계 근거**: [design/v0.2.0/ir-research.md](../../../design/v0.2.0/ir-research.md) §8 ($id 호스팅) ## 스코프 diff --git a/docs/implementation/v0.2.0/stages/stage-5.md b/docs/implementation/v0.2.0/stages/stage-5.md index d0e6c0c..236ffde 100644 --- a/docs/implementation/v0.2.0/stages/stage-5.md +++ b/docs/implementation/v0.2.0/stages/stage-5.md @@ -1,10 +1,14 @@ -# Stage S5 — `iter_blocks` + LangChain IR 통합 (완료) +--- +status: Frozen +ga: v0.2.0 +last_updated: 2026-04-24 +--- -**Status**: Frozen · **GA**: v0.2.0 · **Last updated**: 2026-04-24 +# Stage S5 — `iter_blocks` + LangChain IR 통합 (완료) **작업일**: 2026-04-24 **계획 문서**: [roadmap/v0.2.0/ir.md](../../../roadmap/v0.2.0/ir.md) §Python API §iter API + §모듈 구조 -**설계 근거**: [design/v0.2.0/ir-design-research.md](../../../design/v0.2.0/ir-design-research.md) §5 (iter API 설계) +**설계 근거**: [design/v0.2.0/ir-research.md](../../../design/v0.2.0/ir-research.md) §5 (iter API 설계) ## 스코프 diff --git a/docs/implementation/v0.3.0/aparse-cleanup.md b/docs/implementation/v0.3.0/aparse-cleanup.md index a1db12a..2c30c41 100644 --- a/docs/implementation/v0.3.0/aparse-cleanup.md +++ b/docs/implementation/v0.3.0/aparse-cleanup.md @@ -1,6 +1,10 @@ -# v0.3.0 — `aparse` aiofiles 제거 (stdlib `asyncio.to_thread` 전환) +--- +status: Frozen +ga: v0.3.0 +last_updated: 2026-04-28 +--- -**Status**: Frozen · **GA**: v0.3.0 · **Last updated**: 2026-04-28 +# v0.3.0 — `aparse` aiofiles 제거 (stdlib `asyncio.to_thread` 전환) v0.2.0 에서 도입한 `rhwp.aparse` 의 `aiofiles` 기반 우회를 stdlib `asyncio.to_thread` 로 정리. v0.3.0 GA 전 cleanup — 별도 patch (v0.3.1) 로 분리하지 않고 v0.3.0 에 합침. 본 implementation note 는 결정 근거 (거부된 대안 비교) 를 보존 — CHANGELOG 한 줄로는 표현 부족한 정보. @@ -82,7 +86,7 @@ return Document.from_bytes(data, source_uri=path) | B. `cfg(target_arch)` 분기 | zero | medium (코드 두 갈래) | 두 코드패스 유지 부담 — 메인테이너 보통 거부 | | C. Cache 구조 분리 refactor | zero (read-only `Arc` + `OnceLock`) | large | 가장 우아. cache 사용 패턴 전수 분석 필요 | -**현 시점 우선순위 낮음** — 우리 측 wrapping (stdlib `asyncio.to_thread`) 비용이 낮고 위험도 작음. [find-control-text-positions](../../../upstream/issue-find-control-text-positions.md) 쪽이 IR Provenance 정확도에 직접 영향이라 상류 push 우선순위가 더 높음. 본 항목은 issue 등록 후보로만 추적. +**현 시점 우선순위 낮음** — 우리 측 wrapping (stdlib `asyncio.to_thread`) 비용이 낮고 위험도 작음. [find-control-text-positions](../../upstream/issue-find-control-text-positions.md) 쪽이 IR Provenance 정확도에 직접 영향이라 상류 push 우선순위가 더 높음. 본 항목은 issue 등록 후보로만 추적. ## 5. 산출물 (코드 / CI / 문서) @@ -135,4 +139,4 @@ return Document.from_bytes(data, source_uri=path) - `external/rhwp/src/document_core/` — `RefCell` 기반 cache (unsendable 의 근본 원인) - `src/document.rs` (rhwp-python) — `#[pyclass(unsendable)]` 선언 위치 -- [docs/upstream/issue-find-control-text-positions.md](../../../upstream/issue-find-control-text-positions.md) — 상류 visibility 변경 요청 선례 (참고) +- [docs/upstream/issue-find-control-text-positions.md](../../upstream/issue-find-control-text-positions.md) — 상류 visibility 변경 요청 선례 (참고) diff --git a/docs/implementation/v0.3.0/stages/stage-1.md b/docs/implementation/v0.3.0/stages/stage-1.md index 4a3637c..324daf3 100644 --- a/docs/implementation/v0.3.0/stages/stage-1.md +++ b/docs/implementation/v0.3.0/stages/stage-1.md @@ -1,6 +1,10 @@ -# Stage S1 — PictureBlock + Furniture 채움 (완료) +--- +status: Frozen +ga: v0.3.0 +last_updated: 2026-04-26 +--- -**Status**: Frozen · **GA**: v0.3.0 · **Last updated**: 2026-04-26 +# Stage S1 — PictureBlock + Furniture 채움 (완료) **작업일**: 2026-04-26 **계획 문서**: [roadmap/v0.3.0/ir-expansion.md](../../../roadmap/v0.3.0/ir-expansion.md) §구현 스테이지 분할 diff --git a/docs/implementation/v0.3.0/stages/stage-2.md b/docs/implementation/v0.3.0/stages/stage-2.md index cd1f461..54b8442 100644 --- a/docs/implementation/v0.3.0/stages/stage-2.md +++ b/docs/implementation/v0.3.0/stages/stage-2.md @@ -1,6 +1,10 @@ -# Stage S2 — FormulaBlock + Footnote/Endnote (완료) +--- +status: Frozen +ga: v0.3.0 +last_updated: 2026-04-26 +--- -**Status**: Frozen · **GA**: v0.3.0 · **Last updated**: 2026-04-26 +# Stage S2 — FormulaBlock + Footnote/Endnote (완료) **작업일**: 2026-04-26 **계획 문서**: [roadmap/v0.3.0/ir-expansion.md](../../../roadmap/v0.3.0/ir-expansion.md) §구현 스테이지 분할 diff --git a/docs/implementation/v0.3.0/stages/stage-3.md b/docs/implementation/v0.3.0/stages/stage-3.md index d867291..69183c6 100644 --- a/docs/implementation/v0.3.0/stages/stage-3.md +++ b/docs/implementation/v0.3.0/stages/stage-3.md @@ -1,6 +1,10 @@ -# Stage S3 — ListItem + Caption + Toc + Field (완료) +--- +status: Frozen +ga: v0.3.0 +last_updated: 2026-04-27 +--- -**Status**: Frozen · **GA**: v0.3.0 · **Last updated**: 2026-04-27 +# Stage S3 — ListItem + Caption + Toc + Field (완료) **작업일**: 2026-04-27 **계획 문서**: [roadmap/v0.3.0/ir-expansion.md](../../../roadmap/v0.3.0/ir-expansion.md) §구현 스테이지 분할 diff --git a/docs/implementation/v0.3.0/stages/stage-4.md b/docs/implementation/v0.3.0/stages/stage-4.md index 29cf75e..09a3666 100644 --- a/docs/implementation/v0.3.0/stages/stage-4.md +++ b/docs/implementation/v0.3.0/stages/stage-4.md @@ -1,6 +1,10 @@ -# Stage S4 — Schema v1.1 GA + rhwp-py CLI + LangChain include_furniture (완료) +--- +status: Frozen +ga: v0.3.0 +last_updated: 2026-04-28 +--- -**Status**: Frozen · **GA**: v0.3.0 · **Last updated**: 2026-04-28 +# Stage S4 — Schema v1.1 GA + rhwp-py CLI + LangChain include_furniture (완료) **작업일**: 2026-04-28 **계획 문서**: [roadmap/v0.3.0/ir-expansion.md](../../../roadmap/v0.3.0/ir-expansion.md) §구현 스테이지 분할 + [roadmap/v0.3.0/cli.md](../../../roadmap/v0.3.0/cli.md) @@ -136,7 +140,7 @@ S4 가 GA 의 마지막 stage. release 진입 전 다음 명시: ## 참조 - 상위 설계: [roadmap/v0.3.0/ir-expansion.md](../../../roadmap/v0.3.0/ir-expansion.md), [roadmap/v0.3.0/cli.md](../../../roadmap/v0.3.0/cli.md) -- 결정 사항 증거: [design/v0.3.0/ir-expansion-research.md](../../../design/v0.3.0/ir-expansion-research.md), [design/v0.3.0/cli-design-research.md](../../../design/v0.3.0/cli-design-research.md) +- 결정 사항 증거: [design/v0.3.0/ir-expansion-research.md](../../../design/v0.3.0/ir-expansion-research.md), [design/v0.3.0/cli-research.md](../../../design/v0.3.0/cli-research.md) - 선행 stage: [stage-1.md](stage-1.md), [stage-2.md](stage-2.md), [stage-3.md](stage-3.md) - 상류 제안 이슈 (S2 시점 정리): [docs/upstream/issue-find-control-text-positions.md](../../../upstream/issue-find-control-text-positions.md) - v0.2.0 선례: [implementation/v0.2.0/stages/](../../v0.2.0/stages/) (S1~S5) diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 17052cc..38cd8c7 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -18,21 +18,93 @@ rhwp-python 의 버전별 로드맵 + **활성 spec 인덱스 SSOT**. 모든 spe | 버전 | Status | Roadmap spec | Design research (ADR) | |---|---|---|---| | v0.1.0 / v0.1.1 | Frozen | [v0.1.0/rhwp-python.md](v0.1.0/rhwp-python.md) | — | -| v0.2.0 | Frozen | [v0.2.0/ir.md](v0.2.0/ir.md) | [design/v0.2.0/ir-design-research.md](../design/v0.2.0/ir-design-research.md) | +| v0.2.0 | Frozen | [v0.2.0/ir.md](v0.2.0/ir.md) | [design/v0.2.0/ir-research.md](../design/v0.2.0/ir-research.md) | | v0.3.0 (IR 확장) | Frozen | [v0.3.0/ir-expansion.md](v0.3.0/ir-expansion.md) | [design/v0.3.0/ir-expansion-research.md](../design/v0.3.0/ir-expansion-research.md) | -| v0.3.0 (CLI) | Frozen | [v0.3.0/cli.md](v0.3.0/cli.md) | [design/v0.3.0/cli-design-research.md](../design/v0.3.0/cli-design-research.md) | +| v0.3.0 (CLI) | Frozen | [v0.3.0/cli.md](v0.3.0/cli.md) | [design/v0.3.0/cli-research.md](../design/v0.3.0/cli-research.md) | | v0.7.0 (MCP server) | Draft | [v0.7.0/mcp.md](v0.7.0/mcp.md) | [design/v0.7.0/mcp-research.md](../design/v0.7.0/mcp-research.md) | -## Phase 인덱스 +## 미착수 작업 계획 -Phase 는 여러 MINOR 릴리스에 걸친 기능 묶음. **구체 결정은 vX.Y.Z spec 이 보유** — phase 문서는 의도/스코프와 동시 GA 두 축 연동만 다룸. +본 섹션은 결정 미정 narrative — `vX.Y.Z` 디렉토리가 아직 없는 minor 들의 의도/스코프. 작업 시점이 가까워지면 `/new-spec ` 으로 정식 spec 으로 promote. -| Phase | Status | 대상 버전 | 문서 | -|---|---|---|---| -| Phase 3 | Active | v0.4.0 ~ v0.6.0 | [phase-3.md](phase-3.md) — view 렌더러 + RAG 프레임워크 통합 | -| Phase 4 | Active | v0.8.0 ~ v1.0.0 | [phase-4.md](phase-4.md) — JSON IR → HWP 역생성 | +### v0.4.0 ~ v0.6.0 — view 렌더러 + RAG 프레임워크 통합 + +선행 조건: v0.3.0 IR 확장 안정. + +v0.2.0/v0.3.0 에서 확정된 IR 을 다른 포맷으로 렌더링 (view) 하고, LangChain 외의 RAG 프레임워크와 통합한다. HtmlRAG (WWW 2025, arXiv:2411.02959) 등 최근 연구는 LLM 에 문서를 제공할 때 **구조를 보존하는 HTML** 이 평문화 대비 우수함을 보고하므로, view 변환 품질이 RAG 체감 성능과 직결된다. + +**v0.4.0 — view 렌더러** + +- `HwpDocument.to_markdown()` — IR → CommonMark + GFM 확장 + - 표는 GFM `|a|b|` 형태. `rowspan`/`colspan` 이 있는 셀은 GFM 으로 표현 불가 → HTML 인라인으로 폴백 + - 머리글·꼬리말은 YAML frontmatter (선택) 또는 주석 블록 + - 각주/미주는 CommonMark footnote 확장 + - 수식은 `$$ ... $$` (KaTeX 호환) — `FormulaBlock.tex` 가 있을 때만 +- `HwpDocument.to_html()` — IR → HTML5 + - `
`, `
`, `` 등 시맨틱 태그 + - 접근성: `
`, `
`, `aria-*` 기본 포함 + - CSS 는 기본 미동봉, 별도 `to_html(include_css=True)` 옵션 + - 이미지 처리: `Picture.ref_mode` 를 따름 (`placeholder` → ``, `embedded` → base64 `src=`, `external` → 외부 파일 + 경로 반환) + +HTML 은 `TableBlock.html` 과 별개 — TableBlock 수준 HTML 은 표 하나의 HTML 조각 (RAG 주입용), 문서 전체 `to_html()` 은 완전한 HTML5 문서 (브라우저 표시용). + +미확정 이슈: +- **Markdown 방언** — CommonMark / GFM / Pandoc Markdown / MyST. 기본값 GFM (표·각주 지원). Pandoc-compatible 플래그는 별도 옵션 +- **HTML 출력의 CSS 동봉 여부** — 기본 미동봉, `include_css: bool` 또는 별도 `style_bundle()` 함수 + +**v0.5.0 — LlamaIndex 통합** + +- `rhwp.integrations.llamaindex.HwpReader` (LlamaIndex `BaseReader` 구현) + - `load_data()` / `lazy_load_data()` 동기 + async + - IR → `Document`/`TextNode` 변환, `parent_id`/`prev`/`next` 링크 보존 + - 섹션·단락을 `NodeRelationship.PARENT/CHILD` 로 표현 → `AutoMergingRetriever` 호환 + +미확정 이슈: +- **LlamaIndex 가 IR 스키마를 그대로 소비 가능한가** — 현재 LlamaIndex `BaseNode` 는 자유형 `metadata: dict`. 완전 호환은 불가 (IR 의 Pydantic 타입 손실). 변환 레이어 필수 — 메타데이터에 `rhwp.ir.json` 키로 원본 IR 직렬화 보존하여 라운드트립 가능하게 설계 + +**v0.6.0 — Haystack 통합 + LangChain IR 활용** + +- `rhwp.integrations.haystack.HwpConverter` (Haystack 2.x `Converter`) — **커뮤니티 수요 확인 후** + - Haystack `Document` 로 변환, `meta` 에 섹션 경계 힌트 저장 +- LangChain 로더의 IR 직접 활용 (breadcrumb 자동 삽입 등 Anthropic Contextual Retrieval 스타일) + +미확정 이슈: +- **Contextual Retrieval 자동 지원 여부** — Anthropic 기법은 LLM 호출 비용 유발. rhwp-python 이 이를 내장하면 비용이 사용자에게 불투명 → **미내장**. 대신 `doc.breadcrumb(node_id)` 헬퍼로 사용자가 수동 결합 가능하게 설계 + +### v0.8.0 ~ v1.0.0 — JSON IR → HWP 역생성 + +선행 조건: v0.6.0 까지 GA + v0.7.0 MCP server 안정 + rhwp Rust 코어의 HWP writer API 안정. + +IR 을 축으로 한 양방향 변환 — 사용자가 IR 을 편집해 새 HWP/HWPX 를 생성할 수 있게 함. 본 라인은 rhwp **Rust 코어의 쓰기 API 성숙도** 에 좌우됨. 업스트림 [edwardkim/rhwp](https://github.com/edwardkim/rhwp) 가 HWP writer 를 안정화해야 진행 가능. 시작 전 업스트림 상태 재평가 + 필요 시 writer PR 기여로 진입. + +범위: +- IR → **HWPX** 역직렬화 (HWPX 가 XML 기반이라 먼저) +- IR → **HWP5** 역직렬화 (OLE 컴파운드 파일 — 더 복잡) +- 왕복 (round-trip) 보장 테스트: parse → IR → write → parse 결과가 의미적으로 동일 +- Python API: `rhwp.write(ir, path)` / `rhwp.Document.from_ir(ir).save(path)` + +릴리스 분할: + +| 버전 | 범위 | +|---|---| +| v0.8.0 | HWPX writeback baseline (단순 문서 왕복) | +| v0.9.0 | HWPX writeback 확장 (표·이미지·수식) | +| v0.10.0 | HWP5 writeback baseline | +| v1.0.0 | HWP5 writeback 확장 + API 안정 선언 | + +SemVer 0.x.y 단계에서 minor 는 단조 증가 — v0.9 다음은 v0.10 (v1.0 으로 점프하지 않음). v1.0.0 은 API 안정 선언과 함께 별도 도달. + +1.0 안정화 기준: +- HWPX 왕복 무결성 ≥ 99% (bytewise 는 불가능, 의미적 동등성 기준) +- HWP5 왕복 최소 가능 +- Breaking change 없이 12개월 유지된 API +- 공식 메인테이너 (또는 공신력 있는 커뮤니티) 검토 통과 + +비범위: +- 완전한 레이아웃 보존 (폰트 embedding 미포함 상태의 재생성) — 뷰어 차이 허용 +- 매크로·폼 필드·OLE 임베딩 — HWP 독자 확장 기능은 장기 과제 -Phase 1 (v0.1.x) 은 GA 완료로 별도 phase 문서 없음. Phase 2 (v0.3.0 GA 완료) 는 [CONVENTIONS.md § Phase 완료 후](../CONVENTIONS.md) 정책에 따라 phase 문서 삭제됨 — historical 결정은 [v0.2.0/ir.md](v0.2.0/ir.md) / [v0.3.0/ir-expansion.md](v0.3.0/ir-expansion.md) / [v0.3.0/cli.md](v0.3.0/cli.md) 가 보유. +> 과거 GA 완료된 minor (v0.1.x ~ v0.3.0) 의 historical record 는 [v0.1.0/rhwp-python.md](v0.1.0/rhwp-python.md) / [v0.2.0/ir.md](v0.2.0/ir.md) / [v0.3.0/ir-expansion.md](v0.3.0/ir-expansion.md) / [v0.3.0/cli.md](v0.3.0/cli.md) 가 보유. ## 구현 / 검증 로그 (Frozen) diff --git a/docs/roadmap/phase-3.md b/docs/roadmap/phase-3.md deleted file mode 100644 index 986ed1e..0000000 --- a/docs/roadmap/phase-3.md +++ /dev/null @@ -1,53 +0,0 @@ -# Phase 3 — view 렌더러 + RAG 프레임워크 통합 - -**Status**: Active · **Target**: v0.4.0 ~ v0.6.0 · **Last updated**: 2026-04-26 - -**대상 버전**: v0.4.0 ~ v0.6.0 -**선행 조건**: Phase 2 IR 확장 (v0.3.0) 안정 - -> 원안 대비 버전이 한 번씩 당겨짐 (v0.5~v0.7 → v0.4~v0.6) — v0.2.0 에서 IR 도입이 앞당겨지면서 후속 Phase 도 일괄 하향 이동. - -## 목표 - -v0.2.0/v0.3.0 에서 확정된 IR 을 다른 포맷으로 렌더링(view) 하고, LangChain 외의 RAG 프레임워크와 통합한다. HtmlRAG (WWW 2025, arXiv:2411.02959) 등 최근 연구는 LLM 에 문서를 제공할 때 **구조를 보존하는 HTML** 이 평문화 대비 우수함을 보고하므로, view 변환 품질이 RAG 체감 성능과 직결된다. - -## 범위 - -### view 렌더러 (v0.4.0) - -- `HwpDocument.to_markdown()` — IR → CommonMark + GFM 확장 - - 표는 GFM `|a|b|` 형태. `rowspan`/`colspan` 이 있는 셀은 GFM 으로 표현 불가 → HTML 인라인으로 폴백 - - 머리글·꼬리말은 YAML frontmatter (선택) 또는 주석 블록 - - 각주/미주는 CommonMark footnote 확장 - - 수식은 `$$ ... $$` (KaTeX 호환) — `FormulaBlock.tex` 가 있을 때만 -- `HwpDocument.to_html()` — IR → HTML5 - - `
`, `
`, `` 등 시맨틱 태그 - - 접근성: `
`, `
`, `aria-*` 기본 포함 - - CSS 는 기본 미동봉, 별도 `to_html(include_css=True)` 옵션 - - 이미지 처리: `Picture.ref_mode` 를 따름 (`placeholder` → ``, `embedded` → base64 `src=`, `external` → 외부 파일 + 경로 반환) - -HTML 은 `TableBlock.html` 과 별개 — TableBlock 수준 HTML 은 표 하나의 HTML 조각 (RAG 주입용), 문서 전체 `to_html()` 은 완전한 HTML5 문서 (브라우저 표시용). - -### RAG 통합 확장 (v0.5.0 ~ v0.6.0) - -- **v0.5.0** — `rhwp.integrations.llamaindex.HwpReader` (LlamaIndex `BaseReader` 구현) - - `load_data()` / `lazy_load_data()` 동기 + async - - IR → `Document`/`TextNode` 변환, `parent_id`/`prev`/`next` 링크 보존 (연구 결과 § 1 참조) - - 섹션·단락을 `NodeRelationship.PARENT/CHILD` 로 표현 → `AutoMergingRetriever` 호환 -- **v0.6.0** — `rhwp.integrations.haystack.HwpConverter` (Haystack 2.x `Converter`) — **커뮤니티 수요 확인 후** - - Haystack `Document` 로 변환, `meta` 에 섹션 경계 힌트 저장 - -## 릴리스 분할 - -| 버전 | 범위 | -|---|---| -| v0.4.0 | `to_markdown()` / `to_html()` view — 표 rowspan/colspan HTML 인라인 처리 포함 | -| v0.5.0 | LlamaIndex 통합 (`HwpReader`, AutoMergingRetriever 호환 node 트리) | -| v0.6.0 | Haystack 통합 (커뮤니티 수요 확인 후) + LangChain 로더의 IR 직접 활용 (breadcrumb 자동 삽입 등 Anthropic Contextual Retrieval 스타일) | - -## 미확정 이슈 - -- **Markdown 방언** — CommonMark / GFM / Pandoc Markdown / MyST. 기본값 GFM (표·각주 지원). Pandoc-compatible 플래그는 별도 옵션 -- **HTML 출력의 CSS 동봉 여부** — 기본 미동봉, `include_css: bool` 또는 별도 `style_bundle()` 함수 -- **LlamaIndex 가 IR 스키마를 그대로 소비 가능한가** — 현재 LlamaIndex `BaseNode` 는 자유형 `metadata: dict`. 완전 호환은 불가 (IR 의 Pydantic 타입 손실). 변환 레이어 필수 — 메타데이터에 `rhwp.ir.json` 키로 원본 IR 직렬화 보존하여 라운드트립 가능하게 설계 -- **Contextual Retrieval 자동 지원 여부** — Anthropic 기법은 LLM 호출 비용 유발. rhwp-python 이 이를 내장하면 비용이 사용자에게 불투명 → **미내장**. 대신 `doc.breadcrumb(node_id)` 헬퍼로 사용자가 수동 결합 가능하게 설계 diff --git a/docs/roadmap/phase-4.md b/docs/roadmap/phase-4.md deleted file mode 100644 index 4abad8f..0000000 --- a/docs/roadmap/phase-4.md +++ /dev/null @@ -1,47 +0,0 @@ -# Phase 4 — JSON IR → HWP 역생성 - -**Status**: Active · **Target**: v0.8.0 ~ v1.0.0 · **Last updated**: 2026-04-28 - -**대상 버전**: v0.8.0 ~ v1.0.0 (안정화 + writeback 지원) -**선행 조건**: Phase 3 (v0.6.0 까지) GA + v0.7.0 MCP server 단발 통합 GA + rhwp Rust 코어의 HWP writer API 안정 - -## 목표 - -IR 을 축으로 한 양방향 변환 — 사용자가 IR 을 편집해 새 HWP/HWPX 를 생성할 수 있게 함. - -## 외부 의존성 - -Phase 4 는 rhwp **Rust 코어의 쓰기 API 성숙도** 에 좌우됨. 업스트림 `edwardkim/rhwp` 가 HWP writer 를 안정화해야 진행 가능. - -- Phase 4 시작 전 업스트림 상태 재평가 -- 필요 시 rhwp 코어에 writer PR 기여로 진입 - -## 범위 - -- IR → **HWPX** 역직렬화 (HWPX 가 XML 기반이라 먼저) -- IR → **HWP5** 역직렬화 (OLE 컴파운드 파일 — 더 복잡) -- 왕복 (round-trip) 보장 테스트: parse → IR → write → parse 결과가 의미적으로 동일 -- Python API: `rhwp.write(ir, path)` / `rhwp.Document.from_ir(ir).save(path)` - -## 릴리스 분할 - -| 버전 | 범위 | -|---|---| -| v0.8.0 | HWPX writeback baseline (단순 문서 왕복) | -| v0.9.0 | HWPX writeback 확장 (표·이미지·수식) | -| v0.10.0 | HWP5 writeback baseline | -| v1.0.0 | HWP5 writeback 확장 + API 안정 선언 | - -SemVer 0.x.y 단계에서 minor 는 단조 증가 — v0.9 다음은 v0.10 (v1.0 으로 점프하지 않음). v1.0.0 은 API 안정 선언과 함께 별도 도달. - -## 1.0 안정화 기준 - -- HWPX 왕복 무결성 ≥ 99% (bytewise 는 불가능, 의미적 동등성 기준) -- HWP5 왕복 최소 가능 -- Breaking change 없이 12개월 유지된 API -- 공식 메인테이너 (또는 공신력 있는 커뮤니티) 검토 통과 - -## 비범위 - -- 완전한 레이아웃 보존 (폰트 embedding 미포함 상태의 재생성) — 뷰어 차이 허용 -- 매크로·폼 필드·OLE 임베딩 — HWP 독자 확장 기능은 장기 과제 diff --git a/docs/roadmap/v0.1.0/rhwp-python.md b/docs/roadmap/v0.1.0/rhwp-python.md index 2129519..26c20d9 100644 --- a/docs/roadmap/v0.1.0/rhwp-python.md +++ b/docs/roadmap/v0.1.0/rhwp-python.md @@ -1,6 +1,10 @@ -# 0.1.0 — Phase 1 바인딩 분사 + PyPI 배포 +--- +status: Frozen +ga: v0.1.0 +last_updated: 2026-04-23 +--- -**Status**: Frozen · **GA**: v0.1.0 (patch v0.1.1) · **Last updated**: 2026-04-23 +# 0.1.0 — Phase 1 바인딩 분사 + PyPI 배포 rhwp Rust 코어 ([edwardkim/rhwp](https://github.com/edwardkim/rhwp)) 에 대한 PyO3 Python 바인딩을 별도 리포 `DanMeon/rhwp-python` 으로 분사하고 PyPI 에 `rhwp-python` 으로 배포한다. Phase 1 기능은 원본 `rhwp-python-heuristic/rhwp-python/` 에서 이관하며 기능 추가 없음. @@ -64,4 +68,4 @@ rhwp Rust 코어 ([edwardkim/rhwp](https://github.com/edwardkim/rhwp)) 에 대 ## 범위 외 -Phase 2 이후는 별도 로드맵 문서로 진행 — 진행 중 phase 는 [phase-3.md](../phase-3.md) / [phase-4.md](../phase-4.md), 활성 spec 인덱스는 [README.md](../README.md). Phase 2 는 v0.3.0 GA 완료로 phase 문서 정리됨. +Phase 2 이후는 별도 로드맵 문서로 진행 — 진행 중 phase 는 phase-3.md / phase-4.md (이후 폐기, [README.md](../README.md) § 미착수 작업 계획 으로 흡수), 활성 spec 인덱스는 [README.md](../README.md). Phase 2 는 v0.3.0 GA 완료로 phase 문서 정리됨. diff --git a/docs/roadmap/v0.2.0/ir.md b/docs/roadmap/v0.2.0/ir.md index 5a009b9..3443a11 100644 --- a/docs/roadmap/v0.2.0/ir.md +++ b/docs/roadmap/v0.2.0/ir.md @@ -1,6 +1,10 @@ -# 0.2.0 — Document IR v1 (JSON 직렬화형 문서 모델) +--- +status: Frozen +ga: v0.2.0 +last_updated: 2026-04-25 +--- -**Status**: Frozen · **GA**: v0.2.0 · **Last updated**: 2026-04-25 +# 0.2.0 — Document IR v1 (JSON 직렬화형 문서 모델) `rhwp-python` 을 단순 텍스트 추출기에서 **RAG/LLM 파이프라인이 직접 소비 가능한 구조화 문서 라이브러리** 로 전환하는 첫 단계. Pydantic V2 기반 공개 데이터 모델과 JSON 스키마를 고정하고, Rust 코어가 보유한 구조 정보를 Python 사용자에게 타입-안전하게 노출한다. @@ -138,7 +142,7 @@ v0.2.0 에서 enum 에만 선언하고 v0.3.0+ 에서 구현: ### 블록 태그드 유니온 -**Callable Discriminator 패턴** — `UnknownBlock` catch-all variant 를 v1.0 부터 포함. 이유: Pydantic V2 의 string discriminator 는 미지의 `kind` 를 만나면 `union_tag_invalid` 로 **문서 전체 파싱을 거부**한다. v0.3.0 에서 `PictureBlock` 이 추가될 때 v0.2.0 소비자가 **읽기 불가 상태** 가 되는 것을 방지. 상세 근거: [ir-design-research.md § 1](../../design/v0.2.0/ir-design-research.md#1-block-유니온-확장--minor-bump--unknownblock-안전장치). +**Callable Discriminator 패턴** — `UnknownBlock` catch-all variant 를 v1.0 부터 포함. 이유: Pydantic V2 의 string discriminator 는 미지의 `kind` 를 만나면 `union_tag_invalid` 로 **문서 전체 파싱을 거부**한다. v0.3.0 에서 `PictureBlock` 이 추가될 때 v0.2.0 소비자가 **읽기 불가 상태** 가 되는 것을 방지. 상세 근거: [ir-research.md § 1](../../design/v0.2.0/ir-research.md#1-block-유니온-확장--minor-bump--unknownblock-안전장치). ```python from typing import Annotated, Any, Literal, Union @@ -255,11 +259,11 @@ class Provenance(BaseModel): 모든 `Block` 노드가 `prov: Provenance` 를 보유. -**단위는 Unicode codepoint** — Python `str[i]` 인덱싱과 직접 호환. 상류 `ir-diff` 는 UTF-16 기준이지만 Python 사용자 ergonomics 가 더 중요 (이모지·SMP CJK 혼용 시 UTF-16 오프셋으로 `text[a:b]` 하면 off-by-one 발생). Rust 바인딩 레이어가 상류 `char_offsets` (UTF-16) → codepoint 변환을 `to_ir()` 시점 1회 수행. LSP/JS interop 수요가 생기면 v0.3.0+ 에서 `char_start_utf16` 병렬 필드 추가 (backward-compatible). 상세 근거: [ir-design-research.md § 3](../../design/v0.2.0/ir-design-research.md#3-char-오프셋-단위--unicode-codepoint). +**단위는 Unicode codepoint** — Python `str[i]` 인덱싱과 직접 호환. 상류 `ir-diff` 는 UTF-16 기준이지만 Python 사용자 ergonomics 가 더 중요 (이모지·SMP CJK 혼용 시 UTF-16 오프셋으로 `text[a:b]` 하면 off-by-one 발생). Rust 바인딩 레이어가 상류 `char_offsets` (UTF-16) → codepoint 변환을 `to_ir()` 시점 1회 수행. LSP/JS interop 수요가 생기면 v0.3.0+ 에서 `char_start_utf16` 병렬 필드 추가 (backward-compatible). 상세 근거: [ir-research.md § 3](../../design/v0.2.0/ir-research.md#3-char-오프셋-단위--unicode-codepoint). ### 스키마 버저닝 -**Docling 패턴 채택** (`Annotated[str, StringConstraints]` + validator): `$id` URL + 루트 `schema_version` 필드 병용. **`Literal` 은 사용하지 않음** — forward-read 가 원천 차단되어 라이브러리 목적 (기존 파일 읽기) 에 반함. 상세 근거: [ir-design-research.md § 4](../../design/v0.2.0/ir-design-research.md#4-schema_version-필드-타입--annotatedstr-stringconstraints--validator). +**Docling 패턴 채택** (`Annotated[str, StringConstraints]` + validator): `$id` URL + 루트 `schema_version` 필드 병용. **`Literal` 은 사용하지 않음** — forward-read 가 원천 차단되어 라이브러리 목적 (기존 파일 읽기) 에 반함. 상세 근거: [ir-research.md § 4](../../design/v0.2.0/ir-research.md#4-schema_version-필드-타입--annotatedstr-stringconstraints--validator). ```python from typing import Annotated, Final @@ -361,7 +365,7 @@ for blk in ir.body: # 본문 리스트 직접 순회 headers = ir.furniture.page_headers # 장식 요소 직접 접근 ``` -`iter_blocks` 는 scope+recurse 조합이 필요한 경우용 (`sum(1 for b in doc.iter_blocks(scope="all") if isinstance(b, TableBlock))`). 설계 배경: [ir-design-research.md § 5](../../design/v0.2.0/ir-design-research.md#5-iter-api--docbody--docfurniture-속성--iter_blocksscope-recurse). +`iter_blocks` 는 scope+recurse 조합이 필요한 경우용 (`sum(1 for b in doc.iter_blocks(scope="all") if isinstance(b, TableBlock))`). 설계 배경: [ir-research.md § 5](../../design/v0.2.0/ir-research.md#5-iter-api--docbody--docfurniture-속성--iter_blocksscope-recurse). **Breaking change 없음** — 기존 `Document.paragraphs()`/`extract_text()` 는 변경 없이 유지. @@ -385,7 +389,7 @@ python/rhwp/ ### Rust 경계 패턴 + 캐싱 -**Rust → Python dict → Pydantic `model_validate`** 경로 채택. `pyo3-pydantic` 크레이트(실험적) 는 배제. **캐싱은 Rust `OnceCell` 필드로 구현** — `#[pyclass(dict)]` 불필요 (abi3 limited API 호환 우려 회피). `unsendable` 덕분에 단일 스레드 보장 → lock 불필요. 상세 근거: [ir-design-research.md § 7](../../design/v0.2.0/ir-design-research.md#7-to_ir-캐싱--rust-oncecellpyobject--frozen-ir). +**Rust → Python dict → Pydantic `model_validate`** 경로 채택. `pyo3-pydantic` 크레이트(실험적) 는 배제. **캐싱은 Rust `OnceCell` 필드로 구현** — `#[pyclass(dict)]` 불필요 (abi3 limited API 호환 우려 회피). `unsendable` 덕분에 단일 스레드 보장 → lock 불필요. 상세 근거: [ir-research.md § 7](../../design/v0.2.0/ir-research.md#7-to_ir-캐싱--rust-oncecellpyobject--frozen-ir). ```rust // src/document.rs @@ -428,7 +432,7 @@ def build_document(raw: dict) -> HwpDocument: ### JSON Schema 공개 -**4축 배포** (1차 In-package · 2차 GitHub Pages · 3차 content-addressed · 4차 SchemaStore 카탈로그). 근거: [ir-design-research.md § 8](../../design/v0.2.0/ir-design-research.md#8-json-schema-id-호스팅--github-pages--불변-경로--schemastore--in-package). +**4축 배포** (1차 In-package · 2차 GitHub Pages · 3차 content-addressed · 4차 SchemaStore 카탈로그). 근거: [ir-research.md § 8](../../design/v0.2.0/ir-research.md#8-json-schema-id-호스팅--github-pages--불변-경로--schemastore--in-package). ```python # python/rhwp/ir/schema.py @@ -532,7 +536,7 @@ def load_schema() -> dict: ## 결정 사항 (리서치 기반 확정) -초안 작성 시점의 미결 8건 중 #6 은 사용자 결정으로 스킵, 나머지 7건은 **수행자 + 검증자 2인 1조 × 7 팀 병렬 리서치** 로 확정. 세부 증거·대안 비교·실패 시나리오는 [docs/design/v0.2.0/ir-design-research.md](../../design/v0.2.0/ir-design-research.md) 참조. +초안 작성 시점의 미결 8건 중 #6 은 사용자 결정으로 스킵, 나머지 7건은 **수행자 + 검증자 2인 1조 × 7 팀 병렬 리서치** 로 확정. 세부 증거·대안 비교·실패 시나리오는 [docs/design/v0.2.0/ir-research.md](../../design/v0.2.0/ir-research.md) 참조. | # | 이슈 | 결정 | 핵심 근거 | |---|---|---|---| diff --git a/docs/roadmap/v0.3.0/cli.md b/docs/roadmap/v0.3.0/cli.md index fe5cdeb..5f42d02 100644 --- a/docs/roadmap/v0.3.0/cli.md +++ b/docs/roadmap/v0.3.0/cli.md @@ -1,10 +1,14 @@ -# v0.3.0 — `rhwp-py` 얇은 CLI +--- +status: Frozen +ga: v0.3.0 +last_updated: 2026-04-28 +--- -**Status**: Frozen · **GA**: v0.3.0 · **Last updated**: 2026-04-28 +# v0.3.0 — `rhwp-py` 얇은 CLI v0.2.0 에서 폐기했던 CLI 를 **이름을 분리한 Python 고유 command** 로 재도입한다. `pip install rhwp-python` 만 한 사용자가 shell pipeline 에서 바로 쓸 수 있게 하되, 상류 Rust `rhwp` 바이너리와 기능을 **중복 구현하지 않는다** — Python 레이어 고유 가치 (Document IR, LangChain 청크) 에 집중. -주요 결정 (이름 선정 / overlap=0 정책 / 기본 출력 포맷) 의 업계 선례·대안·실패 시나리오는 별도: [cli-design-research.md](../../design/v0.3.0/cli-design-research.md). +주요 결정 (이름 선정 / overlap=0 정책 / 기본 출력 포맷) 의 업계 선례·대안·실패 시나리오는 별도: [cli-research.md](../../design/v0.3.0/cli-research.md). ## 배경 — 재도입 근거 @@ -234,12 +238,12 @@ Entry point stub 은 typer import 를 **지연 로드** — `rhwp.cli.app` 접 | # | 이슈 | 결정 | 근거 | |---|---|---|---| -| 1 | 이름 | `rhwp-py` | Python 생태계 binding 작명 관행 (`python-docx`/`pyarrow`/`grpcio-tools`) + 상류 `rhwp` 와 PATH 충돌 회피 — 상세: [cli-design-research § 1](../../design/v0.3.0/cli-design-research.md#1-이름--rhwp-py) | +| 1 | 이름 | `rhwp-py` | Python 생태계 binding 작명 관행 (`python-docx`/`pyarrow`/`grpcio-tools`) + 상류 `rhwp` 와 PATH 충돌 회피 — 상세: [cli-research § 1](../../design/v0.3.0/cli-research.md#1-이름--rhwp-py) | | 2 | 렌더링 커맨드 | 제공 안 함 | 업스트림 Rust 바이너리가 강력 | -| 3 | 기본 출력 포맷 | `parse`/`version` 은 사람 가독, `ir`/`schema` 는 JSON, `blocks`/`chunks` 는 NDJSON | aws/kubectl/gh 의 "스크립팅 primary 는 JSON 기본" 관행 + jq streaming 친화 — 상세: [§ 3](../../design/v0.3.0/cli-design-research.md#3-기본-출력-포맷--json-계열) | +| 3 | 기본 출력 포맷 | `parse`/`version` 은 사람 가독, `ir`/`schema` 는 JSON, `blocks`/`chunks` 는 NDJSON | aws/kubectl/gh 의 "스크립팅 primary 는 JSON 기본" 관행 + jq streaming 친화 — 상세: [§ 3](../../design/v0.3.0/cli-research.md#3-기본-출력-포맷--json-계열) | | 4 | typer 의존성 위치 | `[cli]` extras | core dep 팽창 회피 (CLAUDE.md 규칙) | | 5 | `chunks` 의 extras | `[cli-chunks]` 또는 `[cli,langchain]` | 별도 gating — typer 만 설치한 사용자에게 langchain 강요 안 함 | -| 6 | 업스트림과의 overlap | 어느 것도 복제하지 않음 (overlap = 0) | `docker`/`podman` 의 동일-표면 비용 교훈 — 역할 분담 메시지 명확성 우선. 상세: [§ 2](../../design/v0.3.0/cli-design-research.md#2-상류-바이너리와-overlap0) | +| 6 | 업스트림과의 overlap | 어느 것도 복제하지 않음 (overlap = 0) | `docker`/`podman` 의 동일-표면 비용 교훈 — 역할 분담 메시지 명확성 우선. 상세: [§ 2](../../design/v0.3.0/cli-research.md#2-상류-바이너리와-overlap0) | ## 다른 산출물의 파급 (코드 / 데이터) @@ -250,7 +254,7 @@ Entry point stub 은 typer import 를 **지연 로드** — `rhwp.cli.app` 접 ## 참조 -- v0.2.0 § 방향 전환 배경 (CLI 폐기 → 재도입 맥락): 짝 페어인 [cli-design-research.md](../../design/v0.3.0/cli-design-research.md) 가 v0.2.0/ir.md 와의 관계를 보존 -- 짝 페어 (ADR): [cli-design-research.md](../../design/v0.3.0/cli-design-research.md) +- v0.2.0 § 방향 전환 배경 (CLI 폐기 → 재도입 맥락): 짝 페어인 [cli-research.md](../../design/v0.3.0/cli-research.md) 가 v0.2.0/ir.md 와의 관계를 보존 +- 짝 페어 (ADR): [cli-research.md](../../design/v0.3.0/cli-research.md) - Typer 공식: - 업스트림 바이너리 서브커맨드: `external/rhwp/src/main.rs`, `external/rhwp/CLAUDE.md` diff --git a/docs/roadmap/v0.3.0/ir-expansion.md b/docs/roadmap/v0.3.0/ir-expansion.md index ccaadb5..26c14be 100644 --- a/docs/roadmap/v0.3.0/ir-expansion.md +++ b/docs/roadmap/v0.3.0/ir-expansion.md @@ -1,6 +1,10 @@ -# v0.3.0 — Document IR v1.1 (블록 타입 확장) +--- +status: Frozen +ga: v0.3.0 +last_updated: 2026-04-28 +--- -**Status**: Frozen · **GA**: v0.3.0 · **Last updated**: 2026-04-28 +# v0.3.0 — Document IR v1.1 (블록 타입 확장) v0.2.0 의 Document IR 위에 HWP 문서 고유 의미 요소를 더해 RAG/LLM 파이프라인이 표·단락 외에도 그림·수식·각주·목록·캡션·목차·필드를 구조화 형태로 직접 소비할 수 있게 한다. **`UnknownBlock` catch-all 안전장치 위에서 후방 호환을 유지하는 MINOR 증분** — v0.2.0 소비자는 새 `Block.kind` 를 만나도 `UnknownBlock` 으로 graceful skip 한다. @@ -528,4 +532,4 @@ class Document: ### v0.2.0 선례 - 본문 v0.2.0 IR 설계: [v0.2.0/ir.md](../v0.2.0/ir.md) -- v0.2.0 결정 증거: [design/v0.2.0/ir-design-research.md](../../design/v0.2.0/ir-design-research.md) +- v0.2.0 결정 증거: [design/v0.2.0/ir-research.md](../../design/v0.2.0/ir-research.md) diff --git a/docs/roadmap/v0.7.0/mcp.md b/docs/roadmap/v0.7.0/mcp.md index f776031..b2f59bf 100644 --- a/docs/roadmap/v0.7.0/mcp.md +++ b/docs/roadmap/v0.7.0/mcp.md @@ -1,6 +1,10 @@ -# v0.7.0 — MCP server (`rhwp-mcp`) +--- +status: Draft +target: v0.7.0 +last_updated: 2026-04-28 +--- -**Status**: Draft · **Target**: v0.7.0 · **Last updated**: 2026-04-28 +# v0.7.0 — MCP server (`rhwp-mcp`) [Model Context Protocol](https://modelcontextprotocol.io/) (Anthropic, 2024) 기반의 MCP 서버를 새 entry point `rhwp-mcp` 로 노출한다. LLM 에이전트 (Claude Desktop / IDE 통합 / 자체 에이전트) 가 HWP/HWPX 파일을 직접 파싱·요약·청크화할 수 있도록 표준 프로토콜 표면을 제공한다. diff --git a/docs/traces/coverage.md b/docs/traces/coverage.md new file mode 100644 index 0000000..a9ab5bb --- /dev/null +++ b/docs/traces/coverage.md @@ -0,0 +1,392 @@ +# Spec ↔ Test Trace + +자동 생성 — `scripts/generate_spec_trace.py`. Living. + +v0.4.0+ 신규 spec 의 인수조건 ↔ 테스트 매핑. 기존 v0.1.0 ~ v0.3.0 Frozen spec 은 AC ID 부여 안 함 (CONVENTIONS § Trace report). + +| Spec | AC | Tests | +|---|---|---| +| v0.1.0/rhwp-python | — | `tests/test_async.py::test_aparse_document_can_be_used_in_same_thread` | +| v0.1.0/rhwp-python | — | `tests/test_async.py::test_aparse_hwpx` | +| v0.1.0/rhwp-python | — | `tests/test_async.py::test_aparse_ir_shares_cache` | +| v0.1.0/rhwp-python | — | `tests/test_async.py::test_aparse_no_external_dependency` | +| v0.1.0/rhwp-python | — | `tests/test_async.py::test_aparse_raises_file_not_found_for_missing_path` | +| v0.1.0/rhwp-python | — | `tests/test_async.py::test_aparse_result_equivalent_to_parse` | +| v0.1.0/rhwp-python | — | `tests/test_async.py::test_aparse_returns_document_instance` | +| v0.1.0/rhwp-python | — | `tests/test_async.py::test_aparse_source_uri_matches_arg` | +| v0.1.0/rhwp-python | — | `tests/test_errors.py::TestFileNotFound::test_constructor_raises_file_not_found` | +| v0.1.0/rhwp-python | — | `tests/test_errors.py::TestFileNotFound::test_parse_raises_file_not_found` | +| v0.1.0/rhwp-python | — | `tests/test_errors.py::TestInvalidFormat::test_constructor_empty_file` | +| v0.1.0/rhwp-python | — | `tests/test_errors.py::TestInvalidFormat::test_empty_file_raises_valueerror` | +| v0.1.0/rhwp-python | — | `tests/test_errors.py::TestInvalidFormat::test_garbage_bytes_raises_valueerror` | +| v0.1.0/rhwp-python | — | `tests/test_from_bytes.py::test_from_bytes_equivalent_to_parse` | +| v0.1.0/rhwp-python | — | `tests/test_from_bytes.py::test_from_bytes_hwpx` | +| v0.1.0/rhwp-python | — | `tests/test_from_bytes.py::test_from_bytes_invalid_data_raises` | +| v0.1.0/rhwp-python | — | `tests/test_from_bytes.py::test_from_bytes_ir_equivalent_to_parse` | +| v0.1.0/rhwp-python | — | `tests/test_from_bytes.py::test_from_bytes_returns_document` | +| v0.1.0/rhwp-python | — | `tests/test_from_bytes.py::test_from_bytes_with_source_uri` | +| v0.1.0/rhwp-python | — | `tests/test_parse.py::TestGetters::test_page_count_type` | +| v0.1.0/rhwp-python | — | `tests/test_parse.py::TestGetters::test_paragraph_count_type` | +| v0.1.0/rhwp-python | — | `tests/test_parse.py::TestGetters::test_section_count_type` | +| v0.1.0/rhwp-python | — | `tests/test_parse.py::TestParsing::test_parse_hwp5` | +| v0.1.0/rhwp-python | — | `tests/test_parse.py::TestParsing::test_parse_hwpx` | +| v0.1.0/rhwp-python | — | `tests/test_parse.py::TestParsing::test_parse_is_alias_of_constructor` | +| v0.1.0/rhwp-python | — | `tests/test_parse.py::TestParsing::test_returns_document_instance` | +| v0.1.0/rhwp-python | — | `tests/test_parse.py::TestRepr::test_repr_contains_counts` | +| v0.1.0/rhwp-python | — | `tests/test_parse.py::TestRepr::test_repr_format` | +| v0.1.0/rhwp-python | — | `tests/test_pdf_rendering.py::TestExportPdf::test_pdf_signature_in_file` | +| v0.1.0/rhwp-python | — | `tests/test_pdf_rendering.py::TestExportPdf::test_returns_size_as_int` | +| v0.1.0/rhwp-python | — | `tests/test_pdf_rendering.py::TestExportPdf::test_writes_file` | +| v0.1.0/rhwp-python | — | `tests/test_pdf_rendering.py::TestRenderPdf::test_pdf_signature` | +| v0.1.0/rhwp-python | — | `tests/test_pdf_rendering.py::TestRenderPdf::test_returns_bytes` | +| v0.1.0/rhwp-python | — | `tests/test_smoke.py::test_all_exports_available` | +| v0.1.0/rhwp-python | — | `tests/test_smoke.py::test_native_module_path` | +| v0.1.0/rhwp-python | — | `tests/test_smoke.py::test_pyi_and_py_all_match` | +| v0.1.0/rhwp-python | — | `tests/test_smoke.py::test_rhwp_core_version_matches_semver` | +| v0.1.0/rhwp-python | — | `tests/test_smoke.py::test_rhwp_core_version_returns_string` | +| v0.1.0/rhwp-python | — | `tests/test_smoke.py::test_version_is_package_version` | +| v0.1.0/rhwp-python | — | `tests/test_smoke.py::test_version_matches_semver` | +| v0.1.0/rhwp-python | — | `tests/test_smoke.py::test_version_returns_string` | +| v0.1.0/rhwp-python | — | `tests/test_svg_rendering.py::TestExportSvg::test_creates_output_dir` | +| v0.1.0/rhwp-python | — | `tests/test_svg_rendering.py::TestExportSvg::test_default_prefix_is_page` | +| v0.1.0/rhwp-python | — | `tests/test_svg_rendering.py::TestExportSvg::test_multipage_uses_numbering` | +| v0.1.0/rhwp-python | — | `tests/test_svg_rendering.py::TestExportSvg::test_writes_files` | +| v0.1.0/rhwp-python | — | `tests/test_svg_rendering.py::TestRenderAllSvg::test_all_start_with_svg_tag` | +| v0.1.0/rhwp-python | — | `tests/test_svg_rendering.py::TestRenderAllSvg::test_count_matches_page_count` | +| v0.1.0/rhwp-python | — | `tests/test_svg_rendering.py::TestRenderAllSvg::test_returns_list_of_str` | +| v0.1.0/rhwp-python | — | `tests/test_svg_rendering.py::TestRenderSvg::test_out_of_range_raises_valueerror` | +| v0.1.0/rhwp-python | — | `tests/test_svg_rendering.py::TestRenderSvg::test_render_single_page` | +| v0.1.0/rhwp-python | — | `tests/test_text_extraction.py::TestExtractText::test_contains_korean` | +| v0.1.0/rhwp-python | — | `tests/test_text_extraction.py::TestExtractText::test_hwpx_extract_text` | +| v0.1.0/rhwp-python | — | `tests/test_text_extraction.py::TestExtractText::test_returns_string` | +| v0.1.0/rhwp-python | — | `tests/test_text_extraction.py::TestExtractTextConsistency::test_extract_text_equals_join_nonempty_paragraphs` | +| v0.1.0/rhwp-python | — | `tests/test_text_extraction.py::TestParagraphs::test_hwpx_paragraphs` | +| v0.1.0/rhwp-python | — | `tests/test_text_extraction.py::TestParagraphs::test_length_matches_paragraph_count` | +| v0.1.0/rhwp-python | — | `tests/test_text_extraction.py::TestParagraphs::test_returns_list_of_str` | +| v0.2.0/ir | — | `tests/test_ir_iter_blocks.py::test_iter_blocks_all_scope_body_first_then_furniture` | +| v0.2.0/ir | — | `tests/test_ir_iter_blocks.py::test_iter_blocks_body_recurse_enters_table_cells` | +| v0.2.0/ir | — | `tests/test_ir_iter_blocks.py::test_iter_blocks_default_equals_body` | +| v0.2.0/ir | — | `tests/test_ir_iter_blocks.py::test_iter_blocks_furniture_order_is_headers_footers_footnotes` | +| v0.2.0/ir | — | `tests/test_ir_iter_blocks.py::test_iter_blocks_furniture_yields_consistent_with_lists` | +| v0.2.0/ir | — | `tests/test_ir_iter_blocks.py::test_iter_blocks_handles_three_level_nesting` | +| v0.2.0/ir | — | `tests/test_ir_iter_blocks.py::test_iter_blocks_recurse_false_matches_body_len` | +| v0.2.0/ir | — | `tests/test_ir_iter_blocks.py::test_iter_blocks_recurse_false_skips_nested_blocks` | +| v0.2.0/ir | — | `tests/test_ir_iter_blocks.py::test_iter_blocks_recurse_visits_nested_table_cells` | +| v0.2.0/ir | — | `tests/test_ir_iter_blocks.py::test_iter_blocks_recurse_yields_more_on_real_sample` | +| v0.2.0/ir | — | `tests/test_ir_iter_blocks.py::test_iter_blocks_yields_only_known_block_types` | +| v0.2.0/ir | — | `tests/test_ir_mapper.py::test_build_inline_runs_all_zero_width_from_zero_triggers_fallback` | +| v0.2.0/ir | — | `tests/test_ir_mapper.py::test_build_inline_runs_empty_char_runs_fallback` | +| v0.2.0/ir | — | `tests/test_ir_mapper.py::test_build_inline_runs_empty_text_returns_empty` | +| v0.2.0/ir | — | `tests/test_ir_mapper.py::test_build_inline_runs_prepends_prefix_when_first_run_not_at_zero` | +| v0.2.0/ir | — | `tests/test_ir_mapper.py::test_build_inline_runs_preserves_prefix_when_rest_zero_width` | +| v0.2.0/ir | — | `tests/test_ir_mapper.py::test_cell_role_header` | +| v0.2.0/ir | — | `tests/test_ir_mapper.py::test_cell_role_merged_empty_is_layout` | +| v0.2.0/ir | — | `tests/test_ir_mapper.py::test_cell_role_merged_nonempty_is_data` | +| v0.2.0/ir | — | `tests/test_ir_mapper.py::test_cell_role_merged_whitespace_only_is_layout` | +| v0.2.0/ir | — | `tests/test_ir_mapper.py::test_cell_role_unmerged_empty_is_data` | +| v0.2.0/ir | — | `tests/test_ir_mapper.py::test_cell_role_unmerged_is_data` | +| v0.2.0/ir | — | `tests/test_ir_mapper.py::test_escape_html_all_special_chars` | +| v0.2.0/ir | — | `tests/test_ir_mapper.py::test_escape_html_ampersand_first` | +| v0.2.0/ir | — | `tests/test_ir_mapper.py::test_escape_html_does_not_escape_apostrophe` | +| v0.2.0/ir | — | `tests/test_ir_mapper.py::test_escape_html_preserves_non_ascii` | +| v0.2.0/ir | — | `tests/test_ir_mapper.py::test_escape_html_quote` | +| v0.2.0/ir | — | `tests/test_ir_mapper.py::test_table_to_html_escapes_cell_text` | +| v0.2.0/ir | — | `tests/test_ir_mapper.py::test_table_to_html_header_uses_th` | +| v0.2.0/ir | — | `tests/test_ir_mapper.py::test_table_to_html_rowspan_before_colspan` | +| v0.2.0/ir | — | `tests/test_ir_mapper.py::test_table_to_html_span_one_omits_attribute` | +| v0.2.0/ir | — | `tests/test_ir_plain_text.py::test_field_with_cached_value` | +| v0.2.0/ir | — | `tests/test_ir_plain_text.py::test_field_without_cached_value_returns_none` | +| v0.2.0/ir | — | `tests/test_ir_plain_text.py::test_formula_empty_returns_none` | +| v0.2.0/ir | — | `tests/test_ir_plain_text.py::test_formula_falls_back_to_script` | +| v0.2.0/ir | — | `tests/test_ir_plain_text.py::test_formula_prefers_text_alt` | +| v0.2.0/ir | — | `tests/test_ir_plain_text.py::test_join_caption_blocks_works_via_attribute` | +| v0.2.0/ir | — | `tests/test_ir_plain_text.py::test_join_empty_list` | +| v0.2.0/ir | — | `tests/test_ir_plain_text.py::test_join_includes_list_item_in_caption_or_footnote` | +| v0.2.0/ir | — | `tests/test_ir_plain_text.py::test_join_mixes_paragraph_listitem_formula_field` | +| v0.2.0/ir | — | `tests/test_ir_plain_text.py::test_join_skips_blocks_with_no_inline_text` | +| v0.2.0/ir | — | `tests/test_ir_plain_text.py::test_list_item_empty_text_with_marker_returns_marker` | +| v0.2.0/ir | — | `tests/test_ir_plain_text.py::test_list_item_fully_empty_returns_none` | +| v0.2.0/ir | — | `tests/test_ir_plain_text.py::test_list_item_includes_marker` | +| v0.2.0/ir | — | `tests/test_ir_plain_text.py::test_paragraph_empty_returns_none` | +| v0.2.0/ir | — | `tests/test_ir_plain_text.py::test_paragraph_with_text` | +| v0.2.0/ir | — | `tests/test_ir_plain_text.py::test_structural_blocks_return_none` | +| v0.2.0/ir | — | `tests/test_ir_plain_text.py::test_unknown_block_returns_none` | +| v0.2.0/ir | — | `tests/test_ir_roundtrip.py::test_body_contains_only_known_block_kinds` | +| v0.2.0/ir | — | `tests/test_ir_roundtrip.py::test_document_source_is_frozen` | +| v0.2.0/ir | — | `tests/test_ir_roundtrip.py::test_document_source_uri_property` | +| v0.2.0/ir | — | `tests/test_ir_roundtrip.py::test_furniture_lists_have_correct_types` | +| v0.2.0/ir | — | `tests/test_ir_roundtrip.py::test_hwp_document_direct_construction_allows_null_source` | +| v0.2.0/ir | — | `tests/test_ir_roundtrip.py::test_hwp_document_json_null_source_roundtrip` | +| v0.2.0/ir | — | `tests/test_ir_roundtrip.py::test_inline_run_has_styled_runs` | +| v0.2.0/ir | — | `tests/test_ir_roundtrip.py::test_inline_run_text_concatenates_to_paragraph_text` | +| v0.2.0/ir | — | `tests/test_ir_roundtrip.py::test_ir_body_text_joined_matches_extract_text` | +| v0.2.0/ir | — | `tests/test_ir_roundtrip.py::test_ir_is_frozen` | +| v0.2.0/ir | — | `tests/test_ir_roundtrip.py::test_ir_section_count_matches_document` | +| v0.2.0/ir | — | `tests/test_ir_roundtrip.py::test_metadata_fields_are_none` | +| v0.2.0/ir | — | `tests/test_ir_roundtrip.py::test_paragraph_block_count_matches` | +| v0.2.0/ir | — | `tests/test_ir_roundtrip.py::test_provenance_char_end_matches_text_length` | +| v0.2.0/ir | — | `tests/test_ir_roundtrip.py::test_provenance_monotonic` | +| v0.2.0/ir | — | `tests/test_ir_roundtrip.py::test_source_uri_matches_parse_path` | +| v0.2.0/ir | — | `tests/test_ir_roundtrip.py::test_source_uri_matches_parse_path_hwpx` | +| v0.2.0/ir | — | `tests/test_ir_roundtrip.py::test_to_ir_caches_same_object` | +| v0.2.0/ir | — | `tests/test_ir_roundtrip.py::test_to_ir_json_indent_option` | +| v0.2.0/ir | — | `tests/test_ir_roundtrip.py::test_to_ir_json_parses_back` | +| v0.2.0/ir | — | `tests/test_ir_roundtrip.py::test_to_ir_on_hwpx_sample` | +| v0.2.0/ir | — | `tests/test_ir_roundtrip.py::test_to_ir_returns_hwp_document` | +| v0.2.0/ir | — | `tests/test_ir_schema.py::test_discriminator_routes_known_kinds` | +| v0.2.0/ir | — | `tests/test_ir_schema.py::test_discriminator_routes_unknown_kind` | +| v0.2.0/ir | — | `tests/test_ir_schema.py::test_extra_forbid_raises_on_unknown_field` | +| v0.2.0/ir | — | `tests/test_ir_schema.py::test_frozen_raises_on_mutation` | +| v0.2.0/ir | — | `tests/test_ir_schema.py::test_frozen_unknown_block_cannot_be_mutated` | +| v0.2.0/ir | — | `tests/test_ir_schema.py::test_hwp_document_roundtrip` | +| v0.2.0/ir | — | `tests/test_ir_schema.py::test_inline_run_defaults` | +| v0.2.0/ir | — | `tests/test_ir_schema.py::test_paragraph_block_roundtrip` | +| v0.2.0/ir | — | `tests/test_ir_schema.py::test_provenance_char_offsets_are_codepoint_based` | +| v0.2.0/ir | — | `tests/test_ir_schema.py::test_schema_version_accepts_valid` | +| v0.2.0/ir | — | `tests/test_ir_schema.py::test_schema_version_minor_bump_does_not_warn` | +| v0.2.0/ir | — | `tests/test_ir_schema.py::test_schema_version_rejects_invalid` | +| v0.2.0/ir | — | `tests/test_ir_schema.py::test_schema_version_warns_on_future_major` | +| v0.2.0/ir | — | `tests/test_ir_schema.py::test_table_block_simple_roundtrip` | +| v0.2.0/ir | — | `tests/test_ir_schema.py::test_table_nested_three_levels` | +| v0.2.0/ir | — | `tests/test_ir_schema.py::test_unknown_block_preserves_arbitrary_kind` | +| v0.2.0/ir | — | `tests/test_ir_schema_export.py::test_export_schema_defs_are_exactly_the_known_nodes` | +| v0.2.0/ir | — | `tests/test_ir_schema_export.py::test_export_schema_has_id_and_dialect` | +| v0.2.0/ir | — | `tests/test_ir_schema_export.py::test_export_schema_known_blocks_forbid_additional` | +| v0.2.0/ir | — | `tests/test_ir_schema_export.py::test_export_schema_no_numeric_range_keywords` | +| v0.2.0/ir | — | `tests/test_ir_schema_export.py::test_export_schema_passes_meta_validation` | +| v0.2.0/ir | — | `tests/test_ir_schema_export.py::test_export_schema_root_additional_properties_false` | +| v0.2.0/ir | — | `tests/test_ir_schema_export.py::test_invalid_kind_fails_schema_validation` | +| v0.2.0/ir | — | `tests/test_ir_schema_export.py::test_load_schema_is_valid_draft_2020_12` | +| v0.2.0/ir | — | `tests/test_ir_schema_export.py::test_load_schema_matches_export_schema` | +| v0.2.0/ir | — | `tests/test_ir_schema_export.py::test_load_schema_raises_file_not_found_when_packaged_json_missing` | +| v0.2.0/ir | — | `tests/test_ir_schema_export.py::test_minimal_hwp_document_validates` | +| v0.2.0/ir | — | `tests/test_ir_schema_export.py::test_paragraph_block_with_inlines_validates` | +| v0.2.0/ir | — | `tests/test_ir_schema_export.py::test_real_hwp_document_validates_against_schema` | +| v0.2.0/ir | — | `tests/test_ir_schema_export.py::test_schema_id_has_immutable_v1_path` | +| v0.2.0/ir | — | `tests/test_ir_schema_export.py::test_unknown_kind_routing_pydantic_matches_schema` | +| v0.2.0/ir | — | `tests/test_ir_tables.py::test_hwp5_sample_tables_follow_contract` | +| v0.2.0/ir | — | `tests/test_ir_tables.py::test_hwpx_sample_has_tables` | +| v0.2.0/ir | — | `tests/test_ir_tables.py::test_layout_role_on_merged_empty_cells` | +| v0.2.0/ir | — | `tests/test_ir_tables.py::test_nested_tables_are_block_compatible` | +| v0.2.0/ir | — | `tests/test_ir_tables.py::test_table_block_fields_populated` | +| v0.2.0/ir | — | `tests/test_ir_tables.py::test_table_block_shares_provenance_with_paragraph` | +| v0.2.0/ir | — | `tests/test_ir_tables.py::test_table_block_survives_json_roundtrip` | +| v0.2.0/ir | — | `tests/test_ir_tables.py::test_table_cells_blocks_are_paragraph_or_table` | +| v0.2.0/ir | — | `tests/test_ir_tables.py::test_table_cells_have_valid_coordinates` | +| v0.2.0/ir | — | `tests/test_ir_tables.py::test_table_html_tr_td_structure` | +| v0.2.0/ir | — | `tests/test_ir_tables.py::test_table_text_row_and_cell_separators` | +| v0.2.0/ir | — | `tests/test_langchain_loader.py::TestConstruction::test_default_mode_is_single` | +| v0.2.0/ir | — | `tests/test_langchain_loader.py::TestConstruction::test_explicit_paragraph_mode` | +| v0.2.0/ir | — | `tests/test_langchain_loader.py::TestConstruction::test_explicit_single_mode` | +| v0.2.0/ir | — | `tests/test_langchain_loader.py::TestConstruction::test_invalid_mode_empty_string` | +| v0.2.0/ir | — | `tests/test_langchain_loader.py::TestConstruction::test_invalid_mode_raises` | +| v0.2.0/ir | — | `tests/test_langchain_loader.py::TestErrors::test_file_not_found` | +| v0.2.0/ir | — | `tests/test_langchain_loader.py::TestErrors::test_invalid_format` | +| v0.2.0/ir | — | `tests/test_langchain_loader.py::TestHwpxFormat::test_paragraph_mode_works` | +| v0.2.0/ir | — | `tests/test_langchain_loader.py::TestHwpxFormat::test_single_mode_works` | +| v0.2.0/ir | — | `tests/test_langchain_loader.py::TestLazyLoad::test_returns_iterator` | +| v0.2.0/ir | — | `tests/test_langchain_loader.py::TestLazyLoad::test_yields_same_content` | +| v0.2.0/ir | — | `tests/test_langchain_loader.py::TestLazyLoad::test_yields_same_count_as_load` | +| v0.2.0/ir | — | `tests/test_langchain_loader.py::TestParagraphMode::test_base_metadata_shared_across_docs` | +| v0.2.0/ir | — | `tests/test_langchain_loader.py::TestParagraphMode::test_count_matches_non_empty_paragraphs` | +| v0.2.0/ir | — | `tests/test_langchain_loader.py::TestParagraphMode::test_each_doc_has_paragraph_index` | +| v0.2.0/ir | — | `tests/test_langchain_loader.py::TestParagraphMode::test_no_empty_paragraphs` | +| v0.2.0/ir | — | `tests/test_langchain_loader.py::TestParagraphMode::test_paragraph_indices_are_ascending` | +| v0.2.0/ir | — | `tests/test_langchain_loader.py::TestParagraphMode::test_paragraph_indices_are_from_original_list` | +| v0.2.0/ir | — | `tests/test_langchain_loader.py::TestParagraphMode::test_returns_list_of_documents` | +| v0.2.0/ir | — | `tests/test_langchain_loader.py::TestSingleMode::test_metadata_counts_match_rhwp` | +| v0.2.0/ir | — | `tests/test_langchain_loader.py::TestSingleMode::test_metadata_has_required_keys` | +| v0.2.0/ir | — | `tests/test_langchain_loader.py::TestSingleMode::test_metadata_no_paragraph_index_in_single_mode` | +| v0.2.0/ir | — | `tests/test_langchain_loader.py::TestSingleMode::test_metadata_source_matches_input` | +| v0.2.0/ir | — | `tests/test_langchain_loader.py::TestSingleMode::test_page_content_matches_extract_text` | +| v0.2.0/ir | — | `tests/test_langchain_loader.py::TestSingleMode::test_returns_list_with_one_document` | +| v0.2.0/ir | — | `tests/test_langchain_loader.py::TestTextSplitterIntegration::test_chunk_size_respected` | +| v0.2.0/ir | — | `tests/test_langchain_loader.py::TestTextSplitterIntegration::test_metadata_propagates_to_chunks` | +| v0.2.0/ir | — | `tests/test_langchain_loader.py::TestTextSplitterIntegration::test_paragraph_mode_chunks_preserve_paragraph_index` | +| v0.2.0/ir | — | `tests/test_langchain_loader.py::TestTextSplitterIntegration::test_split_produces_chunks` | +| v0.2.0/ir | — | `tests/test_langchain_loader_ir.py::test_caption_with_formula_and_field_includes_them` | +| v0.2.0/ir | — | `tests/test_langchain_loader_ir.py::test_footnote_with_list_items_includes_them_in_content` | +| v0.2.0/ir | — | `tests/test_langchain_loader_ir.py::test_include_furniture_default_false` | +| v0.2.0/ir | — | `tests/test_langchain_loader_ir.py::test_include_furniture_ignored_in_paragraph_mode` | +| v0.2.0/ir | — | `tests/test_langchain_loader_ir.py::test_include_furniture_marks_scope_metadata` | +| v0.2.0/ir | — | `tests/test_langchain_loader_ir.py::test_include_furniture_yields_extra_documents` | +| v0.2.0/ir | — | `tests/test_langchain_loader_ir.py::test_invalid_mode_still_rejects_after_ir_addition` | +| v0.2.0/ir | — | `tests/test_langchain_loader_ir.py::test_ir_blocks_includes_both_paragraph_and_table` | +| v0.2.0/ir | — | `tests/test_langchain_loader_ir.py::test_ir_blocks_metadata_has_base_fields` | +| v0.2.0/ir | — | `tests/test_langchain_loader_ir.py::test_ir_blocks_mode_accepted` | +| v0.2.0/ir | — | `tests/test_langchain_loader_ir.py::test_ir_blocks_mode_lazy_load_yields_documents` | +| v0.2.0/ir | — | `tests/test_langchain_loader_ir.py::test_ir_blocks_mode_returns_list_of_documents` | +| v0.2.0/ir | — | `tests/test_langchain_loader_ir.py::test_ir_blocks_mode_works_on_hwp5_sample` | +| v0.2.0/ir | — | `tests/test_langchain_loader_ir.py::test_ir_blocks_paragraph_content_is_text` | +| v0.2.0/ir | — | `tests/test_langchain_loader_ir.py::test_ir_blocks_preserves_iter_blocks_order` | +| v0.2.0/ir | — | `tests/test_langchain_loader_ir.py::test_ir_blocks_skips_empty_paragraphs` | +| v0.2.0/ir | — | `tests/test_langchain_loader_ir.py::test_ir_blocks_table_content_is_html` | +| v0.3.0/cli | — | `tests/test_cli.py::test_block_to_text_includes_list_items_in_footnote` | +| v0.3.0/cli | — | `tests/test_cli.py::test_blocks_format_json_returns_array` | +| v0.3.0/cli | — | `tests/test_cli.py::test_blocks_format_text_outputs_plain_strings` | +| v0.3.0/cli | — | `tests/test_cli.py::test_blocks_kind_filter_table` | +| v0.3.0/cli | — | `tests/test_cli.py::test_blocks_ndjson_each_line_is_independent_json` | +| v0.3.0/cli | — | `tests/test_cli.py::test_blocks_no_recurse_skips_table_cells` | +| v0.3.0/cli | — | `tests/test_cli.py::test_blocks_scope_furniture_exits_zero` | +| v0.3.0/cli | — | `tests/test_cli.py::test_chunks_include_furniture_yields_more_or_equal` | +| v0.3.0/cli | — | `tests/test_cli.py::test_chunks_ir_blocks_mode` | +| v0.3.0/cli | — | `tests/test_cli.py::test_chunks_missing_file_exit_1` | +| v0.3.0/cli | — | `tests/test_cli.py::test_chunks_missing_text_splitters_exit_2` | +| v0.3.0/cli | — | `tests/test_cli.py::test_chunks_paragraph_default` | +| v0.3.0/cli | — | `tests/test_cli.py::test_help_lists_all_subcommands` | +| v0.3.0/cli | — | `tests/test_cli.py::test_ir_default_compact_single_line` | +| v0.3.0/cli | — | `tests/test_cli.py::test_ir_indent_produces_multiline` | +| v0.3.0/cli | — | `tests/test_cli.py::test_ir_to_file` | +| v0.3.0/cli | — | `tests/test_cli.py::test_parse_missing_file_exit_1` | +| v0.3.0/cli | — | `tests/test_cli.py::test_parse_summary_format` | +| v0.3.0/cli | — | `tests/test_cli.py::test_schema_stdout_matches_export_schema` | +| v0.3.0/cli | — | `tests/test_cli.py::test_schema_to_file_writes_valid_json` | +| v0.3.0/cli | — | `tests/test_cli.py::test_version_outputs_match_rhwp_module` | +| v0.3.0/ir-expansion | — | `tests/test_ir_caption.py::test_build_caption_block_paragraphs_flattened` | +| v0.3.0/ir-expansion | — | `tests/test_ir_caption.py::test_build_caption_block_preserves_known_directions` | +| v0.3.0/ir-expansion | — | `tests/test_ir_caption.py::test_build_caption_block_unknown_direction_falls_back_to_bottom` | +| v0.3.0/ir-expansion | — | `tests/test_ir_caption.py::test_build_hwp_document_table_with_caption_block_routed` | +| v0.3.0/ir-expansion | — | `tests/test_ir_caption.py::test_build_picture_block_caption_none_when_raw_caption_none` | +| v0.3.0/ir-expansion | — | `tests/test_ir_caption.py::test_build_picture_block_with_caption_field` | +| v0.3.0/ir-expansion | — | `tests/test_ir_caption.py::test_build_picture_preserves_description_alongside_caption` | +| v0.3.0/ir-expansion | — | `tests/test_ir_caption.py::test_caption_block_accepts_valid_directions` | +| v0.3.0/ir-expansion | — | `tests/test_ir_caption.py::test_caption_block_default_direction_is_bottom` | +| v0.3.0/ir-expansion | — | `tests/test_ir_caption.py::test_caption_block_extra_forbidden` | +| v0.3.0/ir-expansion | — | `tests/test_ir_caption.py::test_caption_block_frozen` | +| v0.3.0/ir-expansion | — | `tests/test_ir_caption.py::test_caption_block_minimal_roundtrip` | +| v0.3.0/ir-expansion | — | `tests/test_ir_caption.py::test_caption_block_rejects_unknown_direction` | +| v0.3.0/ir-expansion | — | `tests/test_ir_caption.py::test_caption_block_routes_via_discriminator_in_body` | +| v0.3.0/ir-expansion | — | `tests/test_ir_caption.py::test_iter_blocks_recurse_does_not_enter_picture_caption` | +| v0.3.0/ir-expansion | — | `tests/test_ir_caption.py::test_iter_blocks_recurse_does_not_enter_table_caption_block` | +| v0.3.0/ir-expansion | — | `tests/test_ir_caption.py::test_iter_blocks_recurse_enters_caption_inside_table_cell` | +| v0.3.0/ir-expansion | — | `tests/test_ir_caption.py::test_iter_blocks_recurse_enters_standalone_caption_in_body` | +| v0.3.0/ir-expansion | — | `tests/test_ir_caption.py::test_picture_block_caption_field_default_none` | +| v0.3.0/ir-expansion | — | `tests/test_ir_caption.py::test_picture_block_caption_roundtrip` | +| v0.3.0/ir-expansion | — | `tests/test_ir_caption.py::test_table_block_caption_block_field_default_none` | +| v0.3.0/ir-expansion | — | `tests/test_ir_caption.py::test_table_block_caption_str_and_caption_block_coexist` | +| v0.3.0/ir-expansion | — | `tests/test_ir_caption.py::test_table_block_caption_str_only_v0_2_0_pattern` | +| v0.3.0/ir-expansion | — | `tests/test_ir_field.py::test_build_field_block_known_kind_passes_through` | +| v0.3.0/ir-expansion | — | `tests/test_ir_field.py::test_build_field_block_preserves_provenance` | +| v0.3.0/ir-expansion | — | `tests/test_ir_field.py::test_build_field_block_preserves_raw_instruction` | +| v0.3.0/ir-expansion | — | `tests/test_ir_field.py::test_build_field_block_unknown_kind_falls_back_to_unknown` | +| v0.3.0/ir-expansion | — | `tests/test_ir_field.py::test_field_block_accepts_all_known_kinds` | +| v0.3.0/ir-expansion | — | `tests/test_ir_field.py::test_field_block_calc_distinguishes_from_formula_block` | +| v0.3.0/ir-expansion | — | `tests/test_ir_field.py::test_field_block_default_field_kind_is_unknown` | +| v0.3.0/ir-expansion | — | `tests/test_ir_field.py::test_field_block_extra_forbidden` | +| v0.3.0/ir-expansion | — | `tests/test_ir_field.py::test_field_block_frozen` | +| v0.3.0/ir-expansion | — | `tests/test_ir_field.py::test_field_block_full_roundtrip` | +| v0.3.0/ir-expansion | — | `tests/test_ir_field.py::test_field_block_minimal_roundtrip` | +| v0.3.0/ir-expansion | — | `tests/test_ir_field.py::test_field_block_rejects_invalid_field_kind` | +| v0.3.0/ir-expansion | — | `tests/test_ir_field.py::test_field_block_routes_via_discriminator` | +| v0.3.0/ir-expansion | — | `tests/test_ir_field.py::test_field_block_with_toc_kind_is_user_constructible_only` | +| v0.3.0/ir-expansion | — | `tests/test_ir_field.py::test_field_kind_literal_has_15_values` | +| v0.3.0/ir-expansion | — | `tests/test_ir_field.py::test_flatten_paragraph_multiple_fields_in_order` | +| v0.3.0/ir-expansion | — | `tests/test_ir_field.py::test_flatten_paragraph_yields_field_block_when_fields_present` | +| v0.3.0/ir-expansion | — | `tests/test_ir_field.py::test_valid_field_kinds_set_matches_literal` | +| v0.3.0/ir-expansion | — | `tests/test_ir_footnote.py::test_build_endnote_block_mirrors_footnote_pattern` | +| v0.3.0/ir-expansion | — | `tests/test_ir_footnote.py::test_build_footnote_block_flattens_inner_paragraphs` | +| v0.3.0/ir-expansion | — | `tests/test_ir_footnote.py::test_build_footnote_block_preserves_number_and_marker` | +| v0.3.0/ir-expansion | — | `tests/test_ir_footnote.py::test_build_hwp_document_routes_endnotes_to_furniture` | +| v0.3.0/ir-expansion | — | `tests/test_ir_footnote.py::test_build_hwp_document_routes_footnotes_to_furniture` | +| v0.3.0/ir-expansion | — | `tests/test_ir_footnote.py::test_endnote_block_minimal_roundtrip` | +| v0.3.0/ir-expansion | — | `tests/test_ir_footnote.py::test_footnote_block_blocks_supports_recursion_with_table` | +| v0.3.0/ir-expansion | — | `tests/test_ir_footnote.py::test_footnote_block_extra_forbidden` | +| v0.3.0/ir-expansion | — | `tests/test_ir_footnote.py::test_footnote_block_frozen` | +| v0.3.0/ir-expansion | — | `tests/test_ir_footnote.py::test_footnote_block_marker_and_prov_separately_assignable` | +| v0.3.0/ir-expansion | — | `tests/test_ir_footnote.py::test_footnote_block_minimal_roundtrip` | +| v0.3.0/ir-expansion | — | `tests/test_ir_footnote.py::test_footnote_block_routes_via_discriminator` | +| v0.3.0/ir-expansion | — | `tests/test_ir_footnote.py::test_footnote_block_separate_from_endnote_block` | +| v0.3.0/ir-expansion | — | `tests/test_ir_footnote.py::test_footnotes_endnotes_never_appear_in_body` | +| v0.3.0/ir-expansion | — | `tests/test_ir_footnote.py::test_iter_blocks_furniture_order_includes_footnotes_endnotes` | +| v0.3.0/ir-expansion | — | `tests/test_ir_footnote.py::test_iter_blocks_recurse_enters_endnote_blocks` | +| v0.3.0/ir-expansion | — | `tests/test_ir_footnote.py::test_iter_blocks_recurse_enters_footnote_blocks` | +| v0.3.0/ir-expansion | — | `tests/test_ir_footnote.py::test_real_sample_endnotes_exposed_in_furniture` | +| v0.3.0/ir-expansion | — | `tests/test_ir_footnote.py::test_real_sample_footnotes_exposed_in_furniture` | +| v0.3.0/ir-expansion | — | `tests/test_ir_formula.py::test_build_formula_block_preserves_script_and_prov` | +| v0.3.0/ir-expansion | — | `tests/test_ir_formula.py::test_build_formula_block_preserves_text_alt` | +| v0.3.0/ir-expansion | — | `tests/test_ir_formula.py::test_build_formula_block_preserves_text_alt_verbatim` | +| v0.3.0/ir-expansion | — | `tests/test_ir_formula.py::test_build_formula_block_text_alt_can_be_none` | +| v0.3.0/ir-expansion | — | `tests/test_ir_formula.py::test_formula_block_accepts_known_script_kinds` | +| v0.3.0/ir-expansion | — | `tests/test_ir_formula.py::test_formula_block_extra_forbidden` | +| v0.3.0/ir-expansion | — | `tests/test_ir_formula.py::test_formula_block_frozen` | +| v0.3.0/ir-expansion | — | `tests/test_ir_formula.py::test_formula_block_full_roundtrip` | +| v0.3.0/ir-expansion | — | `tests/test_ir_formula.py::test_formula_block_kind_is_formula` | +| v0.3.0/ir-expansion | — | `tests/test_ir_formula.py::test_formula_block_minimal_roundtrip` | +| v0.3.0/ir-expansion | — | `tests/test_ir_formula.py::test_formula_block_model_copy_pattern_for_external_latex_conversion` | +| v0.3.0/ir-expansion | — | `tests/test_ir_formula.py::test_formula_block_rejects_unknown_script_kind` | +| v0.3.0/ir-expansion | — | `tests/test_ir_formula.py::test_formula_block_routes_via_discriminator` | +| v0.3.0/ir-expansion | — | `tests/test_ir_formula.py::test_formula_inside_footnote_body_is_flattened` | +| v0.3.0/ir-expansion | — | `tests/test_ir_formula.py::test_formula_inside_table_cell_is_flattened` | +| v0.3.0/ir-expansion | — | `tests/test_ir_formula.py::test_real_sample_formulas_have_required_fields` | +| v0.3.0/ir-expansion | — | `tests/test_ir_furniture.py::test_build_hwp_document_footnotes_empty_when_raw_empty` | +| v0.3.0/ir-expansion | — | `tests/test_ir_furniture.py::test_build_hwp_document_furniture_paragraphs_share_section_idx` | +| v0.3.0/ir-expansion | — | `tests/test_ir_furniture.py::test_build_hwp_document_header_with_table_flattens_to_furniture` | +| v0.3.0/ir-expansion | — | `tests/test_ir_furniture.py::test_build_hwp_document_preserves_header_footer_order` | +| v0.3.0/ir-expansion | — | `tests/test_ir_furniture.py::test_build_hwp_document_routes_footers_to_furniture` | +| v0.3.0/ir-expansion | — | `tests/test_ir_furniture.py::test_build_hwp_document_routes_headers_to_furniture` | +| v0.3.0/ir-expansion | — | `tests/test_ir_furniture.py::test_furniture_accepts_endnotes_field_in_s2` | +| v0.3.0/ir-expansion | — | `tests/test_ir_furniture.py::test_furniture_default_lists_are_empty` | +| v0.3.0/ir-expansion | — | `tests/test_ir_furniture.py::test_furniture_extra_forbidden` | +| v0.3.0/ir-expansion | — | `tests/test_ir_furniture.py::test_furniture_frozen_blocks_mutation` | +| v0.3.0/ir-expansion | — | `tests/test_ir_furniture.py::test_iter_blocks_all_then_furniture_order` | +| v0.3.0/ir-expansion | — | `tests/test_ir_furniture.py::test_iter_blocks_furniture_recurse_enters_header_table_cells` | +| v0.3.0/ir-expansion | — | `tests/test_ir_furniture.py::test_real_sample_body_excludes_header_footer_text` | +| v0.3.0/ir-expansion | — | `tests/test_ir_furniture.py::test_real_sample_furniture_yields_paragraph_blocks` | +| v0.3.0/ir-expansion | — | `tests/test_ir_furniture.py::test_real_sample_iter_blocks_furniture_matches_lists` | +| v0.3.0/ir-expansion | — | `tests/test_ir_list.py::test_build_list_item_inlines_match_paragraph_pattern` | +| v0.3.0/ir-expansion | — | `tests/test_ir_list.py::test_build_list_item_marker_placeholder_by_head_type` | +| v0.3.0/ir-expansion | — | `tests/test_ir_list.py::test_build_list_item_preserves_provenance` | +| v0.3.0/ir-expansion | — | `tests/test_ir_list.py::test_build_list_item_raises_when_list_info_none` | +| v0.3.0/ir-expansion | — | `tests/test_ir_list.py::test_build_list_item_unknown_head_type_falls_back` | +| v0.3.0/ir-expansion | — | `tests/test_ir_list.py::test_list_item_block_default_fields` | +| v0.3.0/ir-expansion | — | `tests/test_ir_list.py::test_list_item_block_extra_forbidden` | +| v0.3.0/ir-expansion | — | `tests/test_ir_list.py::test_list_item_block_frozen` | +| v0.3.0/ir-expansion | — | `tests/test_ir_list.py::test_list_item_block_minimal_roundtrip` | +| v0.3.0/ir-expansion | — | `tests/test_ir_list.py::test_list_item_block_routes_via_discriminator` | +| v0.3.0/ir-expansion | — | `tests/test_ir_list.py::test_list_item_blocks_can_appear_in_table_cell` | +| v0.3.0/ir-expansion | — | `tests/test_ir_list.py::test_marker_is_placeholder_in_v0_3_0` | +| v0.3.0/ir-expansion | — | `tests/test_ir_list.py::test_paragraph_with_list_info_yields_list_item_block` | +| v0.3.0/ir-expansion | — | `tests/test_ir_list.py::test_paragraph_without_list_info_yields_paragraph_block` | +| v0.3.0/ir-expansion | — | `tests/test_ir_picture.py::test_build_picture_block_broken_reference_routes_to_none` | +| v0.3.0/ir-expansion | — | `tests/test_ir_picture.py::test_build_picture_block_no_extension_falls_back` | +| v0.3.0/ir-expansion | — | `tests/test_ir_picture.py::test_build_picture_block_preserves_description` | +| v0.3.0/ir-expansion | — | `tests/test_ir_picture.py::test_build_picture_block_unknown_extension_falls_back` | +| v0.3.0/ir-expansion | — | `tests/test_ir_picture.py::test_build_picture_block_with_known_extension` | +| v0.3.0/ir-expansion | — | `tests/test_ir_picture.py::test_bytes_for_image_raises_on_broken_reference` | +| v0.3.0/ir-expansion | — | `tests/test_ir_picture.py::test_bytes_for_image_raises_on_invalid_uri` | +| v0.3.0/ir-expansion | — | `tests/test_ir_picture.py::test_bytes_for_image_raises_on_lookup_miss` | +| v0.3.0/ir-expansion | — | `tests/test_ir_picture.py::test_bytes_for_image_raises_on_out_of_range` | +| v0.3.0/ir-expansion | — | `tests/test_ir_picture.py::test_bytes_for_image_raises_on_unsupported_scheme` | +| v0.3.0/ir-expansion | — | `tests/test_ir_picture.py::test_image_ref_extra_forbidden` | +| v0.3.0/ir-expansion | — | `tests/test_ir_picture.py::test_image_ref_frozen` | +| v0.3.0/ir-expansion | — | `tests/test_ir_picture.py::test_image_ref_required_fields` | +| v0.3.0/ir-expansion | — | `tests/test_ir_picture.py::test_image_ref_roundtrip` | +| v0.3.0/ir-expansion | — | `tests/test_ir_picture.py::test_mime_mapping_known_extensions` | +| v0.3.0/ir-expansion | — | `tests/test_ir_picture.py::test_mime_mapping_none_falls_back` | +| v0.3.0/ir-expansion | — | `tests/test_ir_picture.py::test_mime_mapping_unknown_falls_back` | +| v0.3.0/ir-expansion | — | `tests/test_ir_picture.py::test_picture_block_broken_reference` | +| v0.3.0/ir-expansion | — | `tests/test_ir_picture.py::test_picture_block_extra_forbidden` | +| v0.3.0/ir-expansion | — | `tests/test_ir_picture.py::test_picture_block_kind_is_picture` | +| v0.3.0/ir-expansion | — | `tests/test_ir_picture.py::test_picture_block_roundtrip_with_image` | +| v0.3.0/ir-expansion | — | `tests/test_ir_picture.py::test_picture_block_routes_via_discriminator` | +| v0.3.0/ir-expansion | — | `tests/test_ir_picture.py::test_real_sample_bytes_for_image_returns_nonempty` | +| v0.3.0/ir-expansion | — | `tests/test_ir_picture.py::test_real_sample_picture_block_kind_is_picture` | +| v0.3.0/ir-expansion | — | `tests/test_ir_picture.py::test_real_sample_picture_uri_is_bin_scheme` | +| v0.3.0/ir-expansion | — | `tests/test_ir_toc.py::test_build_toc_block_empty_entries` | +| v0.3.0/ir-expansion | — | `tests/test_ir_toc.py::test_build_toc_block_with_entries` | +| v0.3.0/ir-expansion | — | `tests/test_ir_toc.py::test_build_toc_entry_is_stale_always_false_v0_3_0` | +| v0.3.0/ir-expansion | — | `tests/test_ir_toc.py::test_build_toc_entry_target_section_idx_always_none_v0_3_0` | +| v0.3.0/ir-expansion | — | `tests/test_ir_toc.py::test_flatten_paragraph_multiple_tocs` | +| v0.3.0/ir-expansion | — | `tests/test_ir_toc.py::test_flatten_paragraph_yields_toc_block_when_tocs_present` | +| v0.3.0/ir-expansion | — | `tests/test_ir_toc.py::test_iter_blocks_yields_toc_block_only` | +| v0.3.0/ir-expansion | — | `tests/test_ir_toc.py::test_toc_block_extra_forbidden` | +| v0.3.0/ir-expansion | — | `tests/test_ir_toc.py::test_toc_block_frozen` | +| v0.3.0/ir-expansion | — | `tests/test_ir_toc.py::test_toc_block_minimal_roundtrip` | +| v0.3.0/ir-expansion | — | `tests/test_ir_toc.py::test_toc_block_routes_via_discriminator` | +| v0.3.0/ir-expansion | — | `tests/test_ir_toc.py::test_toc_block_with_entries_roundtrip` | +| v0.3.0/ir-expansion | — | `tests/test_ir_toc.py::test_toc_entry_block_default_values` | +| v0.3.0/ir-expansion | — | `tests/test_ir_toc.py::test_toc_entry_block_extra_forbidden` | +| v0.3.0/ir-expansion | — | `tests/test_ir_toc.py::test_toc_entry_block_frozen` | +| v0.3.0/ir-expansion | — | `tests/test_ir_toc.py::test_toc_entry_block_full_fields` | +| v0.3.0/ir-expansion | — | `tests/test_ir_toc.py::test_toc_entry_block_is_not_in_block_union` | +| v0.3.0/ir-expansion | — | `tests/test_ir_toc.py::test_toc_entry_block_minimal_roundtrip` | diff --git a/docs/upstream/issue-find-control-text-positions.md b/docs/upstream/issue-find-control-text-positions.md index 452d2ad..9f4a4ba 100644 --- a/docs/upstream/issue-find-control-text-positions.md +++ b/docs/upstream/issue-find-control-text-positions.md @@ -1,6 +1,11 @@ -# 업스트림 제안 — `find_control_text_positions` 외부 노출 +--- +status: Frozen +last_updated: 2026-04-29 +--- + +> **RESOLVED 2026-04-28** — 옵션 A 채택, [edwardkim/rhwp#390](https://github.com/edwardkim/rhwp/issues/390) closed by cherry-pick into `devel` (commits `2a69fe1` / `b855e2a` / `a59ce71`, [PR #405](https://github.com/edwardkim/rhwp/pull/405)). 상류 main 미반영 — 다음 `external/rhwp` pin bump 시 흡수 예정. 본 파일은 historical record 로 in-place Frozen (다른 v0.3.0 Frozen spec 이 본 파일 참조 → 삭제 대신 보존). -**Status**: Active · **Last updated**: 2026-04-27 +# 업스트림 제안 — `find_control_text_positions` 외부 노출 > 외부 binding (`rhwp-python`) 구현 중 업스트림에서 수정이 필요해 보이는 부분을 발견하여, Claude 로 조사를 진행한 결과입니다. 업스트림 머지 시 본 파일은 archive (또는 삭제) 처리. diff --git a/docs/verification/v0.1.0/spinoff-review.md b/docs/verification/v0.1.0/spinoff-review.md index ecbcb50..31e9238 100644 --- a/docs/verification/v0.1.0/spinoff-review.md +++ b/docs/verification/v0.1.0/spinoff-review.md @@ -1,6 +1,10 @@ -# 분사 작업 독립 검증 리포트 +--- +status: Frozen +ga: v0.1.0 +last_updated: 2026-04-23 +--- -**Status**: Frozen · **GA**: v0.1.0 · **Last updated**: 2026-04-23 +# 분사 작업 독립 검증 리포트 **검증일**: 2026-04-23 **검증 방식**: `code-reviewer` + `architect-reviewer` 서브에이전트를 **독립 컨텍스트**로 병렬 스폰. 각 에이전트는 이 세션의 작업 히스토리를 보지 않고 파일 상태만으로 판정. diff --git a/external/rhwp b/external/rhwp index 033617e..42cf91b 160000 --- a/external/rhwp +++ b/external/rhwp @@ -1 +1 @@ -Subproject commit 033617e23847982135c02091a62f55031a3817b5 +Subproject commit 42cf91b6ba7b50fa1c853c01158a52ef68b45442 diff --git a/pyproject.toml b/pyproject.toml index fe3d44f..107aae4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -126,6 +126,10 @@ addopts = ["-ra", "--strict-markers"] markers = [ "slow: 느린 테스트 (PDF 렌더링 등)", "langchain: LangChain 통합 테스트 (rhwp-python[langchain] extras 필요)", + # ^ spec(spec_id) — 본 테스트가 검증하는 spec 인수조건 식별자. + # 형식: 'vX.Y.Z/#AC-' (CONVENTIONS § Trace report). + # v0.4.0+ 신규 spec 부터 적용 — 기존 v0.1.0~v0.3.0 Frozen 은 marker 없음. + "spec(spec_id): link this test to a spec acceptance criterion (e.g. 'v0.4.0/cli#AC-3')", ] [tool.pyright] diff --git a/scripts/_doc_lint.py b/scripts/_doc_lint.py new file mode 100644 index 0000000..3d0a54f --- /dev/null +++ b/scripts/_doc_lint.py @@ -0,0 +1,338 @@ +"""docs lint shared library — used by .claude/hooks/docs-lint.py (single file) +and scripts/lint_docs.py (whole repo scan). + +CONVENTIONS.md 정책 enforcement. 룰 일람: + +1. **Frontmatter (YAML)** — Living 외 모든 spec + - status enum: Active / Draft / Frozen / Superseded + - last_updated: YYYY-MM-DD (필수) + - status:Active → ga / target 둘 다 금지 + - status:Draft → target 필수, ga 금지 + - status:Frozen → ga 필수 (예외: meta-level docs/implementation/.md / + resolved docs/upstream/.md) + - status:Superseded → ga 필수 + superseded_by 필수 + - ga ↔ target mutex + - ga / target SemVer (vX.Y.Z) +2. **Supersede chain integrity** — superseded_by 가 가리키는 파일이 실재 + + 해당 파일의 supersedes 가 역참조 +3. **파일명 kebab-case** — README / CONVENTIONS 등 ALL-CAPS 는 예외 +4. **vX.Y.Z 디렉토리 SemVer** — v 로 시작하는 디렉토리는 SemVer 정확 +5. **.md ↔ -research.md 페어** — roadmap ↔ design 동시 존재 +6. **upstream monorepo 잔재 키워드** — v0.1.0 historical 예외 +7. **same-version spec ↔ spec direct link** — pair 페어만 예외 +8. **broken .md link** — relative path 가 실제 파일을 가리키는지 +""" + +import re +from pathlib import Path + +# * 정책 상수 +LIVING_FILES = { + "docs/CONVENTIONS.md", + "docs/roadmap/README.md", + "docs/traces/coverage.md", +} +HISTORICAL_FROZEN_PREFIXES = ("docs/implementation/v0.1.0/",) +FORBIDDEN_KEYWORDS = ( + "사용자 Fork", + "rhwp 본체", + "pyo3-sandbox", + "/Cargo.toml (루트)", + "pyo3-bindings.md", +) +STATUS_ENUM = {"Active", "Draft", "Frozen", "Superseded"} + + +# * frontmatter 파서 — flat key:value 한정 (멀티라인 / 중첩 미지원) +def parse_frontmatter(text: str) -> dict[str, str] | None: + """Parse simple YAML frontmatter at the top of `text`. + + Returns ``None`` if no ``---``-delimited block is present at the start. + Comments (``# ...``) and blank lines are skipped. Values are stripped of + surrounding whitespace and unwrapped from matching single/double quotes. + """ + if not text.startswith("---\n"): + return None + end = text.find("\n---\n", 4) + if end < 0: + return None + block = text[4:end] + meta: dict[str, str] = {} + for raw in block.split("\n"): + line = raw.strip() + if not line or line.startswith("#"): + continue + if ":" not in line: + continue + k, _, v = line.partition(":") + v = v.strip() + # ^ trailing inline comment ('foo: bar # note') strip — quoted value 안의 + # '#' 는 보호 (flat key:value 가정상 quote 처리 후 안전) + if not (v.startswith(("'", '"'))): + v = v.split(" #", 1)[0].rstrip() + # ^ 양쪽 동일 quote 만 unwrap (mismatched 는 그대로) + if len(v) >= 2 and v[0] == v[-1] and v[0] in ("'", '"'): + v = v[1:-1] + meta[k.strip()] = v + return meta + + +# * code fence stripper — fence 안 예시 link / 백틱 인라인 link 를 lint 대상에서 배제 +def _strip_code(text: str) -> str: + """Remove ```...``` 블록 + 인라인 `...` 백틱. lint regex 는 raw text 가 + 아니라 본 출력에 적용 — fence 안 예시 link 가 broken/cross-link 위반으로 + 오인식되는 false positive 방지.""" + text = re.sub(r"```.*?```", "", text, flags=re.DOTALL) + text = re.sub(r"`[^`\n]+`", "", text) + return text + + +# * Rule 1+2: frontmatter schema + supersede chain +def validate_frontmatter(rel_str: str, meta: dict[str, str], repo: Path) -> list[str]: + errors: list[str] = [] + + status = meta.get("status") + if not status: + return ["frontmatter: missing 'status' field"] + if status not in STATUS_ENUM: + return [f"frontmatter: invalid 'status' {status!r} — must be one of {sorted(STATUS_ENUM)}"] + + last_updated = meta.get("last_updated", "") + if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", last_updated): + errors.append(f"frontmatter: 'last_updated' must be YYYY-MM-DD (got {last_updated!r})") + + has_ga = "ga" in meta + has_target = "target" in meta + + if status == "Active": + if has_ga or has_target: + errors.append("frontmatter: status:Active forbids 'ga' and 'target'") + elif status == "Draft": + if not has_target: + errors.append("frontmatter: status:Draft requires 'target'") + if has_ga: + errors.append("frontmatter: status:Draft forbids 'ga' (use 'target')") + elif status == "Frozen": + if not has_ga: + # ^ 면제: meta-level (vX.Y.Z 외부) implementation, resolved upstream + is_meta_level = rel_str.startswith("docs/implementation/") and not re.match( + r"docs/implementation/v\d+\.\d+\.\d+/", rel_str + ) + is_upstream_resolved = rel_str.startswith("docs/upstream/") + if not (is_meta_level or is_upstream_resolved): + errors.append( + "frontmatter: status:Frozen requires 'ga' " + "(except meta-level docs/implementation/.md and " + "docs/upstream/.md)" + ) + if has_target: + errors.append("frontmatter: status:Frozen forbids 'target'") + elif status == "Superseded": + if not has_ga: + errors.append("frontmatter: status:Superseded requires 'ga' (preserved)") + if has_target: + errors.append("frontmatter: status:Superseded forbids 'target'") + if "superseded_by" not in meta: + errors.append("frontmatter: status:Superseded requires 'superseded_by'") + + if has_ga and has_target: + errors.append("frontmatter: 'ga' and 'target' are mutually exclusive") + + for field in ("ga", "target"): + val = meta.get(field, "") + if val and not re.fullmatch(r"v\d+\.\d+\.\d+", val): + errors.append(f"frontmatter: {field!r} must be SemVer 'vX.Y.Z' (got {val!r})") + + errors.extend(_validate_supersede_chain(rel_str, meta, repo)) + return errors + + +def _validate_supersede_chain(rel_str: str, meta: dict[str, str], repo: Path) -> list[str]: + errors: list[str] = [] + rel = Path(rel_str) + + superseded_by = meta.get("superseded_by") + # ^ supersede 경로 base: vX.Y.Z 하위면 docs//, meta-level 평면이면 docs// + # format: vX.Y.Z 파일은 '/.md', meta-level 은 '.md'. + base, expected = _supersede_base(rel) + + if superseded_by: + target_rel = base / superseded_by + target = repo / target_rel + if not target.exists(): + errors.append( + f"frontmatter: superseded_by {superseded_by!r} not found (resolved: {target_rel})" + ) + else: + target_meta = parse_frontmatter(target.read_text(encoding="utf-8")) + if target_meta is None: + errors.append( + f"frontmatter: superseded_by target {superseded_by!r} lacks frontmatter" + ) + elif target_meta.get("supersedes") != expected: + errors.append( + f"frontmatter: supersede chain broken — target's " + f"'supersedes' is {target_meta.get('supersedes')!r}, " + f"expected {expected!r}" + ) + + supersedes = meta.get("supersedes") + if supersedes: + target_rel = base / supersedes + if not (repo / target_rel).exists(): + errors.append( + f"frontmatter: supersedes {supersedes!r} not found (resolved: {target_rel})" + ) + + return errors + + +def _supersede_base(rel: Path) -> tuple[Path, str]: + """supersede chain 의 base 디렉토리 + 본 파일의 expected 역참조 ID. + + vX.Y.Z 파일 (`docs///.md`) → base=`docs//`, + expected=`/.md`. + Meta-level 평면 (`docs//.md`) → base=`docs//`, + expected=`.md`. + """ + if re.fullmatch(r"v\d+\.\d+\.\d+", rel.parent.name): + return rel.parent.parent, str(rel.relative_to(rel.parent.parent)) + return rel.parent, rel.name + + +# * Rule 3+4: filename kebab-case + vX.Y.Z directory SemVer +def validate_filename(rel_str: str) -> list[str]: + errors: list[str] = [] + parts = rel_str.split("/") + + stem = parts[-1].removesuffix(".md") + if not (re.fullmatch(r"[a-z0-9]+(-[a-z0-9]+)*", stem) or re.fullmatch(r"[A-Z]+", stem)): + errors.append( + f"filename {parts[-1]!r} must be kebab-case (or ALL-CAPS for " + "README / CONVENTIONS / CHANGELOG)" + ) + + for part in parts[:-1]: + if re.match(r"^v\d", part) and not re.fullmatch(r"v\d+\.\d+\.\d+", part): + errors.append(f"version directory {part!r} must be 'vX.Y.Z' (SemVer)") + + return errors + + +# * Rule 5: .md ↔ -research.md pair existence +# ^ Grandfather: v0.1.0 (spinoff transfer, design research 미진행 — 역사적 예외). +PAIR_EXEMPT_VERSIONS = {"v0.1.0"} + + +def validate_pair(rel_str: str, repo: Path) -> list[str]: + m = re.match(r"docs/(roadmap|design)/(v\d+\.\d+\.\d+)/(.+)\.md$", rel_str) + if not m: + return [] + side, ver, base = m.group(1), m.group(2), m.group(3) + if ver in PAIR_EXEMPT_VERSIONS: + return [] + + if side == "roadmap": + pair = repo / "docs" / "design" / ver / f"{base}-research.md" + if not pair.exists(): + return [f"pair file missing — expected docs/design/{ver}/{base}-research.md"] + else: + if not base.endswith("-research"): + return [f"design file {base!r} must end with '-research'"] + topic = base.removesuffix("-research") + pair = repo / "docs" / "roadmap" / ver / f"{topic}.md" + if not pair.exists(): + return [f"pair file missing — expected docs/roadmap/{ver}/{topic}.md"] + return [] + + +# * Rule 6: upstream monorepo residue keywords +def validate_monorepo_residue(rel_str: str, text: str) -> list[str]: + if any(rel_str.startswith(p) for p in HISTORICAL_FROZEN_PREFIXES): + return [] + return [ + f"upstream monorepo residue keyword {kw!r} — " + "this is a spinoff binding repo, not the source-of-truth repo" + for kw in FORBIDDEN_KEYWORDS + if kw in text + ] + + +# * Rule 7: same-version spec ↔ spec direct link (pair only) +def validate_cross_link(rel_str: str, text: str) -> list[str]: + m = re.match(r"docs/(roadmap|design)/(v\d+\.\d+\.\d+)/(.+)\.md$", rel_str) + if not m: + return [] + base = m.group(3) + if base.endswith("-research"): + allowed_link = f"{base.removesuffix('-research')}.md" + else: + allowed_link = f"{base}-research.md" + self_link = f"{base}.md" + + errors: list[str] = [] + for link in re.findall(r"\]\(([^)]+\.md)[^)]*\)", _strip_code(text)): + link_target = link.split("#")[0] + if "/" in link_target: + continue + if link_target in (allowed_link, self_link): + continue + errors.append( + f"same-version spec direct link {link!r} — " + "route through phase-N.md or roadmap/README.md" + ) + return errors + + +# * Rule 8: broken .md link +# ^ external/ submodule (예: external/rhwp/) 안의 파일은 lint 검증 skip — +# 외부 의존성 추적은 docs/upstream-pins.yaml 이 담당. CI 가 submodule +# 체크아웃 안 해도 lint 통과하도록. +def validate_broken_link(rel_str: str, text: str, repo: Path) -> list[str]: + target_dir = (repo / rel_str).parent + errors: list[str] = [] + for link in re.findall(r"\]\(([^)]+\.md)[^)]*\)", _strip_code(text)): + link_target = link.split("#")[0].split("?")[0] + if not link_target or link_target.startswith("http"): + continue + resolved = (target_dir / link_target).resolve() + try: + resolved_rel = resolved.relative_to(repo) + except ValueError: + # ^ repo 외부 절대경로 — 검증 skip + continue + if str(resolved_rel).startswith("external/"): + continue + if not resolved.exists(): + errors.append(f"broken .md link {link!r} (resolved: {resolved})") + return errors + + +def lint_file(rel_str: str, repo: Path) -> list[str]: + """Run all rules on a single docs/*.md path. Returns list of error strings + prefixed with the file path. Empty list = clean.""" + target = repo / rel_str + if not target.is_file(): + return [] + text = target.read_text(encoding="utf-8") + errors: list[str] = [] + + if rel_str not in LIVING_FILES: + meta = parse_frontmatter(text) + if meta is None: + errors.append( + "missing YAML frontmatter — add " + "'---\\nstatus: \\n" + "[ga|target]: vX.Y.Z\\nlast_updated: YYYY-MM-DD\\n---' " + "(CONVENTIONS § Status 메타데이터)" + ) + else: + errors.extend(validate_frontmatter(rel_str, meta, repo)) + + errors.extend(validate_filename(rel_str)) + errors.extend(validate_pair(rel_str, repo)) + errors.extend(validate_monorepo_residue(rel_str, text)) + errors.extend(validate_cross_link(rel_str, text)) + errors.extend(validate_broken_link(rel_str, text, repo)) + + return [f"{rel_str}: {e}" for e in errors] diff --git a/scripts/generate_spec_trace.py b/scripts/generate_spec_trace.py new file mode 100644 index 0000000..38d5235 --- /dev/null +++ b/scripts/generate_spec_trace.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""tests/ 의 ``@pytest.mark.spec(...)`` marker 를 수집하여 +``docs/traces/coverage.md`` (Living) 갱신. + +사용: + uv run python scripts/generate_spec_trace.py [--check] + +``--check`` flag: 갱신 대신 git diff 검증만 (CI 용 — coverage.md 가 stale 이면 exit 1). + +방식: AST 정적 분석. ``@pytest.mark.spec("vX.Y.Z/topic#AC-N")`` 데코레이터를 +가진 ``test_*`` 함수를 찾아 ``(spec_id → nodeid)`` 매핑. + +기존 v0.1.0 ~ v0.3.0 Frozen spec 은 AC ID 부여 안 함 — marker 없는 테스트는 +그대로 통과 (CONVENTIONS § Trace report). +""" + +import ast +import re +from collections import defaultdict +from pathlib import Path + +import typer + +REPO = Path(__file__).resolve().parent.parent +TESTS_DIR = REPO / "tests" +COVERAGE_FILE = REPO / "docs" / "traces" / "coverage.md" + +# ^ spec_id: vX.Y.Z/[#AC-N] +SPEC_ID_RE = re.compile(r"^v\d+\.\d+\.\d+/[a-z0-9-]+(?:#AC-\d+)?$") + + +def main( + check: bool = typer.Option(False, "--check", help="stale 검증만 (수정 안 함)"), +) -> None: + mapping = _collect_spec_markers(TESTS_DIR) + body = _render(mapping) + + if check: + existing = COVERAGE_FILE.read_text(encoding="utf-8") if COVERAGE_FILE.exists() else "" + if existing != body: + typer.echo( + f"error: {COVERAGE_FILE.relative_to(REPO)} is stale — " + "run scripts/generate_spec_trace.py to refresh.", + err=True, + ) + raise typer.Exit(1) + typer.echo(f"{COVERAGE_FILE.relative_to(REPO)} up to date.") + return + + COVERAGE_FILE.parent.mkdir(parents=True, exist_ok=True) + COVERAGE_FILE.write_text(body, encoding="utf-8") + n = sum(len(v) for v in mapping.values()) + typer.echo( + f"updated {COVERAGE_FILE.relative_to(REPO)} — {len(mapping)} spec / {n} test mapping(s)" + ) + + +def _collect_spec_markers(tests_dir: Path) -> dict[str, list[str]]: + """spec_id → list of pytest nodeids. ``class TestFoo`` 안의 메서드도 정확히 + `tests/x.py::TestFoo::test_bar` 형식으로 출력 (ast.walk 평탄화 회피).""" + mapping: dict[str, list[str]] = defaultdict(list) + if not tests_dir.is_dir(): + return mapping + + for py_file in sorted(tests_dir.rglob("test_*.py")): + try: + tree = ast.parse(py_file.read_text(encoding="utf-8")) + except SyntaxError: + continue + rel = py_file.relative_to(REPO) + visitor = _SpecMarkerVisitor(rel) + visitor.visit(tree) + for spec_id, nodeids in visitor.mapping.items(): + mapping[spec_id].extend(nodeids) + return mapping + + +class _SpecMarkerVisitor(ast.NodeVisitor): + """class 컨텍스트 stack + module-level pytestmark 를 유지하며 + @pytest.mark.spec(...) 추출. + + pytestmark = pytest.mark.spec("vX.Y.Z/topic") (단일 또는 list) 형식의 + 파일-단위 marker 도 지원 — 모든 test_* 함수에 자동 적용. soft retrofit + (per-file mapping) 에 사용. + """ + + def __init__(self, file_rel: Path) -> None: + self.file_rel = file_rel + self.class_stack: list[str] = [] + self.module_specs: list[str] = [] + self.mapping: dict[str, list[str]] = defaultdict(list) + + def visit_Module(self, node: ast.Module) -> None: + # ^ pre-pass: module-level 'pytestmark = ...' 캡처 + for stmt in node.body: + if not isinstance(stmt, ast.Assign): + continue + if not (len(stmt.targets) == 1 and isinstance(stmt.targets[0], ast.Name)): + continue + if stmt.targets[0].id != "pytestmark": + continue + self.module_specs.extend(_extract_spec_ids_from_value(stmt.value)) + self.generic_visit(node) + + def visit_ClassDef(self, node: ast.ClassDef) -> None: + self.class_stack.append(node.name) + self.generic_visit(node) + self.class_stack.pop() + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + self._maybe_add(node) + self.generic_visit(node) + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: + self._maybe_add(node) + self.generic_visit(node) + + def _maybe_add(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None: + if not node.name.startswith("test_"): + return + spec_ids: list[str] = list(self.module_specs) + for decorator in node.decorator_list: + spec_id = _extract_spec_id(decorator) + if spec_id: + spec_ids.append(spec_id) + parts = [*self.class_stack, node.name] + nodeid = f"{self.file_rel}::{'::'.join(parts)}" + for spec_id in spec_ids: + if SPEC_ID_RE.match(spec_id): + self.mapping[spec_id].append(nodeid) + + +def _extract_spec_ids_from_value(value: ast.AST) -> list[str]: + """pytestmark 의 RHS — 단일 marker call 또는 list of marker calls.""" + if isinstance(value, ast.List | ast.Tuple): + return [s for elt in value.elts if (s := _extract_spec_id(elt)) is not None] + spec_id = _extract_spec_id(value) + return [spec_id] if spec_id else [] + + +def _extract_spec_id(node: ast.AST) -> str | None: + """``@pytest.mark.spec("vX.Y.Z/...")`` 또는 ``@mark.spec("...")`` 매칭.""" + if not isinstance(node, ast.Call): + return None + func = node.func + if not (isinstance(func, ast.Attribute) and func.attr == "spec"): + return None + if not (isinstance(func.value, ast.Attribute) and func.value.attr == "mark"): + return None + if not node.args or not isinstance(node.args[0], ast.Constant): + return None + val = node.args[0].value + return val if isinstance(val, str) else None + + +def _render(mapping: dict[str, list[str]]) -> str: + header = ( + "# Spec ↔ Test Trace\n\n" + "자동 생성 — `scripts/generate_spec_trace.py`. Living.\n\n" + "v0.4.0+ 신규 spec 의 인수조건 ↔ 테스트 매핑. " + "기존 v0.1.0 ~ v0.3.0 Frozen spec 은 AC ID 부여 안 함 " + "(CONVENTIONS § Trace report).\n\n" + ) + if not mapping: + return header + "(아직 매핑 없음. v0.4.0+ 부터 채워짐.)\n" + + lines = ["| Spec | AC | Tests |", "|---|---|---|"] + for spec_id in sorted(mapping): + spec, _, ac = spec_id.partition("#") + for nodeid in sorted(mapping[spec_id]): + lines.append(f"| {spec} | {ac or '—'} | `{nodeid}` |") + return header + "\n".join(lines) + "\n" + + +if __name__ == "__main__": + typer.run(main) diff --git a/scripts/lint_docs.py b/scripts/lint_docs.py new file mode 100644 index 0000000..5b92db2 --- /dev/null +++ b/scripts/lint_docs.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""docs/ lint — full repo scan. Used by CI. + +사용: + uv run python scripts/lint_docs.py [TARGET_DIR] + uv run python scripts/lint_docs.py docs/ # default + +각 markdown 파일에 .claude/hooks/docs-lint.py 와 동일한 룰 (공통 lib +scripts/_doc_lint.py) 을 적용. 룰 일람은 _doc_lint.py docstring 참조. + +exit 0: 위반 0 / exit 1: 위반 ≥1. +""" + +import sys +from pathlib import Path + +import typer + +# ^ scripts/ 를 import path 에 추가하여 동일 디렉토리 _doc_lint 모듈 로드 +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _doc_lint import lint_file # noqa: E402 + + +def main( + target: str = typer.Argument("docs", help="repo 기준 상대 디렉토리"), +) -> None: + repo = Path(__file__).resolve().parent.parent + target_dir = (repo / target).resolve() + if not target_dir.is_dir(): + typer.echo(f"error: {target_dir} is not a directory", err=True) + raise typer.Exit(1) + + all_errors: list[str] = [] + for path in sorted(target_dir.rglob("*.md")): + rel = path.relative_to(repo) + rel_str = str(rel).replace("\\", "/") + all_errors.extend(lint_file(rel_str, repo)) + + if all_errors: + for e in all_errors: + typer.echo(e, err=True) + typer.echo( + f"\n{len(all_errors)} violation(s) under {target}/ — policy: docs/CONVENTIONS.md", + err=True, + ) + raise typer.Exit(1) + + +if __name__ == "__main__": + typer.run(main) diff --git a/tests/test_async.py b/tests/test_async.py index 378d4cd..af9f491 100644 --- a/tests/test_async.py +++ b/tests/test_async.py @@ -18,6 +18,9 @@ import rhwp +pytestmark = pytest.mark.spec("v0.1.0/rhwp-python") +# ^ soft retrofit — file-level spec mapping; v0.4.0+ specs add #AC-N to specific tests (CONVENTIONS § Trace report) + def test_aparse_returns_document_instance(hwp_sample: Path) -> None: doc = asyncio.run(rhwp.aparse(str(hwp_sample))) diff --git a/tests/test_cli.py b/tests/test_cli.py index 4ac3ed6..bb078b7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -17,6 +17,9 @@ from rhwp.cli.app import app # noqa: E402 from typer.testing import CliRunner # noqa: E402 (importorskip 뒤 import) +pytestmark = pytest.mark.spec("v0.3.0/cli") +# ^ soft retrofit — file-level spec mapping; v0.4.0+ specs add #AC-N to specific tests (CONVENTIONS § Trace report) + # * Click 8.2+ 부터 CliRunner 가 stdout/stderr 를 기본 분리 — result.stderr 단독 검증 가능 _RUNNER = CliRunner() diff --git a/tests/test_errors.py b/tests/test_errors.py index 13ba30b..a111a20 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -5,6 +5,9 @@ import pytest import rhwp +pytestmark = pytest.mark.spec("v0.1.0/rhwp-python") +# ^ soft retrofit — file-level spec mapping; v0.4.0+ specs add #AC-N to specific tests (CONVENTIONS § Trace report) + class TestFileNotFound: # ^ FileNotFoundError 는 OSError 서브클래스. 메시지는 OS 마다 다르므로 타입만 검증. diff --git a/tests/test_from_bytes.py b/tests/test_from_bytes.py index 2c6901d..81db918 100644 --- a/tests/test_from_bytes.py +++ b/tests/test_from_bytes.py @@ -9,6 +9,9 @@ import pytest import rhwp +pytestmark = pytest.mark.spec("v0.1.0/rhwp-python") +# ^ soft retrofit — file-level spec mapping; v0.4.0+ specs add #AC-N to specific tests (CONVENTIONS § Trace report) + def test_from_bytes_returns_document(hwp_sample: Path) -> None: data = hwp_sample.read_bytes() diff --git a/tests/test_ir_caption.py b/tests/test_ir_caption.py index c7294ac..7e63f46 100644 --- a/tests/test_ir_caption.py +++ b/tests/test_ir_caption.py @@ -31,6 +31,9 @@ TableCell, ) +pytestmark = pytest.mark.spec("v0.3.0/ir-expansion") +# ^ soft retrofit — file-level spec mapping; v0.4.0+ specs add #AC-N to specific tests (CONVENTIONS § Trace report) + def _prov(section_idx: int = 0, para_idx: int = 0) -> Provenance: return Provenance(section_idx=section_idx, para_idx=para_idx) diff --git a/tests/test_ir_field.py b/tests/test_ir_field.py index ba86e96..23781ea 100644 --- a/tests/test_ir_field.py +++ b/tests/test_ir_field.py @@ -21,6 +21,9 @@ Provenance, ) +pytestmark = pytest.mark.spec("v0.3.0/ir-expansion") +# ^ soft retrofit — file-level spec mapping; v0.4.0+ specs add #AC-N to specific tests (CONVENTIONS § Trace report) + def _prov(section_idx: int = 0, para_idx: int = 0) -> Provenance: return Provenance(section_idx=section_idx, para_idx=para_idx) diff --git a/tests/test_ir_footnote.py b/tests/test_ir_footnote.py index 498fa55..6932ced 100644 --- a/tests/test_ir_footnote.py +++ b/tests/test_ir_footnote.py @@ -25,6 +25,9 @@ TableCell, ) +pytestmark = pytest.mark.spec("v0.3.0/ir-expansion") +# ^ soft retrofit — file-level spec mapping; v0.4.0+ specs add #AC-N to specific tests (CONVENTIONS § Trace report) + def _prov(section_idx: int = 0, para_idx: int = 0) -> Provenance: return Provenance(section_idx=section_idx, para_idx=para_idx) diff --git a/tests/test_ir_formula.py b/tests/test_ir_formula.py index 4e6a915..31acc33 100644 --- a/tests/test_ir_formula.py +++ b/tests/test_ir_formula.py @@ -16,6 +16,9 @@ from rhwp.ir._raw_types import RawFormula from rhwp.ir.nodes import FormulaBlock, HwpDocument, Provenance +pytestmark = pytest.mark.spec("v0.3.0/ir-expansion") +# ^ soft retrofit — file-level spec mapping; v0.4.0+ specs add #AC-N to specific tests (CONVENTIONS § Trace report) + def _prov(section_idx: int = 0, para_idx: int = 0) -> Provenance: return Provenance(section_idx=section_idx, para_idx=para_idx) diff --git a/tests/test_ir_furniture.py b/tests/test_ir_furniture.py index 95591c4..9037eaa 100644 --- a/tests/test_ir_furniture.py +++ b/tests/test_ir_furniture.py @@ -22,6 +22,10 @@ TableCell, ) +import pytest +pytestmark = pytest.mark.spec("v0.3.0/ir-expansion") +# ^ soft retrofit — file-level spec mapping; v0.4.0+ specs add #AC-N to specific tests (CONVENTIONS § Trace report) + # * 모델 단독 — Furniture frozen + extra=forbid diff --git a/tests/test_ir_iter_blocks.py b/tests/test_ir_iter_blocks.py index c3e6209..63a2140 100644 --- a/tests/test_ir_iter_blocks.py +++ b/tests/test_ir_iter_blocks.py @@ -19,6 +19,10 @@ TableCell, ) +import pytest +pytestmark = pytest.mark.spec("v0.2.0/ir") +# ^ soft retrofit — file-level spec mapping; v0.4.0+ specs add #AC-N to specific tests (CONVENTIONS § Trace report) + # * 반환 타입 / 기본 scope diff --git a/tests/test_ir_list.py b/tests/test_ir_list.py index 56b6ff4..c13aadf 100644 --- a/tests/test_ir_list.py +++ b/tests/test_ir_list.py @@ -21,6 +21,9 @@ Provenance, ) +pytestmark = pytest.mark.spec("v0.3.0/ir-expansion") +# ^ soft retrofit — file-level spec mapping; v0.4.0+ specs add #AC-N to specific tests (CONVENTIONS § Trace report) + def _prov(section_idx: int = 0, para_idx: int = 0) -> Provenance: return Provenance(section_idx=section_idx, para_idx=para_idx) diff --git a/tests/test_ir_mapper.py b/tests/test_ir_mapper.py index 9f9740d..a17c3b2 100644 --- a/tests/test_ir_mapper.py +++ b/tests/test_ir_mapper.py @@ -18,6 +18,10 @@ ) from rhwp.ir._raw_types import RawCell, RawCharRun, RawParagraph, RawTable +import pytest +pytestmark = pytest.mark.spec("v0.2.0/ir") +# ^ soft retrofit — file-level spec mapping; v0.4.0+ specs add #AC-N to specific tests (CONVENTIONS § Trace report) + # * escape_html — & 우선 치환 규칙 보존 (이중 escape 방지) diff --git a/tests/test_ir_picture.py b/tests/test_ir_picture.py index 39e83af..cd86421 100644 --- a/tests/test_ir_picture.py +++ b/tests/test_ir_picture.py @@ -21,6 +21,9 @@ Provenance, ) +pytestmark = pytest.mark.spec("v0.3.0/ir-expansion") +# ^ soft retrofit — file-level spec mapping; v0.4.0+ specs add #AC-N to specific tests (CONVENTIONS § Trace report) + # * 모델 단독 — ImageRef diff --git a/tests/test_ir_plain_text.py b/tests/test_ir_plain_text.py index 11d0ca2..4e5dc76 100644 --- a/tests/test_ir_plain_text.py +++ b/tests/test_ir_plain_text.py @@ -19,6 +19,10 @@ UnknownBlock, ) +import pytest +pytestmark = pytest.mark.spec("v0.2.0/ir") +# ^ soft retrofit — file-level spec mapping; v0.4.0+ specs add #AC-N to specific tests (CONVENTIONS § Trace report) + _PROV = Provenance(section_idx=0, para_idx=0) diff --git a/tests/test_ir_roundtrip.py b/tests/test_ir_roundtrip.py index cacdfbb..4e6ea5a 100644 --- a/tests/test_ir_roundtrip.py +++ b/tests/test_ir_roundtrip.py @@ -21,6 +21,9 @@ from pydantic import ValidationError from rhwp.ir.nodes import DocumentSource, HwpDocument, ParagraphBlock, TableBlock +pytestmark = pytest.mark.spec("v0.2.0/ir") +# ^ soft retrofit — file-level spec mapping; v0.4.0+ specs add #AC-N to specific tests (CONVENTIONS § Trace report) + # * 반환 타입 / 캐시 diff --git a/tests/test_ir_schema.py b/tests/test_ir_schema.py index 32a446e..62ff9c5 100644 --- a/tests/test_ir_schema.py +++ b/tests/test_ir_schema.py @@ -21,6 +21,9 @@ UnknownBlock, ) +pytestmark = pytest.mark.spec("v0.2.0/ir") +# ^ soft retrofit — file-level spec mapping; v0.4.0+ specs add #AC-N to specific tests (CONVENTIONS § Trace report) + # * 공통 픽스처 헬퍼 diff --git a/tests/test_ir_schema_export.py b/tests/test_ir_schema_export.py index 9da2a0b..67a9956 100644 --- a/tests/test_ir_schema_export.py +++ b/tests/test_ir_schema_export.py @@ -26,6 +26,9 @@ load_schema, ) +pytestmark = pytest.mark.spec("v0.2.0/ir") +# ^ soft retrofit — file-level spec mapping; v0.4.0+ specs add #AC-N to specific tests (CONVENTIONS § Trace report) + # * export_schema 구조 diff --git a/tests/test_ir_tables.py b/tests/test_ir_tables.py index b3bbfbd..ea779fd 100644 --- a/tests/test_ir_tables.py +++ b/tests/test_ir_tables.py @@ -8,6 +8,9 @@ import rhwp from rhwp.ir.nodes import HwpDocument, ParagraphBlock, TableBlock +pytestmark = pytest.mark.spec("v0.2.0/ir") +# ^ soft retrofit — file-level spec mapping; v0.4.0+ specs add #AC-N to specific tests (CONVENTIONS § Trace report) + # * 샘플에 TableBlock 이 실제로 나타나는가 diff --git a/tests/test_ir_toc.py b/tests/test_ir_toc.py index cbe7fdf..b893694 100644 --- a/tests/test_ir_toc.py +++ b/tests/test_ir_toc.py @@ -23,6 +23,9 @@ UnknownBlock, ) +pytestmark = pytest.mark.spec("v0.3.0/ir-expansion") +# ^ soft retrofit — file-level spec mapping; v0.4.0+ specs add #AC-N to specific tests (CONVENTIONS § Trace report) + def _prov(section_idx: int = 0, para_idx: int = 0) -> Provenance: return Provenance(section_idx=section_idx, para_idx=para_idx) diff --git a/tests/test_langchain_loader.py b/tests/test_langchain_loader.py index e8a61d1..6210d04 100644 --- a/tests/test_langchain_loader.py +++ b/tests/test_langchain_loader.py @@ -16,7 +16,8 @@ from langchain_text_splitters import RecursiveCharacterTextSplitter # noqa: E402 from rhwp.integrations.langchain import HwpLoader # noqa: E402 -pytestmark = pytest.mark.langchain +pytestmark = [pytest.mark.langchain, pytest.mark.spec("v0.2.0/ir")] +# ^ soft retrofit — file-level spec mapping; v0.4.0+ specs add #AC-N to specific tests # * 생성자 diff --git a/tests/test_langchain_loader_ir.py b/tests/test_langchain_loader_ir.py index 856fb8d..cd2318a 100644 --- a/tests/test_langchain_loader_ir.py +++ b/tests/test_langchain_loader_ir.py @@ -14,7 +14,8 @@ from langchain_core.documents import Document # noqa: E402 from rhwp.integrations.langchain import HwpLoader # noqa: E402 -pytestmark = pytest.mark.langchain +pytestmark = [pytest.mark.langchain, pytest.mark.spec("v0.2.0/ir")] +# ^ soft retrofit — file-level spec mapping; v0.4.0+ specs add #AC-N to specific tests # * 생성자 diff --git a/tests/test_parse.py b/tests/test_parse.py index dcc936a..5e6f1c5 100644 --- a/tests/test_parse.py +++ b/tests/test_parse.py @@ -4,6 +4,10 @@ import rhwp +import pytest +pytestmark = pytest.mark.spec("v0.1.0/rhwp-python") +# ^ soft retrofit — file-level spec mapping; v0.4.0+ specs add #AC-N to specific tests (CONVENTIONS § Trace report) + class TestParsing: def test_parse_hwp5(self, hwp_sample: Path) -> None: diff --git a/tests/test_pdf_rendering.py b/tests/test_pdf_rendering.py index 66daf58..3087131 100644 --- a/tests/test_pdf_rendering.py +++ b/tests/test_pdf_rendering.py @@ -5,7 +5,8 @@ import pytest import rhwp -pytestmark = pytest.mark.slow +pytestmark = [pytest.mark.slow, pytest.mark.spec("v0.1.0/rhwp-python")] +# ^ soft retrofit — file-level spec mapping; v0.4.0+ specs add #AC-N to specific tests class TestRenderPdf: diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 813fb98..50a634d 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -4,6 +4,10 @@ import rhwp +import pytest +pytestmark = pytest.mark.spec("v0.1.0/rhwp-python") +# ^ soft retrofit — file-level spec mapping; v0.4.0+ specs add #AC-N to specific tests (CONVENTIONS § Trace report) + SEMVER_PATTERN = re.compile(r"^\d+\.\d+\.\d+(?:[-+].+)?$") diff --git a/tests/test_svg_rendering.py b/tests/test_svg_rendering.py index 94125c1..83f6e3a 100644 --- a/tests/test_svg_rendering.py +++ b/tests/test_svg_rendering.py @@ -5,6 +5,9 @@ import pytest import rhwp +pytestmark = pytest.mark.spec("v0.1.0/rhwp-python") +# ^ soft retrofit — file-level spec mapping; v0.4.0+ specs add #AC-N to specific tests (CONVENTIONS § Trace report) + class TestRenderSvg: def test_render_single_page(self, parsed_hwpx: rhwp.Document) -> None: diff --git a/tests/test_text_extraction.py b/tests/test_text_extraction.py index 8185f61..2586973 100644 --- a/tests/test_text_extraction.py +++ b/tests/test_text_extraction.py @@ -2,6 +2,10 @@ import rhwp +import pytest +pytestmark = pytest.mark.spec("v0.1.0/rhwp-python") +# ^ soft retrofit — file-level spec mapping; v0.4.0+ specs add #AC-N to specific tests (CONVENTIONS § Trace report) + class TestExtractText: def test_returns_string(self, parsed_hwp: rhwp.Document) -> None: