[verified] Check trusted-template AC verification classification

This commit is contained in:
2026-10-12 16:28:55 +00:00
parent b687af03ec
commit c1f0034300
3 changed files with 253 additions and 1 deletions
@@ -252,7 +252,7 @@ terminal(command="python3 harness/agents/spec/skills/gitea-implementation-spec-r
8. Same-repository PR이면 head object도 cache에서 검증하고 exact base-to-head tree를 Gitea changed-file set·diff와 대조한다. Fork PR이면 target cache에 head object가 있다고 가정하지 않고 authoritative head repository·branch SHA와 Gitea changed-file set·diff를 대조한다. Diff 직렬화의 byte 차이만으로 실패시키지 않고 변경 경로와 exact base/head blob 및 hunk가 동일 변경을 나타내는지 검증한다. 임의 ref, checkout, clone 또는 worktree를 만들지 않는다. 8. Same-repository PR이면 head object도 cache에서 검증하고 exact base-to-head tree를 Gitea changed-file set·diff와 대조한다. Fork PR이면 target cache에 head object가 있다고 가정하지 않고 authoritative head repository·branch SHA와 Gitea changed-file set·diff를 대조한다. Diff 직렬화의 byte 차이만으로 실패시키지 않고 변경 경로와 exact base/head blob 및 hunk가 동일 변경을 나타내는지 검증한다. 임의 ref, checkout, clone 또는 worktree를 만들지 않는다.
9. `release_repository` request를 보내되 Issue lease는 유지한다. 9. `release_repository` request를 보내되 Issue lease는 유지한다.
10. Acceptance matrix 작성 전에 구현자 실행 보고와 상세 실행 기록의 출처 및 확인 범위를 구분한다. Revision이 식별된 기존 evidence는 source revision과 reviewed head 사이 전체 diff·실행 입력 변경을 확인하고, 필수 검증별 재사용 가능 범위와 현재 head에 새로 결합해야 할 근거를 먼저 요약한다. 상세 기록이나 revision 정보의 부재만으로 새 검증 gate를 만들지 않는다. 재사용 여부는 위 Acceptance matrix의 기존 evidence binding 기준으로 판단하며 새 gate나 일괄 재실행 요구를 추가하지 않는다. 재검토에서는 이전 finding별 승인 계약 근거와 최소 보완 요청을 새 구현·evidence의 확인 위치에 대응하고, 해소 또는 잔여 사유를 기존 criterion의 `evidence`와 `reason`에 기록한다. 이미 충족된 검증을 source revision 차이만으로 다시 요구하지 않되, 변경 영향으로 새로 판정할 수 없어진 필수 조건은 그 근거와 최소 보완 범위를 특정한다. 이 초기 요약만으로 검토를 끝내지 않고 Approved Discovery에서 acceptance matrix를 만들고 모든 changed file, 문서, source, test와 exact-head-bound status evidence를 대응시킨다. 10. Acceptance matrix 작성 전에 구현자 실행 보고와 상세 실행 기록의 출처 및 확인 범위를 구분한다. Revision이 식별된 기존 evidence는 source revision과 reviewed head 사이 전체 diff·실행 입력 변경을 확인하고, 필수 검증별 재사용 가능 범위와 현재 head에 새로 결합해야 할 근거를 먼저 요약한다. 상세 기록이나 revision 정보의 부재만으로 새 검증 gate를 만들지 않는다. 재사용 여부는 위 Acceptance matrix의 기존 evidence binding 기준으로 판단하며 새 gate나 일괄 재실행 요구를 추가하지 않는다. 재검토에서는 이전 finding별 승인 계약 근거와 최소 보완 요청을 새 구현·evidence의 확인 위치에 대응하고, 해소 또는 잔여 사유를 기존 criterion의 `evidence`와 `reason`에 기록한다. 이미 충족된 검증을 source revision 차이만으로 다시 요구하지 않되, 변경 영향으로 새로 판정할 수 없어진 필수 조건은 그 근거와 최소 보완 범위를 특정한다. 이 초기 요약만으로 검토를 끝내지 않고 Approved Discovery에서 acceptance matrix를 만들고 모든 changed file, 문서, source, test와 exact-head-bound status evidence를 대응시킨다.
11. 문서 검토 입력과 구조·의미 분리 절차로 trusted 계약·산출물별 의무표를 준비하고 모든 문서 의무를 대조한다. 위임 시 해당 입력 전체를 제공한다. 독립 code-review worker 결과가 있으면 exact head에 바인딩됐고 authoritative하게 읽을 수 있을 때만 보조 evidence로 사용한다. 그 결과를 spec verdict로 그대로 복사하지 않는다. 11. 문서 검토 입력과 구조·의미 분리 절차로 trusted 계약·산출물별 의무표를 준비하고 모든 문서 의무를 대조한다. 정본 형식 요구사항의 AC 검증 분류는 아래 read-only 구조 검사로 보조 확인하고 비지원 형식·의미 판단은 main이 직접 대조한다. 위임 시 해당 입력 전체를 제공한다. 독립 code-review worker 결과가 있으면 exact head에 바인딩됐고 authoritative하게 읽을 수 있을 때만 보조 evidence로 사용한다. 그 결과를 spec verdict로 그대로 복사하지 않는다.
12. Verdict 규칙으로 하나의 strict result artifact와 canonical review body를 trusted workflow directory에 생성한다. 12. Verdict 규칙으로 하나의 strict result artifact와 canonical review body를 trusted workflow directory에 생성한다.
13. Exact key/type, duplicate key, marker field, digest, deterministic correlation, criterion cardinality, path binding, verdict/state 조합, credential 부재와 render → parse → render byte equality를 검증한다. 13. Exact key/type, duplicate key, marker field, digest, deterministic correlation, criterion cardinality, path binding, verdict/state 조합, credential 부재와 render → parse → render byte equality를 검증한다.
14. 여기까지는 read-only preflight다. Exact review 요청이 native review write 권한을 부여하지 않았거나 preflight가 완전하지 않으면 종료하며 review create 또는 다른 mutation을 수행하지 않는다. 14. 여기까지는 read-only preflight다. Exact review 요청이 native review write 권한을 부여하지 않았거나 preflight가 완전하지 않으면 종료하며 review create 또는 다른 mutation을 수행하지 않는다.
@@ -281,6 +281,14 @@ terminal(command="python3 harness/agents/spec/skills/gitea-implementation-spec-r
- 이는 호출 형태의 확인일 뿐 인증·provider wire schema·pagination completeness·callback 내부 동작이나 향후 네트워크 성공의 증명이 아니다. Write 뒤 readback이 실패하면 응답과 정확한 대상 근거를 보존하고 기존 read-only reconciliation으로 확인하거나 uncertain/partial로 중단한다. Signature 문제를 이유로 native create를 반복하지 않는다. - 이는 호출 형태의 확인일 뿐 인증·provider wire schema·pagination completeness·callback 내부 동작이나 향후 네트워크 성공의 증명이 아니다. Write 뒤 readback이 실패하면 응답과 정확한 대상 근거를 보존하고 기존 read-only reconciliation으로 확인하거나 uncertain/partial로 중단한다. Signature 문제를 이유로 native create를 반복하지 않는다.
- 새 runtime gate·범용 interface 검사기·adapter·schema·journal을 추가하지 않는다. 문서 계약 회귀는 지침의 존재만 확인하고, synthetic binding/collector 회귀는 로컬 호출 동작만 확인한다. 이를 실제 agent 실행이나 live publication 검증으로 보고하지 않는다. - 새 runtime gate·범용 interface 검사기·adapter·schema·journal을 추가하지 않는다. 문서 계약 회귀는 지침의 존재만 확인하고, synthetic binding/collector 회귀는 로컬 호출 동작만 확인한다. 이를 실제 agent 실행이나 live publication 검증으로 보고하지 않는다.
### AC 검증 분류의 read-only 구조 검사
Trusted requirements README/TEMPLATE와 exact reviewed-head 요구사항 snapshot을 준비한 뒤 `scripts/review_support.py check-ac-classification --document <immutable-req-snapshot> --expected-document-sha256 <independently-pinned-document-digest> --expected-template-sha256 <independently-pinned-trusted-template-digest>`를 실행한다. Expected digest는 검사 대상 파일을 그 자리에서 다시 hash해 승인하는 값이 아니라 기존 trusted input·source revision에서 독립 고정한 값이어야 한다. 정본 TEMPLATE는 helper와 같은 harness의 `docs/requirements/TEMPLATE.md`만 읽으며 제품 TEMPLATE나 임의 파일로 대체하지 않는다. CLI는 document·template의 exact UTF-8 bytes digest를 대조하고 원본을 수정하지 않는다. Digest 일치는 파일 결속일 뿐 reviewed-head 실행 provenance나 의미 인증을 대신하지 않는다.
- 현재 정본 형식의 `## 7. 인수 기준`에서 `## 8. 오류 및 충돌 계약`까지 flat H3 `REQ-NNN.XX-ACYY — 제목`별 `**검증 분류**` 하나와 수준·백엔드·케이스의 단일 행 필드를 확인한다. 빈 값·중복 필드·중복 AC ID·필드의 미치환 placeholder는 차단한다. 다른 AC 또는 코드 fence 안의 example로 누락 필드를 충족하지 않는다. `없음`은 기존 계약이 허용하는 값이며 새 backend enum·검증 수준 정책을 만들지 않는다.
- 지원 범위는 canonical flat block과 LF/CRLF에 한정한다. Multiline 분류값·들여쓴/중첩 heading·HTML/HTML comment·불완전 fence·모호한 section 구조는 자동 보정하지 않고 unsupported/미확인으로 남겨 main이 exact source와 trusted 계약을 직접 대조한다. 비지원 형식의 실패를 의미 결함으로 자동 판정하지 않는다. 입력은 1MiB 이하 regular UTF-8 파일만 읽으며 최종 symlink·FIFO는 거부한다. Ancestor 경로 admission과 authenticated source·revision binding은 기존 main 책임이다.
- Exit 0과 AC ID·checked fields·digest 출력은 해당 분류의 구조 검사 통과만 뜻한다. 전체 문서 적합성, AC ID 연속성·traceability, 필드값의 의미·검증 수준 적절성·실제 backend/test 실행·AC 충족은 기존 main 검토에 남긴다. Exit 2는 sanitized 오류이며 raw 입력이나 credential을 출력하지 않는다. 기존 result의 `evidence`·`reason`에 검사 범위·결과·미확인 이유를 기록하고 새 schema·제품 문서 자동 수정·publisher gate를 추가하지 않는다.
## 문서 검토 입력과 구조·의미 분리 ## 문서 검토 입력과 구조·의미 분리
문서 검토를 main이 수행하거나 delegate에 위임하기 전에 다음 입력을 trusted workflow에 준비한다. 이 준비는 제품 저장소의 새 산출물이나 result schema 변경을 요구하지 않는다. 문서 검토를 main이 수행하거나 delegate에 위임하기 전에 다음 입력을 trusted workflow에 준비한다. 이 준비는 제품 저장소의 새 산출물이나 result schema 변경을 요구하지 않는다.
@@ -642,6 +642,119 @@ def verify_publication(before, after, exact_review, result, allowed_paths, revie
return {"review_id": rid, "state": expected, "review_records_added": 1, return {"review_id": rid, "state": expected, "review_records_added": 1,
"timeline_events_added": 1, "projected_review_ids": projected, "read_only": True} "timeline_events_added": 1, "projected_review_ids": projected, "read_only": True}
def _classification_bytes(path):
"""Bounded regular-file read; caller independently admits source provenance."""
fd = None
try:
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
if not stat.S_ISREG(os.fstat(fd).st_mode):
_fail("classification input unavailable")
with os.fdopen(fd, "rb") as stream:
fd = None
raw = stream.read(1048577)
if len(raw) > 1048576:
_fail("classification input too large")
raw.decode("utf-8")
return raw
except (OSError, UnicodeError, ValueError):
_fail("classification input unavailable")
finally:
if fd is not None:
os.close(fd)
def _classification_lines(text):
if type(text) is not str or len(text.encode("utf-8")) > 1048576:
_fail("invalid classification text")
text = text.replace("\r\n", "\n")
if any(c in text for c in ("\r", "\x00", "\u2028", "\u2029")):
_fail("unsupported classification text")
visible, fence = [], None
for line in text.split("\n"):
match = re.fullmatch(r" {0,3}(`{3,}|~{3,})(.*)", line)
if fence is not None:
if match and match[1][0] == fence[0] and len(match[1]) >= fence[1] and not match[2].strip():
fence = None
continue
if match:
if match[1][0] == "`" and "`" in match[2]:
_fail("ambiguous classification fence")
fence = (match[1][0], len(match[1]))
continue
if '<!--' in line or '-->' in line or re.search(r'</?[A-Za-z][^>]*>', line):
_fail("unsupported classification HTML")
visible.append(line)
if fence is not None:
_fail("unterminated classification fence")
return visible
def check_ac_classification(body, expected_template_sha256):
"""Check only canonical section-7 AC classification, not full compliance."""
if type(expected_template_sha256) is not str or not HEX64.fullmatch(expected_template_sha256):
_fail("invalid trusted template digest")
template = _classification_bytes(Path(__file__).resolve().parents[5] / "docs/requirements/TEMPLATE.md")
if hashlib.sha256(template).hexdigest() != expected_template_sha256:
_fail("trusted template drift")
template_text = template.decode("utf-8")
try:
template_block = template_text.split("## 7. 인수 기준\n", 1)[1].split("## 8.", 1)[0]
classification = template_block.split("**검증 분류**\n", 1)[1].split("**상태 불변조건**", 1)[0]
except IndexError:
_fail("unsupported trusted template")
fields = re.findall(r"^- ([^:]+): \$\{[a-z_]+\}$", classification, flags=re.M)
if fields != ["수준", "백엔드", "케이스"]:
_fail("unsupported trusted template")
lines = _classification_lines(body)
section = "## 7. 인수 기준"
if lines.count(section) != 1:
_fail("ambiguous AC section")
start = lines.index(section) + 1
end = next((i for i in range(start, len(lines)) if lines[i].startswith("## ")), len(lines))
if end == len(lines) or lines[end] != "## 8. 오류 및 충돌 계약":
_fail("unsupported AC section boundary")
blocks, current = [], None
for line in lines[start:end]:
if line.startswith("### "):
match = re.fullmatch(r"### (REQ-[0-9]{3}\.[0-9]{2}-AC[0-9]{2}) — (\S.*)", line)
if not match:
_fail("unsupported AC heading")
current = (match[1], [])
blocks.append(current)
elif current is None:
if line.strip():
_fail("unsupported AC preamble")
else:
if line.lstrip().startswith("#"):
_fail("ambiguous AC heading")
current[1].append(line)
ids = [item[0] for item in blocks]
if not ids or len(ids) != len(set(ids)):
_fail("missing or duplicate AC")
for _, block in blocks:
marker = "**검증 분류**"
if block.count(marker) != 1:
_fail("missing or duplicate AC classification")
pos = block.index(marker) + 1
stop = next((i for i in range(pos, len(block)) if block[i].startswith("**")), len(block))
values = {}
for line in block[pos:stop]:
if not line.strip():
continue
match = re.fullmatch(r"- ([^:]+): (.*)", line)
if not match or match[1] not in fields or match[1] in values:
_fail("ambiguous AC classification field")
value = match[2].strip()
if not value or "${" in value:
_fail("empty or placeholder AC classification")
values[match[1]] = value
if set(values) != set(fields):
_fail("missing AC classification field")
return {"ac_ids": ids, "checked_fields": fields, "read_only": True,
"template_sha256": expected_template_sha256,
"document_sha256": hashlib.sha256(body.encode("utf-8")).hexdigest()}
def _read(path): def _read(path):
try: try:
with open(path, encoding="utf-8", newline="") as stream: with open(path, encoding="utf-8", newline="") as stream:
@@ -670,6 +783,10 @@ def main(argv=None):
q=sub.add_parser("check-cache"); q.add_argument("--contract-path",required=True); q.add_argument("--owner",required=True); q.add_argument("--repository",required=True); q.add_argument("--issue",required=True,type=int) q=sub.add_parser("check-cache"); q.add_argument("--contract-path",required=True); q.add_argument("--owner",required=True); q.add_argument("--repository",required=True); q.add_argument("--issue",required=True,type=int)
q=sub.add_parser("check-pages"); q.add_argument("--pages",required=True); q.add_argument("--key",required=True) q=sub.add_parser("check-pages"); q.add_argument("--pages",required=True); q.add_argument("--key",required=True)
q.add_argument("--page-size", type=int, default=50); q.add_argument("--max-pages", type=int, default=10000) q.add_argument("--page-size", type=int, default=50); q.add_argument("--max-pages", type=int, default=10000)
q=sub.add_parser("check-ac-classification")
q.add_argument("--document", required=True)
q.add_argument("--expected-document-sha256", required=True)
q.add_argument("--expected-template-sha256", required=True)
q=sub.add_parser("check-unchanged"); q.add_argument("--before",required=True); q.add_argument("--after",required=True) q=sub.add_parser("check-unchanged"); q.add_argument("--before",required=True); q.add_argument("--after",required=True)
q=sub.add_parser("verify-publication"); q=sub.add_parser("verify-publication");
for n in ("before","after","exact-review","result","allowed-paths","inline-comments"): q.add_argument("--"+n,required=True) for n in ("before","after","exact-review","result","allowed-paths","inline-comments"): q.add_argument("--"+n,required=True)
@@ -703,6 +820,11 @@ def main(argv=None):
items = collect_pages(f, a.key, a.page_size, a.max_pages) items = collect_pages(f, a.key, a.page_size, a.max_pages)
if len(used) != len(pages): _fail("extra saved pages") if len(used) != len(pages): _fail("extra saved pages")
print(json.dumps(items,ensure_ascii=False,sort_keys=True)) print(json.dumps(items,ensure_ascii=False,sort_keys=True))
elif a.cmd=="check-ac-classification":
raw = _classification_bytes(a.document)
if type(a.expected_document_sha256) is not str or not HEX64.fullmatch(a.expected_document_sha256) or hashlib.sha256(raw).hexdigest() != a.expected_document_sha256:
_fail("classification document drift")
print(json.dumps(check_ac_classification(raw.decode("utf-8"), a.expected_template_sha256), ensure_ascii=False, sort_keys=True))
elif a.cmd=="check-unchanged": print(json.dumps({"unchanged":verify_unchanged(_json_file(a.before),_json_file(a.after))})) elif a.cmd=="check-unchanged": print(json.dumps({"unchanged":verify_unchanged(_json_file(a.before),_json_file(a.after))}))
else: else:
print(json.dumps(verify_publication(_json_file(a.before),_json_file(a.after),_json_file(a.exact_review),_json_file(a.result),_json_file(a.allowed_paths),a.reviewer_id,_json_file(a.inline_comments),_json_file(a.provider_contract) if a.provider_contract else None, expected_pr_url=a.expected_pr_url),sort_keys=True)) print(json.dumps(verify_publication(_json_file(a.before),_json_file(a.after),_json_file(a.exact_review),_json_file(a.result),_json_file(a.allowed_paths),a.reviewer_id,_json_file(a.inline_comments),_json_file(a.provider_contract) if a.provider_contract else None, expected_pr_url=a.expected_pr_url),sort_keys=True))
@@ -61,6 +61,128 @@ def verify_fixture(before, after, review, r, paths, contract=None):
expected_pr_url="https://example.test/acme/repo/pulls/7") expected_pr_url="https://example.test/acme/repo/pulls/7")
class ACClassificationTests(unittest.TestCase):
TEMPLATE = ROOT / 'harness/docs/requirements/TEMPLATE.md'
def body(self):
return ('## 7. 인수 기준\n\n### REQ-001.01-AC01 — example\n\n'
'**판정 기준**\n\nexample\n\n**검증**\n\nexample\n\n'
'**검증 분류**\n\n- 수준: unit\n- 백엔드: 없음\n- 케이스: normal\n\n'
'**상태 불변조건**\n\n없음\n\n## 8. 오류 및 충돌 계약\n\n없음\n')
def check(self, body):
import hashlib
self.assertTrue(callable(getattr(mod, 'check_ac_classification', None)),
'trusted-template AC classification checker missing')
return mod.check_ac_classification(body, hashlib.sha256(self.TEMPLATE.read_bytes()).hexdigest())
def test_valid_canonical_ac_classification(self):
out = self.check(self.body())
self.assertEqual(out['ac_ids'], ['REQ-001.01-AC01'])
self.assertEqual(out['checked_fields'], ['수준', '백엔드', '케이스'])
self.assertTrue(out['read_only'])
def test_missing_empty_duplicate_and_placeholder_fields_rejected_per_ac(self):
valid = self.body()
self.check(valid)
for changed in (valid.replace('- 수준: unit\n', ''),
valid.replace('- 백엔드: 없음', '- 백엔드: '),
valid.replace('- 케이스: normal', '- 케이스: ${verification_cases}'),
valid.replace('- 수준: unit', '- 수준: unit\n- 수준: unit'),
valid.replace('**검증 분류**', '**검증**'),
valid.replace('**검증 분류**', '**검증 분류**\n\n**검증 분류**')):
with self.subTest(changed=changed):
with self.assertRaises(mod.ValidationError):
self.check(changed)
def test_other_ac_and_fenced_examples_cannot_supply_missing_field(self):
good = self.body().split('## 8.', 1)[0]
bad = good.replace('AC01', 'AC02').replace('- 수준: unit\n', '')
self.check(good + good.replace('AC01', 'AC02').split('\n\n', 1)[1] + '## 8. 오류 및 충돌 계약\n')
for extra in ('', '```markdown\n- 수준: unit\n```\n', '~~~\n- 수준: unit\n~~~\n'):
with self.subTest(extra=extra):
with self.assertRaises(mod.ValidationError):
self.check(good + bad.split('\n\n', 1)[1].replace('**상태 불변조건**', extra + '**상태 불변조건**') + '## 8. 오류 및 충돌 계약\n')
def test_ambiguous_hidden_sections_and_template_drift_fail_closed(self):
valid = self.body()
self.check(valid)
import hashlib
with self.assertRaises(mod.ValidationError):
mod.check_ac_classification(valid, '0' * 64)
for bad in ('<!--\n' + valid + '\n-->', valid + '\n```unclosed',
valid.replace('### REQ', '#### REQ'),
valid.replace('## 8. 오류 및 충돌 계약', '## 7. 인수 기준'),
valid.replace('- 수준: unit', ' - 수준: unit')):
with self.subTest(bad=bad):
with self.assertRaises(mod.ValidationError):
self.check(bad)
crlf = valid.replace('\n', '\r\n')
self.assertEqual(self.check(crlf)['document_sha256'], hashlib.sha256(crlf.encode()).hexdigest())
def test_cli_read_only_exact_bytes_digest_and_sanitized_failure(self):
import hashlib
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / 'req.md'
raw = self.body().replace('\n', '\r\n').encode()
path.write_bytes(raw)
os.chmod(path, 0o600)
before = (path.read_bytes(), path.stat().st_mode, path.stat().st_mtime_ns, path.stat().st_ino)
argv = [sys.executable, '-B', str(MODPATH), 'check-ac-classification',
'--document', str(path), '--expected-document-sha256', hashlib.sha256(raw).hexdigest(),
'--expected-template-sha256', hashlib.sha256(self.TEMPLATE.read_bytes()).hexdigest()]
actual = subprocess.run(argv, capture_output=True, text=True, timeout=10)
self.assertEqual(actual.returncode, 0, actual.stderr)
self.assertEqual(json.loads(actual.stdout)['document_sha256'], hashlib.sha256(raw).hexdigest())
self.assertEqual(before, (path.read_bytes(), path.stat().st_mode, path.stat().st_mtime_ns, path.stat().st_ino))
argv[-3] = '0' * 64
bad = subprocess.run(argv, capture_output=True, text=True, timeout=10)
self.assertEqual(bad.returncode, 2)
self.assertEqual(json.loads(bad.stderr), {'error': 'invalid input'})
self.assertEqual(bad.stdout, '')
def test_supported_scope_rejects_hidden_html_and_noncanonical_ac_heading(self):
valid = self.body()
self.check(valid)
for bad in ('<div>\n' + valid + '</div>',
valid.replace('### REQ-001.01-AC01 — example', '### REQ-001.01-AC01'),
valid.replace('**검증 분류**', '<span>**검증 분류**</span>')):
with self.subTest(bad=bad):
with self.assertRaises(mod.ValidationError):
self.check(bad)
def test_file_admission_rejects_symlink_fifo_invalid_utf8_and_oversize(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
valid = root / 'valid.md'
valid.write_text(self.body())
self.assertEqual(mod._classification_bytes(valid), valid.read_bytes())
alias = root / 'alias.md'
alias.symlink_to(valid)
fifo = root / 'fifo'
os.mkfifo(fifo)
invalid = root / 'invalid.md'
invalid.write_bytes(b'\xff')
large = root / 'large.md'
large.write_bytes(b'x' * 1048577)
for path in (alias, fifo, invalid, large, root):
with self.subTest(path=path.name):
with self.assertRaises(mod.ValidationError):
mod._classification_bytes(path)
def test_documentation_contract_scopes_checker_and_binds_digests(self):
text = (MODPATH.parent.parent / 'SKILL.md').read_text()
self.assertIn('### AC 검증 분류의 read-only 구조 검사', text)
section = text.split('### AC 검증 분류의 read-only 구조 검사', 1)[1].split('\n## ', 1)[0]
for rule in ('check-ac-classification', '--expected-document-sha256',
'--expected-template-sha256', '전체 문서 적합성', '의미',
'없음', '미확인', '코드 fence', '새 schema', '단일 행'):
self.assertIn(rule, section)
class SupportTests(unittest.TestCase): class SupportTests(unittest.TestCase):
def test_loads_json_rejects_duplicate_and_nonfinite(self): def test_loads_json_rejects_duplicate_and_nonfinite(self):
with self.assertRaises(mod.ValidationError): with self.assertRaises(mod.ValidationError):