mirror of
https://github.com/Imbad0202/academic-research-skills.git
synced 2026-09-14 13:51:17 +08:00
17bf063456
* 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>
226 lines
7.3 KiB
Python
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
|
|
)
|