From ee563e70a5415b4aa5f1f1d26780435765151468 Mon Sep 17 00:00:00 2001 From: DanMeon Date: Wed, 29 Apr 2026 11:07:00 +0900 Subject: [PATCH 01/30] =?UTF-8?q?docs:=20CONVENTIONS.md=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0=20=E2=80=94=20frontmatter=20schema=20+=20=EC=A0=95?= =?UTF-8?q?=EC=B1=85=20=EC=A0=95=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Status 인라인 형식 → YAML frontmatter (status / ga | target / supersedes / superseded_by / last_updated) - Frozen 면제 조항 추가 (Living-policy schema migration: non-semantic 형식 갱신만 허용, 결정·인용 보존, 전체 spec 일괄) - verification/ 정책 약화 (큰 단위 작업 한정 — 작은 작업은 git log + PR description SSOT) - last_updated 자동화로 전환 (수기 갱신 절차 폐기) - 명명 규칙에 상대경로 implicit 표준 추가 (./ prefix 금지) - Implementation log 구조에 meta-level slot 추가 (vX.Y.Z 외부 직속 평면, ga 생략 가능) - 신규 섹션: EARS 인수조건 (v0.4.0+) / CHANGELOG ↔ log 분리 / Frozen 외부 의존성 부패 / Trace report 후속 commit 들이 본 정책 따름. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/CONVENTIONS.md | 191 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 160 insertions(+), 31 deletions(-) diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md index 096833d..6b12bb5 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` | +| **Living** | 항상 최신 — 다른 문서의 위치 포인터 + 시간선 + 규칙 | 자유 갱신, 매 변경 시 손봐도 무방 | `docs/CONVENTIONS.md` (자체), `docs/roadmap/README.md`, `CHANGELOG.md`, `CLAUDE.md`, `AGENTS.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 전) | +| **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` (예: `phase-N.md`, `upstream/.md`) 는 `ga` / `target` 둘 다 생략. + +`Living` 은 frontmatter 없음 — 정의상 항상 최신. 대신 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** (Phase 진행 중): + +```markdown +--- +status: Active +last_updated: 2026-04-26 +--- + +# Phase 3 — view 렌더러 + RAG 프레임워크 통합 + +**대상 버전**: v0.4.0 ~ v0.6.0 +``` + +**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 자동 갱신 -`Living` 문서는 정의상 항상 최신이므로 Status 헤더 없음. 대신 README 같은 인덱스가 다른 문서들의 Status 를 노출. +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 로 이전. ## 디렉토리별 정책 @@ -44,11 +134,15 @@ docs/ ├── 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 +├── upstream-pins.yaml Living — external/rhwp 커밋 핀 SSOT └── verification/ - └── v/... Frozen — 완료된 검증 리포트 + └── v/... Frozen — 큰 단위 작업 검증 리포트 (한정) ``` ### roadmap/ @@ -65,7 +159,7 @@ 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/ @@ -74,7 +168,8 @@ docs/ ### 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 방향성 규칙 @@ -100,21 +195,22 @@ Living ───→ Active ───→ Draft ───→ Frozen ## 새 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) +2. spec 파일 작성 — frontmatter `status: Draft`, `target: vX.Y.Z` +3. 짝이 되는 design research 파일 작성 — 같은 frontmatter 4. `docs/roadmap/README.md` 의 인덱스 표에 행 추가 5. 해당 phase 가 있다면 `phase-N.md` 의 § 대상 버전 / § 산하 spec 갱신 (Active 갱신은 자유) ### 버전 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) +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) ### Phase 완료 후 @@ -130,22 +226,53 @@ Living ───→ Active ───→ Draft ───→ 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 작성 안 함. + +## 인수조건 형식 — EARS notation (v0.4.0+ 신규 spec) + +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 보존. + +## Trace report — pytest spec markers + +v0.4.0+ 부터 테스트는 `pytest.mark.spec("vX.Y.Z/topic#AC-N")` marker 로 spec 인수조건과 1:1 매핑. CI 에서 `scripts/generate_spec_trace.py` 가 매 빌드 시 `docs/traces/coverage.md` (Living) 자동 갱신 — spec 별 인수조건 ↔ 테스트 mapping 표. + +기존 v0.1.0 ~ v0.3.0 Frozen spec 은 AC ID 부여 안 함 — marker 없는 테스트 허용. ## Archive 정책 (v1.0+) @@ -164,12 +291,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 +307,4 @@ CONVENTIONS.md 자체는 Living. 정책 변경 시 in-place 갱신. **단** 본 - ADR 패턴 (Architecture Decision Records): - Diátaxis 4-axis: - GitHub Spec Kit: +- EARS notation: From a0e2235ecc98840f4c1a7bbebb53d6ef8dd5fd33 Mon Sep 17 00:00:00 2001 From: DanMeon Date: Wed, 29 Apr 2026 11:09:51 +0900 Subject: [PATCH 02/30] =?UTF-8?q?docs:=20AGENTS.md=20=EC=A0=95=EB=B3=B8?= =?UTF-8?q?=ED=99=94=20+=20CLAUDE.md=20stub?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md 가 2025-12 Linux Foundation/AAIF 표준이 되어 Codex / Factory / Cursor / Kilo 등이 인식. 본문은 AGENTS.md 가 SSOT, CLAUDE.md 는 1줄 stub 으로 backward compat 유지. symlink 가 아닌 별도 파일 — Windows / 일부 git 클라이언트 호환성 우선. 기존 CLAUDE.md 본문 그대로 복사 + 다음만 적응: - frontmatter schema 반영 (Status inline → YAML) - /new-spec skill 언급 추가 (Documentation 섹션) - upstream-pins.yaml SSOT 언급 추가 (Versioning 섹션) - "AGENTS.md / CLAUDE.md" 둘 다 Living 명시 Co-Authored-By: Claude Opus 4.7 (1M context) --- AGENTS.md | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 74 ++----------------------------------------------------- 2 files changed, 73 insertions(+), 72 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..dc9ae78 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,71 @@ +# 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 + +## 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 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.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 (SSOT: `docs/upstream-pins.yaml`) +- 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/CLAUDE.md b/CLAUDE.md index 787afd0..9dc8a8d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,72 +1,2 @@ -# 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 - + +This project's agent context lives in [AGENTS.md](AGENTS.md). Claude Code reads both — keeping this stub for backward compatibility. From e1ba5c609dd5245d08474da139ce6e36c8260a40 Mon Sep 17 00:00:00 2001 From: DanMeon Date: Wed, 29 Apr 2026 11:18:55 +0900 Subject: [PATCH 03/30] =?UTF-8?q?docs:=2024=EA=B0=9C=20spec=20frontmatter?= =?UTF-8?q?=20=EB=A7=88=EC=9D=B4=EA=B7=B8=20(Frozen=20=EB=A9=B4=EC=A0=9C?= =?UTF-8?q?=20=EC=A1=B0=ED=95=AD=20=EC=A0=81=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit inline **Status**: 라인 → YAML frontmatter. 본문 의미 변경 0. Living-policy schema migration — Frozen 19개 / Draft 2개 / Active 3개 일괄 (CONVENTIONS § Frozen 면제 조항 — non-semantic 형식 갱신). 각 파일의 기존 메타 값 정확 보존: - ga / target / last_updated 모두 inline 값 그대로 frontmatter 로 이전 - v0.1.0/rhwp-python.md 의 "(patch v0.1.1)" annotation 만 drop (strict SemVer schema, README 인덱스 + CHANGELOG 가 v0.1.1 별도 노출) 부수 변경 2건: - .claude/hooks/docs-lint.py 의 rule #1 (Status header) 을 inline OR frontmatter 둘 다 허용으로 임시 확장 — Commit 4 에서 frontmatter-only 로 strict 화 + 새 룰 (schema / kebab-case / supersede chain) 추가 - docs/implementation/v0.3.0/aparse-cleanup.md 의 사전 존재 broken link (../../../upstream/... → ../../upstream/...) 수정 — Frozen 본문이지만 CONVENTIONS § Frozen 정의가 broken link in-place fix 명시 허용 Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/hooks/docs-lint.py | 17 +++++++++++------ docs/design/v0.2.0/ir-design-research.md | 8 ++++++-- docs/design/v0.3.0/cli-design-research.md | 8 ++++++-- docs/design/v0.3.0/ir-expansion-research.md | 8 ++++++-- docs/design/v0.7.0/mcp-research.md | 8 ++++++-- docs/implementation/v0.1.0/migration.md | 8 ++++++-- docs/implementation/v0.2.0/stages/stage-1.md | 8 ++++++-- docs/implementation/v0.2.0/stages/stage-2.md | 8 ++++++-- docs/implementation/v0.2.0/stages/stage-3.md | 8 ++++++-- docs/implementation/v0.2.0/stages/stage-4.md | 8 ++++++-- docs/implementation/v0.2.0/stages/stage-5.md | 8 ++++++-- docs/implementation/v0.3.0/aparse-cleanup.md | 12 ++++++++---- docs/implementation/v0.3.0/stages/stage-1.md | 8 ++++++-- docs/implementation/v0.3.0/stages/stage-2.md | 8 ++++++-- docs/implementation/v0.3.0/stages/stage-3.md | 8 ++++++-- docs/implementation/v0.3.0/stages/stage-4.md | 8 ++++++-- docs/roadmap/phase-3.md | 7 +++++-- docs/roadmap/phase-4.md | 7 +++++-- docs/roadmap/v0.1.0/rhwp-python.md | 8 ++++++-- docs/roadmap/v0.2.0/ir.md | 8 ++++++-- docs/roadmap/v0.3.0/cli.md | 8 ++++++-- docs/roadmap/v0.3.0/ir-expansion.md | 8 ++++++-- docs/roadmap/v0.7.0/mcp.md | 8 ++++++-- .../issue-find-control-text-positions.md | 7 +++++-- docs/verification/v0.1.0/spinoff-review.md | 8 ++++++-- 25 files changed, 154 insertions(+), 56 deletions(-) diff --git a/.claude/hooks/docs-lint.py b/.claude/hooks/docs-lint.py index 24b7723..340583f 100755 --- a/.claude/hooks/docs-lint.py +++ b/.claude/hooks/docs-lint.py @@ -52,15 +52,20 @@ errors: list[str] = [] -# * 1. Status header (required outside Living docs) +# * 1. Status metadata (YAML frontmatter or legacy inline) — required outside Living docs +# ^ Commit 3 (spec-system-overhaul): accept either YAML frontmatter or legacy inline +# format during the transition. Commit 4 will tighten to frontmatter-only + add +# schema validation (status enum, ga ↔ target mutex, supersede chain). 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): + has_inline = re.search(r"^\*\*Status\*\*:", text, re.MULTILINE) + has_frontmatter = text.startswith("---\n") and re.search(r"^status:\s*", text, re.MULTILINE) + if not (has_inline or has_frontmatter): errors.append( - "missing Status header — add '**Status**: " - " · " - "**GA|Target**: vX.Y.Z · **Last updated**: YYYY-MM-DD' " - "(CONVENTIONS § Status header format)" + "missing Status metadata — add YAML frontmatter " + "'---\\nstatus: \\n" + "ga|target: vX.Y.Z\\nlast_updated: YYYY-MM-DD\\n---' " + "(CONVENTIONS § Status 메타데이터)" ) diff --git a/docs/design/v0.2.0/ir-design-research.md b/docs/design/v0.2.0/ir-design-research.md index 5442fc0..1b5fb5b 100644 --- a/docs/design/v0.2.0/ir-design-research.md +++ b/docs/design/v0.2.0/ir-design-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-design-research.md index c96fec2..0859939 100644 --- a/docs/design/v0.3.0/cli-design-research.md +++ b/docs/design/v0.3.0/cli-design-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 본문이 최종 결정을 기술하고, 본 문서는 그 결정의 근거를 담는다. diff --git a/docs/design/v0.3.0/ir-expansion-research.md b/docs/design/v0.3.0/ir-expansion-research.md index 18b68c7..950d850 100644 --- a/docs/design/v0.3.0/ir-expansion-research.md +++ b/docs/design/v0.3.0/ir-expansion-research.md @@ -1,6 +1,10 @@ -# 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 본문이 최종 결정을 기술하고, 본 문서는 그 결정의 근거를 담는다. 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/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..3222121 100644 --- a/docs/implementation/v0.2.0/stages/stage-1.md +++ b/docs/implementation/v0.2.0/stages/stage-1.md @@ -1,6 +1,10 @@ -# 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) §구현 스테이지 분할 diff --git a/docs/implementation/v0.2.0/stages/stage-2.md b/docs/implementation/v0.2.0/stages/stage-2.md index 746b282..fc6cc76 100644 --- a/docs/implementation/v0.2.0/stages/stage-2.md +++ b/docs/implementation/v0.2.0/stages/stage-2.md @@ -1,6 +1,10 @@ -# 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) §구현 스테이지 분할 diff --git a/docs/implementation/v0.2.0/stages/stage-3.md b/docs/implementation/v0.2.0/stages/stage-3.md index e9520e8..a675af7 100644 --- a/docs/implementation/v0.2.0/stages/stage-3.md +++ b/docs/implementation/v0.2.0/stages/stage-3.md @@ -1,6 +1,10 @@ -# 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) §테이블 표현 + §구현 스테이지 분할 diff --git a/docs/implementation/v0.2.0/stages/stage-4.md b/docs/implementation/v0.2.0/stages/stage-4.md index e17d0c4..86d4ffd 100644 --- a/docs/implementation/v0.2.0/stages/stage-4.md +++ b/docs/implementation/v0.2.0/stages/stage-4.md @@ -1,6 +1,10 @@ -# 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 공개 diff --git a/docs/implementation/v0.2.0/stages/stage-5.md b/docs/implementation/v0.2.0/stages/stage-5.md index d0e6c0c..f93527d 100644 --- a/docs/implementation/v0.2.0/stages/stage-5.md +++ b/docs/implementation/v0.2.0/stages/stage-5.md @@ -1,6 +1,10 @@ -# 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 + §모듈 구조 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..a25d607 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) diff --git a/docs/roadmap/phase-3.md b/docs/roadmap/phase-3.md index 986ed1e..0d05f81 100644 --- a/docs/roadmap/phase-3.md +++ b/docs/roadmap/phase-3.md @@ -1,6 +1,9 @@ -# Phase 3 — view 렌더러 + RAG 프레임워크 통합 +--- +status: Active +last_updated: 2026-04-26 +--- -**Status**: Active · **Target**: v0.4.0 ~ v0.6.0 · **Last updated**: 2026-04-26 +# Phase 3 — view 렌더러 + RAG 프레임워크 통합 **대상 버전**: v0.4.0 ~ v0.6.0 **선행 조건**: Phase 2 IR 확장 (v0.3.0) 안정 diff --git a/docs/roadmap/phase-4.md b/docs/roadmap/phase-4.md index 4abad8f..6fb9237 100644 --- a/docs/roadmap/phase-4.md +++ b/docs/roadmap/phase-4.md @@ -1,6 +1,9 @@ -# Phase 4 — JSON IR → HWP 역생성 +--- +status: Active +last_updated: 2026-04-28 +--- -**Status**: Active · **Target**: v0.8.0 ~ v1.0.0 · **Last updated**: 2026-04-28 +# Phase 4 — JSON IR → HWP 역생성 **대상 버전**: v0.8.0 ~ v1.0.0 (안정화 + writeback 지원) **선행 조건**: Phase 3 (v0.6.0 까지) GA + v0.7.0 MCP server 단발 통합 GA + rhwp Rust 코어의 HWP writer API 안정 diff --git a/docs/roadmap/v0.1.0/rhwp-python.md b/docs/roadmap/v0.1.0/rhwp-python.md index 2129519..b7b7858 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/` 에서 이관하며 기능 추가 없음. diff --git a/docs/roadmap/v0.2.0/ir.md b/docs/roadmap/v0.2.0/ir.md index 5a009b9..4ff1936 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 사용자에게 타입-안전하게 노출한다. diff --git a/docs/roadmap/v0.3.0/cli.md b/docs/roadmap/v0.3.0/cli.md index fe5cdeb..3c89cc6 100644 --- a/docs/roadmap/v0.3.0/cli.md +++ b/docs/roadmap/v0.3.0/cli.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.2.0 에서 폐기했던 CLI 를 **이름을 분리한 Python 고유 command** 로 재도입한다. `pip install rhwp-python` 만 한 사용자가 shell pipeline 에서 바로 쓸 수 있게 하되, 상류 Rust `rhwp` 바이너리와 기능을 **중복 구현하지 않는다** — Python 레이어 고유 가치 (Document IR, LangChain 청크) 에 집중. diff --git a/docs/roadmap/v0.3.0/ir-expansion.md b/docs/roadmap/v0.3.0/ir-expansion.md index ccaadb5..402f3de 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 한다. 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/upstream/issue-find-control-text-positions.md b/docs/upstream/issue-find-control-text-positions.md index 452d2ad..2a5650e 100644 --- a/docs/upstream/issue-find-control-text-positions.md +++ b/docs/upstream/issue-find-control-text-positions.md @@ -1,6 +1,9 @@ -# 업스트림 제안 — `find_control_text_positions` 외부 노출 +--- +status: Active +last_updated: 2026-04-27 +--- -**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` 서브에이전트를 **독립 컨텍스트**로 병렬 스폰. 각 에이전트는 이 세션의 작업 히스토리를 보지 않고 파일 상태만으로 판정. From 67c79d223ed637a9c32c870c1cfbce4f5f2608a1 Mon Sep 17 00:00:00 2001 From: DanMeon Date: Wed, 29 Apr 2026 11:45:35 +0900 Subject: [PATCH 04/30] =?UTF-8?q?docs:=20=EC=83=81=EB=A5=98=20issue=20#390?= =?UTF-8?q?=20RESOLVED=20=E2=80=94=20find=5Fcontrol=5Ftext=5Fpositions=20i?= =?UTF-8?q?n-place=20Frozen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 옵션 A (Paragraph::control_text_positions 메서드 캡슐화) 가 우리 spec 그대로 edwardkim/rhwp#390 에 채택, 2026-04-28 PR #405 cherry-pick → devel 머지. 3 commits: 2a69fe1 / b855e2a / a59ce71. 상류 main 미반영 — 다음 external/rhwp pin bump 시 흡수 예정. 처리 방식: - 본 파일 in-place Frozen 전환 (status: Active → Frozen, ga 생략) - 본문 첫 헤더 위에 RESOLVED notice 한 줄 인용 블록 (PR/commit reference) - 기존 body 보존 — 다른 v0.3.0 Frozen spec (stage-2/3/4, aparse-cleanup) 이 본 파일 참조하므로 삭제 대신 보존 (link 유지, historical record) CONVENTIONS § upstream/ 정책 보강 — "해결 시" 두 옵션 (삭제 / in-place Frozen 전환) 명시. 기준: 다른 spec 의 참조 유무. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/CONVENTIONS.md | 5 ++++- docs/upstream/issue-find-control-text-positions.md | 6 ++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md index 6b12bb5..4651c7a 100644 --- a/docs/CONVENTIONS.md +++ b/docs/CONVENTIONS.md @@ -163,8 +163,11 @@ docs/ ### 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/ diff --git a/docs/upstream/issue-find-control-text-positions.md b/docs/upstream/issue-find-control-text-positions.md index 2a5650e..9f4a4ba 100644 --- a/docs/upstream/issue-find-control-text-positions.md +++ b/docs/upstream/issue-find-control-text-positions.md @@ -1,8 +1,10 @@ --- -status: Active -last_updated: 2026-04-27 +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 이 본 파일 참조 → 삭제 대신 보존). + # 업스트림 제안 — `find_control_text_positions` 외부 노출 > 외부 binding (`rhwp-python`) 구현 중 업스트림에서 수정이 필요해 보이는 부분을 발견하여, Claude 로 조사를 진행한 결과입니다. 업스트림 머지 시 본 파일은 archive (또는 삭제) 처리. From 539b34614fa8a335b37149b878d03c8e43e07bec Mon Sep 17 00:00:00 2001 From: DanMeon Date: Wed, 29 Apr 2026 11:52:25 +0900 Subject: [PATCH 05/30] =?UTF-8?q?docs:=20CLAUDE.md=20=E2=86=92=20AGENTS.md?= =?UTF-8?q?=20symlink=20=EC=A0=84=ED=99=98=20(stub=20=EC=A0=9C=EA=B1=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경사항: - CLAUDE.md stub 텍스트 삭제 - CLAUDE.md 를 AGENTS.md 심볼릭 링크로 교체 - 단일 소스 보장 — Claude Code 자동 로드 시 AGENTS.md 본문이 그대로 적용됨 Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) mode change 100644 => 120000 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 9dc8a8d..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,2 +0,0 @@ - -This project's agent context lives in [AGENTS.md](AGENTS.md). Claude Code reads both — keeping this stub for backward compatibility. 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 From e15637336723afb6ee43c3719e2a38a9e288cc61 Mon Sep 17 00:00:00 2001 From: DanMeon Date: Wed, 29 Apr 2026 11:57:11 +0900 Subject: [PATCH 06/30] =?UTF-8?q?chore:=20spec=20lint=20=ED=99=95=EC=9E=A5?= =?UTF-8?q?=20=E2=80=94=20frontmatter=20/=20supersede=20chain=20/=20kebab-?= =?UTF-8?q?case=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 .claude/hooks/docs-lint.py 의 4개 룰을 scripts/_doc_lint.py 공통 lib 로 분리. CLI entry (scripts/lint_docs.py) 신설 — CI 에서 전체 repo scan. hook 은 편집 파일 단위 즉시 알림 유지. 추가 룰 (4 → 8): - frontmatter schema (status enum / ga ↔ target mutex / SemVer / 면제) - kebab-case 파일명 (README / CONVENTIONS 등 ALL-CAPS 예외) - vX.Y.Z 디렉토리 SemVer - .md ↔ -research.md pair 존재 - supersede chain integrity (양방향 참조 검증) Frontmatter ga 면제 (Frozen 한정): - meta-level docs/implementation/.md (vX.Y.Z 외부) - 해결된 docs/upstream/.md (특정 버전 미귀속) Pair grandfather: - v0.1.0 (spinoff transfer, 디자인 리서치 미진행) — pair 검사 면제 - v0.2.0/ir / v0.3.0/cli 의 -design-research suffix 수용 (legacy) - CONVENTIONS § 명명 규칙 에 grandfather 명시 + 신규는 -research.md 만 pyyaml 의존성 미추가 — flat string key:value 만 쓰는 frontmatter 라 hand-rolled parser 가 충분 + hook 은 system python3 라 venv 미접근 회피. CI: .github/workflows/docs.yml 신설 (paths filter 로 빌드/테스트와 분리, .md 또는 lint 스크립트 변경 시만 트리거). Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/hooks/docs-lint.py | 114 ++------------ .github/workflows/docs.yml | 41 +++++ docs/CONVENTIONS.md | 2 +- scripts/_doc_lint.py | 309 +++++++++++++++++++++++++++++++++++++ scripts/lint_docs.py | 49 ++++++ 5 files changed, 414 insertions(+), 101 deletions(-) create mode 100644 .github/workflows/docs.yml create mode 100644 scripts/_doc_lint.py create mode 100644 scripts/lint_docs.py diff --git a/.claude/hooks/docs-lint.py b/.claude/hooks/docs-lint.py index 340583f..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,89 +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 metadata (YAML frontmatter or legacy inline) — required outside Living docs -# ^ Commit 3 (spec-system-overhaul): accept either YAML frontmatter or legacy inline -# format during the transition. Commit 4 will tighten to frontmatter-only + add -# schema validation (status enum, ga ↔ target mutex, supersede chain). -LIVING_FILES = {"docs/CONVENTIONS.md", "docs/roadmap/README.md"} -if rel_str not in LIVING_FILES: - has_inline = re.search(r"^\*\*Status\*\*:", text, re.MULTILINE) - has_frontmatter = text.startswith("---\n") and re.search(r"^status:\s*", text, re.MULTILINE) - if not (has_inline or has_frontmatter): - errors.append( - "missing Status metadata — add YAML frontmatter " - "'---\\nstatus: \\n" - "ga|target: vX.Y.Z\\nlast_updated: YYYY-MM-DD\\n---' " - "(CONVENTIONS § Status 메타데이터)" - ) - - -# * 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/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..829e95e --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,41 @@ +name: Docs lint + +# 트리거: docs/*.md / scripts/_doc_lint.py / hook 변경 시. 빌드/테스트와 분리. +on: + push: + branches: [main] + paths: + - '**.md' + - 'docs/**' + - 'scripts/_doc_lint.py' + - 'scripts/lint_docs.py' + - '.claude/hooks/docs-lint.py' + - '.github/workflows/docs.yml' + pull_request: + branches: [main] + paths: + - '**.md' + - 'docs/**' + - 'scripts/_doc_lint.py' + - 'scripts/lint_docs.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: actions/setup-python@v6 + with: + python-version: "3.12" + - run: python3 scripts/lint_docs.py docs/ diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md index 4651c7a..f465905 100644 --- a/docs/CONVENTIONS.md +++ b/docs/CONVENTIONS.md @@ -292,7 +292,7 @@ v1.0 GA 전까지 아무것도 안 함 — 본 정책은 v1.0 GA 직전 작업 - 파일명: kebab-case (`ir-expansion.md`, not `ir_expansion.md`) - 디렉토리: `v` prefix + SemVer (`v0.3.0/`, not `0.3.0/`) -- ADR 파일: `-research.md` (roadmap spec `.md` 와 stem 일치) +- ADR 파일: `-research.md` (roadmap spec `.md` 와 stem 일치). v0.2.0/v0.3.0 의 `-design-research.md` (`-design-` infix) 는 historical 패턴 — lint 가 grandfather 로 수용, **신규는 `-research.md` 만** - stage 파일: `stage-.md` (1-indexed) - **상대경로**: 같은 / 하위 디렉토리는 implicit (`foo.md`, `subdir/foo.md`). 상위는 `../foo.md`. `./` prefix 금지 (redundant). 외부 자원만 fully-qualified URL diff --git a/scripts/_doc_lint.py b/scripts/_doc_lint.py new file mode 100644 index 0000000..e1fdda6 --- /dev/null +++ b/scripts/_doc_lint.py @@ -0,0 +1,309 @@ +"""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() + # ^ 양쪽 동일 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 + + +# * 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") + if superseded_by: + target_rel = rel.parent.parent / 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" + ) + else: + expected = str(rel.relative_to(rel.parent.parent)) + if 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 = rel.parent.parent / supersedes + if not (repo / target_rel).exists(): + errors.append( + f"frontmatter: supersedes {supersedes!r} not found (resolved: {target_rel})" + ) + + return errors + + +# * 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 spec (spinoff transfer, no design research) + +# legacy "-design-research" suffix (v0.2.0/ir, v0.3.0/cli) preserved. +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": + candidates = ( + repo / "docs" / "design" / ver / f"{base}-research.md", + repo / "docs" / "design" / ver / f"{base}-design-research.md", + ) + if not any(c.exists() for c in candidates): + return [f"pair file missing — expected docs/design/{ver}/{base}-research.md"] + else: + # ^ design-side: accept both -research / -design-research + if base.endswith("-design-research"): + topic = base.removesuffix("-design-research") + elif base.endswith("-research"): + topic = base.removesuffix("-research") + else: + return [ + f"design file {base!r} must end with '-research' (or legacy '-design-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)[^)]*\)", 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 +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)[^)]*\)", text): + link_target = link.split("#")[0].split("?")[0] + if not link_target or link_target.startswith("http"): + continue + resolved = (target_dir / link_target).resolve() + 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/lint_docs.py b/scripts/lint_docs.py new file mode 100644 index 0000000..b6e658a --- /dev/null +++ b/scripts/lint_docs.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""docs/ lint — full repo scan. Used by CI. + +사용: + python3 scripts/lint_docs.py [target_dir] + python3 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 + +# ^ scripts/ 를 import path 에 추가하여 동일 디렉토리 _doc_lint 모듈 로드 +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _doc_lint import lint_file # noqa: E402 + + +def main(args: list[str]) -> int: + repo = Path(__file__).resolve().parent.parent + target = args[0] if args else "docs" + target_dir = (repo / target).resolve() + if not target_dir.is_dir(): + sys.stderr.write(f"error: {target_dir} is not a directory\n") + return 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: + sys.stderr.write(f"{e}\n") + sys.stderr.write( + f"\n{len(all_errors)} violation(s) under {target}/ — policy: docs/CONVENTIONS.md\n" + ) + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) From b58f2445b842bbb0205498b11f694e77f3491cc0 Mon Sep 17 00:00:00 2001 From: DanMeon Date: Wed, 29 Apr 2026 11:59:26 +0900 Subject: [PATCH 07/30] =?UTF-8?q?docs:=20AGENTS.md=20"Global=20rules=20inh?= =?UTF-8?q?erited"=20=EC=84=B9=EC=85=98=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경사항: - 글로벌 룰 적용 안내 섹션(헤더 + 본문) 삭제 — 3번 줄 한 줄 안내와 중복 - AI/사람 모두에게 자명한 메타 안내라 가치 낮음 Co-Authored-By: Claude Opus 4.7 (1M context) --- AGENTS.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index dc9ae78..f422cbb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,10 +10,6 @@ Project-specific instructions. Inherits all rules from `~/.claude/CLAUDE.md` (gl - **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 From d6a4006c5bff11b90a819edd6e3903a104d1bbe1 Mon Sep 17 00:00:00 2001 From: DanMeon Date: Wed, 29 Apr 2026 12:00:04 +0900 Subject: [PATCH 08/30] =?UTF-8?q?chore:=20lint=5Fdocs.py=20argparse=20?= =?UTF-8?q?=E2=86=92=20typer=20(=EC=A0=84=EC=97=AD=20=EC=BB=A8=EB=B2=A4?= =?UTF-8?q?=EC=85=98=20=EC=A0=95=ED=95=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 전역 CLAUDE.md 의 "Use Typer instead of argparse" 정책 적용. typer 는 이미 [dependency-groups] testing 에 포함 → 추가 의존성 없음. --help 자동 + 타입 검증 + 프로젝트 다른 CLI (rhwp-py) 와 일관 UX. CI: docs.yml 가 setup-python 직접 호출 → setup-uv + typer 단발 install 로 전환. 가볍게 유지 (full all group 안 함). hook (.claude/hooks/docs-lint.py) 영향 0 — _doc_lint.py 만 import 하여 typer 미사용, system python3 호환 유지. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/docs.yml | 6 ++++-- scripts/lint_docs.py | 28 +++++++++++++++------------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 829e95e..77db059 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -35,7 +35,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - - uses: actions/setup-python@v6 + - uses: astral-sh/setup-uv@v8.1.0 with: python-version: "3.12" - - run: python3 scripts/lint_docs.py docs/ + # ^ typer 만 필요 (testing 그룹에 포함). full all 그룹은 CI 빌드용 — lint 는 가벼움. + - run: uv pip install --system "typer>=0.12" + - run: python scripts/lint_docs.py docs/ diff --git a/scripts/lint_docs.py b/scripts/lint_docs.py index b6e658a..5b92db2 100644 --- a/scripts/lint_docs.py +++ b/scripts/lint_docs.py @@ -2,8 +2,8 @@ """docs/ lint — full repo scan. Used by CI. 사용: - python3 scripts/lint_docs.py [target_dir] - python3 scripts/lint_docs.py docs/ # default + 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 참조. @@ -14,19 +14,22 @@ 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(args: list[str]) -> int: +def main( + target: str = typer.Argument("docs", help="repo 기준 상대 디렉토리"), +) -> None: repo = Path(__file__).resolve().parent.parent - target = args[0] if args else "docs" target_dir = (repo / target).resolve() if not target_dir.is_dir(): - sys.stderr.write(f"error: {target_dir} is not a directory\n") - return 1 + 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")): @@ -36,14 +39,13 @@ def main(args: list[str]) -> int: if all_errors: for e in all_errors: - sys.stderr.write(f"{e}\n") - sys.stderr.write( - f"\n{len(all_errors)} violation(s) under {target}/ — policy: docs/CONVENTIONS.md\n" + typer.echo(e, err=True) + typer.echo( + f"\n{len(all_errors)} violation(s) under {target}/ — policy: docs/CONVENTIONS.md", + err=True, ) - return 1 - - return 0 + raise typer.Exit(1) if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) + typer.run(main) From a3ed251555102e0b70553f692bf1686094225219 Mon Sep 17 00:00:00 2001 From: DanMeon Date: Wed, 29 Apr 2026 12:06:01 +0900 Subject: [PATCH 09/30] =?UTF-8?q?docs:=20design=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EB=AA=85=EB=AA=85=20=EC=A0=95=ED=95=A9=20=E2=80=94=20-design-r?= =?UTF-8?q?esearch=20=E2=86=92=20-research=20(rename=20+=20=EB=A7=81?= =?UTF-8?q?=ED=81=AC=20=EC=A0=95=EC=A0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frozen 면제 조항 (Living-policy schema migration) 의 일관 적용. Commit 3 에서 frontmatter 마이그를 non-semantic 으로 처리한 동일 논리 — 결정 / 인용 / 본문 의미 보존하고 표현 형식 (파일명 / 메타 형식) 만 정합화. Rename (git mv, history 보존): - docs/design/v0.2.0/ir-design-research.md → ir-research.md - docs/design/v0.3.0/cli-design-research.md → cli-research.md Cross-link 정정 (15개 파일, 24개 reference): - roadmap/README.md, v0.2.0/ir.md, v0.3.0/cli.md, v0.3.0/ir-expansion.md - design/v0.3.0/cli-research.md, ir-expansion-research.md - implementation/v0.2.0/stages/stage-{1,2,3,4,5}.md - implementation/v0.3.0/stages/stage-4.md - CHANGELOG.md ([Unreleased] / [0.2.0] / [0.3.0] entry) Lint 정리 (Commit 4 의 transitional grandfather 철회): - _doc_lint.py 의 -design-research suffix 수용 로직 제거 - CONVENTIONS.md § 명명 규칙 의 grandfather 한 줄 제거 - 이제 단일 표준: .md ↔ -research.md (예외 0) v0.1.0 페어 면제 (PAIR_EXEMPT_VERSIONS) 만 유지 — 진짜 historical (spinoff transfer, 디자인 리서치 미진행) 케이스라 형식 통일 무관. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 4 ++-- docs/CONVENTIONS.md | 2 +- .../{ir-design-research.md => ir-research.md} | 0 ...cli-design-research.md => cli-research.md} | 4 ++-- docs/design/v0.3.0/ir-expansion-research.md | 6 ++--- docs/implementation/v0.2.0/stages/stage-1.md | 4 ++-- docs/implementation/v0.2.0/stages/stage-2.md | 2 +- docs/implementation/v0.2.0/stages/stage-3.md | 4 ++-- docs/implementation/v0.2.0/stages/stage-4.md | 2 +- docs/implementation/v0.2.0/stages/stage-5.md | 2 +- docs/implementation/v0.3.0/stages/stage-4.md | 2 +- docs/roadmap/README.md | 4 ++-- docs/roadmap/v0.2.0/ir.md | 14 ++++++------ docs/roadmap/v0.3.0/cli.md | 12 +++++----- docs/roadmap/v0.3.0/ir-expansion.md | 2 +- scripts/_doc_lint.py | 22 +++++-------------- 16 files changed, 38 insertions(+), 48 deletions(-) rename docs/design/v0.2.0/{ir-design-research.md => ir-research.md} (100%) rename docs/design/v0.3.0/{cli-design-research.md => cli-research.md} (97%) diff --git a/CHANGELOG.md b/CHANGELOG.md index b06788c..d2a39f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,7 +62,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 +104,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/docs/CONVENTIONS.md b/docs/CONVENTIONS.md index f465905..4651c7a 100644 --- a/docs/CONVENTIONS.md +++ b/docs/CONVENTIONS.md @@ -292,7 +292,7 @@ v1.0 GA 전까지 아무것도 안 함 — 본 정책은 v1.0 GA 직전 작업 - 파일명: kebab-case (`ir-expansion.md`, not `ir_expansion.md`) - 디렉토리: `v` prefix + SemVer (`v0.3.0/`, not `0.3.0/`) -- ADR 파일: `-research.md` (roadmap spec `.md` 와 stem 일치). v0.2.0/v0.3.0 의 `-design-research.md` (`-design-` infix) 는 historical 패턴 — lint 가 grandfather 로 수용, **신규는 `-research.md` 만** +- 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 diff --git a/docs/design/v0.2.0/ir-design-research.md b/docs/design/v0.2.0/ir-research.md similarity index 100% rename from docs/design/v0.2.0/ir-design-research.md rename to docs/design/v0.2.0/ir-research.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 0859939..ec4d48a 100644 --- a/docs/design/v0.3.0/cli-design-research.md +++ b/docs/design/v0.3.0/cli-research.md @@ -169,10 +169,10 @@ last_updated: 2026-04-28 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 950d850..7a10c3b 100644 --- a/docs/design/v0.3.0/ir-expansion-research.md +++ b/docs/design/v0.3.0/ir-expansion-research.md @@ -8,7 +8,7 @@ last_updated: 2026-04-28 [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 § 영구 비목표 한 줄로 처리. ## 결정 매트릭스 @@ -542,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) --- @@ -567,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/implementation/v0.2.0/stages/stage-1.md b/docs/implementation/v0.2.0/stages/stage-1.md index 3222121..6276583 100644 --- a/docs/implementation/v0.2.0/stages/stage-1.md +++ b/docs/implementation/v0.2.0/stages/stage-1.md @@ -8,7 +8,7 @@ last_updated: 2026-04-24 **작업일**: 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) ## 스코프 @@ -87,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 fc6cc76..fc54cb3 100644 --- a/docs/implementation/v0.2.0/stages/stage-2.md +++ b/docs/implementation/v0.2.0/stages/stage-2.md @@ -8,7 +8,7 @@ last_updated: 2026-04-24 **작업일**: 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 a675af7..3c7dd52 100644 --- a/docs/implementation/v0.2.0/stages/stage-3.md +++ b/docs/implementation/v0.2.0/stages/stage-3.md @@ -8,7 +8,7 @@ last_updated: 2026-04-24 **작업일**: 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 직렬화 위치) ## 스코프 @@ -39,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 86d4ffd..1636ddf 100644 --- a/docs/implementation/v0.2.0/stages/stage-4.md +++ b/docs/implementation/v0.2.0/stages/stage-4.md @@ -8,7 +8,7 @@ last_updated: 2026-04-24 **작업일**: 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 f93527d..236ffde 100644 --- a/docs/implementation/v0.2.0/stages/stage-5.md +++ b/docs/implementation/v0.2.0/stages/stage-5.md @@ -8,7 +8,7 @@ last_updated: 2026-04-24 **작업일**: 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/stages/stage-4.md b/docs/implementation/v0.3.0/stages/stage-4.md index a25d607..09a3666 100644 --- a/docs/implementation/v0.3.0/stages/stage-4.md +++ b/docs/implementation/v0.3.0/stages/stage-4.md @@ -140,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..2d0fcc9 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -18,9 +18,9 @@ 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 인덱스 diff --git a/docs/roadmap/v0.2.0/ir.md b/docs/roadmap/v0.2.0/ir.md index 4ff1936..3443a11 100644 --- a/docs/roadmap/v0.2.0/ir.md +++ b/docs/roadmap/v0.2.0/ir.md @@ -142,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 @@ -259,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 @@ -365,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()` 는 변경 없이 유지. @@ -389,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 @@ -432,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 @@ -536,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 3c89cc6..5f42d02 100644 --- a/docs/roadmap/v0.3.0/cli.md +++ b/docs/roadmap/v0.3.0/cli.md @@ -8,7 +8,7 @@ last_updated: 2026-04-28 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). ## 배경 — 재도입 근거 @@ -238,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) | ## 다른 산출물의 파급 (코드 / 데이터) @@ -254,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 402f3de..26c14be 100644 --- a/docs/roadmap/v0.3.0/ir-expansion.md +++ b/docs/roadmap/v0.3.0/ir-expansion.md @@ -532,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/scripts/_doc_lint.py b/scripts/_doc_lint.py index e1fdda6..d1c4538 100644 --- a/scripts/_doc_lint.py +++ b/scripts/_doc_lint.py @@ -191,8 +191,7 @@ def validate_filename(rel_str: str) -> list[str]: # * Rule 5: .md ↔ -research.md pair existence -# ^ Grandfather: v0.1.0 spec (spinoff transfer, no design research) + -# legacy "-design-research" suffix (v0.2.0/ir, v0.3.0/cli) preserved. +# ^ Grandfather: v0.1.0 (spinoff transfer, design research 미진행 — 역사적 예외). PAIR_EXEMPT_VERSIONS = {"v0.1.0"} @@ -205,22 +204,13 @@ def validate_pair(rel_str: str, repo: Path) -> list[str]: return [] if side == "roadmap": - candidates = ( - repo / "docs" / "design" / ver / f"{base}-research.md", - repo / "docs" / "design" / ver / f"{base}-design-research.md", - ) - if not any(c.exists() for c in candidates): + 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: - # ^ design-side: accept both -research / -design-research - if base.endswith("-design-research"): - topic = base.removesuffix("-design-research") - elif base.endswith("-research"): - topic = base.removesuffix("-research") - else: - return [ - f"design file {base!r} must end with '-research' (or legacy '-design-research')" - ] + 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"] From baafe82348fb044efbc9c4141ae6f504713cf2a8 Mon Sep 17 00:00:00 2001 From: DanMeon Date: Wed, 29 Apr 2026 12:14:33 +0900 Subject: [PATCH 10/30] =?UTF-8?q?chore:=20upstream-pins.yaml=20SSOT=20+=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=B6=94=EC=B6=9C=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A6=BD=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit external/rhwp 커밋 핀의 SSOT 를 yaml 로 분리. CHANGELOG.md 가 본 파일 값을 prose 로 인용 — 둘이 어긋나면 yaml 이 SSOT. scripts/update_upstream_pin.py — 릴리스 직전 작업자가 호출: - external/rhwp HEAD 의 short hash 추출 - 직전 entry 와 비교하여 commits_integrated 자동 계산 - 표준 field 순서 (upstream_commit / previous_commit / commits_integrated / bumped_at / note) 로 갱신, round-trip 안정 자동화 (release workflow 통합) 미적용 — 핀 결정은 "어느 commit 까지 흡수 할지" 사람의 판단이라 수기 한 단계 둠. typer + pyyaml. 초기 entries 4종 (v0.1.0 / v0.1.1 / v0.2.0 / v0.3.0) 채움: - v0.1.0 / v0.1.1: 1636213 (CHANGELOG + spec 본문 교차 검증) - v0.2.0: bea635b - v0.3.0: 033617e (380 commits from bea635b) pyyaml 을 [dependency-groups] dev 에 명시 — 기존 transitive 의존성을 SSOT 화. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/upstream-pins.yaml | 25 +++++++ pyproject.toml | 3 +- scripts/update_upstream_pin.py | 119 +++++++++++++++++++++++++++++++++ uv.lock | 12 +++- 4 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 docs/upstream-pins.yaml create mode 100644 scripts/update_upstream_pin.py diff --git a/docs/upstream-pins.yaml b/docs/upstream-pins.yaml new file mode 100644 index 0000000..a725f2b --- /dev/null +++ b/docs/upstream-pins.yaml @@ -0,0 +1,25 @@ +# external/rhwp 커밋 핀 SSOT — Living. +# 각 vX.Y.Z 릴리스가 의존하는 상류 (edwardkim/rhwp) 커밋 hash 와 갱신 메타. +# 갱신: scripts/update_upstream_pin.py vX.Y.Z (릴리스 직전 / pin lock 시점). +# CHANGELOG.md 가 본 파일 값을 prose 로 인용 — 둘이 어긋나면 본 파일이 SSOT. + +pins: + v0.1.0: + upstream_commit: '1636213' + bumped_at: 2026-04-22 + note: 초판 — edwardkim/rhwp main HEAD as of 2026-04-22 (full hash 163621382ba13be233b155df050375e900a038e2). + v0.1.1: + upstream_commit: '1636213' + bumped_at: 2026-04-23 + note: Patch release — sdist packaging fix only. submodule pin 무변경 (v0.1.0 동일). + v0.2.0: + upstream_commit: bea635b + previous_commit: '1636213' + bumped_at: 2026-04-25 + note: Document IR v1 GA. v0.1.0 → v0.2.0 사이 상류 변경은 docs (매뉴얼 현행화 등) 만 — 코드 동작 변화 없음. + v0.3.0: + upstream_commit: 033617e + previous_commit: bea635b + commits_integrated: 380 + bumped_at: 2026-04-28 + note: IR 확장 + rhwp-py CLI GA. upstream v0.6.x → v0.7.7 흡수 (TypesetEngine pagination drift 정정, TAC 표/그림 좌표 통합 수정, export text/markdown 추가, v0.7.6/v0.7.7 흡수). diff --git a/pyproject.toml b/pyproject.toml index fe3d44f..c431075 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,7 +73,8 @@ examples = [ rhwp-py = "rhwp.cli:app" [dependency-groups] -dev = ["maturin>=1.7"] +# ^ pyyaml: scripts/update_upstream_pin.py 의 round-trip (load + modify + dump) +dev = ["maturin>=1.7", "pyyaml>=6"] testing = [ {include-group = "dev"}, "pytest>=8", diff --git a/scripts/update_upstream_pin.py b/scripts/update_upstream_pin.py new file mode 100644 index 0000000..f1c660f --- /dev/null +++ b/scripts/update_upstream_pin.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""external/rhwp 의 현재 commit hash 를 docs/upstream-pins.yaml 에 기록. + +사용: + uv run python scripts/update_upstream_pin.py vX.Y.Z [--note "..."] + +동작: + 1. external/rhwp 에서 git rev-parse --short HEAD 추출 + 2. 직전 entry 의 upstream_commit 을 previous_commit 으로 설정 + 3. previous..current 사이 commit 수 계산 (commits_integrated) + 4. docs/upstream-pins.yaml 의 pins[vX.Y.Z] 갱신 또는 신규 추가 + 5. bumped_at 은 오늘 날짜 + +릴리스 직전 / pin lock 시점에 작업자가 호출. 자동화 (예: release workflow) +는 의도적 미적용 — 핀 결정은 사람의 판단 (어느 commit 까지 흡수할지) 이라 +수기 한 단계 둠. +""" + +import re +import subprocess +from datetime import date +from pathlib import Path + +import typer +import yaml + +REPO = Path(__file__).resolve().parent.parent +PINS_FILE = REPO / "docs" / "upstream-pins.yaml" +UPSTREAM_DIR = REPO / "external" / "rhwp" + + +def main( + version: str = typer.Argument(..., help="릴리스 버전 (vX.Y.Z)"), + note: str = typer.Option("", help="갱신 사유 한 줄 (선택)"), +) -> None: + if not re.fullmatch(r"v\d+\.\d+\.\d+", version): + typer.echo(f"error: version must be vX.Y.Z (got {version!r})", err=True) + raise typer.Exit(1) + + if not UPSTREAM_DIR.is_dir(): + typer.echo(f"error: {UPSTREAM_DIR} not found", err=True) + raise typer.Exit(1) + + current = subprocess.check_output( + ["git", "rev-parse", "--short", "HEAD"], + cwd=UPSTREAM_DIR, + text=True, + ).strip() + + data = yaml.safe_load(PINS_FILE.read_text(encoding="utf-8")) or {} + pins = data.setdefault("pins", {}) + + previous_entry = _previous_pin(pins, version) + # ^ 표준 field 순서: upstream_commit / previous_commit / commits_integrated / + # bumped_at / note. 조건부 필드는 건너뛰되 나머지 순서 유지 → round-trip 안정. + entry: dict[str, object] = {"upstream_commit": current} + if previous_entry: + prev_commit = previous_entry["upstream_commit"] + if prev_commit != current: + entry["previous_commit"] = prev_commit + entry["commits_integrated"] = _count_commits(prev_commit, current) + # ^ date 객체 그대로 dump → YAML native date (unquoted), round-trip 안정 + entry["bumped_at"] = date.today() + if note: + entry["note"] = note + + pins[version] = entry + _write_yaml(PINS_FILE, data) + typer.echo(f"updated {PINS_FILE.relative_to(REPO)} — {version}: {current}") + + +def _previous_pin(pins: dict, current_version: str) -> dict | None: + """SemVer 정렬에서 current_version 직전 entry 반환. 없으면 None.""" + + def key(v: str) -> tuple[int, int, int]: + m = re.match(r"v(\d+)\.(\d+)\.(\d+)", v) + return (int(m.group(1)), int(m.group(2)), int(m.group(3))) if m else (0, 0, 0) + + target = key(current_version) + candidates = sorted( + ((k, v) for k, v in pins.items() if key(k) < target), + key=lambda kv: key(kv[0]), + ) + return candidates[-1][1] if candidates else None + + +def _count_commits(prev: str, curr: str) -> int: + return len( + subprocess.check_output( + ["git", "log", "--oneline", f"{prev}..{curr}"], + cwd=UPSTREAM_DIR, + text=True, + ) + .strip() + .splitlines() + ) + + +def _write_yaml(path: Path, data: dict) -> None: + # ^ default_flow_style=False → block style (사람 가독), allow_unicode → 한글 보존, + # width 매우 크게 → 긴 한글 note 줄바꿈 방지 (CI diff 안정). + body = yaml.dump( + data, + default_flow_style=False, + allow_unicode=True, + sort_keys=False, + width=10000, + ) + header = ( + "# external/rhwp 커밋 핀 SSOT — Living.\n" + "# 각 vX.Y.Z 릴리스가 의존하는 상류 (edwardkim/rhwp) 커밋 hash 와 갱신 메타.\n" + "# 갱신: scripts/update_upstream_pin.py vX.Y.Z (릴리스 직전 / pin lock 시점).\n" + "# CHANGELOG.md 가 본 파일 값을 prose 로 인용 — 둘이 어긋나면 본 파일이 SSOT.\n\n" + ) + path.write_text(header + body, encoding="utf-8") + + +if __name__ == "__main__": + typer.run(main) diff --git a/uv.lock b/uv.lock index 82e3809..d09af26 100644 --- a/uv.lock +++ b/uv.lock @@ -940,15 +940,18 @@ all = [ { name = "pyright" }, { name = "pytest" }, { name = "pytest-cov" }, + { name = "pyyaml" }, { name = "ruff" }, { name = "typer" }, ] dev = [ { name = "maturin" }, + { name = "pyyaml" }, ] linting = [ { name = "maturin" }, { name = "pyright" }, + { name = "pyyaml" }, { name = "ruff" }, ] testing = [ @@ -958,6 +961,7 @@ testing = [ { name = "maturin" }, { name = "pytest" }, { name = "pytest-cov" }, + { name = "pyyaml" }, { name = "typer" }, ] @@ -985,13 +989,18 @@ all = [ { name = "pyright" }, { name = "pytest", specifier = ">=8" }, { name = "pytest-cov" }, + { name = "pyyaml", specifier = ">=6" }, { name = "ruff" }, { name = "typer", specifier = ">=0.12" }, ] -dev = [{ name = "maturin", specifier = ">=1.7" }] +dev = [ + { name = "maturin", specifier = ">=1.7" }, + { name = "pyyaml", specifier = ">=6" }, +] linting = [ { name = "maturin", specifier = ">=1.7" }, { name = "pyright" }, + { name = "pyyaml", specifier = ">=6" }, { name = "ruff" }, ] testing = [ @@ -1001,6 +1010,7 @@ testing = [ { name = "maturin", specifier = ">=1.7" }, { name = "pytest", specifier = ">=8" }, { name = "pytest-cov" }, + { name = "pyyaml", specifier = ">=6" }, { name = "typer", specifier = ">=0.12" }, ] From 514c9e79bae379e73431eb74d1dc056af07285e3 Mon Sep 17 00:00:00 2001 From: DanMeon Date: Wed, 29 Apr 2026 12:21:38 +0900 Subject: [PATCH 11/30] =?UTF-8?q?test:=20pytest.mark.spec=20marker=20+=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20trace=20report?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v0.4.0+ 신규 spec 의 인수조건 ↔ 테스트 매핑 자동화 인프라. 추가: - pyproject.toml [tool.pytest.ini_options] markers 에 spec(spec_id) 등록 - scripts/generate_spec_trace.py — AST 정적 분석으로 @pytest.mark.spec 데코레이터 추출 (subprocess 없이, typer + ast). --check flag 로 CI 검증 - docs/traces/coverage.md (Living, placeholder) — v0.4.0+ 첫 marker 시 자동 채워짐 - .github/workflows/docs.yml 에 trace --check step 추가 (paths 에 tests/ 포함 — marker 변경 시 트리거) 기존 v0.1.0 ~ v0.3.0 Frozen spec 은 AC ID 부여 안 함 — marker 없는 테스트 그대로 통과 (CONVENTIONS § Trace report). 검증: 합성 marker 3개 (v0.4.0/cli#AC-1 외) 로 추출 / 테이블 생성 round-trip 확인 후 cleanup. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/docs.yml | 10 ++- docs/traces/coverage.md | 7 ++ pyproject.toml | 4 ++ scripts/generate_spec_trace.py | 116 +++++++++++++++++++++++++++++++++ 4 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 docs/traces/coverage.md create mode 100644 scripts/generate_spec_trace.py diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 77db059..04c1d09 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -7,8 +7,10 @@ on: paths: - '**.md' - '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: @@ -16,8 +18,10 @@ on: paths: - '**.md' - '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: {} @@ -40,4 +44,8 @@ jobs: python-version: "3.12" # ^ typer 만 필요 (testing 그룹에 포함). full all 그룹은 CI 빌드용 — lint 는 가벼움. - run: uv pip install --system "typer>=0.12" - - run: python scripts/lint_docs.py docs/ + - name: Lint docs + run: python scripts/lint_docs.py docs/ + - name: Verify spec trace report up to date + # ^ tests/ 의 @pytest.mark.spec 변경 시 docs/traces/coverage.md 동기화 필수. + run: python scripts/generate_spec_trace.py --check diff --git a/docs/traces/coverage.md b/docs/traces/coverage.md new file mode 100644 index 0000000..2663519 --- /dev/null +++ b/docs/traces/coverage.md @@ -0,0 +1,7 @@ +# 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). + +(아직 매핑 없음. v0.4.0+ 부터 채워짐.) diff --git a/pyproject.toml b/pyproject.toml index c431075..ac106f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -127,6 +127,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/generate_spec_trace.py b/scripts/generate_spec_trace.py new file mode 100644 index 0000000..e5052d7 --- /dev/null +++ b/scripts/generate_spec_trace.py @@ -0,0 +1,116 @@ +#!/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.""" + 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) + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef): + if not node.name.startswith("test_"): + continue + for decorator in node.decorator_list: + spec_id = _extract_spec_id(decorator) + if spec_id and SPEC_ID_RE.match(spec_id): + mapping[spec_id].append(f"{rel}::{node.name}") + return mapping + + +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) From b4f474cb613ae27bab6e1e6a3682d3d20fcead55 Mon Sep 17 00:00:00 2001 From: DanMeon Date: Wed, 29 Apr 2026 12:22:52 +0900 Subject: [PATCH 12/30] =?UTF-8?q?feat:=20/new-spec=20Claude=20Code=20skill?= =?UTF-8?q?=20=E2=80=94=20=EC=83=88=20version=20spec=20=EC=8A=A4=EC=BA=90?= =?UTF-8?q?=ED=8F=B4=EB=93=9C=20=EC=9E=90=EB=8F=99=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 호출: /new-spec (예: /new-spec v0.4.0 view-renderer) 산출: - docs/roadmap//.md (frontmatter Draft + EARS placeholder) - docs/design//-research.md (페어 ADR) - docs/roadmap/README.md 의 활성 spec 인덱스 row 추가 - 작업 후 scripts/lint_docs.py docs/ 자동 실행 (무결성 검증) CONVENTIONS § 새 spec 추가 절차 자동화. disable-model-invocation: true — 사용자 명시 호출만 받음. 콘텐츠 결정 (실제 결정 / 인수조건 / 비목표) 은 사람의 판단으로 유지, skill 은 구조 일관성만 자동화. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/new-spec/SKILL.md | 130 +++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 .claude/skills/new-spec/SKILL.md diff --git a/.claude/skills/new-spec/SKILL.md b/.claude/skills/new-spec/SKILL.md new file mode 100644 index 0000000..e325f3c --- /dev/null +++ b/.claude/skills/new-spec/SKILL.md @@ -0,0 +1,130 @@ +--- +name: new-spec +description: Scaffold a new version spec and paired ADR following docs/CONVENTIONS.md +argument-hint: +arguments: + - version + - topic +disable-model-invocation: true +--- + +# /new-spec — 새 spec 스캐폴드 + +목적: `` (예: `v0.4.0`) + `` (예: `view-renderer`) 인자로 신규 per-version spec + 짝 페어 ADR + roadmap 인덱스 row 를 일괄 생성. + +## 산출물 + +다음 4가지를 한 번에 생성/갱신: + +1. **`docs/roadmap//.md`** — spec 본문 (frontmatter `status: Draft`, `target: `) +2. **`docs/design//-research.md`** — 짝 페어 ADR (frontmatter `status: Draft`, `target: `) +3. **`docs/roadmap/README.md`** — § 활성 spec 인덱스 표에 row 추가 +4. **§ 인수조건 placeholder** — EARS 5종 키워드 예시 (Ubiquitous / Event-Driven / State-Driven / Optional / Unwanted) + +## 작업 절차 + +본 skill 호출 시 모델은 다음 순서로 진행: + +1. **인자 검증** + - `version` 이 `vX.Y.Z` SemVer 형식인지 + - `topic` 이 kebab-case 인지 + - `docs/roadmap//.md` 가 이미 존재하면 abort (기존 spec 침해 방지) + +2. **CONVENTIONS.md 정독** — frontmatter schema / 명명 규칙 / cross-link 방향성 / EARS notation 섹션을 읽고 본 작업에 적용 + +3. **디렉토리 생성** (없으면) + - `docs/roadmap//` + - `docs/design//` + +4. **`docs/roadmap//.md` 작성** — 아래 템플릿: + + ```markdown + --- + status: Draft + target: + last_updated: <오늘 YYYY-MM-DD> + --- + + # + + <한 문단 요약 — 본 spec 이 무엇을 도입하고 왜 필요한지>. + + 주요 결정의 근거·대안·실패 시나리오는 짝 페어: [-research.md](../../design//-research.md). + + ## 결정 사항 + + | 항목 | 값 | 근거 | + |---|---|---| + | 1 | (placeholder) | (placeholder) | + + ## 인수조건 + + + + - **AC-1** (Ubiquitous) — `THE SHALL ` + - **AC-2** (Event-Driven) — `WHEN , THE SHALL ` + - **AC-3** (State-Driven) — `WHILE , THE SHALL ` + - **AC-4** (Optional) — `WHERE , THE SHALL ` + - **AC-5** (Unwanted) — `IF , THEN THE SHALL ` + + ## 영구 비목표 + + - (본 spec 의 범위에서 명시적으로 제외하는 항목) + + ## 참조 + + - 짝 페어 (ADR): [-research.md](../../design//-research.md) + ``` + +5. **`docs/design//-research.md` 작성** — 아래 템플릿: + + ```markdown + --- + status: Draft + target: + last_updated: <오늘 YYYY-MM-DD> + --- + + # — 설계 의사결정 리서치 요약 + + [/.md](../../roadmap//.md) §결정 사항 중 외부 독자가 "왜?" 를 던질 만한 N건의 업계 선례·대안·실패 시나리오를 기록한다. .md 본문이 최종 결정을 기술하고, 본 문서는 그 결정의 근거를 담는다. + + ## 결정 매트릭스 + + | # | 항목 | 옵션 비교 | 채택 | 1차 근거 | + |---|---|---|---|---| + | 1 | (placeholder) | A: ... / B: ... / C: ... | (?) | (?) | + + ## 1. <첫 결정 항목> + + ### 팩트 + ### 검증자 반박 + ### 최종 결정 + ### 1차 소스 + + ## 참조 + + - [roadmap//.md](../../roadmap//.md) — 본 리서치의 결정 요약 + ``` + +6. **`docs/roadmap/README.md` 의 § 활성 spec 인덱스 표에 row 추가** — 기존 표 마지막 줄 다음에: + + ```markdown + | () | Draft | [/.md](/.md) | [design//-research.md](../design//-research.md) | + ``` + +7. **무결성 검증** — 작업 후 `python3 scripts/lint_docs.py docs/` 실행. 위반 시 사용자에게 보고하고 수정 안내. + +## 규칙 (CONVENTIONS.md 준수) + +- frontmatter schema 정확 적용 (status enum / target SemVer / last_updated YYYY-MM-DD) +- spec ↔ research 페어 외 다른 spec 파일 직접 link **금지** (인덱스 경유) +- kebab-case 파일명, vX.Y.Z 디렉토리명 +- 상대경로 implicit (`./` prefix 금지) + +## 한계 + +- spec 본문은 placeholder — 실제 결정 / 인수조건 / 비목표는 사용자가 채움 +- design research 의 결정 매트릭스도 placeholder — N개 결정의 실제 비교는 사용자가 작성 + +본 skill 은 **구조 일관성** (frontmatter / 페어 / 인덱스 / EARS placeholder) 만 자동화. 콘텐츠 결정은 사람의 판단. From cacdf809bb3ff71011a29963316dd406ae3b1fba Mon Sep 17 00:00:00 2001 From: DanMeon Date: Wed, 29 Apr 2026 12:24:32 +0900 Subject: [PATCH 13/30] =?UTF-8?q?chore:=20last=5Fupdated=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EA=B0=B1=EC=8B=A0=20hook=20(Claude=20Code=20PostTo?= =?UTF-8?q?olUse)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Edit / Write / MultiEdit 시 docs/*.md 의 frontmatter last_updated 를 오늘 날짜로 in-place 갱신. CONVENTIONS § Status 메타데이터 의 자동화 정책 구현 — 수기 갱신 절차 폐기. skip 조건: - docs/ 외부 파일 - frontmatter 없는 파일 (Living: CONVENTIONS / roadmap/README / traces/coverage) - last_updated 가 이미 오늘 날짜 - status 가 Frozen 또는 Superseded — 본문 의미 변경 금지가 원칙. 면제 조항 활용 일괄 마이그 vs 오타·링크 fix 둘 다 가능 → 자동 처리는 위험, 사용자가 명시 결정 settings.json 의 PostToolUse hook 배열에 등록 — docs-lint 보다 먼저 실행되어 lint 가 갱신된 frontmatter 를 검증. 검증: 합성 Draft 파일 (last_updated: 2026-01-01) → 오늘 날짜로 갱신, 합성 Frozen 파일은 그대로 유지. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/hooks/update-last-updated.py | 77 ++++++++++++++++++++++++++++ .claude/settings.json | 4 ++ 2 files changed, 81 insertions(+) create mode 100644 .claude/hooks/update-last-updated.py 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" From 97319c893a5d784b062d81e9571b4cb9c01d13cd Mon Sep 17 00:00:00 2001 From: DanMeon Date: Wed, 29 Apr 2026 12:27:20 +0900 Subject: [PATCH 14/30] =?UTF-8?q?docs:=20CHANGELOG=20[Unreleased]=20+=20sp?= =?UTF-8?q?ec-system-overhaul=20=EB=AA=85=EC=84=B8=EC=84=9C=20=EC=9D=B4?= =?UTF-8?q?=EC=A3=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CHANGELOG.md [Unreleased] § Changed — 문서 시스템 대규모 개편 entry 작성. 사용자 facing API / wheel 영향 0 명시. 본 PR 의 8 변경 카테고리 + 부수 정리 (rename / 상류 #390) 모두 한 entry 로 압축. OVERHAUL_PLAN.md (루트) → docs/implementation/spec-system-overhaul.md (Frozen, vX.Y.Z 외부 직속 평면 — meta-level / cross-version 슬롯 활용, ga 필드 생략). 14개 결정 + 9 commit 단계 + a/b/c 옵션 비교 + invariant 정의 historical record 보존. PR 진행 중 발생한 사후 결정 (rename, RESOLVED in-place Frozen, typer 전환 등) 은 본 문서가 아닌 git log + commit message + CHANGELOG 가 보유 — frontmatter notice 로 명시. Cross-link 경로 보정 24건 (root → docs/implementation/ 이동에 따른 ../<...> / ../../<...> 변환): docs/* → ../*, CLAUDE/AGENTS → ../../ Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 15 + docs/implementation/spec-system-overhaul.md | 804 ++++++++++++++++++++ 2 files changed, 819 insertions(+) create mode 100644 docs/implementation/spec-system-overhaul.md diff --git a/CHANGELOG.md b/CHANGELOG.md index d2a39f1..84d7633 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ 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 자동 검증. +- `docs/upstream-pins.yaml` (Living) SSOT + `scripts/update_upstream_pin.py` (typer + pyyaml round-trip 안정) — `external/rhwp` 커밋 핀 자동 추출. CHANGELOG prose 와 yaml 어긋나면 yaml 이 SSOT. +- `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 의존성 정리 diff --git a/docs/implementation/spec-system-overhaul.md b/docs/implementation/spec-system-overhaul.md new file mode 100644 index 0000000..6be003a --- /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](../roadmap/phase-3.md), [phase-4.md](../roadmap/phase-4.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). + +--- + +본 명세서 끝. From e28d0cb8bd581c57ce71f3343b66ecdda4d22b4e Mon Sep 17 00:00:00 2001 From: DanMeon Date: Wed, 29 Apr 2026 12:47:32 +0900 Subject: [PATCH 15/30] =?UTF-8?q?ci(docs):=20uv=20pip=20install=20--system?= =?UTF-8?q?=20=E2=86=92=20uv=20run=20--with=20(PEP=20668=20=ED=9A=8C?= =?UTF-8?q?=ED=94=BC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ubuntu 24.04 의 system Python 이 externally-managed (PEP 668) 라 'uv pip install --system "typer>=0.12"' 가 거부됨 (exit 2). 해결: 'uv run --with "typer>=0.12" python ...' 로 ad-hoc 설치 — uv 가 임시 venv 자동 생성, lint 단발 호출이라 캐시 부담도 없음. 이전 commit baafe82 의 docs.yml 만 영향. ci.yml (build/test) 는 무관. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/docs.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 04c1d09..bafc357 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -42,10 +42,10 @@ jobs: - uses: astral-sh/setup-uv@v8.1.0 with: python-version: "3.12" - # ^ typer 만 필요 (testing 그룹에 포함). full all 그룹은 CI 빌드용 — lint 는 가벼움. - - run: uv pip install --system "typer>=0.12" + # ^ uv run --with 로 typer ad-hoc 설치 (ubuntu PEP 668 externally-managed 회피). + # full all 그룹은 CI 빌드용 — lint 는 가벼우니 uv 가 만든 임시 venv 면 충분. - name: Lint docs - run: python scripts/lint_docs.py docs/ + run: uv run --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: python scripts/generate_spec_trace.py --check + run: uv run --with "typer>=0.12" python scripts/generate_spec_trace.py --check From 3bb68efe96c171a95f23235c9272e06d6504a6f5 Mon Sep 17 00:00:00 2001 From: DanMeon Date: Wed, 29 Apr 2026 12:49:36 +0900 Subject: [PATCH 16/30] =?UTF-8?q?ci(docs):=20uv=20run=20--no-project=20?= =?UTF-8?q?=E2=80=94=20pyproject=20maturin=20build=20skip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이전 fix (uv run --with) 가 PEP 668 은 회피했으나 uv 가 pyproject.toml 의 [project] 를 보고 자동으로 maturin build_editable 을 시도. external/ rhwp submodule 미체크아웃 + Rust toolchain 미설치 → exit 1. 해결: --no-project 추가 → uv 가 pyproject 무시, 임시 venv 에 typer 만 설치. lint script 는 rhwp import 안 하므로 프로젝트 빌드 불필요. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/docs.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index bafc357..e6d6212 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -42,10 +42,10 @@ jobs: - uses: astral-sh/setup-uv@v8.1.0 with: python-version: "3.12" - # ^ uv run --with 로 typer ad-hoc 설치 (ubuntu PEP 668 externally-managed 회피). - # full all 그룹은 CI 빌드용 — lint 는 가벼우니 uv 가 만든 임시 venv 면 충분. + # ^ --no-project: pyproject.toml 의 maturin build (Rust + submodule 필요) skip. + # --with: typer 만 ad-hoc 설치 (lint 는 rhwp import 안 함, 가벼움). - name: Lint docs - run: uv run --with "typer>=0.12" python scripts/lint_docs.py 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 --with "typer>=0.12" python scripts/generate_spec_trace.py --check + run: uv run --no-project --with "typer>=0.12" python scripts/generate_spec_trace.py --check From 8df7be7b39f776a71fdb3b2ad22adf463bb2d8c4 Mon Sep 17 00:00:00 2001 From: DanMeon Date: Wed, 29 Apr 2026 12:52:12 +0900 Subject: [PATCH 17/30] =?UTF-8?q?fix(lint):=20external/=20=EB=94=94?= =?UTF-8?q?=EB=A0=89=ED=86=A0=EB=A6=AC=20link=20=EA=B2=80=EC=A6=9D=20skip?= =?UTF-8?q?=20=E2=80=94=20submodule=20=EC=9D=98=EC=A1=B4=EC=84=B1=20?= =?UTF-8?q?=EC=99=B8=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI 의 docs.yml 이 external/rhwp submodule 체크아웃 안 해도 lint 통과 가능. 외부 의존성 (submodule 안 파일) 추적은 본 lint 의 책임 밖 — docs/upstream-pins.yaml 이 SSOT. 방식: validate_broken_link 가 link 의 resolved path 가 repo/external/ 하위면 skip. repo 외부 절대경로 (relative_to 실패) 도 skip. 영향 케이스: docs/design/v0.2.0/ir-research.md L105 의 "../../../external/rhwp/CLAUDE.md" 참조 (rhwp 코어의 waterfall 워크플로우 설명 link). Frozen body 라 link 제거 대신 lint 측 수용. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/_doc_lint.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scripts/_doc_lint.py b/scripts/_doc_lint.py index d1c4538..fe918fe 100644 --- a/scripts/_doc_lint.py +++ b/scripts/_doc_lint.py @@ -256,6 +256,9 @@ def validate_cross_link(rel_str: str, text: str) -> list[str]: # * 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] = [] @@ -264,6 +267,13 @@ def validate_broken_link(rel_str: str, text: str, repo: Path) -> list[str]: 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 From 8bc5172d2d39778ddbec30e9438a524acb507af4 Mon Sep 17 00:00:00 2001 From: DanMeon Date: Wed, 29 Apr 2026 13:08:54 +0900 Subject: [PATCH 18/30] =?UTF-8?q?fix(lint+scripts):=20code-reviewer=207?= =?UTF-8?q?=EA=B0=9C=20=EC=9D=B4=EC=8A=88=20=EC=9D=BC=EA=B4=84=20fix=20+?= =?UTF-8?q?=20.gitignore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 검증 에이전트 보고 7건 모두 재검증 후 CONFIRMED → 적용. scripts/_doc_lint.py: - Major: validate_broken_link / validate_cross_link 의 code-fence false positive — _strip_code() helper 추가 (```...``` + 인라인 백틱 제거). 본 fix 없이는 향후 spec 의 예시 link 가 violation 으로 오인식 - Minor: parse_frontmatter 가 trailing inline comment ('foo: bar # x') 를 value 로 흡수 → quote 시작 아닌 경우 ' #' 로 split - Minor: _validate_supersede_chain 의 path depth 가정 (vX.Y.Z 2-level 하드코딩) → _supersede_base() 도입, meta-level 1-level 평면도 지원 scripts/generate_spec_trace.py: - Minor: ast.walk 가 class 컨텍스트 평탄화 → class TestFoo: def test_bar 의 nodeid 가 ::test_bar 만 출력. _SpecMarkerVisitor (NodeVisitor + class_stack) 로 정확히 ::TestFoo::test_bar 출력 scripts/update_upstream_pin.py: - Minor: _previous_pin 의 SemVer 매치 실패 시 (0,0,0) silent fallback (전역 fail-fast 정책 위반) → ValueError raise .github/workflows/docs.yml: - Minor: paths 의 '**.md' 가 lint 무관 .md (README/CHANGELOG/PR template 등) 변경에도 fire — 'docs/**' 가 이미 docs 하위 .md 포괄하므로 제거 .gitignore: - .claude/scheduled_tasks.lock 추가 — ScheduleWakeup hook 의 PID/세션ID lock 파일 (런타임 머신-로컬, repo 추적 무의미) 검증: 합성 케이스 4종 (fence false positive / class method / 잘못된 pin key / meta-level supersede) 모두 expected behavior. 기존 lint 0 violation 유지. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/docs.yml | 4 +-- .gitignore | 2 ++ scripts/_doc_lint.py | 53 ++++++++++++++++++++++++++-------- scripts/generate_spec_trace.py | 46 +++++++++++++++++++++++------ scripts/update_upstream_pin.py | 4 ++- 5 files changed, 85 insertions(+), 24 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e6d6212..404ddd3 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -4,8 +4,9 @@ name: Docs lint on: push: branches: [main] + # ^ 'docs/**' 가 docs 안 모든 .md 포괄. README/CHANGELOG 등 lint 무관 .md + # 변경에 fire 안 하려고 '**.md' 제외. tests/** 는 spec-trace 검증 때문에 유지. paths: - - '**.md' - 'docs/**' - 'tests/**' - 'scripts/_doc_lint.py' @@ -16,7 +17,6 @@ on: pull_request: branches: [main] paths: - - '**.md' - 'docs/**' - 'tests/**' - 'scripts/_doc_lint.py' 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/scripts/_doc_lint.py b/scripts/_doc_lint.py index fe918fe..3d0a54f 100644 --- a/scripts/_doc_lint.py +++ b/scripts/_doc_lint.py @@ -66,6 +66,10 @@ def parse_frontmatter(text: str) -> dict[str, str] | None: 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] @@ -73,6 +77,16 @@ def parse_frontmatter(text: str) -> dict[str, str] | None: 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] = [] @@ -138,8 +152,12 @@ def _validate_supersede_chain(rel_str: str, meta: dict[str, str], repo: Path) -> 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 = rel.parent.parent / superseded_by + target_rel = base / superseded_by target = repo / target_rel if not target.exists(): errors.append( @@ -151,18 +169,16 @@ def _validate_supersede_chain(rel_str: str, meta: dict[str, str], repo: Path) -> errors.append( f"frontmatter: superseded_by target {superseded_by!r} lacks frontmatter" ) - else: - expected = str(rel.relative_to(rel.parent.parent)) - if 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}" - ) + 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 = rel.parent.parent / supersedes + target_rel = base / supersedes if not (repo / target_rel).exists(): errors.append( f"frontmatter: supersedes {supersedes!r} not found (resolved: {target_rel})" @@ -171,6 +187,19 @@ def _validate_supersede_chain(rel_str: str, meta: dict[str, str], repo: Path) -> 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] = [] @@ -242,7 +271,7 @@ def validate_cross_link(rel_str: str, text: str) -> list[str]: self_link = f"{base}.md" errors: list[str] = [] - for link in re.findall(r"\]\(([^)]+\.md)[^)]*\)", text): + for link in re.findall(r"\]\(([^)]+\.md)[^)]*\)", _strip_code(text)): link_target = link.split("#")[0] if "/" in link_target: continue @@ -262,7 +291,7 @@ def validate_cross_link(rel_str: str, text: str) -> list[str]: 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)[^)]*\)", text): + 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 diff --git a/scripts/generate_spec_trace.py b/scripts/generate_spec_trace.py index e5052d7..6e6f3de 100644 --- a/scripts/generate_spec_trace.py +++ b/scripts/generate_spec_trace.py @@ -56,7 +56,8 @@ def main( def _collect_spec_markers(tests_dir: Path) -> dict[str, list[str]]: - """spec_id → list of pytest nodeids.""" + """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 @@ -67,17 +68,44 @@ def _collect_spec_markers(tests_dir: Path) -> dict[str, list[str]]: except SyntaxError: continue rel = py_file.relative_to(REPO) - for node in ast.walk(tree): - if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef): - if not node.name.startswith("test_"): - continue - for decorator in node.decorator_list: - spec_id = _extract_spec_id(decorator) - if spec_id and SPEC_ID_RE.match(spec_id): - mapping[spec_id].append(f"{rel}::{node.name}") + 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 을 유지하며 @pytest.mark.spec(...) 추출.""" + + def __init__(self, file_rel: Path) -> None: + self.file_rel = file_rel + self.class_stack: list[str] = [] + self.mapping: dict[str, list[str]] = defaultdict(list) + + 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 + for decorator in node.decorator_list: + spec_id = _extract_spec_id(decorator) + if spec_id and SPEC_ID_RE.match(spec_id): + parts = [*self.class_stack, node.name] + self.mapping[spec_id].append(f"{self.file_rel}::{'::'.join(parts)}") + + def _extract_spec_id(node: ast.AST) -> str | None: """``@pytest.mark.spec("vX.Y.Z/...")`` 또는 ``@mark.spec("...")`` 매칭.""" if not isinstance(node, ast.Call): diff --git a/scripts/update_upstream_pin.py b/scripts/update_upstream_pin.py index f1c660f..a2edee9 100644 --- a/scripts/update_upstream_pin.py +++ b/scripts/update_upstream_pin.py @@ -74,7 +74,9 @@ def _previous_pin(pins: dict, current_version: str) -> dict | None: def key(v: str) -> tuple[int, int, int]: m = re.match(r"v(\d+)\.(\d+)\.(\d+)", v) - return (int(m.group(1)), int(m.group(2)), int(m.group(3))) if m else (0, 0, 0) + if not m: + raise ValueError(f"malformed pin key {v!r} — expected vX.Y.Z") + return (int(m.group(1)), int(m.group(2)), int(m.group(3))) target = key(current_version) candidates = sorted( From ea4870b96652ded9a4272d57ef6d190d34170355 Mon Sep 17 00:00:00 2001 From: DanMeon Date: Wed, 29 Apr 2026 13:10:59 +0900 Subject: [PATCH 19/30] =?UTF-8?q?docs(skill):=20SKILL.md=20=EB=B3=B8?= =?UTF-8?q?=EB=AC=B8=20=ED=95=9C=EA=B5=AD=EC=96=B4=20=E2=86=92=20=EC=98=81?= =?UTF-8?q?=EB=AC=B8=20(LLM-facing=20=EC=A0=95=EC=B1=85=20=EC=A0=95?= =?UTF-8?q?=ED=95=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 전역 CLAUDE.md 정책: "Respond in Korean, except for code, technical terms, and LLM-facing text (CLAUDE.md, prompts, rules → English)". SKILL.md 는 Claude Code 가 /new-spec 호출 시 읽고 행동하는 prompt/rule 이라 영문이 정합. 변경: - procedure / rules / limits 섹션 prose: 영문화 - 템플릿 안의 한국어 placeholder hint (<...>): 영문 directive 로 (Claude 에 대한 지시이므로) - 템플릿 안의 *한국어 섹션 헤더* (## 결정 사항 / ## 인수조건 / 영구 비목표 등): 그대로 유지 — 생성된 문서가 한국어로 작성되어야 하므로 출력 형식의 일부 Lint 호출도 실제 동작하는 명령으로 갱신 (uv run --no-project --with). Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/new-spec/SKILL.md | 73 +++++++++++++++----------------- 1 file changed, 35 insertions(+), 38 deletions(-) diff --git a/.claude/skills/new-spec/SKILL.md b/.claude/skills/new-spec/SKILL.md index e325f3c..28ec1d8 100644 --- a/.claude/skills/new-spec/SKILL.md +++ b/.claude/skills/new-spec/SKILL.md @@ -8,46 +8,44 @@ arguments: disable-model-invocation: true --- -# /new-spec — 새 spec 스캐폴드 +# /new-spec — scaffold a new version spec -목적: `` (예: `v0.4.0`) + `` (예: `view-renderer`) 인자로 신규 per-version spec + 짝 페어 ADR + roadmap 인덱스 row 를 일괄 생성. +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 -다음 4가지를 한 번에 생성/갱신: +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 -1. **`docs/roadmap//.md`** — spec 본문 (frontmatter `status: Draft`, `target: `) -2. **`docs/design//-research.md`** — 짝 페어 ADR (frontmatter `status: Draft`, `target: `) -3. **`docs/roadmap/README.md`** — § 활성 spec 인덱스 표에 row 추가 -4. **§ 인수조건 placeholder** — EARS 5종 키워드 예시 (Ubiquitous / Event-Driven / State-Driven / Optional / Unwanted) +## Procedure -## 작업 절차 +When this skill is invoked, execute the following steps in order: -본 skill 호출 시 모델은 다음 순서로 진행: +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 -1. **인자 검증** - - `version` 이 `vX.Y.Z` SemVer 형식인지 - - `topic` 이 kebab-case 인지 - - `docs/roadmap//.md` 가 이미 존재하면 abort (기존 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. -2. **CONVENTIONS.md 정독** — frontmatter schema / 명명 규칙 / cross-link 방향성 / EARS notation 섹션을 읽고 본 작업에 적용 - -3. **디렉토리 생성** (없으면) +3. **Create directories** if missing: - `docs/roadmap//` - `docs/design//` -4. **`docs/roadmap//.md` 작성** — 아래 템플릿: +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: <오늘 YYYY-MM-DD> + last_updated: --- - # + # - <한 문단 요약 — 본 spec 이 무엇을 도입하고 왜 필요한지>. + . 주요 결정의 근거·대안·실패 시나리오는 짝 페어: [-research.md](../../design//-research.md). @@ -59,7 +57,8 @@ disable-model-invocation: true ## 인수조건 - + - **AC-1** (Ubiquitous) — `THE SHALL ` - **AC-2** (Event-Driven) — `WHEN , THE SHALL ` @@ -69,20 +68,20 @@ disable-model-invocation: true ## 영구 비목표 - - (본 spec 의 범위에서 명시적으로 제외하는 항목) + - ## 참조 - 짝 페어 (ADR): [-research.md](../../design//-research.md) ``` -5. **`docs/design//-research.md` 작성** — 아래 템플릿: +5. **Write `docs/design//-research.md`** using this template: ```markdown --- status: Draft target: - last_updated: <오늘 YYYY-MM-DD> + last_updated: --- # — 설계 의사결정 리서치 요약 @@ -95,7 +94,7 @@ disable-model-invocation: true |---|---|---|---|---| | 1 | (placeholder) | A: ... / B: ... / C: ... | (?) | (?) | - ## 1. <첫 결정 항목> + ## 1. ### 팩트 ### 검증자 반박 @@ -107,24 +106,22 @@ disable-model-invocation: true - [roadmap//.md](../../roadmap//.md) — 본 리서치의 결정 요약 ``` -6. **`docs/roadmap/README.md` 의 § 활성 spec 인덱스 표에 row 추가** — 기존 표 마지막 줄 다음에: +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. **무결성 검증** — 작업 후 `python3 scripts/lint_docs.py docs/` 실행. 위반 시 사용자에게 보고하고 수정 안내. - -## 규칙 (CONVENTIONS.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. -- frontmatter schema 정확 적용 (status enum / target SemVer / last_updated YYYY-MM-DD) -- spec ↔ research 페어 외 다른 spec 파일 직접 link **금지** (인덱스 경유) -- kebab-case 파일명, vX.Y.Z 디렉토리명 -- 상대경로 implicit (`./` prefix 금지) +## 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 -- spec 본문은 placeholder — 실제 결정 / 인수조건 / 비목표는 사용자가 채움 -- design research 의 결정 매트릭스도 placeholder — N개 결정의 실제 비교는 사용자가 작성 +## Limits -본 skill 은 **구조 일관성** (frontmatter / 페어 / 인덱스 / EARS placeholder) 만 자동화. 콘텐츠 결정은 사람의 판단. +- 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. From 57f8be34158b9dca81ffd124c8f59a7dec6f3745 Mon Sep 17 00:00:00 2001 From: DanMeon Date: Wed, 29 Apr 2026 13:16:29 +0900 Subject: [PATCH 20/30] =?UTF-8?q?docs(contributing):=20path-based=20onboar?= =?UTF-8?q?ding=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 25줄 setup-only → 60줄 path-based onboarding 가이드. 핵심 추가: - TL;DR quick start (4 commands) — 30초 안에 빌드/테스트 가능 여부 확인 - "Pick your contribution path" 표 — 5가지 기여 유형별 (버그픽스 / 기능 / Rust 코어 / 문서 / 새 spec) "추가로 읽을 문서 + 주의점" 매트릭스 - 90%+ PR (버그픽스) 은 본 문서 외 추가 reading 0 명시 — spec 시스템 정책 부담 회피 - 새 spec 작성 path 에서 /new-spec skill + CONVENTIONS § 새 spec 추가 절차 안내 - pre-submit checklist 에 docs/ lint 명령 추가 - 브랜치 네이밍 (PATCH vs MINOR) + Conventional Commits + PR 템플릿 명시 - "Where to learn more" 섹션 — AGENTS.md / CONVENTIONS.md / roadmap README / upstream-pins.yaml 4개 링크 이전: setup 명령만, 어떤 PR 이 어떤 정책 알아야 하는지 가이드 0 이후: path-based — 외부 기여자가 자기 case 빠르게 식별 가능 Co-Authored-By: Claude Opus 4.7 (1M context) --- CONTRIBUTING.md | 62 ++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 48 insertions(+), 14 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3284299..07a375e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,25 +1,59 @@ # Contributing to rhwp-python -Thanks for your interest in contributing! AI-assisted contributions (issue creation, coding, reviews) are welcome. +Thanks for your interest! AI-assisted contributions (issue creation, coding, reviews) are welcome. -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. +## TL;DR — quick start -## Before You Submit +```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" +``` -- `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 +If those four commands succeed, you're ready to make changes. Branch off `main`, commit (Conventional Commits), push, open a PR. -## Code Style +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 +## 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 -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 +- **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**: [docs/upstream-pins.yaml](docs/upstream-pins.yaml) — which `external/rhwp` commit each release uses From 34e9e6bd1c6d99d7bb176add093bd09eab7c0a31 Mon Sep 17 00:00:00 2001 From: DanMeon Date: Wed, 29 Apr 2026 13:19:18 +0900 Subject: [PATCH 21/30] =?UTF-8?q?docs(contributing):=20=ED=95=9C=EA=B8=80?= =?UTF-8?q?=20primary=20+=20CONTRIBUTING=5FEN=20=EB=B6=84=EB=A6=AC=20(READ?= =?UTF-8?q?ME=20=ED=8C=A8=ED=84=B4=20=EC=A0=95=ED=95=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 README.md / README_EN.md 와 동일한 i18n 패턴 적용: - CONTRIBUTING.md → 한글 primary (헤더에 'English' link) - CONTRIBUTING_EN.md → 영문 (헤더에 '한국어' link), git mv 로 history 보존 링크 정정: - README_EN.md 에 CONTRIBUTING_EN.md 참조 추가 (기존 누락) - AGENTS.md 의 contributor flow 링크 → CONTRIBUTING_EN.md primary (한글 link 도 보조로 명시) - README.md 의 한글 CONTRIBUTING.md 참조는 그대로 (이미 정확) Co-Authored-By: Claude Opus 4.7 (1M context) --- AGENTS.md | 2 +- CONTRIBUTING.md | 68 ++++++++++++++++++++++++---------------------- CONTRIBUTING_EN.md | 61 +++++++++++++++++++++++++++++++++++++++++ README_EN.md | 2 ++ 4 files changed, 99 insertions(+), 34 deletions(-) create mode 100644 CONTRIBUTING_EN.md diff --git a/AGENTS.md b/AGENTS.md index f422cbb..6792d3e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,7 @@ Project-specific instructions. Inherits all rules from `~/.claude/CLAUDE.md` (gl - 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) +- 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 07a375e..2b5c47d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,8 +1,10 @@ -# Contributing to rhwp-python +# rhwp-python 기여 가이드 -Thanks for your interest! AI-assisted contributions (issue creation, coding, reviews) are welcome. +**한국어** | [**English**](CONTRIBUTING_EN.md) -## TL;DR — quick start +기여에 관심 가져주셔서 감사합니다. AI 기반 기여 (이슈 작성 / 코딩 / 리뷰) 도 환영합니다. + +## TL;DR — 빠른 시작 ```bash git clone --recurse-submodules https://github.com/DanMeon/rhwp-python.git @@ -12,48 +14,48 @@ 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. +위 4 명령이 모두 통과하면 작업 시작 준비 완료. `main` 에서 분기 → Conventional Commits 으로 commit → push → PR. -Already cloned without submodules? Run `git submodule update --init --recursive`. +submodule 없이 클론한 경우: `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 추가 절차 | +| 버그 수정 / 테스트 추가 | — | 가장 흔한 경로 — `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 추가 절차 따라 수기 작성 | -90%+ of PRs are the first row — you don't need to read the spec system policy. +PR 의 90% 이상은 첫 행 (버그 수정 / 테스트) — spec 시스템 정책을 읽을 필요 없음. -## Pre-submit checklist +## 제출 전 체크리스트 -All of these run in CI; running locally first saves a round trip: +모두 CI 에서 실행되지만, 로컬 선실행이 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`) +- `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 과 동일) -## 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 +- Python 3.10+, `T | None` (`Optional[T]` 금지), PEP 561 typed +- Rust 1.83+ (PyO3 0.28 MSRV). 바인딩 레이어에 새 `unsafe` 추가 금지 -## Pull requests +## Pull Request -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`) +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 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 +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 — 본 리포는 바인딩만 추가 -## 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**: [docs/upstream-pins.yaml](docs/upstream-pins.yaml) — which `external/rhwp` commit each release uses +- **프로젝트 규칙 + 아키텍처**: [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**: [docs/upstream-pins.yaml](docs/upstream-pins.yaml) — 각 릴리스가 사용하는 `external/rhwp` commit diff --git a/CONTRIBUTING_EN.md b/CONTRIBUTING_EN.md new file mode 100644 index 0000000..c42735d --- /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**: [docs/upstream-pins.yaml](docs/upstream-pins.yaml) — which `external/rhwp` commit each release uses 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**. From ea2377fdb495ea29064589c6a1f3477cdaa0e5e0 Mon Sep 17 00:00:00 2001 From: DanMeon Date: Wed, 29 Apr 2026 13:26:50 +0900 Subject: [PATCH 22/30] =?UTF-8?q?revert:=20docs/upstream-pins.yaml=20+=20u?= =?UTF-8?q?pdate=5Fupstream=5Fpin.py=20=EC=A0=9C=EA=B1=B0=20(YAGNI)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 본 PR 진행 중 도입했던 yaml SSOT 가 사실상 중복 — 같은 정보가 (a) git submodule 자체 ('git ls-tree external/rhwp'), (b) CHANGELOG 산문 노트, (c) yaml 3 곳에 존재. 자동화 의도 (release notes 생성 등) 가 있긴 하나, 정작 yaml 을 *읽는* consumer 가 0개 — 쓰기만 하고 읽는 데 없음. YAGNI 적용: 자동화가 실재할 때 그 시점에 *실제 필요한 형태* 로 도입이 정확. 미리 만들면 (1) stale 위험 (매 릴리스마다 수기 갱신 부담), (2) 3 SSOT 충돌 시 우선순위 미정의, (3) 미래 자동화가 원하는 schema 와 안 맞을 가능성. 삭제 대상: - docs/upstream-pins.yaml - scripts/update_upstream_pin.py - pyproject.toml [dependency-groups] dev 의 pyyaml (sole consumer) - uv.lock 갱신 문서 정리: - docs/CONVENTIONS.md: § 디렉토리별 정책 의 tree 다이어그램에서 한 줄 제거 - AGENTS.md (= CLAUDE.md symlink): "SSOT: docs/upstream-pins.yaml" → "git ls-tree external/rhwp" 로 정정 - CONTRIBUTING.md / CONTRIBUTING_EN.md: '더 읽을 자료' 섹션의 yaml link → git submodule + CHANGELOG 안내로 변경 - CHANGELOG.md [Unreleased]: 본 entry 한 줄 제거 docs/implementation/spec-system-overhaul.md (Frozen) 의 D14 / Commit 5 섹션은 변경 없음 — historical record. 본 commit message + git log 가 "D14 를 도입했다가 YAGNI 로 철회" 의 SSOT. Co-Authored-By: Claude Opus 4.7 (1M context) --- AGENTS.md | 2 +- CHANGELOG.md | 1 - CONTRIBUTING.md | 2 +- CONTRIBUTING_EN.md | 2 +- docs/CONVENTIONS.md | 1 - docs/upstream-pins.yaml | 25 ------- pyproject.toml | 3 +- scripts/update_upstream_pin.py | 121 --------------------------------- uv.lock | 12 +--- 9 files changed, 5 insertions(+), 164 deletions(-) delete mode 100644 docs/upstream-pins.yaml delete mode 100644 scripts/update_upstream_pin.py diff --git a/AGENTS.md b/AGENTS.md index 6792d3e..b3771ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,7 +48,7 @@ Project-specific instructions. Inherits all rules from `~/.claude/CLAUDE.md` (gl - **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 (SSOT: `docs/upstream-pins.yaml`) +- 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 84d7633..a763be3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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 자동 검증. -- `docs/upstream-pins.yaml` (Living) SSOT + `scripts/update_upstream_pin.py` (typer + pyyaml round-trip 안정) — `external/rhwp` 커밋 핀 자동 추출. CHANGELOG prose 와 yaml 어긋나면 yaml 이 SSOT. - `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 일괄 정정). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2b5c47d..bbf5f65 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,4 +58,4 @@ PR 의 90% 이상은 첫 행 (버그 수정 / 테스트) — spec 시스템 정 - **프로젝트 규칙 + 아키텍처**: [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**: [docs/upstream-pins.yaml](docs/upstream-pins.yaml) — 각 릴리스가 사용하는 `external/rhwp` commit +- **상류 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 index c42735d..21a02df 100644 --- a/CONTRIBUTING_EN.md +++ b/CONTRIBUTING_EN.md @@ -58,4 +58,4 @@ All of these run in CI; running locally first saves a round trip: - **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**: [docs/upstream-pins.yaml](docs/upstream-pins.yaml) — which `external/rhwp` commit each release uses +- **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/docs/CONVENTIONS.md b/docs/CONVENTIONS.md index 4651c7a..d886e18 100644 --- a/docs/CONVENTIONS.md +++ b/docs/CONVENTIONS.md @@ -140,7 +140,6 @@ docs/ │ └── coverage.md Living — spec ↔ test 자동 매핑 ├── upstream/ │ └── .md Active — 외부 (rhwp Rust 코어) 이슈 초안. 업스트림 머지 시 archive -├── upstream-pins.yaml Living — external/rhwp 커밋 핀 SSOT └── verification/ └── v/... Frozen — 큰 단위 작업 검증 리포트 (한정) ``` diff --git a/docs/upstream-pins.yaml b/docs/upstream-pins.yaml deleted file mode 100644 index a725f2b..0000000 --- a/docs/upstream-pins.yaml +++ /dev/null @@ -1,25 +0,0 @@ -# external/rhwp 커밋 핀 SSOT — Living. -# 각 vX.Y.Z 릴리스가 의존하는 상류 (edwardkim/rhwp) 커밋 hash 와 갱신 메타. -# 갱신: scripts/update_upstream_pin.py vX.Y.Z (릴리스 직전 / pin lock 시점). -# CHANGELOG.md 가 본 파일 값을 prose 로 인용 — 둘이 어긋나면 본 파일이 SSOT. - -pins: - v0.1.0: - upstream_commit: '1636213' - bumped_at: 2026-04-22 - note: 초판 — edwardkim/rhwp main HEAD as of 2026-04-22 (full hash 163621382ba13be233b155df050375e900a038e2). - v0.1.1: - upstream_commit: '1636213' - bumped_at: 2026-04-23 - note: Patch release — sdist packaging fix only. submodule pin 무변경 (v0.1.0 동일). - v0.2.0: - upstream_commit: bea635b - previous_commit: '1636213' - bumped_at: 2026-04-25 - note: Document IR v1 GA. v0.1.0 → v0.2.0 사이 상류 변경은 docs (매뉴얼 현행화 등) 만 — 코드 동작 변화 없음. - v0.3.0: - upstream_commit: 033617e - previous_commit: bea635b - commits_integrated: 380 - bumped_at: 2026-04-28 - note: IR 확장 + rhwp-py CLI GA. upstream v0.6.x → v0.7.7 흡수 (TypesetEngine pagination drift 정정, TAC 표/그림 좌표 통합 수정, export text/markdown 추가, v0.7.6/v0.7.7 흡수). diff --git a/pyproject.toml b/pyproject.toml index ac106f8..107aae4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,8 +73,7 @@ examples = [ rhwp-py = "rhwp.cli:app" [dependency-groups] -# ^ pyyaml: scripts/update_upstream_pin.py 의 round-trip (load + modify + dump) -dev = ["maturin>=1.7", "pyyaml>=6"] +dev = ["maturin>=1.7"] testing = [ {include-group = "dev"}, "pytest>=8", diff --git a/scripts/update_upstream_pin.py b/scripts/update_upstream_pin.py deleted file mode 100644 index a2edee9..0000000 --- a/scripts/update_upstream_pin.py +++ /dev/null @@ -1,121 +0,0 @@ -#!/usr/bin/env python3 -"""external/rhwp 의 현재 commit hash 를 docs/upstream-pins.yaml 에 기록. - -사용: - uv run python scripts/update_upstream_pin.py vX.Y.Z [--note "..."] - -동작: - 1. external/rhwp 에서 git rev-parse --short HEAD 추출 - 2. 직전 entry 의 upstream_commit 을 previous_commit 으로 설정 - 3. previous..current 사이 commit 수 계산 (commits_integrated) - 4. docs/upstream-pins.yaml 의 pins[vX.Y.Z] 갱신 또는 신규 추가 - 5. bumped_at 은 오늘 날짜 - -릴리스 직전 / pin lock 시점에 작업자가 호출. 자동화 (예: release workflow) -는 의도적 미적용 — 핀 결정은 사람의 판단 (어느 commit 까지 흡수할지) 이라 -수기 한 단계 둠. -""" - -import re -import subprocess -from datetime import date -from pathlib import Path - -import typer -import yaml - -REPO = Path(__file__).resolve().parent.parent -PINS_FILE = REPO / "docs" / "upstream-pins.yaml" -UPSTREAM_DIR = REPO / "external" / "rhwp" - - -def main( - version: str = typer.Argument(..., help="릴리스 버전 (vX.Y.Z)"), - note: str = typer.Option("", help="갱신 사유 한 줄 (선택)"), -) -> None: - if not re.fullmatch(r"v\d+\.\d+\.\d+", version): - typer.echo(f"error: version must be vX.Y.Z (got {version!r})", err=True) - raise typer.Exit(1) - - if not UPSTREAM_DIR.is_dir(): - typer.echo(f"error: {UPSTREAM_DIR} not found", err=True) - raise typer.Exit(1) - - current = subprocess.check_output( - ["git", "rev-parse", "--short", "HEAD"], - cwd=UPSTREAM_DIR, - text=True, - ).strip() - - data = yaml.safe_load(PINS_FILE.read_text(encoding="utf-8")) or {} - pins = data.setdefault("pins", {}) - - previous_entry = _previous_pin(pins, version) - # ^ 표준 field 순서: upstream_commit / previous_commit / commits_integrated / - # bumped_at / note. 조건부 필드는 건너뛰되 나머지 순서 유지 → round-trip 안정. - entry: dict[str, object] = {"upstream_commit": current} - if previous_entry: - prev_commit = previous_entry["upstream_commit"] - if prev_commit != current: - entry["previous_commit"] = prev_commit - entry["commits_integrated"] = _count_commits(prev_commit, current) - # ^ date 객체 그대로 dump → YAML native date (unquoted), round-trip 안정 - entry["bumped_at"] = date.today() - if note: - entry["note"] = note - - pins[version] = entry - _write_yaml(PINS_FILE, data) - typer.echo(f"updated {PINS_FILE.relative_to(REPO)} — {version}: {current}") - - -def _previous_pin(pins: dict, current_version: str) -> dict | None: - """SemVer 정렬에서 current_version 직전 entry 반환. 없으면 None.""" - - def key(v: str) -> tuple[int, int, int]: - m = re.match(r"v(\d+)\.(\d+)\.(\d+)", v) - if not m: - raise ValueError(f"malformed pin key {v!r} — expected vX.Y.Z") - return (int(m.group(1)), int(m.group(2)), int(m.group(3))) - - target = key(current_version) - candidates = sorted( - ((k, v) for k, v in pins.items() if key(k) < target), - key=lambda kv: key(kv[0]), - ) - return candidates[-1][1] if candidates else None - - -def _count_commits(prev: str, curr: str) -> int: - return len( - subprocess.check_output( - ["git", "log", "--oneline", f"{prev}..{curr}"], - cwd=UPSTREAM_DIR, - text=True, - ) - .strip() - .splitlines() - ) - - -def _write_yaml(path: Path, data: dict) -> None: - # ^ default_flow_style=False → block style (사람 가독), allow_unicode → 한글 보존, - # width 매우 크게 → 긴 한글 note 줄바꿈 방지 (CI diff 안정). - body = yaml.dump( - data, - default_flow_style=False, - allow_unicode=True, - sort_keys=False, - width=10000, - ) - header = ( - "# external/rhwp 커밋 핀 SSOT — Living.\n" - "# 각 vX.Y.Z 릴리스가 의존하는 상류 (edwardkim/rhwp) 커밋 hash 와 갱신 메타.\n" - "# 갱신: scripts/update_upstream_pin.py vX.Y.Z (릴리스 직전 / pin lock 시점).\n" - "# CHANGELOG.md 가 본 파일 값을 prose 로 인용 — 둘이 어긋나면 본 파일이 SSOT.\n\n" - ) - path.write_text(header + body, encoding="utf-8") - - -if __name__ == "__main__": - typer.run(main) diff --git a/uv.lock b/uv.lock index d09af26..82e3809 100644 --- a/uv.lock +++ b/uv.lock @@ -940,18 +940,15 @@ all = [ { name = "pyright" }, { name = "pytest" }, { name = "pytest-cov" }, - { name = "pyyaml" }, { name = "ruff" }, { name = "typer" }, ] dev = [ { name = "maturin" }, - { name = "pyyaml" }, ] linting = [ { name = "maturin" }, { name = "pyright" }, - { name = "pyyaml" }, { name = "ruff" }, ] testing = [ @@ -961,7 +958,6 @@ testing = [ { name = "maturin" }, { name = "pytest" }, { name = "pytest-cov" }, - { name = "pyyaml" }, { name = "typer" }, ] @@ -989,18 +985,13 @@ all = [ { name = "pyright" }, { name = "pytest", specifier = ">=8" }, { name = "pytest-cov" }, - { name = "pyyaml", specifier = ">=6" }, { name = "ruff" }, { name = "typer", specifier = ">=0.12" }, ] -dev = [ - { name = "maturin", specifier = ">=1.7" }, - { name = "pyyaml", specifier = ">=6" }, -] +dev = [{ name = "maturin", specifier = ">=1.7" }] linting = [ { name = "maturin", specifier = ">=1.7" }, { name = "pyright" }, - { name = "pyyaml", specifier = ">=6" }, { name = "ruff" }, ] testing = [ @@ -1010,7 +1001,6 @@ testing = [ { name = "maturin", specifier = ">=1.7" }, { name = "pytest", specifier = ">=8" }, { name = "pytest-cov" }, - { name = "pyyaml", specifier = ">=6" }, { name = "typer", specifier = ">=0.12" }, ] From 189457ceaef782f2d56273cb01a3d569b3beb787 Mon Sep 17 00:00:00 2001 From: DanMeon Date: Wed, 29 Apr 2026 13:47:29 +0900 Subject: [PATCH 23/30] =?UTF-8?q?feat(spec-trace):=20EARS=20strict=20?= =?UTF-8?q?=EC=99=84=ED=99=94=20+=20=EA=B8=B0=EC=A1=B4=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20soft=20retrofit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EARS 5 패턴 강제는 본 프로젝트 규모 (월 1회 spec, Python binding) 에 과 ceremony — 가치 (모호성 감소) 대비 비용 (패턴 선택 + 키워드) 큼. 핵심 이득 (AC-N ID 로 trace 매핑) 만 유지하고 EARS 는 optional reference 로 완화. CONVENTIONS.md § 인수조건 형식: - "EARS 5 패턴 으로 작성한다" → "AC-N ID 부여, 형식은 자유 (testable + 명확하면 OK), EARS 패턴 참고 가능 (강제 X)" - 기존 v0.1.0 ~ v0.3.0 spec 도 trace 는 매핑됨 (spec 단위, AC-N 생략) CONVENTIONS.md § Trace report: - v0.4.0+ → "vX.Y.Z/topic#AC-N" (full) - v0.1.0~v0.3.0 → "vX.Y.Z/topic" (soft, AC 생략) — 일관성 확보 - 파일 단위: 'pytestmark = pytest.mark.spec("...")' 한 줄 SKILL.md placeholder: 5 패턴 list → 단순 "AC-N: ", EARS 는 optional reference 로 1줄 주석. scripts/generate_spec_trace.py: - _SpecMarkerVisitor 에 visit_Module 추가 — module-level pytestmark = pytest.mark.spec(...) (단일 또는 list) 도 추출. 모든 test_* 함수에 자동 적용 (per-file mapping 지원, soft retrofit 핵심) tests/ soft retrofit (26 파일): - 매핑: test_smoke/parse/text_extraction/errors/svg_rendering/pdf_rendering/ async/from_bytes → v0.1.0/rhwp-python - test_langchain_loader{,_ir}/test_ir_{schema,roundtrip,tables,iter_blocks, schema_export,mapper,plain_text} → v0.2.0/ir - test_ir_{picture,furniture,formula,footnote,list,caption,toc,field} → v0.3.0/ir-expansion - test_cli → v0.3.0/cli - 기존 pytestmark 보유 파일 (slow / langchain) 은 list 형태로 추가 - 모든 파일에 file-level pytestmark + 한 줄 주석 (soft retrofit 표시) 검증: - coverage.md: 4 spec / 384 test mapping (parametrize invocation 제외한 test 함수 단위) - lint 0 violation, pytest 450 pass / 2 skip (regression 0) Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/new-spec/SKILL.md | 15 +- docs/CONVENTIONS.md | 30 ++- docs/traces/coverage.md | 387 +++++++++++++++++++++++++++++- scripts/generate_spec_trace.py | 40 ++- tests/test_async.py | 3 + tests/test_cli.py | 3 + tests/test_errors.py | 3 + tests/test_from_bytes.py | 3 + tests/test_ir_caption.py | 3 + tests/test_ir_field.py | 3 + tests/test_ir_footnote.py | 3 + tests/test_ir_formula.py | 3 + tests/test_ir_furniture.py | 4 + tests/test_ir_iter_blocks.py | 4 + tests/test_ir_list.py | 3 + tests/test_ir_mapper.py | 4 + tests/test_ir_picture.py | 3 + tests/test_ir_plain_text.py | 4 + tests/test_ir_roundtrip.py | 3 + tests/test_ir_schema.py | 3 + tests/test_ir_schema_export.py | 3 + tests/test_ir_tables.py | 3 + tests/test_ir_toc.py | 3 + tests/test_langchain_loader.py | 3 +- tests/test_langchain_loader_ir.py | 3 +- tests/test_parse.py | 4 + tests/test_pdf_rendering.py | 3 +- tests/test_smoke.py | 4 + tests/test_svg_rendering.py | 3 + tests/test_text_extraction.py | 4 + 30 files changed, 529 insertions(+), 28 deletions(-) diff --git a/.claude/skills/new-spec/SKILL.md b/.claude/skills/new-spec/SKILL.md index 28ec1d8..e8990ec 100644 --- a/.claude/skills/new-spec/SKILL.md +++ b/.claude/skills/new-spec/SKILL.md @@ -57,14 +57,13 @@ When this skill is invoked, execute the following steps in order: ## 인수조건 - - - - **AC-1** (Ubiquitous) — `THE SHALL ` - - **AC-2** (Event-Driven) — `WHEN , THE SHALL ` - - **AC-3** (State-Driven) — `WHILE , THE SHALL ` - - **AC-4** (Optional) — `WHERE , THE SHALL ` - - **AC-5** (Unwanted) — `IF , THEN THE SHALL ` + + + - **AC-1** — + - **AC-2** — ## 영구 비목표 diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md index d886e18..e584a52 100644 --- a/docs/CONVENTIONS.md +++ b/docs/CONVENTIONS.md @@ -256,25 +256,31 @@ CHANGELOG 한 줄로 충분한 변경 (typo 정리, 단순 dep bump, 작은 docs 같은 사실 중복 기록 금지 — CHANGELOG 가 *what*, log 가 *why/how*. 결정 비교 (a/b/c) 가치가 없는 변경 (단순 dep bump, typo) 은 CHANGELOG 한 줄로 충분 — implementation log 작성 안 함. -## 인수조건 형식 — EARS notation (v0.4.0+ 신규 spec) +## 인수조건 형식 (v0.4.0+ 신규 spec) -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 매핑. +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` 등) 같은 구조화 패턴을 참고 가능 (강제 아님). -| 패턴 | 형식 | 용도 | -|---|---|---| -| 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}` | 예외/실패 | +```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 보존. +기존 v0.1.0 ~ v0.3.0 Frozen spec 은 본 형식 미적용 — historical record 보존. 단 트레이스 매핑은 spec 단위 (AC-N 생략) 로 retrofit 적용 (Trace report 섹션 참조). ## Trace report — pytest spec markers -v0.4.0+ 부터 테스트는 `pytest.mark.spec("vX.Y.Z/topic#AC-N")` marker 로 spec 인수조건과 1:1 매핑. CI 에서 `scripts/generate_spec_trace.py` 가 매 빌드 시 `docs/traces/coverage.md` (Living) 자동 갱신 — spec 별 인수조건 ↔ 테스트 mapping 표. +테스트는 `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(...)` 데코레이터 추가 (양쪽 누적). -기존 v0.1.0 ~ v0.3.0 Frozen spec 은 AC ID 부여 안 함 — marker 없는 테스트 허용. +CI 에서 `scripts/generate_spec_trace.py` 가 AST 정적 분석으로 marker 추출 → `docs/traces/coverage.md` (Living) 자동 갱신. ## Archive 정책 (v1.0+) diff --git a/docs/traces/coverage.md b/docs/traces/coverage.md index 2663519..a9ab5bb 100644 --- a/docs/traces/coverage.md +++ b/docs/traces/coverage.md @@ -4,4 +4,389 @@ v0.4.0+ 신규 spec 의 인수조건 ↔ 테스트 매핑. 기존 v0.1.0 ~ v0.3.0 Frozen spec 은 AC ID 부여 안 함 (CONVENTIONS § Trace report). -(아직 매핑 없음. v0.4.0+ 부터 채워짐.) +| 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/scripts/generate_spec_trace.py b/scripts/generate_spec_trace.py index 6e6f3de..38d5235 100644 --- a/scripts/generate_spec_trace.py +++ b/scripts/generate_spec_trace.py @@ -76,13 +76,32 @@ def _collect_spec_markers(tests_dir: Path) -> dict[str, list[str]]: class _SpecMarkerVisitor(ast.NodeVisitor): - """class 컨텍스트 stack 을 유지하며 @pytest.mark.spec(...) 추출.""" + """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) @@ -99,11 +118,24 @@ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: 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 and SPEC_ID_RE.match(spec_id): - parts = [*self.class_stack, node.name] - self.mapping[spec_id].append(f"{self.file_rel}::{'::'.join(parts)}") + 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: 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: From 921704a732923df63958d6976998298b93168fa3 Mon Sep 17 00:00:00 2001 From: DanMeon Date: Wed, 29 Apr 2026 14:28:56 +0900 Subject: [PATCH 24/30] =?UTF-8?q?docs(conventions):=20phase=20=EC=A0=95?= =?UTF-8?q?=EC=B1=85=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit phase 추상화 폐기 — vX.Y.Z 디렉토리 자체가 작업 단위라 phase 라는 추가 묶음 layer 잉여. CONVENTIONS 의 phase 관련 항목 정리: - Active 정의 / 예시 (phase-N.md → upstream/.md) - 디렉토리 구조 다이어그램의 phase 라인 제거 - roadmap/ 정의의 phase-N.md 항목 제거 - Cross-link "또는 phase-N.md" 제거 - 새 spec 추가 절차의 phase 항목 제거 - "## Phase 완료 후" 섹션 통째 제거 Active 카테고리 자체는 upstream/ 가 남아 유지. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/CONVENTIONS.md | 32 +++++++++----------------------- 1 file changed, 9 insertions(+), 23 deletions(-) diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md index e584a52..a576511 100644 --- a/docs/CONVENTIONS.md +++ b/docs/CONVENTIONS.md @@ -7,7 +7,7 @@ | 분류 | 의미 | 갱신 정책 | 예시 | |---|---|---|---| | **Living** | 항상 최신 — 다른 문서의 위치 포인터 + 시간선 + 규칙 | 자유 갱신, 매 변경 시 손봐도 무방 | `docs/CONVENTIONS.md` (자체), `docs/roadmap/README.md`, `CHANGELOG.md`, `CLAUDE.md`, `AGENTS.md`, `README.md` | -| **Active** | 현재 진행 중 — 의도/스코프 수준의 진화하는 문서 | 큰 변경만, in-place 갱신 OK | `docs/roadmap/phase-N.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` | @@ -56,7 +56,7 @@ last_updated: 2026-04-28 | `superseded_by` | `/.md` | `status: Superseded` 일 때 필수 | | `last_updated` | `YYYY-MM-DD` | 필수. 의미 변경 commit 시 자동 갱신 ([D3 hook](#last_updated-자동-갱신)) | -`Active` (예: `phase-N.md`, `upstream/.md`) 는 `ga` / `target` 둘 다 생략. +`Active` (예: `upstream/.md`) 는 `ga` / `target` 둘 다 생략. `Living` 은 frontmatter 없음 — 정의상 항상 최신. 대신 README 같은 인덱스가 다른 문서들의 Status 를 노출. @@ -90,7 +90,7 @@ last_updated: 2026-04-28 [Model Context Protocol](https://modelcontextprotocol.io/)... ``` -**Active** (Phase 진행 중): +**Active** (외부 시스템 staging): ```markdown --- @@ -98,9 +98,9 @@ status: Active last_updated: 2026-04-26 --- -# Phase 3 — view 렌더러 + RAG 프레임워크 통합 +# upstream issue 초안 — find_control_text_positions 누락 -**대상 버전**: v0.4.0 ~ v0.6.0 +[edwardkim/rhwp](https://github.com/edwardkim/rhwp) 의 `Document::find_control_text_positions` ... ``` **Superseded** (새 spec 으로 대체된 Frozen): @@ -128,8 +128,7 @@ Claude Code PostToolUse hook (`.claude/hooks/update-last-updated.py`) 이 docs/* 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 결정 증거 @@ -146,8 +145,7 @@ docs/ ### 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/ @@ -192,7 +190,7 @@ 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 추가 절차 @@ -204,8 +202,7 @@ Living ───→ Active ───→ Draft ───→ Frozen 1. 디렉토리 생성: `docs/roadmap/v/`, `docs/design/v/` 2. spec 파일 작성 — frontmatter `status: Draft`, `target: vX.Y.Z` 3. 짝이 되는 design research 파일 작성 — 같은 frontmatter -4. `docs/roadmap/README.md` 의 인덱스 표에 행 추가 -5. 해당 phase 가 있다면 `phase-N.md` 의 § 대상 버전 / § 산하 spec 갱신 (Active 갱신은 자유) +4. `docs/roadmap/README.md` 의 인덱스 표에 행 추가 (해당 minor 가 미착수 narrative 에 있었다면 거기서 promote) ### 버전 GA 후 @@ -214,17 +211,6 @@ Living ───→ Active ───→ Draft ───→ Frozen 3. `CHANGELOG.md` 의 해당 버전 섹션 마무리 4. 구현 로그 작성 — `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 가 살아있는 것처럼" 오해 유발. - ### Frozen 후 결정 변경이 필요한 경우 1. **새 spec 작성** — 기존 파일 수정 금지. 새 파일 (예: `docs/roadmap/v0.4.0/ir-correction.md`) From 1e70bba26be97870c85a03218bc7f0d7134c0d07 Mon Sep 17 00:00:00 2001 From: DanMeon Date: Wed, 29 Apr 2026 14:29:06 +0900 Subject: [PATCH 25/30] =?UTF-8?q?docs(roadmap):=20phase-{3,4}.md=20?= =?UTF-8?q?=ED=8F=90=EA=B8=B0=20+=20README=20=EB=AF=B8=EC=B0=A9=EC=88=98?= =?UTF-8?q?=20narrative=20=ED=9D=A1=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit phase 본문 (view 렌더러 / RAG 통합 / writeback 계획) 을 roadmap/README.md § 미착수 작업 계획 으로 흡수. minor 별 분해 (v0.4 view, v0.5 LlamaIndex, v0.6 Haystack / v0.8~v1.0 writeback) 보존. phase-N.md 두 파일 삭제. 근거: vX.Y.Z 디렉토리가 작업 단위라 phase 라는 별도 묶음 추상화 잉여. README 한 자리에 활성 spec + 미착수 계획 모두 노출. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/roadmap/README.md | 86 +++++++++++++++++++++++++++++++++++++---- docs/roadmap/phase-3.md | 56 --------------------------- docs/roadmap/phase-4.md | 50 ------------------------ 3 files changed, 79 insertions(+), 113 deletions(-) delete mode 100644 docs/roadmap/phase-3.md delete mode 100644 docs/roadmap/phase-4.md diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 2d0fcc9..38cd8c7 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -23,16 +23,88 @@ rhwp-python 의 버전별 로드맵 + **활성 spec 인덱스 SSOT**. 모든 spe | 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 0d05f81..0000000 --- a/docs/roadmap/phase-3.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -status: Active -last_updated: 2026-04-26 ---- - -# Phase 3 — view 렌더러 + RAG 프레임워크 통합 - -**대상 버전**: 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 6fb9237..0000000 --- a/docs/roadmap/phase-4.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -status: Active -last_updated: 2026-04-28 ---- - -# Phase 4 — JSON IR → HWP 역생성 - -**대상 버전**: 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 독자 확장 기능은 장기 과제 From 71b6082dd54fa86cd5e3d6d984ea5d9020e9d062 Mon Sep 17 00:00:00 2001 From: DanMeon Date: Wed, 29 Apr 2026 14:29:15 +0900 Subject: [PATCH 26/30] =?UTF-8?q?docs:=20Frozen=20body=20=EC=9D=98=20phase?= =?UTF-8?q?=20link=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit phase 폐기로 broken 된 link 정리: - spec-system-overhaul.md L74 (Active 예시 표) - v0.1.0/rhwp-python.md L71 (§ 범위 외) Frozen body 변경 — CONVENTIONS L12 "오타·링크 수정만 in-place 허용" 적용. link 만 제거하고 텍스트 보존 (historical record 유지), 현재 위치는 README redirect. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/implementation/spec-system-overhaul.md | 2 +- docs/roadmap/v0.1.0/rhwp-python.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/implementation/spec-system-overhaul.md b/docs/implementation/spec-system-overhaul.md index 6be003a..e5d588c 100644 --- a/docs/implementation/spec-system-overhaul.md +++ b/docs/implementation/spec-system-overhaul.md @@ -71,7 +71,7 @@ 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](../roadmap/phase-3.md), [phase-4.md](../roadmap/phase-4.md), [docs/upstream/issue-find-control-text-positions.md](../upstream/issue-find-control-text-positions.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건) | diff --git a/docs/roadmap/v0.1.0/rhwp-python.md b/docs/roadmap/v0.1.0/rhwp-python.md index b7b7858..26c20d9 100644 --- a/docs/roadmap/v0.1.0/rhwp-python.md +++ b/docs/roadmap/v0.1.0/rhwp-python.md @@ -68,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 문서 정리됨. From eb84c6d965a0de81a26b22596494d61bda8980a5 Mon Sep 17 00:00:00 2001 From: DanMeon Date: Wed, 29 Apr 2026 14:31:28 +0900 Subject: [PATCH 27/30] =?UTF-8?q?ci:=20codeql=20+=20dependabot-uv-sync=20?= =?UTF-8?q?=EC=97=90=20concurrency=20group=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ci.yml / docs.yml 의 기존 패턴 (PR 만 cancel, main push 는 보존) 을 일관 적용. 새 push 마다 이전 PR run 자동 cancel — 자원 절약 + PR Commits 탭 시각 노이즈 정리. publish.yml / publish-schema.yml 은 cancel-in-progress: false 유지 (partial publish / Pages race 방지). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/codeql.yml | 4 ++++ .github/workflows/dependabot-uv-sync.yml | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c61fd2c..a8f8e98 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -18,6 +18,10 @@ on: schedule: - cron: "0 6 * * 1" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: analyze: name: Analyze 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 From cee3c1eed74b83319c139fa46015735a42041364 Mon Sep 17 00:00:00 2001 From: DanMeon Date: Wed, 29 Apr 2026 14:33:45 +0900 Subject: [PATCH 28/30] chore: sync rhwp upstream --- external/rhwp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 69150a5e5d7420bd0e70790e19e23cba3bf5a769 Mon Sep 17 00:00:00 2001 From: DanMeon Date: Wed, 29 Apr 2026 15:50:45 +0900 Subject: [PATCH 29/30] =?UTF-8?q?ci:=20paths-filter=20Pattern=20A=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20(paths-ignore=20=ED=95=A8=EC=A0=95=20?= =?UTF-8?q?=ED=9A=8C=ED=94=BC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit paths-ignore 가 required check "All tests passed" 와 충돌 — docs-only commit 시 workflow skip → status 가 expected 에서 stuck. GitHub 공식 docs 도 권고하는 changes job + job-level if 패턴으로 전환. 변경사항: - paths-ignore 제거 — workflow 자체는 항상 트리거 - changes job 신규 (dorny/paths-filter v4, predicate-quantifier: every) - 모든 test job 에 needs/if 추가 — docs-only commit 은 if-skip (success) - all-tests-passed 의 alls-green 에 allowed-skips 명시 - codeql.yml analyze: schedule || code change 조건 (cron 항상 분석) Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 51 +++++++++++++++++++++++++----------- .github/workflows/codeql.yml | 32 +++++++++++++++------- 2 files changed, 58 insertions(+), 25 deletions(-) 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 a8f8e98..1fcaa8c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -3,18 +3,8 @@ 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" @@ -23,8 +13,30 @@ concurrency: 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 From e7496bac456f28105a6bd2c48ce347d139e1d1c0 Mon Sep 17 00:00:00 2001 From: DanMeon Date: Wed, 29 Apr 2026 15:55:11 +0900 Subject: [PATCH 30/30] =?UTF-8?q?feat(skill):=20/new-spec=20auto=20invocat?= =?UTF-8?q?ion=20=ED=97=88=EC=9A=A9=20+=20description=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit disable-model-invocation: true 제거 → Claude / 외부 AI agent (Codex / Cursor) 가 자연어 의도 ("v0.4.0 view 렌더러 시작") 에서 자동 호출 가능. description 필드에 invocation trigger 예시 + idempotent / pre-confirm 규약 명시 — model 이 의도 매칭 + 사용자 사전 확인 패턴 자연스럽게 따름. 안전장치 (skill 자체): - 기존 spec 파일 있으면 abort (덮어쓰기 X) - 출력 bounded: 3 파일 + README 1 행 - non-destructive: 신규 파일 / 인덱스 추가만 - lint 즉시 검증 오용 위험은 "Claude 가 호출 전 의도 명시 + 사용자 확인" 컨벤션으로 회피. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/new-spec/SKILL.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.claude/skills/new-spec/SKILL.md b/.claude/skills/new-spec/SKILL.md index e8990ec..d7b6e81 100644 --- a/.claude/skills/new-spec/SKILL.md +++ b/.claude/skills/new-spec/SKILL.md @@ -1,11 +1,10 @@ --- name: new-spec -description: Scaffold a new version spec and paired ADR following docs/CONVENTIONS.md +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 -disable-model-invocation: true --- # /new-spec — scaffold a new version spec