diff --git a/scripts/orchestration/execution_context.py b/scripts/orchestration/execution_context.py index 2eb7822..8a66feb 100644 --- a/scripts/orchestration/execution_context.py +++ b/scripts/orchestration/execution_context.py @@ -17,7 +17,8 @@ from repository_preflight import capture_repository_evidence, task_caused_paths -SOURCE_ID_RE = re.compile(r"^[A-Z][A-Z0-9_-]*-\d+$") +SOURCE_ID_TOKEN = r"[A-Z][A-Z0-9_-]*-\d+[A-Z]?" +SOURCE_ID_RE = re.compile(rf"^{SOURCE_ID_TOKEN}$") AUTH_ALIAS_RE = re.compile(r"^AUTH-\d{3}$") EXCELLENCE_PROPOSAL_RE = re.compile(r"^EXC-\d+$") SAFE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") @@ -441,15 +442,39 @@ def add(identifier: str, value: str) -> None: records[identifier] = value for line in body.splitlines(): - bullet = re.match(r"^\s*[-*]\s+\*\*([A-Z][A-Z0-9_-]*-\d+)\*\*\s*:\s*(.+)$", line) + titled_bullet = re.match( + rf"^\s*[-*]\s+\*\*({SOURCE_ID_TOKEN})\s*[—-]\s*(.+?)(?::\*\*\s*|\*\*\s*:\s*)(.+)$", + line, + ) + if titled_bullet: + title = titled_bullet.group(2).strip() + detail = titled_bullet.group(3).strip() + add(titled_bullet.group(1), f"{title}: {detail}") + continue + bullet = re.match( + rf"^\s*[-*]\s+\*\*({SOURCE_ID_TOKEN})(?::\*\*\s*|\*\*\s*:\s*)(.+)$", + line, + ) if bullet: add(bullet.group(1), bullet.group(2)) continue - heading = re.match(r"^#{2,6}\s+([A-Z][A-Z0-9_-]*-\d+)\s*(?:[:—-]\s*)?(.*)$", line) + titled_plain = re.match( + rf"^\s*\*\*({SOURCE_ID_TOKEN})\s*[—-]\s*(.+?)(?::\*\*\s*|\*\*\s*:\s*)(.+)$", + line, + ) + if titled_plain: + title = titled_plain.group(2).strip() + detail = titled_plain.group(3).strip() + add(titled_plain.group(1), f"{title}: {detail}") + continue + heading = re.match( + rf"^#{{2,6}}\s+({SOURCE_ID_TOKEN})(?:(?:\s+[:—-]?\s*)|(?:[:—-]\s*))(.*)$", + line, + ) if heading and heading.group(2).strip(): add(heading.group(1), heading.group(2)) continue - plain = re.match(r"^\s*([A-Z][A-Z0-9_-]*-\d+)\s*:\s*(.+)$", line) + plain = re.match(rf"^\s*({SOURCE_ID_TOKEN})\s*:\s*(.+)$", line) if plain: add(plain.group(1), plain.group(2)) continue diff --git a/tests/test_orchestration_execution_context.py b/tests/test_orchestration_execution_context.py index 63a1423..34de1a7 100644 --- a/tests/test_orchestration_execution_context.py +++ b/tests/test_orchestration_execution_context.py @@ -19,6 +19,48 @@ from execution_context import build_review_package, build_task_brief # noqa: E402 +def test_source_records_keep_letter_suffixed_ids_distinct(tmp_path: Path) -> None: + specification = tmp_path / "spec.md" + body = ( + "### API-003 — Descriptor and pagination\n" + "### API-003A — Closed resource identity inventory\n" + "- **AC-005A:** Closed applicability bindings.\n" + "- **AC-005B**: Legacy outside-colon form.\n" + "- **REQ-001 — Repair first:** Repair the prerelease schema.\n" + "**DELTA-017 — Accepted resolution:** Repair in place.\n" + "| AC-005C | Closed skill-to-rule edge inventory |\n" + ) + + records = execution_context._source_records(specification, body) + + assert records == { + "API-003": "Descriptor and pagination", + "API-003A": "Closed resource identity inventory", + "AC-005A": "Closed applicability bindings.", + "AC-005B": "Legacy outside-colon form.", + "REQ-001": "Repair first: Repair the prerelease schema.", + "DELTA-017": "Accepted resolution: Repair in place.", + "AC-005C": "Closed skill-to-rule edge inventory", + } + + +def test_source_records_reject_malformed_suffixes_and_colonless_bullets(tmp_path: Path) -> None: + specification = tmp_path / "spec.md" + body = ( + "### API-003 — Descriptor and pagination\n" + "### API-003a — Lowercase suffix must not alias.\n" + "- **REQ-001** prose without a delimiter\n" + ) + + assert execution_context._source_records(specification, body) == { + "API-003": "Descriptor and pagination", + } + assert execution_context.SOURCE_ID_RE.fullmatch("API-003A") + assert not execution_context.SOURCE_ID_RE.fullmatch("API-003a") + assert not execution_context.SOURCE_ID_RE.fullmatch("API-003-A") + assert not execution_context.SOURCE_ID_RE.fullmatch("API-003AB") + + def test_compile_evidence_capability_maps_stable_task_local_invariants() -> None: validation = [{"id": "VAL-001", "invariant_ids": ["INV-001"], "capability_reason": "Observes violation."}] task = {"evidence_capability": {"result": "mapped", "reason": "Required.", "invariants": [{"id": "INV-001", "source_ids": ["REQ-001"], "invariant": "Observable behavior", "boundary": "unit", "oracle": "VAL-001", "capability_reason": "Unit oracle distinguishes violation.", "freshness": "current_task_batch", "task_id": "task-001", "evidence_ids": ["VAL-001"], "closure_result": "pending"}]}}