diff --git a/harness/agents/spec/skills/gitea-implementation-spec-review/SKILL.md b/harness/agents/spec/skills/gitea-implementation-spec-review/SKILL.md index ba1180d..8973434 100644 --- a/harness/agents/spec/skills/gitea-implementation-spec-review/SKILL.md +++ b/harness/agents/spec/skills/gitea-implementation-spec-review/SKILL.md @@ -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를 만들지 않는다. 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를 대응시킨다. -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에 생성한다. 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을 수행하지 않는다. @@ -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를 반복하지 않는다. - 새 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 --expected-document-sha256 --expected-template-sha256 `를 실행한다. 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 변경을 요구하지 않는다. diff --git a/harness/agents/spec/skills/gitea-implementation-spec-review/scripts/review_support.py b/harness/agents/spec/skills/gitea-implementation-spec-review/scripts/review_support.py index 2c7dda5..b224cb0 100644 --- a/harness/agents/spec/skills/gitea-implementation-spec-review/scripts/review_support.py +++ b/harness/agents/spec/skills/gitea-implementation-spec-review/scripts/review_support.py @@ -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, "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 re.search(r']*>', 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): try: 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-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=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("verify-publication"); 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) if len(used) != len(pages): _fail("extra saved pages") 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))})) 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)) diff --git a/harness/tests/spec/test_implementation_spec_review_support.py b/harness/tests/spec/test_implementation_spec_review_support.py index 83e3e8c..aa8fad2 100644 --- a/harness/tests/spec/test_implementation_spec_review_support.py +++ b/harness/tests/spec/test_implementation_spec_review_support.py @@ -61,6 +61,128 @@ def verify_fixture(before, after, review, r, paths, contract=None): 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 ('', 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 ('
\n' + valid + '
', + valid.replace('### REQ-001.01-AC01 — example', '### REQ-001.01-AC01'), + valid.replace('**검증 분류**', '**검증 분류**')): + 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): def test_loads_json_rejects_duplicate_and_nonfinite(self): with self.assertRaises(mod.ValidationError):