refactor: 분리되어 관리하고 있던 문서 시스템을 하나로 통일
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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=<cwd></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 |
Executable
+148
@@ -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())
|
||||
Binary file not shown.
@@ -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("<tag>& 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()
|
||||
Executable
+225
@@ -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())
|
||||
Executable
+193
@@ -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())
|
||||
Reference in New Issue
Block a user