"""차단(blocking) 검사 허용목록 — phase 를 멈출 수 있는 실패만 여기 등록한다.

배경: 검증 실패에는 등급이 없었다. 한 건이라도 나오면 run 이
`contract-violated` 가 되고 phase 가 전진하지 못한다
(`validators/validate-run.py` 의 `update_validation_metadata`,
`scripts/okstra_ctl/workflow.py` 의 `compute_workflow_state`). 그래서 리포트
문서의 상호참조 표기 하나가 실행 전체를 멈춰 세웠다.

실측 근거: 2026-08-20 이후 run-manifest 의 `validation.failures` 1209건 중
약 78% 가 리포트 문서 안의 기록·상호참조 항목이었다 — 승인 항목의 역추적
링크, `Evidence checked:` 문구, 활동 원장 backlink, 역할 이름 대조 같은
것들이다. 이 항목들이 틀려도 다음 단계가 읽는 파일은 그대로 읽히고,
렌더러도 그대로 돈다.

정책 — 허용목록(allowlist):

- 여기 등록된 조각(fragment)을 포함하는 실패만 run 을 실패시킨다.
- 등록되지 않은 실패는 advisory 로 강등된다. `validation.advisories` 에
  그대로 남고 stderr 로도 출력되지만 phase 를 막지 않는다.
- 새 검사는 기본이 advisory 다. 차단이 필요하면 여기 한 줄을 추가하되,
  **그 실패가 어떤 소비자를 실제로 깨뜨리는지** 함께 적는다. 그 문장을
  쓸 수 없으면 차단이 아니라 advisory 다.

2026-08-28 정리(`.project-docs/reports/2026-08-28-validate-run-audit.md`):
도달 불가·중복 조각 5개를 빼고, 접두 일치로 문서 표기 검사를 끌어들이던 조각
2개를 조이고, 릴리스 경로를 여는 값 4계열과 self-mock 게이트를 승격했다.
"""

from __future__ import annotations

