Files
imbad0202__academic-researc…/scripts/test_check_workflow_classification.py
Edward Cheng-I Wu 17bf063456 docs: CI workflow enforcement-class table + inventory lint (#755) (#774)
* docs: CI workflow enforcement-class table + WC-1/WC-2 lint (#755)

docs/ARCHITECTURE.md gains §7.1: all 14 workflows classified by
trigger / what it checks / enforcement class (blocking / advisory /
administrative / post-push detection) / bypass token, with the honest
count line (8 blocking on at least one event class, 2 advisory, 1
administrative, 3 post-push detection) and the explicit statement that
tag workflows detect after the push — their stop-power is the
maintainer acting on the failure. Per-workflow facts verified against
the workflow files (eval-harness ack token + PR-only gating; changelog
gate release/** head scope; pytest path filters; the three tag
triggers).

CONTRIBUTING release-checklist prose now points at the classification
instead of implying uniform CI enforcement.

Lint (same-PR drift-point discipline): check_workflow_classification.py
— WC-1 inventory sync both directions (a new, renamed, or removed
workflow fails CI until the table matches; duplicates refused), WC-2
class cells begin with the closed four-term vocabulary. Class
semantics stay review-owned (degradation-registry posture). 9 mutation
tests; wired into spec-consistency.yml + the pytest manifest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EosnA4RdUYgbF2KmZ1DTmc

* refactor: apply /simplify + codex R1 — table accuracy + lint hardening (#755)

Review round (3 cleanup agents + codex gpt-5.6-sol xhigh R1), findings
deduped and applied:

Table accuracy (codex 3 P2 + 1 P3, cleanup F2/F3): every trigger cell
now states its actual branch/path/tag filters (repository-hygiene and
command-invariants had birth-drifted cells; several rows omitted
targeting-main scopes); freshness-check reclassified honestly
(Advisory for staleness, but malformed protocol metadata is a hard
failure); bypass cells say "justification requested, not
machine-validated" (both workflows accept the bare token);
command-invariants "what it checks" gains its other two enforced
checks; bypass column normalized to "none"; the legend absorbs the
tag-workflows sentence and the duplicated qualifier prose is trimmed.

Lint hardening: section extraction switches to the shared
_skill_lint.heading_section (exact full-line heading incl. the #755
anchor, fence-aware — 15 fewer bespoke lines); rows parse once with
escaped-pipe-aware cell splitting; the inventory glob covers *.yaml;
WC-2 matches vocabulary terms as whole words (Blockingg fails); the
arity guard moves under WC-1 with a test; new WC-3 recomputes the
bolded count line from the Class column (the honesty sentence can no
longer self-invalidate when a workflow is added); new WC-4 pins every
[bypass-token] in a Bypass cell to verbatim presence in its workflow
file. 14 mutation tests.

Surfaces: docs/CONTROL_AVAILABILITY.md corrects its "on every change"
claim and links §7.1; the ARCHITECTURE "How to read" §7 bullet indexes
the CI sub-view.

Skipped with reason: read_or_exit2 exit-2 convention (sibling lints in
this fleet use the exit-1 missing-doc violation shape; consistency
wins).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EosnA4RdUYgbF2KmZ1DTmc

* fix: close codex R2 findings — .yaml fixture parity + comment-blind WC-4 (#755)

- The mutation fixture copies *.yaml alongside *.yml, so a future
  .yaml workflow with a valid row passes the fixture as it passes the
  real lint.
- WC-4 strips full-comment lines before the token search: a renamed
  executable token surviving only in a YAML comment no longer
  satisfies the pin (token in a non-comment echo/log string recorded
  as an accepted edge). Mutation test added (15 total).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EosnA4RdUYgbF2KmZ1DTmc

* fix: close codex R3 finding — tag pushes reach three more workflows (#755)

GitHub Actions matches tag pushes on unfiltered or paths-only push:
triggers (paths filters are not evaluated for tags), so
spec-consistency, command-invariants, and freshness-check also run on
every v* tag push — where their failures are post-push detection like
the tag-only workflows. Trigger cells amended and a subtlety note
added above the table; "three tag workflows" narrowed to "three
tag-only workflows".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EosnA4RdUYgbF2KmZ1DTmc

* fix: close codex R4 finding — malformed token spellings fail loudly (#755)

Any bracketed span in a Bypass cell must be a well-formed
[lowercase-hyphen] token: a typo like [skip_cooldown] now yields a
WC-4 violation instead of silently falling out of the token grammar.
Mutation test added (16 total).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EosnA4RdUYgbF2KmZ1DTmc

* fix: close codex R5 finding — whitespace token typos caught (#755)

The any-bracket span matcher now accepts any non-] content, so
[skip cooldown] (space typo) reaches the well-formedness check and
fails loudly. Mutation test added (17 total).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EosnA4RdUYgbF2KmZ1DTmc

* fix: close codex R6 finding — bogus rows fail instead of dropping out (#755)

Every pipe row in the section that is not the header or the separator
must open with a backticked workflow filename; a malformed row now
yields a WC-1 violation instead of silently leaving the inventory and
the WC-3 count. Mutation test added (18 total).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EosnA4RdUYgbF2KmZ1DTmc

* test: mirror docs/ARCHITECTURE.md into the CA fixture (#755)

The new CONTROL_AVAILABILITY link to ARCHITECTURE §7.1 made the #768
test fixture (which mirrors only the files the doc links) miss its
target, failing CA-1 in the fixture tree while the real tree passes —
caught by CI, not locally, because the local sweep re-ran the lint but
not its sibling test file. ARCHITECTURE.md joins the mirrored list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EosnA4RdUYgbF2KmZ1DTmc

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 09:03:53 +08:00

226 lines
7.3 KiB
Python

"""Mutation tests for check_workflow_classification.py (#755)."""
from __future__ import annotations
import shutil
from pathlib import Path
import pytest
from check_workflow_classification import (
DOC_RELPATH,
WORKFLOWS_DIR,
run_all_checks,
)
REPO_ROOT = Path(__file__).resolve().parent.parent
@pytest.fixture()
def repo(tmp_path: Path) -> Path:
"""Real doc + real workflow inventory, copied so mutations are isolated."""
dst_doc = tmp_path / DOC_RELPATH
dst_doc.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(REPO_ROOT / DOC_RELPATH, dst_doc)
(tmp_path / WORKFLOWS_DIR).mkdir(parents=True, exist_ok=True)
for pattern in ("*.yml", "*.yaml"):
for wf in sorted((REPO_ROOT / WORKFLOWS_DIR).glob(pattern)):
shutil.copyfile(wf, tmp_path / WORKFLOWS_DIR / wf.name)
return tmp_path
def _mutate_doc(root: Path, old: str, new: str) -> None:
doc = root / DOC_RELPATH
text = doc.read_text(encoding="utf-8")
assert old in text, f"mutation anchor not found: {old!r}"
doc.write_text(text.replace(old, new), encoding="utf-8")
def test_real_tree_passes() -> None:
assert run_all_checks(REPO_ROOT) == []
def test_fixture_baseline_passes(repo: Path) -> None:
assert run_all_checks(repo) == []
def test_missing_doc_is_single_fatal_error(repo: Path) -> None:
(repo / DOC_RELPATH).unlink()
errors = run_all_checks(repo)
assert len(errors) == 1
assert "missing" in errors[0]
def test_missing_section_fires(repo: Path) -> None:
_mutate_doc(
repo,
"### 7.1 CI workflow enforcement classes",
"### 7.1 CI workflow something else",
)
errors = run_all_checks(repo)
assert any("WC-1" in e and "not found" in e for e in errors)
def test_new_workflow_without_row_fires(repo: Path) -> None:
(repo / WORKFLOWS_DIR / "brand-new-check.yml").write_text(
"name: Brand New\non:\n push:\n", encoding="utf-8"
)
errors = run_all_checks(repo)
assert any(
"WC-1" in e and "brand-new-check.yml" in e and "no row" in e
for e in errors
)
def test_row_for_removed_workflow_fires(repo: Path) -> None:
(repo / WORKFLOWS_DIR / "freshness-check.yml").unlink()
errors = run_all_checks(repo)
assert any(
"WC-1" in e and "freshness-check.yml" in e and "names no file" in e
for e in errors
)
def test_duplicate_row_fires(repo: Path) -> None:
doc = repo / DOC_RELPATH
lines = doc.read_text(encoding="utf-8").split("\n")
idx = next(
i for i, line in enumerate(lines) if line.startswith("| `pytest.yml`")
)
lines.insert(idx, lines[idx])
doc.write_text("\n".join(lines), encoding="utf-8")
errors = run_all_checks(repo)
assert any(
"pytest.yml" in e and "more than one table row" in e for e in errors
)
def test_off_vocabulary_class_fires(repo: Path) -> None:
_mutate_doc(repo, "| Administrative |", "| Mandatory |")
errors = run_all_checks(repo)
assert any(
"WC-2" in e and "Mandatory" in e for e in errors
)
# The count line still says 1 administrative; the reclassified table
# has 0 — WC-3 must fire alongside.
assert any("WC-3" in e for e in errors)
def test_typo_suffix_class_fires(repo: Path) -> None:
# `Blockingg` must not pass on a prefix match — the vocabulary term is
# matched as a whole word (codex R1 finding).
_mutate_doc(repo, "| Administrative |", "| Administrativee |")
errors = run_all_checks(repo)
assert any("WC-2" in e and "Administrativee" in e for e in errors)
def test_class_with_qualifier_still_passes(repo: Path) -> None:
# Qualifiers after a boundary are in-vocabulary; the count is unchanged,
# so WC-3 stays quiet too.
_mutate_doc(
repo,
"| Administrative |",
"| Administrative (opens an issue) |",
)
assert run_all_checks(repo) == []
def test_yaml_extension_workflow_fires(repo: Path) -> None:
# GitHub Actions also reads *.yaml — a completeness lint bypassable by a
# file extension fails open in the exact direction it exists to prevent.
(repo / WORKFLOWS_DIR / "alt-ext.yaml").write_text(
"name: Alt\non:\n push:\n", encoding="utf-8"
)
errors = run_all_checks(repo)
assert any(
"WC-1" in e and "alt-ext.yaml" in e and "no row" in e for e in errors
)
def test_count_line_drift_fires(repo: Path) -> None:
_mutate_doc(repo, "8 blocking", "9 blocking")
errors = run_all_checks(repo)
assert any("WC-3" in e and "count line" in e for e in errors)
def test_renamed_bypass_token_fires(repo: Path) -> None:
# The table's `[skip-cooldown]` must exist verbatim in the workflow —
# renaming it there cannot leave the table stale with CI green.
wf = repo / WORKFLOWS_DIR / "release-cooldown.yml"
wf.write_text(
wf.read_text(encoding="utf-8").replace(
"[skip-cooldown]", "[cooldown-override]"
),
encoding="utf-8",
)
errors = run_all_checks(repo)
assert any(
"WC-4" in e and "[skip-cooldown]" in e for e in errors
)
def test_token_surviving_only_in_comment_fires(repo: Path) -> None:
# Renaming the executable token while an old comment still mentions it
# must not satisfy WC-4 (codex R2 finding).
wf = repo / WORKFLOWS_DIR / "release-cooldown.yml"
text = wf.read_text(encoding="utf-8")
text = text.replace("[skip-cooldown]", "[cooldown-override]")
wf.write_text(
text + "\n# legacy note: the old token was [skip-cooldown]\n",
encoding="utf-8",
)
errors = run_all_checks(repo)
assert any(
"WC-4" in e and "[skip-cooldown]" in e for e in errors
)
def test_malformed_token_spelling_fires(repo: Path) -> None:
# `[skip_cooldown]` (underscore typo) must fail loudly, not silently
# fall out of the token grammar (codex R4 finding).
_mutate_doc(repo, "[skip-cooldown]", "[skip_cooldown]")
errors = run_all_checks(repo)
assert any(
"WC-4" in e and "skip_cooldown" in e and "not a well-formed" in e
for e in errors
)
def test_whitespace_token_spelling_fires(repo: Path) -> None:
# `[skip cooldown]` (space typo) must also fail loudly (codex R5).
_mutate_doc(repo, "[skip-cooldown]", "[skip cooldown]")
errors = run_all_checks(repo)
assert any(
"WC-4" in e and "skip cooldown" in e and "not a well-formed" in e
for e in errors
)
def test_bogus_unbackticked_row_fires(repo: Path) -> None:
# A data row whose first cell is not a backticked workflow filename
# must fail WC-1, not silently drop out of the inventory and count
# (codex R6 finding).
_mutate_doc(
repo,
"| `tag-version-match.yml` |",
"| not-a-workflow | bogus | bogus | Advisory | none |\n"
"| `tag-version-match.yml` |",
)
errors = run_all_checks(repo)
assert any(
"WC-1" in e and "not-a-workflow" in e and "malformed" in e
for e in errors
)
def test_malformed_row_arity_fires(repo: Path) -> None:
_mutate_doc(
repo,
"| `tag-version-match.yml` | tag push `v*` | re-runs the full version-consistency lint at the tag | Post-push detection | none |",
"| `tag-version-match.yml` | tag push `v*` | Post-push detection | none |",
)
errors = run_all_checks(repo)
assert any(
"WC-1" in e and "tag-version-match.yml" in e and "expected 5" in e
for e in errors
)