Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/branch-protection/develop.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"Action pinning audit",
"Tests required",
"src/ README audit",
"Aspirational ticket cite",
"Frontend Build",
"Frontend Quality",
"Branch-protection contexts sync",
Expand Down
1 change: 1 addition & 0 deletions .github/branch-protection/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"Action pinning audit",
"Tests required",
"src/ README audit",
"Aspirational ticket cite",
"Frontend Build",
"Frontend Quality",
"Branch-protection contexts sync",
Expand Down
183 changes: 183 additions & 0 deletions .github/scripts/check_aspirational_tickets.py
Original file line number Diff line number Diff line change
@@ -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())
15 changes: 15 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions docs/INVARIANTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. <rule>` 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.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading