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
123 changes: 123 additions & 0 deletions scripts/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@
The mirror of `check`: active/ prompts that no registry entry claims.
Report-only by default (see cmd_orphans for why it is not yet a gate).

issues
The ONLINE leg (needs `gh` + network, so deliberately not part of
`check`): every registry entry's tracking issue cross-checked against
GitHub. Catches finished work still listed as pending — the class no
offline check can see.

This file is intentionally stdlib-only (no PyAuto imports) so it runs in any
environment, including a bare template checkout.
"""
Expand Down Expand Up @@ -221,6 +227,118 @@ def registry_problems(root: Path) -> "list[str]":
return problems


# --------------------------------------------------------------------------- #
# the online leg — tracking-issue state
#
# The offline checks catch STRUCTURAL rot (bad paths, state contradictions).
# They cannot catch the class that costs most: an entry describing work that is
# finished. The 2026-08-08 audit found six such entries, including the whole
# M0-M3 release-validation chain, and not one was locally detectable — every one
# had correct upstream state (a closed issue, a merged PR, a capability live on
# main) that the Mind simply never read back. This is that read-back.
#
# Deliberately NOT part of `check`: it needs the network and `gh` credentials,
# and `check` is wired into CI where it must stay hermetic.
# --------------------------------------------------------------------------- #
# Only TRACKING refs. A task's `library-pr:`/`workspace-pr:` are merged by
# definition once it ships, so reporting those as closed would be pure noise.
ISSUE_FIELDS = ("issue", "epic")
ISSUE_URL_RE = re.compile(r"https://github\.com/([\w.-]+)/([\w.-]+)/issues/(\d+)")


class GhUnavailable(RuntimeError):
"""`gh` is not installed. Distinct from "gh ran and said no" so the command
can report "could not run" instead of the far worse "nothing to report"."""


def registry_issue_refs(root: Path) -> "list[tuple[str, str, str]]":
"""(registry, slug, issue_url) for every entry carrying a tracking issue.

Entries legitimately carry prose instead of a URL ("(no issue — a
human-authorized release drive)", "NEEDS A FRESH ISSUE — ..."); those have
nothing to query and are skipped rather than reported."""
refs = []
for reg in REGISTRY_FILES:
for slug, fields in registry_entries(root / reg):
for key in ISSUE_FIELDS:
m = ISSUE_URL_RE.search(fields.get(key, ""))
if m:
refs.append((reg, slug, m.group(0)))
return refs


def _gh_issue_states(urls: "list[str]") -> "dict[str, str]":
"""{url: state} via the `gh` CLI. Requires gh + network; online leg only."""
import subprocess

states: "dict[str, str]" = {}
for url in urls:
m = ISSUE_URL_RE.match(url)
if not m:
continue
owner, repo, num = m.groups()
try:
r = subprocess.run(
["gh", "api", f"repos/{owner}/{repo}/issues/{num}", "--jq", ".state"],
capture_output=True, text=True,
)
except FileNotFoundError:
raise GhUnavailable
if r.returncode != 0:
tail = (r.stderr.strip().splitlines() or ["error"])[-1]
states[url] = f"unreadable: {tail}"
continue
states[url] = r.stdout.strip()
return states


def issue_problems(root: Path, fetch=None) -> "list[str]":
"""Registry entries whose tracking issue is CLOSED — i.e. finished work
still listed as pending.

`fetch` maps urls -> {url: state}; injectable so the logic is testable
without a network."""
refs = registry_issue_refs(root)
if not refs:
return []
fetch = fetch or _gh_issue_states
states = fetch([url for _, _, url in refs])

problems = []
for reg, slug, url in refs:
state = states.get(url, "unknown")
if state == "closed":
problems.append(
f"{reg}: {slug}: tracking issue is CLOSED but the entry is still "
f"listed as pending: {url}"
)
elif state != "open":
problems.append(f"{reg}: {slug}: could not read issue state ({state}): {url}")
return problems


def cmd_issues(args) -> int:
"""Cross-check every registry entry's tracking issue against GitHub."""
try:
problems = issue_problems(ROOT)
except GhUnavailable:
print(
"lifecycle issues: cannot run — the `gh` CLI is not installed.\n"
" This leg needs GitHub; it is deliberately separate from `check`,\n"
" which stays hermetic for CI. Install gh, or run this from a\n"
" session that has it.",
file=sys.stderr,
)
return 2
if not problems:
print(f"lifecycle issues: OK ({len(registry_issue_refs(ROOT))} tracking issue(s) open)")
return 0
print("lifecycle issues: DRIFT")
for p in problems:
print(f" - {p}")
return 1