# (조각, 왜 차단인가 — 이 실패가 깨뜨리는 소비자)
#
# 조각은 실패 메시지에 substring 으로 존재하면 일치로 본다.
_BLOCKING: tuple[tuple[str, str], ...] = (
    # 1. 다음 단계가 읽어야 할 산출물이 없거나 파싱되지 않는다.
    (
        "final-report data.json is missing",
        "렌더러의 단일 입력이 없다 — 마크다운/HTML 산출 자체가 불가능하다.",
    ),
    (
        "final-report data.json is not valid JSON",
        "같은 입력이 파싱되지 않는다.",
    ),
    (
        "final-report schema could not be loaded",
        "스키마 없이는 데이터 형태를 판정할 수 없어 렌더 결과를 보증할 수 없다.",
    ),
    (
        "final-report data.json schema:",
        "스키마 구조 위반 — 템플릿이 데이터를 순회하므로 필수 필드가 없으면 렌더가 깨진다.",
    ),
    # 콜론이 붙어야 파일 부재 1건(validate-run.py 의
    # `final report is missing: {report_path}`)만 잡는다. 콜론 없는 조각은
    # `final report is missing the ...` 로 시작하는 문서 섹션 검사 3건(Token
    # Usage Summary / Verdict Card / Index)까지 접두 일치로 끌어들여, 표시
    # 제목을 차단 사유에서 뺀 ADR-0024 와 정면으로 어긋났다.
    (
        "final report is missing:",
        "리포트 파일 자체가 없다.",
    ),
    # 주어 없는 `is not importable` 은 어떤 모듈이 못 올라와도 차단이 됐다.
    # 실제 메시지는 validate-run.py 의
    # `validate-run: okstra_ctl.final_report_schema is not importable — ...`
    # 한 곳뿐이다.
    (
        "okstra_ctl.final_report_schema is not importable",
        "스키마 모듈이 없으면 final-report 를 검증하지 않고 통과시킨다 — "
        "통과 판정 자체를 신뢰할 수 없다.",
    ),
    (
        "report-views: missing html artifact",
        "열람본이 생성되지 않았다 — 사용자에게 전달할 산출물이 없다.",
    ),
    (
        "report-views validator timed out",
        "열람본 생성 여부를 판정하지 못했다.",
    ),
    # 타임아웃과 같은 등급이다. 둘 다 "열람본이 계약을 지켰는지 판정하지
    # 못했다" 이고, 대리 스크립트 부재는 설치가 깨졌다는 뜻이라 이후 run 도
    # 전부 같은 상태다.
    (
        "validate-report-views.py missing under",
        "열람본 검증기가 설치본에 없다 — 판정 없이 통과가 된다.",
    ),
    (
        "conformance manifest unreadable",
        "적합성 결과를 읽지 못해 게이트 통과 여부를 알 수 없다.",
    ),
    (
        "plan-body verification state file is unreadable",
        "검증 라운드 상태를 읽지 못해 라운드 수행 여부를 알 수 없다.",
    ),
    # 파싱 불가와 같은 등급이다. 소비자(`scripts/okstra_ctl/incremental_carry.py`
    # 의 상태 파일 읽기)는 "파일이 없다" 와 "파일이 안 읽힌다" 를 구분하지
    # 못하고 둘 다 라운드 이력 없음으로 떨어진다.
    (
        "`state/plan-body-verification-*.json` was written",
        "덮어써진 검증 라운드의 유일한 기록이 없다 — 라운드 이력을 복원할 "
        "방법이 사라진다.",
    ),
    (
        "is missing worker prompt history file",
        "디스패치됐다고 기록된 워커가 받은 프롬프트가 없다 — 그 워커가 무엇을 "
        "지시받았는지 확인할 방법이 없어 결과를 감사할 수 없다.",
    ),
    (
        "persisted initial prompt path is missing",
        "같은 이유 — 워커의 최초 프롬프트 경로가 기록되지 않았다.",
    ),
    # 승인 게이트의 값 자체가 근거보다 건강하다고 주장한다. 이 검사는 선언값이
    # 재계산값보다 **높을 때만** 발화하므로(`_PLAN_GATE_RANK`), 발화 자체가
    # 거짓 주장이라는 뜻이다. 가장 흔한 모양은 라운드를 한 번도 못 돈 run 이다:
    # `roundCount` 0 · 판정 없음 · `gateResult: passed`. validate-run 의 다른
    # 계획 본문 검사들은 전부 `roundCount < 1` 에서 빠져나가므로, 이 조합을
    # 잡는 검사는 이것 하나뿐이다(2026-09-09 실측, dev-10642
    # implementation-planning 001: 8단계 전부 ok 로 발행됐다).
    #
    # 앞으로 가는 길이 있다. `plan-verify-r<N>` 디스패치로 라운드를 돌려 실제
    # 판정을 기록하거나, 라운드를 못 돈 사실 그대로 `aborted-non-result` 를
    # 적으면 통과한다. 이 kind 가 없던 동안에는 라운드를 여는 것 자체가
    # 불가능했으므로 이 규칙을 차단으로 두면 통과 가능한 값이 없었다 —
    # 디스패치 경로가 생긴 뒤라야 이 등급이 성립한다.
    (
        "but the recorded planItems[].verdicts only support",
        "승인 게이트 값이 기록된 판정보다 건강하다고 주장한다 — 다음 단계는 "
        "이 값을 읽어 승인 여부를 정하므로, 검증받지 않은 계획이 검증된 "
        "계획과 구분되지 않는다.",
    ),
    # 2. 다음 단계가 읽는 입력이 실제 내용과 어긋난다.
    #
    # 여기에 수렴 분류 재생 불일치와 triggerEvidence 불일치는 없다. 둘 다
    # 리포트 본문의 사실 주장이 틀렸다는 뜻이지만, 그 값을 읽어 실행을
    # 구동하는 소비자가 없다 — 검증기와 리포트 투영(report_projections)만
    # 읽는다. 프로그램 진행에는 문제가 없으므로 advisory 다.
    (
        "design-prep request content or fingerprint is stale",
        "디자인 준비 요청이 실제 내용과 어긋난다 — implementation 단계가 "
        "이 요청을 읽어 `wait_for_input` 까지 분기하므로"
        "(scripts/okstra_ctl/implementation_stage.py:113) 옛 요청으로 진행한다.",
    ),
    # selected-direction 계획의 두 조각. 주 집행은 조립 게시 게이트
    # (report_assembly `selected_direction_plan_errors`)이고, 여기는
    # 게시 후 변조·우회 경로의 백스톱이다. dev-10341 실측: 이 접두 17건이
    # advisory 로 통과 발행된 계획을 구현 진입이 그대로 하드 거부해 run 이
    # wedge 됐다.
    (
        "implementation-planning selected-direction:",
        "구현 진입(scripts/okstra_ctl/run.py `_validate_selected_implementation_plan`)"
        "이 같은 검증을 하드 거부한다 — 통과 발행된 계획이 다음 phase 에서 "
        "준비 자체가 안 된다.",
    ),
    (
        "has malformed conformanceTests declaration",
        "승인 경계(scripts/okstra_ctl/run.py `_validate_approved_plan_conformance`)"
        "가 같은 형식을 하드 거부한다 — 같은 이유로 구현 준비가 막힌다.",
    ),
    # Stage 관계 검사(S-검사: depends-on DAG, 병렬 stage 파일 안전, RED→GREEN
    # 순서, TDD 면제 어휘). 계획 런은 validate-run.py `_append_stage_data_failures`
    # 가 이 접두로 기록하고, 구현 진입은 같은 검증기를 subprocess 로 돌려
    # exit≠0 이면 PrepareError 로 거부한다. 2026-09-09 dev-10628 실측: Stage 1
    # 첫 단계가 `RED:` 가 아닌 계획(S10c)이 advisory 로 승인까지 갔다가 구현
    # 준비에서 거부돼, 승인된 불변 계획을 되돌려 다시 계획해야 했다.
    (
        "implementation-planning stage contract invalid",
        "구현 진입(scripts/okstra_ctl/run.py `_validate_stage_structure`)이 같은 "
        "검증기를 하드 거부한다 — 통과 발행된 계획이 다음 phase 에서 준비 자체가 "
        "안 된다.",
    ),
    # 3. 게이트 결과가 없거나 무의미하다.
    #
    # `conformance gate BLOCKING` 이 실제로 잡는 범위는 "스크립트가 안 돌았다"
    # 한 갈래보다 넓다. validate-run.py 의 발생 지점 22곳은 세 갈래다 —
    # 게이트 미실행, 승인된 계획 증거의 배관 실패(run-inputs 부재·approvedPlanPath
    # 부재·경로 해석 실패), 선언 규칙 위반(선언되지 않은 경로를 만진 diff,
    # 선언된 스크립트 누락). 세 갈래 전부 같은 소비자 문장으로 정당화된다:
    # 이후 단계가 "이 stage 는 적합성 검사를 통과했다"를 전제로 진행하는데,
    # 그 전제를 세울 근거 자체가 없다.
    #
    # 접두 결합에 주의한다. `missing_declared_scripts` 와
    # `_declared_conformance_errors` 가 만든 임의 문자열이 이 접두 뒤에 붙어
    # (`f"conformance gate BLOCKING: {error}"`) 여기 등록되지 않은 검사가
    # 자동으로 차단 등급을 얻는다. 그 함수들에 새 오류 문구를 추가하는 것은
    # 곧 이 허용목록에 한 줄을 추가하는 것과 같다.
    (
        "conformance gate BLOCKING",
        "적합성 게이트의 결과가 없다 — 이후 단계가 적합성 전제를 세울 수 없다.",
    ),
    # self-mock 게이트(PR #356)는 차단 목적으로 설계됐다. 테스트가 SUT 를
    # 스텁하면 통과 판정이 "무엇도 검증하지 않았다"와 같은 뜻이 되고,
    # final-verification 과 릴리스 게이트(scripts/okstra_ctl/release_gate.py)가
    # 검증되지 않은 코드를 통과시킨다. validate-run.py 의 self-mock 실패는
    # 전부 이 접두를 단다.
    (
        "self-mock gate BLOCKING:",
        "테스트가 SUT 를 스텁해 통과 판정이 무의미하다 — 검증되지 않은 코드가 "
        "릴리스 게이트를 지난다.",
    ),
    # 4. 검증되지 않은 결과가 릴리스·후속 stage 로 넘어간다.
    #
    # 이 군은 전부 릴리스 경로를 여는 값을 읽는 소비자가 검증기 밖에 있다.
    # 리포트 본문 표기가 아니라 판정 자체가 틀린 경우다.
    (
        "final-verification report declares",
        "리포트가 선언한 verificationScope·worktree·base/head 가 준비된 검증 "
        "대상과 다르다 — scripts/okstra_ctl/handoff.py 가 verificationScope 로 "
        "stage-group 적격성과 release-handoff 라우팅을 정하므로, 검증하지 않은 "
        "head 가 릴리스로 간다.",
    ),
    (
        "final-verification report covers stages",
        "같은 이유 — 증거를 준비하지 않은 stage 집합에 판정이 내려진다.",
    ),
    (
        "`verdict: FAIL` but `finalVerdict.verdictToken` is",
        "검증자가 기록한 FAIL 이 통과 판정과 공존한다 — release_gate.py 와 "
        "stage_fix_carry.py 가 통과 판정만 보므로 거부된 작업이 "
        "release-handoff 로 넘어간다.",
    ),
    (
        "implementation run declares stage-",
        "stage 캐리 사이드카가 디스크에 없다 — consumers.py 의 "
        "backfill_done_from_carry 가 이 파일로 stage 를 done 처리하므로, "
        "없으면 후속 stage 가 전부 PrepareError 로 준비에 실패한다.",
    ),
    # final-verification 판정의 교차 필드 모순. 세 조각으로 나눠 등록한 이유는
    # `final-verification: ` 접두 하나로 묶으면 같은 접두를 쓰는
    # addedSurfaceAudit 표기 검사 3건까지 차단으로 끌려오기 때문이다.
    (
        "final-verification: verdict ",
        "판정과 blocker/condition 이 모순이다 — release_gate.py 와 "
        "next_phase.py 가 이 판정으로 분기하므로 잘못된 다음 phase 가 열린다.",
    ),
    (
        "final-verification: routingRecommendation",
        "같은 이유 — 라우팅 대상이 없거나 판정이 허용하지 않는 대상을 가리킨다.",
    ),
    (
        "final-verification: verificationScope ",
        "같은 이유 — 검증 범위가 열거값 밖이거나 단일 stage 검증이 전체 "
        "release-handoff 를 요구한다.",
    ),
)

