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>
224 lines
8.4 KiB
Python
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())
|