init: llm-wiki-haness 하네스 설계
This commit is contained in:
@@ -0,0 +1,389 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Materialize a project Work Item as a validated branch contract packet."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from datetime import date
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from typing import Any
|
||||
|
||||
from contract_markdown import cell, clean, parse_frontmatter, parse_tables, replace_table_cell, table_for
|
||||
from fs_transaction import ReplacementValue, SymlinkValue, replace_many
|
||||
import layout_check
|
||||
import moc_indexer
|
||||
import quality_gate
|
||||
import semantic_certificate
|
||||
import semantic_surface_extractor
|
||||
import template_renderer
|
||||
import typed_contract_check
|
||||
import vault_migrate
|
||||
|
||||
|
||||
DEFAULT_ROOT = Path(__file__).resolve().parents[2]
|
||||
DEC_REF_RE = re.compile(r"^(DEC-[A-Z0-9][A-Z0-9-]*-\d{3})@([1-9]\d*)$")
|
||||
DEC_ID_RE = re.compile(r"^DEC-[A-Z0-9][A-Z0-9-]*-\d{3}$")
|
||||
WI_RE = re.compile(r"^WI-[A-Z0-9][A-Z0-9-]*-\d{3}$")
|
||||
SLUG_RE = re.compile(r"^(feature|fix|chore|experiment)-[a-z0-9]+(?:-[a-z0-9]+)*$")
|
||||
|
||||
|
||||
class BranchError(ValueError):
|
||||
def __init__(self, code: str, message: str, location: str = "") -> None:
|
||||
self.code = code
|
||||
self.location = location
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
def _refs(value: str) -> dict[str, int]:
|
||||
items = [clean(item) for item in value.split(",") if clean(item) and clean(item) != "-"]
|
||||
if not items:
|
||||
raise BranchError("MISSING_APPLIED_DECISION", "Applies Decisions must contain at least one pinned reference")
|
||||
parsed: dict[str, int] = {}
|
||||
for item in items:
|
||||
match = DEC_REF_RE.fullmatch(item)
|
||||
if not match:
|
||||
raise BranchError("INVALID_DECISION_REF", f"invalid pinned decision reference: {item}")
|
||||
if match.group(1) in parsed:
|
||||
raise BranchError("DUPLICATE_DECISION_REF", f"duplicate pinned decision reference: {item}")
|
||||
parsed[match.group(1)] = int(match.group(2))
|
||||
return parsed
|
||||
|
||||
|
||||
def _dependencies(value: str) -> list[str]:
|
||||
items = [clean(item) for item in value.split(",") if clean(item) and clean(item) != "-"]
|
||||
for item in items:
|
||||
if not WI_RE.fullmatch(item):
|
||||
raise BranchError("INVALID_WORK_ITEM_DEPENDENCY", f"invalid Work Item dependency: {item}")
|
||||
return sorted(set(items))
|
||||
|
||||
|
||||
def _inline(values: list[str]) -> str:
|
||||
return "[" + ", ".join(values) + "]"
|
||||
|
||||
|
||||
def _table_cell(value: str) -> str:
|
||||
return value.replace("|", "\\|")
|
||||
|
||||
|
||||
def _render_branch(
|
||||
root: Path,
|
||||
project: str,
|
||||
wi_id: str,
|
||||
slug: str,
|
||||
revision: int,
|
||||
completion: str,
|
||||
decisions: list[tuple[str, int, str]],
|
||||
dependencies: list[str],
|
||||
) -> str:
|
||||
branch_id = wi_id.replace("WI-", "BR-", 1)
|
||||
inherited = [f"{decision_id}@{decision_revision}" for decision_id, decision_revision, _ in decisions]
|
||||
rows = "\n".join(
|
||||
f"| `{decision_id}@{decision_revision}` | {_table_cell(summary)} | `{wi_id}` 완료 조건에 적용 | "
|
||||
f"`[[raw/project-notes/{project}]]` |"
|
||||
for decision_id, decision_revision, summary in decisions
|
||||
)
|
||||
template_path = root / "templates/branch-note-template.md"
|
||||
if not template_path.is_file():
|
||||
template_path = DEFAULT_ROOT / "templates/branch-note-template.md"
|
||||
values = {
|
||||
"branch_slug": slug,
|
||||
"branch_id": branch_id,
|
||||
"project": project,
|
||||
"project_parent_link": f"- [[raw/project-notes/{project}]]",
|
||||
"work_item": wi_id,
|
||||
"inherits_yaml": _inline(inherited),
|
||||
"depends_on_yaml": _inline(dependencies),
|
||||
"created": date.today().isoformat(),
|
||||
"contract_packet_sha256": "0" * 64,
|
||||
"project_revision": str(revision),
|
||||
"completion": completion,
|
||||
"inherited_rows": rows,
|
||||
"dependency_display": ", ".join(f"`{item}`" for item in dependencies) if dependencies else "해당 없음",
|
||||
}
|
||||
try:
|
||||
provisional = template_renderer.render_branch_note(template_path, values)
|
||||
values["contract_packet_sha256"] = template_renderer.generated_sha256(provisional)
|
||||
return template_renderer.render_branch_note(template_path, values)
|
||||
except template_renderer.TemplateRenderError as exc:
|
||||
raise BranchError(exc.code, str(exc), str(template_path)) from exc
|
||||
|
||||
|
||||
def _moc_staging_roots() -> set[Path]:
|
||||
"""staging 에 담을 문서 root — moc_indexer 가 스캔하는 모든 relation root.
|
||||
|
||||
staging 이 이보다 좁으면 stage 에 없는 child_root(raw/official-docs 등)의
|
||||
문서로 파생되던 generated region 이 *빈 목록* 으로 재생성되고, 그 파괴적
|
||||
결과가 실 저장소에 그대로 반영된다(2026-07-23 실측 — 무관 문서 100+개의
|
||||
sources region 소실). relations config 를 읽지 못하면 기존 최소 2개 root 로
|
||||
되돌아간다 — 그 경우 build_updates 가 같은 파일을 읽다 스스로 실패한다.
|
||||
"""
|
||||
roots = {Path("raw/project-notes"), Path("raw/branch-notes")}
|
||||
try:
|
||||
relations = json.loads(moc_indexer.DEFAULT_RELATIONS.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return roots
|
||||
for relation in relations.get("relations", []):
|
||||
for key in ("child_roots", "parent_roots"):
|
||||
for value in relation.get(key) or []:
|
||||
roots.add(Path(str(value)))
|
||||
return roots
|
||||
|
||||
|
||||
def _plan_sha256(root: Path, changes: dict[Path, ReplacementValue]) -> str:
|
||||
digest = hashlib.sha256()
|
||||
for path in sorted(changes, key=lambda item: item.relative_to(root).as_posix()):
|
||||
relative = path.relative_to(root).as_posix().encode("utf-8")
|
||||
payload = changes[path]
|
||||
if isinstance(payload, SymlinkValue):
|
||||
# canonical 모드 신규 문서의 호환 심링크. vault_migrate 의
|
||||
# _replacement_fingerprint 와 같은 "symlink\0<target>" 인코딩으로
|
||||
# bytes 와 구분해 결정론 해시에 넣는다.
|
||||
payload = ("symlink\0" + payload.target).encode("utf-8")
|
||||
digest.update(len(relative).to_bytes(8, "big"))
|
||||
digest.update(relative)
|
||||
digest.update(len(payload).to_bytes(8, "big"))
|
||||
digest.update(payload)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def prepare(
|
||||
root: Path,
|
||||
project: str,
|
||||
wi_id: str,
|
||||
*,
|
||||
layout_path: Path = layout_check.DEFAULT_MANIFEST,
|
||||
) -> tuple[dict[Path, bytes], dict[str, Any]]:
|
||||
root = root.resolve()
|
||||
# Isolated runtime fixtures may omit copied harness sources. In that case
|
||||
# validate their documents with the packaged schema. For the real repo
|
||||
# this resolves to the same local path, so a missing schema still fails
|
||||
# closed instead of silently disabling the gate.
|
||||
typed_schema = (
|
||||
typed_contract_check.DEFAULT_SCHEMA
|
||||
if (root / typed_contract_check.DEFAULT_SCHEMA).is_file()
|
||||
else DEFAULT_ROOT / typed_contract_check.DEFAULT_SCHEMA
|
||||
)
|
||||
try:
|
||||
typed_result = typed_contract_check.check(root, typed_schema)
|
||||
except (typed_contract_check.TypedContractError, OSError, UnicodeError) as exc:
|
||||
raise BranchError("TYPED_CONTRACT_ERROR", str(exc)) from exc
|
||||
if typed_result["status"] != "PASS":
|
||||
codes = ",".join(
|
||||
sorted({str(item.get("code", "UNKNOWN")) for item in typed_result.get("findings", [])})
|
||||
)
|
||||
raise BranchError("TYPED_CONTRACT_FAILED", codes or "typed contract gate failed")
|
||||
if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", project):
|
||||
raise BranchError("INVALID_PROJECT_SLUG", f"invalid project slug: {project}")
|
||||
expected_wi = re.compile(rf"^WI-{re.escape(project.upper())}-\d{{3}}$")
|
||||
if not expected_wi.fullmatch(wi_id):
|
||||
raise BranchError("INVALID_WORK_ITEM_ID", f"Work Item must match WI-{project.upper()}-NNN")
|
||||
project_path = root / "raw/project-notes" / f"{project}.md"
|
||||
# cutover 이후 legacy 경로는 정본을 가리키는 심링크다. lexical 경로 그대로
|
||||
# write-root 집행에 넘기면 canonical 모드에서 "raw/… 는 write root 밖" 으로
|
||||
# 거부돼 **모든 입력에서 실패**한다 — 실제로 그 상태였다. 위치 판정은 정본으로 한다.
|
||||
project_authority_path = project_path.resolve() if project_path.is_symlink() else project_path
|
||||
# 신규 branch 노트의 목적지는 이제 vault_migrate.plan_new_documents 가 계산한다
|
||||
# (정본 + 호환 심링크 + manifest 2행). 존재하지 않는 합성 경로로 write root 를
|
||||
# 미리 찔러보던 authority-check probe 는 그 계산을 우회하므로 제거한다.
|
||||
try:
|
||||
authority = layout_check.resolve_authority(root, layout_path)
|
||||
layout_check.enforce_write_paths(root, [project_authority_path], authority)
|
||||
except layout_check.LayoutContractError as exc:
|
||||
raise BranchError(exc.code, str(exc), exc.location) from exc
|
||||
if not project_path.is_file():
|
||||
raise BranchError("PROJECT_NOT_FOUND", f"project note does not exist: {project_path}")
|
||||
if (root / semantic_surface_extractor.DEFAULT_POLICY).is_file():
|
||||
try:
|
||||
semantic_parent = semantic_certificate.check(root, mode="hub", paths=[project_path])
|
||||
except (
|
||||
semantic_certificate.SemanticCertificateError,
|
||||
semantic_surface_extractor.SemanticSurfaceError,
|
||||
typed_contract_check.TypedContractError,
|
||||
OSError,
|
||||
UnicodeError,
|
||||
) as exc:
|
||||
raise BranchError("PARENT_SEMANTIC_CERTIFICATE_ERROR", str(exc), project_path.relative_to(root).as_posix()) from exc
|
||||
if semantic_parent["status"] != "PASS":
|
||||
codes = ",".join(sorted({str(item.get("code", "UNKNOWN")) for item in semantic_parent["findings"]}))
|
||||
raise BranchError(
|
||||
"PARENT_SEMANTIC_CERTIFICATE_INVALID",
|
||||
codes or "parent project hub certificate is missing, stale, or blocking",
|
||||
project_path.relative_to(root).as_posix(),
|
||||
)
|
||||
project_text = project_path.read_text(encoding="utf-8")
|
||||
frontmatter = parse_frontmatter(project_text)
|
||||
revision_raw = str(frontmatter.get("project_revision", ""))
|
||||
if not revision_raw.isdigit() or int(revision_raw) < 1:
|
||||
raise BranchError("LEGACY_PROJECT_CONTRACT", "project_revision must be a positive integer")
|
||||
revision = int(revision_raw)
|
||||
tables = parse_tables(project_text)
|
||||
decision_table = table_for(tables, "section-id:project-decisions", "안정 결정 레지스트리", "Project Decision Registry")
|
||||
work_table = table_for(tables, "section-id:project-work-items", "실행계획", "Work Item Registry")
|
||||
if decision_table is None or work_table is None:
|
||||
raise BranchError("LEGACY_PROJECT_CONTRACT", "project decision/work-item registry is required")
|
||||
|
||||
registry: dict[str, tuple[int, str]] = {}
|
||||
for line, row in decision_table.rows:
|
||||
decision_id = clean(cell(row, "Decision ID"))
|
||||
decision_revision = clean(cell(row, "Revision"))
|
||||
if not DEC_ID_RE.fullmatch(decision_id) or not decision_revision.isdigit() or int(decision_revision) < 1:
|
||||
raise BranchError("INVALID_PROJECT_DECISION", "invalid decision registry row", f"{project_path}:{line}")
|
||||
if decision_id in registry:
|
||||
raise BranchError("DUPLICATE_DECISION_OWNER", f"duplicate decision: {decision_id}", f"{project_path}:{line}")
|
||||
registry[decision_id] = (int(decision_revision), clean(cell(row, "Decision Summary")))
|
||||
|
||||
work_rows = [(line, row) for line, row in work_table.rows if clean(cell(row, "Work Item ID")) == wi_id]
|
||||
if len(work_rows) != 1:
|
||||
code = "WORK_ITEM_NOT_FOUND" if not work_rows else "DUPLICATE_WORK_ITEM"
|
||||
raise BranchError(code, f"expected exactly one {wi_id} row, observed {len(work_rows)}")
|
||||
work_line, work_row = work_rows[0]
|
||||
all_work_ids = {clean(cell(row, "Work Item ID")) for _line, row in work_table.rows}
|
||||
slug = clean(cell(work_row, "branch slug"))
|
||||
if not SLUG_RE.fullmatch(slug) or not 4 <= len(slug.split("-")[1:]) <= 8:
|
||||
raise BranchError("INVALID_BRANCH_SLUG", f"branch slug violates naming-conventions: {slug}")
|
||||
target = root / "raw/branch-notes" / f"{slug}.md"
|
||||
if target.exists():
|
||||
raise BranchError("TARGET_EXISTS", f"branch target already exists: {target}")
|
||||
completion = clean(cell(work_row, "완료 조건 (측정가능)"))
|
||||
if not completion:
|
||||
raise BranchError("MISSING_COMPLETION_CRITERION", f"{wi_id} has no completion criterion")
|
||||
applied = _refs(cell(work_row, "Applies Decisions"))
|
||||
inherited: list[tuple[str, int, str]] = []
|
||||
for decision_id, pinned_revision in applied.items():
|
||||
current = registry.get(decision_id)
|
||||
if current is None:
|
||||
raise BranchError("MISSING_PROJECT_DECISION", f"{decision_id} is not in the project registry")
|
||||
if pinned_revision != current[0]:
|
||||
raise BranchError(
|
||||
"STALE_INHERITANCE_REVISION",
|
||||
f"{decision_id}@{pinned_revision} does not match current @{current[0]}",
|
||||
)
|
||||
inherited.append((decision_id, pinned_revision, current[1]))
|
||||
dependencies = _dependencies(cell(work_row, "Dependencies"))
|
||||
missing_dependencies = sorted(set(dependencies) - all_work_ids)
|
||||
if missing_dependencies:
|
||||
raise BranchError("MISSING_WORK_ITEM_DEPENDENCY", f"unknown dependencies: {missing_dependencies}")
|
||||
|
||||
updated_project = project_text
|
||||
if clean(cell(work_row, "Status")) == "planned":
|
||||
updated_project = replace_table_cell(updated_project, work_table, work_line, "Status", "`in-progress`")
|
||||
branch_text = _render_branch(root, project, wi_id, slug, revision, completion, sorted(inherited), dependencies)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix=".branch-from-project-stage-", dir=root) as directory:
|
||||
stage = Path(directory)
|
||||
for rel in sorted(_moc_staging_roots()):
|
||||
source = root / rel
|
||||
if source.exists():
|
||||
shutil.copytree(source, stage / rel)
|
||||
staged_project = stage / project_path.relative_to(root)
|
||||
staged_target = stage / target.relative_to(root)
|
||||
staged_project.parent.mkdir(parents=True, exist_ok=True)
|
||||
staged_target.parent.mkdir(parents=True, exist_ok=True)
|
||||
staged_project.write_text(updated_project, encoding="utf-8")
|
||||
staged_target.write_text(branch_text, encoding="utf-8")
|
||||
moc_updates, _moc_stats = moc_indexer.build_updates(stage)
|
||||
for path, text in moc_updates.items():
|
||||
path.write_text(text, encoding="utf-8")
|
||||
remaining, _ = moc_indexer.build_updates(stage)
|
||||
if remaining:
|
||||
raise BranchError("MOC_VALIDATION_FAILED", "MOC indexer did not converge")
|
||||
try:
|
||||
gate = quality_gate.run(
|
||||
stage,
|
||||
[staged_project, staged_target],
|
||||
structure_paths=[staged_target],
|
||||
template_root=DEFAULT_ROOT,
|
||||
)
|
||||
except quality_gate.QualityGateError as exc:
|
||||
raise BranchError("QUALITY_GATE_ERROR", str(exc)) from exc
|
||||
if gate["status"] != "PASS":
|
||||
summary = "; ".join(
|
||||
f"{item['code']} {item['path']}: {item['message']}" for item in gate["findings"][:10]
|
||||
)
|
||||
raise BranchError("QUALITY_GATE_FAILED", summary)
|
||||
changes = {project_path: staged_project.read_bytes(), target: staged_target.read_bytes()}
|
||||
for staged_path in moc_updates:
|
||||
actual_path = root / staged_path.relative_to(stage)
|
||||
changes[actual_path] = staged_path.read_bytes()
|
||||
|
||||
try:
|
||||
changes, authority = vault_migrate.expand_authoritative_changes(
|
||||
root,
|
||||
changes,
|
||||
layout_path=layout_path,
|
||||
)
|
||||
except vault_migrate.MigrationError as exc:
|
||||
raise BranchError(exc.code, str(exc), exc.location) from exc
|
||||
plan_sha256 = _plan_sha256(root, changes)
|
||||
|
||||
return changes, {
|
||||
"project": project,
|
||||
"work_item": wi_id,
|
||||
"project_revision": revision,
|
||||
"target": target.relative_to(root).as_posix(),
|
||||
"inherits_count": len(inherited),
|
||||
"depends_on_count": len(dependencies),
|
||||
"changed_paths": [path.relative_to(root).as_posix() for path in sorted(changes)],
|
||||
"must_not_exist_paths": [
|
||||
path.relative_to(root).as_posix() for path in sorted(changes) if not path.exists()
|
||||
],
|
||||
"active_layout": {
|
||||
"mode": authority["mode"],
|
||||
"authority": authority["authority"],
|
||||
"write_roots": authority["write_roots"],
|
||||
"manifest_sha256": authority["manifest_sha256"],
|
||||
},
|
||||
"plan_sha256": plan_sha256,
|
||||
}
|
||||
|
||||
|
||||
def _emit(document: dict[str, Any]) -> None:
|
||||
json.dump(document, sys.stdout, ensure_ascii=False, indent=2, sort_keys=True)
|
||||
sys.stdout.write("\n")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("project")
|
||||
parser.add_argument("work_item")
|
||||
parser.add_argument("--root", type=Path, default=DEFAULT_ROOT)
|
||||
parser.add_argument("--layout", type=Path, default=layout_check.DEFAULT_MANIFEST)
|
||||
mode = parser.add_mutually_exclusive_group(required=True)
|
||||
mode.add_argument("--dry-run", action="store_true")
|
||||
mode.add_argument("--apply", action="store_true")
|
||||
parser.add_argument("--expected-plan-sha256")
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
root = args.root.resolve(strict=True)
|
||||
changes, result = prepare(root, args.project, args.work_item, layout_path=args.layout)
|
||||
if args.apply:
|
||||
expected = args.expected_plan_sha256
|
||||
if expected is not None:
|
||||
if not re.fullmatch(r"[0-9a-f]{64}", expected):
|
||||
raise BranchError("INVALID_PLAN_SHA256", "expected plan hash must be 64 lowercase hex characters")
|
||||
if expected != result["plan_sha256"]:
|
||||
raise BranchError(
|
||||
"PLAN_HASH_MISMATCH",
|
||||
f"expected {expected}, current plan is {result['plan_sha256']}",
|
||||
)
|
||||
forbidden = {root / path for path in result["must_not_exist_paths"]}
|
||||
replace_many(changes, must_not_exist=forbidden)
|
||||
_emit({"schema_version": "branch-from-project-result/v1", "status": "APPLIED" if args.apply else "DRY_RUN", "findings": [], **result})
|
||||
return 0
|
||||
except BranchError as exc:
|
||||
error = {"code": exc.code, "location": exc.location, "message": str(exc)}
|
||||
_emit({"schema_version": "branch-from-project-result/v1", "status": "FAIL", "errors": [error]})
|
||||
return 1
|
||||
except (OSError, UnicodeError) as exc:
|
||||
_emit({"schema_version": "branch-from-project-result/v1", "status": "ERROR", "errors": [{"code": "IO_ERROR", "location": "", "message": str(exc)}]})
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user