# 2026-08-28 에 제거한 조각과 근거 (감사 보고서 §2):
#
# - `failed to parse JSON:` — validate-run.py 의 `load_json` 이 이 문구로
#   ValueError 를 raise 하는데, 호출부가 failures 리스트를 만들기 전이라
#   프로세스가 그대로 죽는다. partition() 에 도달하지 못한다.
# - `cannot load Stage Map validator` — `spec_from_file_location` 은 파일이
#   없어도 spec 을 돌려주므로 조건이 성립하지 않고, 파일 부재는 그 다음
#   `exec_module` 에서 예외로 끝난다.
# - `missing or malformed at` — 유일 발생 지점(approvedPlanPath 부재)이
#   `conformance gate BLOCKING:` 접두를 함께 달고 있어 고유 승격이 0 이었다.
# - `unreadable at` — 발생 지점 4곳 중 3곳이 `conformance gate BLOCKING:`
#   접두 2곳과 `conformance manifest unreadable at` 1곳으로 이미 덮이고,
#   남은 1곳(self-mock 사이드카)은 위 `self-mock gate BLOCKING:` 로 덮인다.
#   이 조각이 하던 일은 self-mock 게이트에서 "사이드카를 못 읽었다" 갈래만
#   차단하고 실제 FAIL·미검출 뮤턴트는 advisory 로 두는 등급 역전이었다.
# - `phase-boundary:` — 판정 근거가 리드가 손으로 쓴 에러 로그 산문의
#   substring 스캔이었고 검사 자체가 삭제됐다.

BLOCKING_FRAGMENTS: tuple[str, ...] = tuple(fragment for fragment, _why in _BLOCKING)

BLOCKING_REASONS: dict[str, str] = {fragment: why for fragment, why in _BLOCKING}


def is_blocking(failure: str) -> bool:
    """``failure`` 가 허용목록에 걸리면 True."""
    return any(fragment in failure for fragment in BLOCKING_FRAGMENTS)


def partition(failures: list[str]) -> tuple[list[str], list[str]]:
    """``failures`` 를 (차단, advisory) 로 나눈다.

    순서는 입력 순서를 유지한다 — 리포트에 실릴 때 원래 검사 순서가 곧
    읽는 순서이기 때문이다.
    """
    blocking: list[str] = []
    advisory: list[str] = []
    for failure in failures:
        (blocking if is_blocking(failure) else advisory).append(failure)
    return blocking, advisory
