Skip to content

Commit 461cab6

Browse files
authored
chore: aspirational-ticket gate + INVARIANTS marker convention (#133, #153) (#72)
1 parent 843c253 commit 461cab6

7 files changed

Lines changed: 216 additions & 2 deletions

File tree

.github/branch-protection/develop.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"Action pinning audit",
1414
"Tests required",
1515
"src/ README audit",
16+
"Aspirational ticket cite",
1617
"Frontend Build",
1718
"Frontend Quality",
1819
"Branch-protection contexts sync",

.github/branch-protection/main.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"Action pinning audit",
1414
"Tests required",
1515
"src/ README audit",
16+
"Aspirational ticket cite",
1617
"Frontend Build",
1718
"Frontend Quality",
1819
"Branch-protection contexts sync",
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
#!/usr/bin/env python3
2+
"""Enforce the *aspirational invariants are ticketed* rule from `docs/HARNESS.md`.
3+
4+
`docs/HARNESS.md`: *"Do not merge an 'aspirational' invariant without a
5+
follow-up issue."* `docs/INVARIANTS.md` documents this with the
6+
`*Aspirational:*` marker line per invariant. Until #133 this rule was
7+
enforced by reviewer memory only.
8+
9+
Behaviour:
10+
11+
- Walks `docs/INVARIANTS.md`, reading line by line.
12+
- A line is treated as an *aspirational marker* when it starts with
13+
`*Aspirational` (one leading asterisk) or `**Aspirational**` (two).
14+
Mid-sentence prose like "items marked *aspirational* are…" is
15+
ignored — the marker shape is documented in
16+
`docs/INVARIANTS.md`.
17+
- For each marker line, requires at least one `#NNN` reference on the
18+
same line; fails when none is present.
19+
- When `GITHUB_TOKEN` and `GITHUB_REPOSITORY` are set, fetches each
20+
cited issue via the REST API and emits a `::warning::` (not
21+
failure) when the ticket is closed. API failures (network, rate
22+
limit, 404) downgrade to a warning — the gate's job is to catch
23+
drift in the doc, not to be a transient-CI tripwire.
24+
- When `ASPIRATIONAL_STRICT=1` is set, a closed-ticket cite is
25+
promoted from `::warning::` to a hard failure (#153). Default off.
26+
This is the toggle to flip when the project decides "an aspirational
27+
invariant whose cite is closed must be promoted to enforced or
28+
refiled in this PR" — useful as the harness matures and accumulated
29+
closed-cite drift would otherwise sit unread. API failures still
30+
downgrade to a warning even under strict mode (the gate is still
31+
not a transient-CI tripwire — only documented closed state hits).
32+
33+
There is **no exemption mechanism** (see `feedback_no_noqa`). If an
34+
entry should not be flagged, it is not aspirational — reword it with
35+
a different marker (e.g. `**Production note:**` for future product
36+
evolution that's not a yet-to-be-enforced rule).
37+
38+
Exit codes:
39+
0 — every aspirational marker cites at least one open / unverified ticket
40+
1 — at least one marker has no ticket cite, or (strict mode) has a
41+
closed cite
42+
2 — script-level error (`docs/INVARIANTS.md` not found)
43+
44+
Usage (from repo root):
45+
46+
python .github/scripts/check_aspirational_tickets.py
47+
"""
48+
49+
from __future__ import annotations
50+
51+
import json
52+
import os
53+
import re
54+
import sys
55+
import urllib.error
56+
import urllib.request
57+
from pathlib import Path
58+
59+
INVARIANTS_DOC = Path("docs/INVARIANTS.md")
60+
61+
# A marker line *starts* with one or two asterisks immediately followed by
62+
# `Aspirational` and a word boundary. Avoids picking up mid-sentence prose
63+
# such as `Items marked *aspirational* are rules…` (lower-case + non-anchored).
64+
_MARKER_RE = re.compile(r"^\*{1,2}Aspirational\b")
65+
_TICKET_RE = re.compile(r"#(\d+)")
66+
67+
68+
def _find_markers(text: str) -> list[tuple[int, str]]:
69+
"""Return (1-indexed line number, line text) for each aspirational marker."""
70+
found: list[tuple[int, str]] = []
71+
for index, line in enumerate(text.splitlines(), start=1):
72+
if _MARKER_RE.match(line.lstrip()):
73+
found.append((index, line.strip()))
74+
return found
75+
76+
77+
def _issue_state(repo: str, number: str, token: str) -> str | None:
78+
"""Return the issue state ("open" / "closed") or None on any API failure."""
79+
url = f"https://api.github.com/repos/{repo}/issues/{number}"
80+
req = urllib.request.Request( # noqa: S310 — fixed api.github.com host
81+
url,
82+
headers={
83+
"Authorization": f"Bearer {token}",
84+
"Accept": "application/vnd.github+json",
85+
"X-GitHub-Api-Version": "2022-11-28",
86+
},
87+
)
88+
try:
89+
with urllib.request.urlopen(req, timeout=5) as response: # noqa: S310
90+
payload = json.loads(response.read().decode("utf-8"))
91+
except urllib.error.URLError, TimeoutError, json.JSONDecodeError:
92+
return None
93+
state = payload.get("state")
94+
return state if isinstance(state, str) else None
95+
96+
97+
def _check_closed_cites(
98+
line_number: int,
99+
tickets: list[str],
100+
repo: str,
101+
token: str,
102+
*,
103+
strict: bool,
104+
) -> list[str]:
105+
"""Return failure messages (strict only); print warnings otherwise."""
106+
extra_failures: list[str] = []
107+
for ticket in tickets:
108+
state = _issue_state(repo, ticket, token)
109+
if state != "closed":
110+
continue
111+
severity = "error" if strict else "warning"
112+
message = (
113+
f"::{severity} file={INVARIANTS_DOC.as_posix()},line={line_number}::"
114+
f"cited ticket #{ticket} is closed. Promote the invariant to "
115+
"enforced (and remove the marker), or refile with a fresh ticket."
116+
)
117+
if strict:
118+
extra_failures.append(message)
119+
else:
120+
print(message)
121+
return extra_failures
122+
123+
124+
def main() -> int:
125+
if not INVARIANTS_DOC.is_file():
126+
print(f"::error::{INVARIANTS_DOC.as_posix()} not found; run from repo root")
127+
return 2
128+
129+
text = INVARIANTS_DOC.read_text(encoding="utf-8")
130+
markers = _find_markers(text)
131+
132+
if not markers:
133+
print("No aspirational markers found in docs/INVARIANTS.md.")
134+
return 0
135+
136+
failures: list[str] = []
137+
repo = os.environ.get("GITHUB_REPOSITORY", "")
138+
token = os.environ.get("GITHUB_TOKEN", "")
139+
can_check_state = bool(repo and token)
140+
strict_closed = os.environ.get("ASPIRATIONAL_STRICT", "") == "1"
141+
142+
for line_number, line in markers:
143+
tickets = _TICKET_RE.findall(line)
144+
if not tickets:
145+
failures.append(
146+
f"::error file={INVARIANTS_DOC.as_posix()},line={line_number}::"
147+
"aspirational marker has no `#NNN` ticket reference. Add one or "
148+
"reword (see the doc's *How to add an invariant* footer)."
149+
)
150+
continue
151+
print(
152+
f"{INVARIANTS_DOC.as_posix()}:{line_number} — "
153+
f"aspirational, cites: {', '.join('#' + t for t in tickets)}"
154+
)
155+
if can_check_state:
156+
failures.extend(
157+
_check_closed_cites(
158+
line_number, tickets, repo, token, strict=strict_closed
159+
)
160+
)
161+
162+
if failures:
163+
for line in failures:
164+
print(line)
165+
print(
166+
f"\n{len(failures)} aspirational marker(s) missing a ticket cite. "
167+
"Fix in this PR — there is no exemption mechanism, see the "
168+
"module docstring."
169+
)
170+
return 1
171+
172+
suffix_parts: list[str] = []
173+
if can_check_state:
174+
suffix_parts.append("with state lookup")
175+
if strict_closed:
176+
suffix_parts.append("strict closed-cite mode")
177+
suffix = f" ({', '.join(suffix_parts)})" if suffix_parts else ""
178+
print(f"Aspirational-ticket audit OK — {len(markers)} marker(s) checked{suffix}.")
179+
return 0
180+
181+
182+
if __name__ == "__main__":
183+
sys.exit(main())

.github/workflows/ci.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,21 @@ jobs:
151151
python-version: "3.14"
152152
- run: python .github/scripts/check_tests_present.py
153153

154+
aspirational-tickets:
155+
name: Aspirational ticket cite
156+
runs-on: ubuntu-latest
157+
# docs/INVARIANTS.md: every `*Aspirational` / `**Aspirational**` marker
158+
# line cites a `#NNN` ticket; closed cites warn (or fail under
159+
# ASPIRATIONAL_STRICT=1). GITHUB_TOKEN enables ticket-state lookup.
160+
steps:
161+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
162+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
163+
with:
164+
python-version: "3.14"
165+
- env:
166+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
167+
run: python .github/scripts/check_aspirational_tickets.py
168+
154169
src-readmes:
155170
name: src/ README audit
156171
runs-on: ubuntu-latest

docs/INVARIANTS.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,17 @@ Add invariants below as your domain stabilises. Each entry should describe:
4848
- *Enforced by:* test, review, or specific CI job.
4949

5050
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.
51+
52+
---
53+
54+
## How to add an invariant
55+
56+
Add a new `## N. <rule>` section at the bottom of slots 6+. Each entry has three lines:
57+
58+
- The rule, one sentence.
59+
- **Where:** module / config file path.
60+
- **Enforced by:** test name, CI job, or `review` if no automated check exists yet.
61+
62+
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.
63+
64+
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.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "harness-python-react"
3-
version = "0.2.1"
3+
version = "0.2.2"
44
description = "Production-quality LLM-driven coding harness — Python (FastAPI) backend, Vite + React + TypeScript frontend."
55
readme = "README.md"
66
requires-python = ">=3.14"

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)