refactor: 분리되어 관리하고 있던 문서 시스템을 하나로 통일

This commit is contained in:
DongHyeonka
2026-09-04 18:56:01 +09:00
parent 4b9e7148b5
commit 43bccd08a8
121 changed files with 2861 additions and 534 deletions
+28 -6
View File
@@ -1,6 +1,10 @@
#!/usr/bin/env python3
"""tech-log-tree.json 을 다시 만든다.
노드 필드는 document-detail 의 root-tree 계약을 따른다 — readiness, source, code,
evidence, classification, missing-verification, relations. 제목만 보고 기록을 만들지
못하게 하려는 것이다.
SSOT(final/document.md)에서 뽑은 글감과 이미 쓴 기록을 한 파일에 모은다. 기록 파일이
정본이므로 이 스크립트는 그것을 읽어 채우고, 아직 글이 없는 글감은 사람이 적은 항목을
그대로 둔다.
@@ -8,7 +12,7 @@ SSOT(final/document.md)에서 뽑은 글감과 이미 쓴 기록을 한 파일
python3 scripts/build-tech-log-tree.py [프로젝트 ...]
"""
from __future__ import annotations
import json, re, sys, glob, os, datetime
import json, re, sys, glob, os, datetime, hashlib
KINDS = ["case", "concept", "reference", "question", "decision"]
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -45,15 +49,24 @@ def build(project: str) -> dict:
for f in sorted(glob.glob(os.path.join(topic_dir, kind, "*.md"))):
fm = front_matter(f)
text = open(f, encoding="utf-8").read()
items.append({
node = {
"title": fm.get("title", os.path.basename(f)),
"slug": fm.get("slug", ""),
"file": os.path.relpath(f, studio),
"readiness": "READY" if fm.get("id") else "NEEDS_EVIDENCE",
"status": fm.get("status", "미작성"),
"studioId": fm.get("id", ""),
"assets": len(re.findall(r"^ - key: ", text, re.M)),
"evidence": len(re.findall(r"^ - \.\./", text, re.M)),
})
"assets": re.findall(r"^ - key: (\S+)", text, re.M),
"evidence": re.findall(r"^ - (\.\./\S+)", text, re.M),
"relations": re.findall(r"^- \*\*(.+?)\*\*$", text, re.M),
}
# 이미 쓴 글감은 지난 트리의 사람이 적은 칸을 잃지 않는다
for old in previous.get("topics", {}).get(topic, {}).get("kinds", {}).get(kind, []):
if old.get("slug") == node["slug"]:
for key in ("classification", "missing-verification", "source", "code"):
if old.get(key):
node[key] = old[key]
items.append(node)
# 아직 글이 없는 글감은 지난 tree 에서 가져와 유지한다
written = {i["slug"] for i in items if i["slug"]}
for old in previous.get("topics", {}).get(topic, {}).get("kinds", {}).get(kind, []):
@@ -62,11 +75,20 @@ def build(project: str) -> dict:
entry["kinds"][kind] = items
topics[topic] = entry
ssot_path = os.path.join(base, ssot) if ssot else None
digest = None
if ssot_path and os.path.exists(ssot_path):
digest = hashlib.sha256(open(ssot_path, "rb").read()).hexdigest()
return {
"schemaVersion": 2,
"project": project,
"ssot": ssot,
"ssotSha256": digest,
"generatedAt": datetime.date.today().isoformat(),
"note": "글감 목록이다. file 이 있으면 이미 쓴 기록이고, 없으면 아직 쓰지 않은 글감이다.",
"note": "글감 목록이다. file 이 있으면 이미 쓴 기록이고, 없으면 아직 쓰지 않은 글감이다. "
"ssotSha256 이 지금 final/document.md 와 다르면 SSOT 가 바뀐 뒤 트리를 다시 보지 않은 것이다.",
"readinessValues": ["READY", "NEEDS_EVIDENCE", "BLOCKED", "REJECTED"],
"topics": topics,
}
+51
View File
@@ -0,0 +1,51 @@
# Terminal Evidence Renderer
실제 명령 출력 원문을 보존한 뒤, 그 원문을 terminal UI 형태의 SVG로 렌더링한다.
## Evidence rule
`SVG`가 정본이 아니다. **실행한 명령의 raw output과 metadata가 정본**이고 SVG는 문서에 넣기 위한 표현이다.
```text
command execution
├── raw/<name>.txt # stdout/stderr 원문
├── meta/<name>.json # command, cwd, executedAt, exitCode, revision
└── terminal/<name>.svg # raw에서 생성
```
시각화 단계에서는 Authorization Bearer, Cookie, token/password/client_secret/API key 형태의 값을 `[REDACTED]`로 치환한다. 그래도 raw 파일에 secret이 들어간 채 보관하면 안 된다. **명령 자체를 secret이 출력되지 않도록 구성하고, raw 저장 전에도 검사한다.**
## Render
```bash
python3 /shared/Tech-Log-Document/tools/terminal-evidence/render_terminal.py \
evidence/raw/gradle-test.txt \
evidence/terminal/gradle-test.svg \
--command './gradlew test' \
--cwd '/shared/codebase/my-project' \
--exit-code 0 \
--executed-at '2026-08-28T15:00:00+09:00'
```
## Capture pattern
Agent가 명령을 실제로 실행할 때 stdout/stderr, exit code, 실행 시간, cwd를 함께 기록한다. 실패 명령도 Evidence가 될 수 있으므로 exit code를 버리지 않는다.
예시 shell pattern:
```bash
set +e
executed_at=$(date --iso-8601=seconds)
pwd_value=$(pwd)
./gradlew test > evidence/raw/gradle-test.txt 2>&1
exit_code=$?
set -e
```
그 뒤 metadata JSON을 쓰고 renderer를 호출한다. command에 credential을 직접 넣지 않는다.
## Tests
```bash
python3 -m unittest discover -s tests -v
```
@@ -0,0 +1,8 @@
{
"command": "python3 --version; git --version; printf workspace=<cwd>",
"cwd": "/shared/Tech-Log-Document/tools/terminal-evidence",
"executedAt": "2026-08-28T06:03:38+00:00",
"exitCode": 0,
"raw": "../raw/toolchain.txt",
"rendered": "../terminal/toolchain.svg"
}
@@ -0,0 +1,3 @@
Python 3.12.3
git version 2.43.0
workspace=/shared/Tech-Log-Document/tools/terminal-evidence
@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="242" viewBox="0 0 1200 242" role="img">
<title>terminal evidence</title>
<desc>Terminal-style rendering generated from retained command output. Sensitive-looking values are redacted in the visual asset.</desc>
<rect x="1" y="1" width="1198" height="240" rx="14" fill="#0d1117" stroke="#30363d"/>
<rect x="1" y="1" width="1198" height="44" rx="14" fill="#161b22"/>
<rect x="1" y="30" width="1198" height="14" fill="#161b22"/>
<circle cx="24" cy="22" r="6" fill="#ff5f57"/>
<circle cx="44" cy="22" r="6" fill="#febc2e"/>
<circle cx="64" cy="22" r="6" fill="#28c840"/>
<text x="92" y="27" fill="#8b949e" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">terminal evidence</text>
<text x="24" y="68" fill="#c9d1d9" font-size="15" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">$ python3 --version; git --version; printf workspace=&lt;cwd&gt;</text>
<text x="24" y="92" fill="#8b949e" font-size="13" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">cwd: /shared/Tech-Log-Document/tools/terminal-evidence</text>
<text x="24" y="116" fill="#8b949e" font-size="13" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">time: 2026-08-28T06:03:38+00:00 · exit 0</text>
<line x1="24" y1="130" x2="1176" y2="130" stroke="#30363d"/>
<text x="24" y="170" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve">Python 3.12.3</text>
<text x="24" y="192" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve">git version 2.43.0</text>
<text x="24" y="214" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve">workspace=/shared/Tech-Log-Document/tools/terminal-evidence</text>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import html
import re
from pathlib import Path
ANSI_RE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
_REDACTION_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
(re.compile(r"(?i)^(\s*authorization\s*:\s*bearer\s+).*$"), r"\1[REDACTED]"),
(re.compile(r"(?i)^(\s*(?:cookie|set-cookie)\s*:\s*).*$"), r"\1[REDACTED]"),
(
re.compile(
r"(?i)(\b(?:access[_-]?token|refresh[_-]?token|id[_-]?token|token|password|passwd|pwd|client[_-]?secret|api[_-]?key|secret|aws_secret_access_key)\b\s*[=:]\s*)([^\s,;]+)"
),
r"\1[REDACTED]",
),
(
re.compile(
r'(?i)(["\'](?:access[_-]?token|refresh[_-]?token|id[_-]?token|token|password|client[_-]?secret|api[_-]?key|secret)["\']\s*:\s*["\'])(.*?)(["\'])'
),
r"\1[REDACTED]\3",
),
)
def strip_ansi(value: str) -> str:
return ANSI_RE.sub("", value)
def redact_line(line: str) -> str:
redacted = strip_ansi(line)
for pattern, replacement in _REDACTION_PATTERNS:
redacted = pattern.sub(replacement, redacted)
return redacted
def _escape(value: str) -> str:
return html.escape(value, quote=True)
def _display_lines(output: str, max_lines: int) -> list[str]:
if max_lines < 1:
raise ValueError("max_lines must be >= 1")
source_lines = output.splitlines()
if not source_lines and output == "":
source_lines = [""]
visible = source_lines[:max_lines]
rendered = [redact_line(line) for line in visible]
omitted = len(source_lines) - len(visible)
if omitted > 0:
rendered.append(f"[{omitted} more lines omitted]")
return rendered
def render_svg(
output: str,
*,
command: str,
cwd: str,
exit_code: int,
executed_at: str,
max_lines: int = 120,
width: int = 1200,
) -> str:
lines = _display_lines(output, max_lines=max_lines)
line_height = 22
top_bar = 44
metadata_height = 86
output_top = top_bar + metadata_height + 18
bottom_padding = 28
height = output_top + max(1, len(lines)) * line_height + bottom_padding
safe_command = _escape(redact_line(command))
safe_cwd = _escape(redact_line(cwd))
safe_time = _escape(executed_at)
safe_exit = _escape(str(exit_code))
parts = [
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}" role="img">',
"<title>terminal evidence</title>",
"<desc>Terminal-style rendering generated from retained command output. Sensitive-looking values are redacted in the visual asset.</desc>",
f'<rect x="1" y="1" width="{width - 2}" height="{height - 2}" rx="14" fill="#0d1117" stroke="#30363d"/>',
f'<rect x="1" y="1" width="{width - 2}" height="{top_bar}" rx="14" fill="#161b22"/>',
f'<rect x="1" y="{top_bar - 14}" width="{width - 2}" height="14" fill="#161b22"/>',
'<circle cx="24" cy="22" r="6" fill="#ff5f57"/>',
'<circle cx="44" cy="22" r="6" fill="#febc2e"/>',
'<circle cx="64" cy="22" r="6" fill="#28c840"/>',
'<text x="92" y="27" fill="#8b949e" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">terminal evidence</text>',
f'<text x="24" y="68" fill="#c9d1d9" font-size="15" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">$ {safe_command}</text>',
f'<text x="24" y="92" fill="#8b949e" font-size="13" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">cwd: {safe_cwd}</text>',
f'<text x="24" y="116" fill="#8b949e" font-size="13" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">time: {safe_time} · exit {safe_exit}</text>',
f'<line x1="24" y1="{top_bar + metadata_height}" x2="{width - 24}" y2="{top_bar + metadata_height}" stroke="#30363d"/>',
]
for index, line in enumerate(lines):
y = output_top + (index + 1) * line_height
escaped = _escape(line)
# Keep all text derived from output. The SVG viewport clips extreme-width lines
# rather than inventing wrapped content or changing the raw evidence.
fill = "#d2a8ff" if line.startswith("[") and line.endswith("more lines omitted]") else "#e6edf3"
parts.append(
f'<text x="24" y="{y}" fill="{fill}" font-size="14" '
'font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve">'
f"{escaped}</text>"
)
parts.append("</svg>")
return "\n".join(parts) + "\n"
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Render retained command output as a terminal-style SVG evidence asset."
)
parser.add_argument("input", type=Path, help="UTF-8 raw command output file")
parser.add_argument("output", type=Path, help="Destination SVG")
parser.add_argument("--command", required=True, help="Command that produced the raw output")
parser.add_argument("--cwd", required=True, help="Working directory of the command")
parser.add_argument("--exit-code", type=int, required=True, help="Actual process exit code")
parser.add_argument("--executed-at", required=True, help="Actual ISO-8601 execution timestamp")
parser.add_argument("--max-lines", type=int, default=120, help="Maximum raw lines to render")
return parser.parse_args()
def main() -> int:
args = _parse_args()
raw = args.input.read_text(encoding="utf-8", errors="replace")
svg = render_svg(
raw,
command=args.command,
cwd=args.cwd,
exit_code=args.exit_code,
executed_at=args.executed_at,
max_lines=args.max_lines,
)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(svg, encoding="utf-8")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,79 @@
import re
import unittest
import xml.etree.ElementTree as ET
from render_terminal import redact_line, render_svg
class RenderTerminalTest(unittest.TestCase):
def test_svg_is_valid_xml_and_escapes_output(self):
svg = render_svg(
"<tag>& value\nsecond",
command="printf '<tag>& value'",
cwd="/shared/codebase/demo",
exit_code=0,
executed_at="2026-08-28T06:00:00Z",
)
ET.fromstring(svg)
self.assertIn("&lt;tag&gt;&amp; value", svg)
self.assertNotIn("<tag>& value", svg)
def test_metadata_is_rendered(self):
svg = render_svg(
"BUILD SUCCESSFUL",
command="./gradlew test",
cwd="/shared/codebase/demo",
exit_code=0,
executed_at="2026-08-28T06:00:00Z",
)
self.assertIn("./gradlew test", svg)
self.assertIn("/shared/codebase/demo", svg)
self.assertIn("exit 0", svg)
self.assertIn("2026-08-28T06:00:00Z", svg)
def test_obvious_secrets_are_redacted(self):
cases = {
"Authorization: Bearer abc.def.ghi": "Authorization: Bearer [REDACTED]",
"TOKEN=super-secret": "TOKEN=[REDACTED]",
"PASSWORD=hunter2": "PASSWORD=[REDACTED]",
"client_secret: abc123": "client_secret: [REDACTED]",
"Cookie: SESSION=abcdef": "Cookie: [REDACTED]",
}
for raw, expected in cases.items():
with self.subTest(raw=raw):
self.assertEqual(expected, redact_line(raw))
def test_normal_output_is_not_changed_by_redaction(self):
line = "GET /api/me -> 200 in 14ms"
self.assertEqual(line, redact_line(line))
def test_truncation_marker_is_rendered_without_fabricating_hidden_lines(self):
output = "\n".join(f"line-{i}" for i in range(8))
svg = render_svg(
output,
command="demo",
cwd="/tmp",
exit_code=1,
executed_at="2026-08-28T06:00:00Z",
max_lines=3,
)
self.assertIn("line-0", svg)
self.assertIn("line-2", svg)
self.assertNotIn("line-3", svg)
self.assertIn("[5 more lines omitted]", svg)
def test_terminal_chrome_and_monospace_are_present(self):
svg = render_svg(
"ok",
command="echo ok",
cwd="/tmp",
exit_code=0,
executed_at="2026-08-28T06:00:00Z",
)
self.assertGreaterEqual(len(re.findall(r"<circle\b", svg)), 3)
self.assertIn("monospace", svg)
self.assertIn("terminal evidence", svg)
if __name__ == "__main__":
unittest.main()
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
from pathlib import Path
REQUIRED_PATHS = (
# 스킬 — 분석에서 게시까지
".agents/skills/analyzing-codebase-for-tech-log/SKILL.md",
".agents/skills/deriving-tech-log-root-tree/SKILL.md",
".agents/skills/deriving-tech-log-root-tree/references/decomposition-checklist.md",
".agents/skills/writing-tech-log-records/SKILL.md",
".agents/skills/writing-tech-log-records/references/root-tree-contract.md",
".agents/skills/writing-tech-log-records/references/record-kinds.md",
".agents/skills/writing-tech-log-records/references/body-syntax.md",
".agents/skills/writing-tech-log-records/references/code-tables-diagrams.md",
".agents/skills/writing-tech-log-records/references/review-checklist.md",
".agents/skills/writing-tech-log-records/references/from-ssot-to-records.md",
".agents/skills/writing-tech-log-records/references/writing-each-kind.md",
".agents/skills/rewriting-technical-prose-naturally/SKILL.md",
".agents/skills/rewriting-technical-prose-naturally/references/protected-content.md",
".agents/skills/rewriting-technical-prose-naturally/references/editorial-rules.md",
".agents/skills/writing-tech-log-records/references/explaining.md",
".agents/skills/writing-tech-log-records/references/ai-tells.md",
".agents/skills/rewriting-technical-prose-naturally/references/research-method.md",
".agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs",
".agents/skills/rewriting-technical-prose-naturally/scripts/style_profile.mjs",
".agents/skills/technical-visualizer/SKILL.md",
".agents/skills/refactoring-from-analysis/SKILL.md",
# 틀
"docs/_templates/state.json",
"docs/_templates/source-index.md",
"docs/_templates/root-tree.md",
"docs/_templates/analysis/00-project-overview.md",
"docs/_templates/analysis/module.md",
"docs/_templates/final/document.md",
".agents/skills/writing-tech-log-records/templates/case.md",
".agents/skills/writing-tech-log-records/templates/concept.md",
# 도구
"scripts/techviz",
"scripts/build-tech-log-tree.py",
"scripts/terminal-evidence/render_terminal.py",
"scripts/terminal-evidence/README.md",
".agents/skills/writing-tech-log-records/scripts/check_body.mjs",
)
ROOT_TREE_TOKENS = (
"PROJECT",
"TOPIC",
"├── CASE",
"├── REFERENCE",
"├── OPEN QUESTION",
"└── DECISION",
"# Node Specifications",
"readiness:",
"source:",
"classification:",
)
QUEUE_TOKENS = ("version:", "activeProject:", "projects:")
REFACTOR_QUEUE_TOKENS = ("version:", "activeItem:", "items:")
STATE_REANALYSIS_TOKENS = (
'"reanalysis"',
'"baselineRevision"',
'"targetRevision"',
'"mode"',
'"changedPaths"',
'"impactedScopes"',
)
ALLOWED_QUEUE_STATUSES = {"PENDING", "IN_PROGRESS", "COMPLETE", "REANALYZE", "BLOCKED", "SKIPPED"}
def _parse_analysis_queue(text: str):
active = None
projects = []
current = None
for raw in text.splitlines():
stripped = raw.strip()
if not stripped or stripped.startswith("#"):
continue
if raw.startswith("activeProject:"):
value = raw.split(":", 1)[1].strip().strip("\"'")
active = None if value in {"", "null", "~"} else value
continue
if stripped.startswith("- name:"):
name = stripped.split(":", 1)[1].strip().strip("\"'")
current = {"name": name, "status": None}
projects.append(current)
continue
if current is not None and stripped.startswith("status:"):
current["status"] = stripped.split(":", 1)[1].strip().strip("\"'")
return active, projects
def _verify_refactor_queue(path: Path) -> list[str]:
errors: list[str] = []
text = path.read_text(encoding="utf-8", errors="replace")
for token in REFACTOR_QUEUE_TOKENS:
if token not in text:
errors.append(f"refactor queue missing token: {token}")
return errors
def _verify_analysis_queue(path: Path) -> list[str]:
errors = []
text = path.read_text(encoding="utf-8", errors="replace")
for token in QUEUE_TOKENS:
if token not in text:
errors.append(f"analysis queue missing token: {token}")
if errors:
return errors
active, projects = _parse_analysis_queue(text)
names = [p["name"] for p in projects]
if len(names) != len(set(names)):
errors.append("analysis queue contains duplicate project names")
for project in projects:
status = project.get("status")
if status not in ALLOWED_QUEUE_STATUSES:
errors.append(f"analysis queue invalid status for {project['name']}: {status}")
in_progress = [p["name"] for p in projects if p.get("status") == "IN_PROGRESS"]
if len(in_progress) > 1:
errors.append("analysis queue has multiple IN_PROGRESS projects")
owned = [p["name"] for p in projects if p.get("status") in {"IN_PROGRESS", "BLOCKED"}]
if len(owned) > 1:
errors.append("analysis queue has multiple active-owned projects")
if active is None:
if owned:
errors.append("activeProject does not match active-owned project")
elif owned != [active]:
errors.append("activeProject does not match active-owned project")
return errors
FORBIDDEN_LITERAL = "document-" + "haness"
TEXT_SUFFIXES = {".md", ".json", ".py", ".sh", ".txt", ".yaml", ".yml", ".toml"}
def _iter_pipeline_text_files(shared_root: Path):
for rel_root in (".agents", "docs/_templates", "scripts"):
root = shared_root / rel_root
if not root.exists():
continue
for path in root.rglob("*"):
if not path.is_file() or path.suffix.lower() not in TEXT_SUFFIXES:
continue
if "__pycache__" in path.parts:
continue
yield path
def verify_pipeline(shared_root: Path) -> list[str]:
shared_root = Path(shared_root)
errors: list[str] = []
for rel in REQUIRED_PATHS:
if not (shared_root / rel).exists():
errors.append(f"missing required path: {rel}")
queue = shared_root / "docs/analysis-queue.yaml"
if queue.exists():
errors.extend(_verify_analysis_queue(queue))
refactor_queue = shared_root / "docs/refactor-queue.yaml"
if refactor_queue.exists():
errors.extend(_verify_refactor_queue(refactor_queue))
state_template = shared_root / "docs/_templates/state.json"
if state_template.exists():
state_text = state_template.read_text(encoding="utf-8", errors="replace")
for token in STATE_REANALYSIS_TOKENS:
if token not in state_text:
errors.append(f"state template missing reanalysis token: {token}")
root_tree = shared_root / "docs/_templates/root-tree.md"
if root_tree.exists():
text = root_tree.read_text(encoding="utf-8", errors="replace")
for token in ROOT_TREE_TOKENS:
if token not in text:
errors.append(f"root-tree template missing token: {token}")
for path in _iter_pipeline_text_files(shared_root):
text = path.read_text(encoding="utf-8", errors="replace")
if FORBIDDEN_LITERAL in text:
rel = path.relative_to(shared_root)
errors.append(f"forbidden legacy dependency in {rel}: {FORBIDDEN_LITERAL}")
if path.is_symlink():
try:
target = str(path.resolve())
except OSError:
target = ""
if FORBIDDEN_LITERAL in target:
rel = path.relative_to(shared_root)
errors.append(f"forbidden legacy symlink target in {rel}: {target}")
return errors
def main() -> int:
parser = argparse.ArgumentParser(description="Verify the Tech Log documentation pipeline workspace.")
parser.add_argument("shared_root", nargs="?", type=Path,
default=Path(__file__).resolve().parent.parent)
args = parser.parse_args()
errors = verify_pipeline(args.shared_root)
if errors:
print("PIPELINE VERIFICATION: FAIL")
for error in errors:
print(f"- {error}")
return 1
print("PIPELINE VERIFICATION: PASS")
print(f"- required paths: {len(REQUIRED_PATHS)}")
print("- analysis queue contract: valid")
print("- root-tree contract: present")
print("- forbidden legacy dependency: absent")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import sys
from pathlib import Path
ALLOWED_TYPES = {
"PERFORMANCE", "CODE_STRUCTURE", "MODULE_STRUCTURE", "ARCHITECTURE",
"DATA_ACCESS", "RELIABILITY", "CONCURRENCY", "TRANSACTION",
"SECURITY", "OPERABILITY", "CONFIGURATION", "DEPENDENCY",
"BUILD", "TESTABILITY", "CLEANUP",
}
ALLOWED_SCOPES = {"LOCAL", "MODULE", "CROSS_MODULE", "PROJECT"}
ALLOWED_STATUSES = {"CANDIDATE", "READY", "BASELINING", "IN_PROGRESS", "VERIFYING", "WAITING_APPROVAL", "APPROVED", "MERGED", "REJECTED", "BLOCKED", "COMPLETE"}
PERFORMANCE_BASELINE_STATUSES = {"IN_PROGRESS", "VERIFYING", "WAITING_APPROVAL", "APPROVED", "MERGED", "COMPLETE"}
PERFORMANCE_COMPLETE_EVIDENCE_STATUSES = {"WAITING_APPROVAL", "APPROVED", "MERGED", "COMPLETE"}
def _load(item_dir: Path) -> dict:
path = item_dir / "work-item.json"
if not path.exists():
raise FileNotFoundError(path)
return json.loads(path.read_text(encoding="utf-8"))
def _resolve(item_dir: Path, rel: str | None) -> Path | None:
if not rel:
return None
return item_dir / rel
def _comparison_fields(text: str) -> dict[str, str]:
labels = (
"Same measurement command/procedure",
"Same metric definitions",
"Same dataset/load profile",
"Environment materially equivalent",
"Result",
"Acceptance criteria satisfied",
)
values: dict[str, str] = {}
for raw in text.splitlines():
stripped = raw.strip().lstrip("- ")
for label in labels:
prefix = label + ":"
if stripped.startswith(prefix):
values[label] = stripped[len(prefix):].strip().upper()
return values
def verify_work_item(item_dir: Path) -> list[str]:
item_dir = Path(item_dir)
errors: list[str] = []
try:
data = _load(item_dir)
except (FileNotFoundError, json.JSONDecodeError) as exc:
return [f"invalid work-item.json: {exc}"]
required = (
"schemaVersion", "id", "project", "analysisRevision", "type", "scope",
"target", "priority", "status", "problem", "goal", "acceptanceCriteria", "evidence",
)
for key in required:
if key not in data:
errors.append(f"missing work item field: {key}")
item_type = data.get("type")
if item_type not in ALLOWED_TYPES:
errors.append(f"invalid type: {item_type}")
scope = data.get("scope")
if scope not in ALLOWED_SCOPES:
errors.append(f"invalid scope: {scope}")
status = data.get("status")
if status not in ALLOWED_STATUSES:
errors.append(f"invalid status: {status}")
if item_type == "PERFORMANCE":
contract = data.get("measurementContract")
if not isinstance(contract, dict):
errors.append("performance baseline measurement contract is required before refactoring")
else:
for key in ("command", "cwd", "environment", "dataset", "metrics"):
value = contract.get(key)
if value in (None, "", []):
errors.append(f"performance baseline measurement contract missing: {key}")
environment = _resolve(item_dir, contract.get("environment"))
if environment is not None and not environment.exists():
errors.append(f"performance environment evidence missing: {contract.get('environment')}")
evidence = data.get("evidence") or {}
baseline = evidence.get("baseline") or []
after = evidence.get("after") or []
comparison = evidence.get("comparison")
def validate_metadata(phase: str, require: bool) -> dict | None:
meta_path = item_dir / f"evidence/{phase}/metadata.json"
if not meta_path.exists():
if require:
errors.append(f"performance {phase} metadata is required")
return None
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
errors.append(f"performance {phase} metadata invalid: {exc}")
return None
for key in ("sourceRevision", "command", "cwd", "exitCode", "dataset", "metrics", "rawFiles"):
if key not in meta or meta.get(key) in (None, "", []):
if key == "exitCode" and meta.get(key) == 0:
continue
errors.append(f"performance {phase} metadata missing: {key}")
if isinstance(contract, dict):
for key in ("command", "cwd", "dataset", "metrics"):
if meta.get(key) != contract.get(key):
errors.append(f"performance {phase} metadata {key} differs from measurement contract")
if meta.get("exitCode") not in (0,):
errors.append(f"performance {phase} measurement exitCode is not zero")
for rel in meta.get("rawFiles") or []:
if not (meta_path.parent / rel).exists():
errors.append(f"performance {phase} metadata raw file missing: {rel}")
return meta
if status in PERFORMANCE_BASELINE_STATUSES:
if not baseline:
errors.append("performance baseline evidence is required before source changes")
else:
for rel in baseline:
if not (item_dir / rel).exists():
errors.append(f"performance baseline evidence missing: {rel}")
baseline_meta = validate_metadata("baseline", True)
if baseline_meta is not None and baseline_meta.get("sourceRevision") != data.get("analysisRevision"):
errors.append("performance baseline metadata sourceRevision differs from analysisRevision")
if status in PERFORMANCE_COMPLETE_EVIDENCE_STATUSES:
if not after:
errors.append("performance after evidence is required")
else:
for rel in after:
if not (item_dir / rel).exists():
errors.append(f"performance after evidence missing: {rel}")
validate_metadata("after", True)
if not comparison:
errors.append("performance comparison evidence is required")
elif not (item_dir / comparison).exists():
errors.append(f"performance comparison evidence missing: {comparison}")
else:
comparison_text = (item_dir / comparison).read_text(encoding="utf-8", errors="replace")
fields = _comparison_fields(comparison_text)
required_comparison_fields = (
"Same measurement command/procedure",
"Same metric definitions",
"Same dataset/load profile",
"Environment materially equivalent",
"Result",
"Acceptance criteria satisfied",
)
for field in required_comparison_fields:
if not fields.get(field):
errors.append(f"performance comparison missing field: {field}")
for field in required_comparison_fields[:4]:
value = fields.get(field)
if value and value not in {"YES", "NO"}:
errors.append(f"performance comparison invalid equivalence value for {field}: {value}")
result = fields.get("Result")
if result and result not in {"IMPROVED", "NEUTRAL", "REGRESSED", "INCOMPARABLE"}:
errors.append(f"performance comparison invalid result: {result}")
acceptance = fields.get("Acceptance criteria satisfied")
if acceptance and acceptance not in {"YES", "NO"}:
errors.append(f"performance comparison invalid acceptance value: {acceptance}")
equivalent = all(fields.get(field) == "YES" for field in required_comparison_fields[:4])
if not equivalent and result and result != "INCOMPARABLE":
errors.append("performance incomparable conditions cannot claim a comparable result")
return errors
def main(argv: list[str] | None = None) -> int:
argv = sys.argv[1:] if argv is None else argv
if len(argv) != 1:
print("usage: verify_refactor_work_item.py <work-item-directory>")
return 2
errors = verify_work_item(Path(argv[0]))
if errors:
print("REFACTOR WORK ITEM VERIFICATION: FAIL")
for error in errors:
print(f"- {error}")
return 1
print("REFACTOR WORK ITEM VERIFICATION: PASS")
return 0
if __name__ == "__main__":
raise SystemExit(main())