def orphan_prompts(root: Path) -> "list[Path]":
"""active/*.md that no registry entry claims — the mirror of registry_problems().

Expand Down Expand Up @@ -624,6 +742,11 @@ def main() -> int:
c = sub.add_parser("check", help="drift guard (non-zero exit on drift)")
c.set_defaults(func=cmd_check)

iss = sub.add_parser(
"issues", help="cross-check registry tracking issues against GitHub (needs gh)"
)
iss.set_defaults(func=cmd_issues)

o = sub.add_parser("orphans", help="report active/ prompts no registry claims")
o.add_argument("--check", action="store_true",
help="exit non-zero if any orphan exists (once the backlog is cleared)")
Expand Down
95 changes: 95 additions & 0 deletions tests/test_lifecycle_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,101 @@ def test_trailing_parenthetical_after_the_path_is_tolerated(tmp_path):
assert lifecycle.registry_problems(root) == []


# --------------------------------------------------------------------------- #
# the online leg — tracking-issue state
#
# `fetch` is injected so these stay hermetic: no network, no `gh`, no live repo.
# --------------------------------------------------------------------------- #
GHOST_ISSUE = "https://github.com/FictionalOrg/FlywheelRepo/issues/17"
OTHER_ISSUE = "https://github.com/FictionalOrg/FlywheelRepo/issues/18"


def _states(mapping):
return lambda urls: {u: mapping.get(u, "unknown") for u in urls}


def test_closed_tracking_issue_on_a_pending_entry_is_drift(tmp_path):
"""The class no offline check can see: the entry reads as pending, the work
is finished, and only GitHub knows."""
(tmp_path / "planned.md").write_text(
_entry("sprocket-calibration", extra=f"- issue: {GHOST_ISSUE}\n")
)
problems = lifecycle.issue_problems(tmp_path, fetch=_states({GHOST_ISSUE: "closed"}))
assert len(problems) == 1
assert "CLOSED" in problems[0]
assert "sprocket-calibration" in problems[0]


def test_open_tracking_issue_is_not_drift(tmp_path):
(tmp_path / "planned.md").write_text(
_entry("sprocket-calibration", extra=f"- issue: {GHOST_ISSUE}\n")
)
assert lifecycle.issue_problems(tmp_path, fetch=_states({GHOST_ISSUE: "open"})) == []


def test_prose_instead_of_an_issue_url_is_skipped(tmp_path):
"""Real entries carry '(no issue — a human-authorized release drive)' and
'NEEDS A FRESH ISSUE — ...'. There is nothing to query; not a finding."""
body = (
_entry("release-drive", extra="- issue: (no issue — a release drive)\n")
+ _entry("needs-one", extra="- issue: NEEDS A FRESH ISSUE — file at start_dev\n")
)
(tmp_path / "active.md").write_text(body)
assert lifecycle.registry_issue_refs(tmp_path) == []
assert lifecycle.issue_problems(tmp_path, fetch=_states({})) == []


def test_epic_field_is_treated_as_a_tracking_ref(tmp_path):
(tmp_path / "planned.md").write_text(
_entry("phased-task", extra=f"- epic: {GHOST_ISSUE} (the public watch point)\n")
)
problems = lifecycle.issue_problems(tmp_path, fetch=_states({GHOST_ISSUE: "closed"}))
assert len(problems) == 1


def test_merged_pr_links_are_not_treated_as_tracking_refs(tmp_path):
"""A shipped task's library-pr/workspace-pr are merged by definition.
Reporting those as closed would bury the real signal in noise."""
body = _entry(
"sprocket-calibration",
extra=(
f"- issue: {GHOST_ISSUE}\n"
"- library-pr: https://github.com/FictionalOrg/FlywheelRepo/pull/99\n"
"- workspace-pr: https://github.com/FictionalOrg/GadgetRepo/pull/12\n"
),
)
(tmp_path / "active.md").write_text(body)
refs = lifecycle.registry_issue_refs(tmp_path)
assert [r[2] for r in refs] == [GHOST_ISSUE]


def test_missing_gh_propagates_rather_than_reporting_all_clear(tmp_path):
""""gh is not installed" must never be mistaken for "no findings" — a check
that silently could not run is worse than one that fails loudly."""
import pytest

(tmp_path / "planned.md").write_text(
_entry("sprocket-calibration", extra=f"- issue: {GHOST_ISSUE}\n")
)

def _no_gh(urls):
raise lifecycle.GhUnavailable

with pytest.raises(lifecycle.GhUnavailable):
lifecycle.issue_problems(tmp_path, fetch=_no_gh)


def test_unreadable_issue_state_is_reported_not_swallowed(tmp_path):
"""A deleted repo, a revoked token or a network failure must surface — a
silent 'no findings' from a check that could not run is the worst outcome."""
(tmp_path / "planned.md").write_text(
_entry("sprocket-calibration", extra=f"- issue: {OTHER_ISSUE}\n")
)
problems = lifecycle.issue_problems(tmp_path, fetch=_states({}))
assert len(problems) == 1
assert "could not read issue state" in problems[0]


# --------------------------------------------------------------------------- #
# the mirror direction — active/ prompts no registry claims
# --------------------------------------------------------------------------- #
Expand Down
Loading