From d396dc0a53c6a207ad84c4268b82d1a0f3cc735e Mon Sep 17 00:00:00 2001 From: "const.koutsakis@aurecongroup.com" Date: Thu, 30 Apr 2026 20:28:54 +1000 Subject: [PATCH] chore: aspirational-ticket gate + INVARIANTS marker convention (#133, #153) --- .github/branch-protection/develop.json | 1 + .github/branch-protection/main.json | 1 + .github/scripts/check_aspirational_tickets.py | 183 ++++++++++++++++++ .github/workflows/ci.yml | 15 ++ docs/INVARIANTS.md | 14 ++ pyproject.toml | 2 +- uv.lock | 2 +- 7 files changed, 216 insertions(+), 2 deletions(-) create mode 100644 .github/scripts/check_aspirational_tickets.py diff --git a/.github/branch-protection/develop.json b/.github/branch-protection/develop.json index 9543e69..54cbbe7 100644 --- a/.github/branch-protection/develop.json +++ b/.github/branch-protection/develop.json @@ -13,6 +13,7 @@ "Action pinning audit", "Tests required", "src/ README audit", + "Aspirational ticket cite", "Frontend Build", "Frontend Quality", "Branch-protection contexts sync", diff --git a/.github/branch-protection/main.json b/.github/branch-protection/main.json index d7bafaa..455b74a 100644 --- a/.github/branch-protection/main.json +++ b/.github/branch-protection/main.json @@ -13,6 +13,7 @@ "Action pinning audit", "Tests required", "src/ README audit", + "Aspirational ticket cite", "Frontend Build", "Frontend Quality", "Branch-protection contexts sync", diff --git a/.github/scripts/check_aspirational_tickets.py b/.github/scripts/check_aspirational_tickets.py new file mode 100644 index 0000000..ded7013 --- /dev/null +++ b/.github/scripts/check_aspirational_tickets.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""Enforce the *aspirational invariants are ticketed* rule from `docs/HARNESS.md`. + +`docs/HARNESS.md`: *"Do not merge an 'aspirational' invariant without a +follow-up issue."* `docs/INVARIANTS.md` documents this with the +`*Aspirational:*` marker line per invariant. Until #133 this rule was +enforced by reviewer memory only. + +Behaviour: + +- Walks `docs/INVARIANTS.md`, reading line by line. +- A line is treated as an *aspirational marker* when it starts with + `*Aspirational` (one leading asterisk) or `**Aspirational**` (two). + Mid-sentence prose like "items marked *aspirational* are…" is + ignored — the marker shape is documented in + `docs/INVARIANTS.md`. +- For each marker line, requires at least one `#NNN` reference on the + same line; fails when none is present. +- When `GITHUB_TOKEN` and `GITHUB_REPOSITORY` are set, fetches each + cited issue via the REST API and emits a `::warning::` (not + failure) when the ticket is closed. API failures (network, rate + limit, 404) downgrade to a warning — the gate's job is to catch + drift in the doc, not to be a transient-CI tripwire. +- When `ASPIRATIONAL_STRICT=1` is set, a closed-ticket cite is + promoted from `::warning::` to a hard failure (#153). Default off. + This is the toggle to flip when the project decides "an aspirational + invariant whose cite is closed must be promoted to enforced or + refiled in this PR" — useful as the harness matures and accumulated + closed-cite drift would otherwise sit unread. API failures still + downgrade to a warning even under strict mode (the gate is still + not a transient-CI tripwire — only documented closed state hits). + +There is **no exemption mechanism** (see `feedback_no_noqa`). If an +entry should not be flagged, it is not aspirational — reword it with +a different marker (e.g. `**Production note:**` for future product +evolution that's not a yet-to-be-enforced rule). + +Exit codes: + 0 — every aspirational marker cites at least one open / unverified ticket + 1 — at least one marker has no ticket cite, or (strict mode) has a + closed cite + 2 — script-level error (`docs/INVARIANTS.md` not found) + +Usage (from repo root): + + python .github/scripts/check_aspirational_tickets.py +""" + +from __future__ import annotations + +import json +import os +import re +import sys +import urllib.error +import urllib.request +from pathlib import Path + +INVARIANTS_DOC = Path("docs/INVARIANTS.md") + +# A marker line *starts* with one or two asterisks immediately followed by +# `Aspirational` and a word boundary. Avoids picking up mid-sentence prose +# such as `Items marked *aspirational* are rules…` (lower-case + non-anchored). +_MARKER_RE = re.compile(r"^\*{1,2}Aspirational\b") +_TICKET_RE = re.compile(r"#(\d+)") + + +def _find_markers(text: str) -> list[tuple[int, str]]: + """Return (1-indexed line number, line text) for each aspirational marker.""" + found: list[tuple[int, str]] = [] + for index, line in enumerate(text.splitlines(), start=1): + if _MARKER_RE.match(line.lstrip()): + found.append((index, line.strip())) + return found + + +def _issue_state(repo: str, number: str, token: str) -> str | None: + """Return the issue state ("open" / "closed") or None on any API failure.""" + url = f"https://api.github.com/repos/{repo}/issues/{number}" + req = urllib.request.Request( # noqa: S310 — fixed api.github.com host + url, + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) + try: + with urllib.request.urlopen(req, timeout=5) as response: # noqa: S310 + payload = json.loads(response.read().decode("utf-8")) + except urllib.error.URLError, TimeoutError, json.JSONDecodeError: + return None + state = payload.get("state") + return state if isinstance(state, str) else None + + +def _check_closed_cites( + line_number: int, + tickets: list[str], + repo: str, + token: str, + *, + strict: bool, +) -> list[str]: + """Return failure messages (strict only); print warnings otherwise.""" + extra_failures: list[str] = [] + for ticket in tickets: + state = _issue_state(repo, ticket, token) + if state != "closed": + continue + severity = "error" if strict else "warning" + message = ( + f"::{severity} file={INVARIANTS_DOC.as_posix()},line={line_number}::" + f"cited ticket #{ticket} is closed. Promote the invariant to " + "enforced (and remove the marker), or refile with a fresh ticket." + ) + if strict: + extra_failures.append(message) + else: + print(message) + return extra_failures + + +def main() -> int: + if not INVARIANTS_DOC.is_file(): + print(f"::error::{INVARIANTS_DOC.as_posix()} not found; run from repo root") + return 2 + + text = INVARIANTS_DOC.read_text(encoding="utf-8") + markers = _find_markers(text) + + if not markers: + print("No aspirational markers found in docs/INVARIANTS.md.") + return 0 + + failures: list[str] = [] + repo = os.environ.get("GITHUB_REPOSITORY", "") + token = os.environ.get("GITHUB_TOKEN", "") + can_check_state = bool(repo and token) + strict_closed = os.environ.get("ASPIRATIONAL_STRICT", "") == "1" + + for line_number, line in markers: + tickets = _TICKET_RE.findall(line) + if not tickets: + failures.append( + f"::error file={INVARIANTS_DOC.as_posix()},line={line_number}::" + "aspirational marker has no `#NNN` ticket reference. Add one or " + "reword (see the doc's *How to add an invariant* footer)." + ) + continue + print( + f"{INVARIANTS_DOC.as_posix()}:{line_number} — " + f"aspirational, cites: {', '.join('#' + t for t in tickets)}" + ) + if can_check_state: + failures.extend( + _check_closed_cites( + line_number, tickets, repo, token, strict=strict_closed + ) + ) + + if failures: + for line in failures: + print(line) + print( + f"\n{len(failures)} aspirational marker(s) missing a ticket cite. " + "Fix in this PR — there is no exemption mechanism, see the " + "module docstring." + ) + return 1 + + suffix_parts: list[str] = [] + if can_check_state: + suffix_parts.append("with state lookup") + if strict_closed: + suffix_parts.append("strict closed-cite mode") + suffix = f" ({', '.join(suffix_parts)})" if suffix_parts else "" + print(f"Aspirational-ticket audit OK — {len(markers)} marker(s) checked{suffix}.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f0b2cb..0c9bf08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -151,6 +151,21 @@ jobs: python-version: "3.14" - run: python .github/scripts/check_tests_present.py + aspirational-tickets: + name: Aspirational ticket cite + runs-on: ubuntu-latest + # docs/INVARIANTS.md: every `*Aspirational` / `**Aspirational**` marker + # line cites a `#NNN` ticket; closed cites warn (or fail under + # ASPIRATIONAL_STRICT=1). GITHUB_TOKEN enables ticket-state lookup. + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.14" + - env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: python .github/scripts/check_aspirational_tickets.py + src-readmes: name: src/ README audit runs-on: ubuntu-latest diff --git a/docs/INVARIANTS.md b/docs/INVARIANTS.md index 093305b..74c3010 100644 --- a/docs/INVARIANTS.md +++ b/docs/INVARIANTS.md @@ -48,3 +48,17 @@ Add invariants below as your domain stabilises. Each entry should describe: - *Enforced by:* test, review, or specific CI job. Examples of the kind of invariant that earns a slot here: a domain-specific data contract that must validate at ingestion, a security boundary that must not log PII, a tool-call protocol that the agent must follow before the LLM emits a final response. + +--- + +## How to add an invariant + +Add a new `## N. ` section at the bottom of slots 6+. Each entry has three lines: + +- The rule, one sentence. +- **Where:** module / config file path. +- **Enforced by:** test name, CI job, or `review` if no automated check exists yet. + +If the invariant is not yet automated, add a marker line whose first non-whitespace characters are `*Aspirational` or `**Aspirational**` — both shapes are recognised by the `Aspirational ticket cite` CI job (`.github/scripts/check_aspirational_tickets.py`). The marker line MUST cite at least one `#NNN` ticket; the gate fails CI otherwise. When the cited ticket closes, promote the invariant to enforced in the same PR (delete the marker line, fill in `Enforced by:`). Set `ASPIRATIONAL_STRICT=1` on the gate's CI job to escalate closed-cite drift from `::warning::` to a hard failure. + +Use `**Production note:**` (not `**Aspirational**`) for forward-looking product evolution that is NOT a future-enforced rule — e.g. "this changes when multi-tenant lands". Production notes are not picked up by the gate. diff --git a/pyproject.toml b/pyproject.toml index 2e06350..1f84568 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "harness-python-react" -version = "0.2.1" +version = "0.2.2" description = "Production-quality LLM-driven coding harness — Python (FastAPI) backend, Vite + React + TypeScript frontend." readme = "README.md" requires-python = ">=3.14" diff --git a/uv.lock b/uv.lock index df07bc8..e4cbcb4 100644 --- a/uv.lock +++ b/uv.lock @@ -328,7 +328,7 @@ wheels = [ [[package]] name = "harness-python-react" -version = "0.2.1" +version = "0.2.2" source = { virtual = "." } dependencies = [ { name = "fastapi" },