Files
imbad0202__academic-researc…/scripts/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

224 lines
8.4 KiB
Python

#!/usr/bin/env python3
"""Defrift lint for the CI workflow classification table (#755).
docs/ARCHITECTURE.md §7.1 claims to classify EVERY workflow's enforcement
strength — a completeness claim that silently rots when a workflow is
added, renamed, or removed. This lint pins the mechanical halves and
deliberately nothing else — whether a row's class honestly describes its
workflow's behavior stays owned by code review (degradation-registry
posture):
WC-1 Inventory sync, both directions: every *.yml / *.yaml under
.github/workflows/ has exactly one well-formed 5-cell table row,
and every row names an existing workflow file.
WC-2 Every row's Class cell begins with one of the closed four-term
vocabulary — Blocking / Advisory / Administrative / Post-push
detection — as a whole term (qualifiers may follow after a
boundary; `Blockingg` does not count).
WC-3 The bolded count line recomputes from the Class column: total,
blocking, advisory, administrative, and post-push numbers must
all match the table.
WC-4 Every `[bypass-token]` named in a row's Bypass cell appears
verbatim in that row's workflow file, on a non-comment line (a
renamed token cannot leave the table stale while CI stays green,
and a stale token surviving only in a YAML comment does not
count; one surviving only inside a non-comment echo/log string
is an accepted edge).
Exit 0 when all hold; exit 1 with one line per violation.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
from _skill_lint import heading_section
DOC_RELPATH = Path("docs/ARCHITECTURE.md")
SECTION_HEADING = "### 7.1 CI workflow enforcement classes (#755)"
WORKFLOWS_DIR = Path(".github/workflows")
CLASS_VOCABULARY = (
"Blocking",
"Advisory",
"Administrative",
"Post-push detection",
)
# The count line tallies by leading class term (order: total, then one count
# per vocabulary term as worded in the sentence).
_COUNT_LINE_RE = re.compile(
r"\*\*(\d+) workflows — (\d+) blocking[^,]*,\s+(\d+)\s+advisory,"
r"\s+(\d+) administrative,\s+(\d+) post-push detection\.\*\*",
re.DOTALL,
)
_FILENAME_CELL_RE = re.compile(r"`([^`]+\.ya?ml)`")
_BYPASS_TOKEN_RE = re.compile(r"\[[a-z][a-z-]*\]")
# Any bracketed span in a Bypass cell must BE a well-formed token — a typo
# spelling like `[skip_cooldown]` fails loudly instead of silently skipping
# WC-4.
_ANY_BRACKET_RE = re.compile(r"\[[^\]]+\]")
# Markdown cell split on unescaped pipes only (`\|` stays inside a cell).
_CELL_SPLIT_RE = re.compile(r"(?<!\\)\|")
def _table_rows(section: str) -> tuple[list[list[str]], list[str]]:
"""(data rows, malformed-first-cell errors). Every pipe row that is not
the header or the separator must open with a backticked workflow
filename — a bogus row cannot silently drop out of WC-1/WC-3."""
rows: list[list[str]] = []
errors: list[str] = []
for line in section.split("\n"):
stripped = line.strip()
if not stripped.startswith("|"):
continue
cells = [c.strip() for c in _CELL_SPLIT_RE.split(stripped)]
# Leading/trailing empties from the outer pipes.
if cells and cells[0] == "":
cells = cells[1:]
if cells and cells[-1] == "":
cells = cells[:-1]
if not cells:
continue
if cells[0] == "Workflow":
continue # header row
if all(re.fullmatch(r":?-{3,}:?", c) for c in cells):
continue # separator row
if _FILENAME_CELL_RE.fullmatch(cells[0]):
rows.append(cells)
else:
errors.append(
f"WC-1: table row opening with {cells[0]!r} does not name a "
f"backticked workflow file — malformed or misplaced row"
)
return rows, errors
def _class_term(class_cell: str) -> str | None:
"""The vocabulary term the cell begins with — as a whole term, followed
by end-of-cell, whitespace, or an opening parenthesis."""
for term in sorted(CLASS_VOCABULARY, key=len, reverse=True):
if class_cell == term or class_cell.startswith(term + " ") or (
class_cell.startswith(term + "(")
):
return term
return None
def run_all_checks(root: Path) -> list[str]:
doc_path = root / DOC_RELPATH
if not doc_path.exists():
return [f"WC-1: {DOC_RELPATH} is missing"]
doc_text = doc_path.read_text(encoding="utf-8")
section = heading_section(doc_text, SECTION_HEADING)
if section is None:
return [
f"WC-1: section '{SECTION_HEADING}' not found in {DOC_RELPATH}"
]
rows, errors = _table_rows(section)
on_disk = {
p.name
for pattern in ("*.yml", "*.yaml")
for p in (root / WORKFLOWS_DIR).glob(pattern)
}
in_table = [
_FILENAME_CELL_RE.fullmatch(cells[0]).group(1) for cells in rows
]
for name in sorted(on_disk - set(in_table)):
errors.append(
f"WC-1: workflow '{name}' exists on disk but has no row in the "
f"§7.1 classification table — classify it (new or renamed "
f"workflow)"
)
for name in sorted(set(in_table) - on_disk):
errors.append(
f"WC-1: table row '{name}' names no file under {WORKFLOWS_DIR} "
f"(removed or renamed workflow)"
)
for name in sorted({n for n in in_table if in_table.count(n) > 1}):
errors.append(f"WC-1: workflow '{name}' has more than one table row")
tally: dict[str, int] = {term: 0 for term in CLASS_VOCABULARY}
for name, cells in zip(in_table, rows):
# Cells: workflow | trigger | what it checks | class | bypass.
if len(cells) != 5:
errors.append(
f"WC-1: row '{name}' has {len(cells)} cells, expected 5"
)
continue
term = _class_term(cells[3])
if term is None:
errors.append(
f"WC-2: row '{name}' class cell {cells[3]!r} does not begin "
f"with a whole term from {list(CLASS_VOCABULARY)}"
)
else:
tally[term] += 1
if name in on_disk:
# Full-comment lines don't execute: a token surviving only in a
# comment must not satisfy WC-4.
workflow_text = "\n".join(
line
for line in (root / WORKFLOWS_DIR / name)
.read_text(encoding="utf-8")
.split("\n")
if not line.lstrip().startswith("#")
)
for span in _ANY_BRACKET_RE.findall(cells[4]):
if not _BYPASS_TOKEN_RE.fullmatch(span):
errors.append(
f"WC-4: bypass cell of '{name}' carries {span!r}, "
f"which is not a well-formed [lowercase-hyphen] "
f"token — typo or unsupported spelling"
)
for token in _BYPASS_TOKEN_RE.findall(cells[4]):
if token not in workflow_text:
errors.append(
f"WC-4: bypass token '{token}' in the '{name}' row "
f"does not appear in {WORKFLOWS_DIR / name} — "
f"renamed or removed token"
)
count_match = _COUNT_LINE_RE.search(section)
if not count_match:
errors.append(
"WC-3: the bolded count line is missing or no longer matches "
"the expected sentence shape"
)
else:
stated = [int(g) for g in count_match.groups()]
actual = [
len(rows),
tally["Blocking"],
tally["Advisory"],
tally["Administrative"],
tally["Post-push detection"],
]
if stated != actual:
errors.append(
f"WC-3: count line states "
f"total/blocking/advisory/administrative/post-push = "
f"{stated}, table has {actual}"
)
return errors
def main() -> int:
root = Path(__file__).resolve().parent.parent
errors = run_all_checks(root)
if errors:
for line in errors:
print(f"ERROR: {line}", file=sys.stderr)
print(
f"check_workflow_classification: {len(errors)} violation(s)",
file=sys.stderr,
)
return 1
print("check_workflow_classification: OK (WC-1..WC-4)")
return 0
if __name__ == "__main__":
sys.exit(main())