diff --git a/CLAUDE.md b/CLAUDE.md index b136dcb..5675afd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,8 +23,13 @@ Mutating commands accept `--dry-run`; use it to preview a `pr create`, `pr resub `plan/` holds one file per epic, and `plan/README.md` is the register. An epic's filename is its id, and that id is the Conventional Commits scope: `feat(camo): ...` belongs to `plan/complete/camo.md`. `scripts/check-commit-scope.sh` -enforces it at commit-msg time against both the files and the register, so an -epic that is only a row so far is still a valid scope. +enforces it at commit-msg time against the epic files, including the archived +ones. + +The register's tables are generated from the epics' frontmatter by +`scripts/gen-plan-readme.py`, and the `plan-register` hook fails a commit that +leaves them stale. Adding an epic is writing `plan/.md` and running that +script — never editing a table by hand. There is no TODO.md. Work that is worth writing down goes in its epic. diff --git a/plan/README.md b/plan/README.md index d7056aa..8e66c43 100644 --- a/plan/README.md +++ b/plan/README.md @@ -45,6 +45,12 @@ cannot start first, and `status`, which says what is blocked. Those are per epic, they are true independently of what anybody else is doing, and moving an epic is moving one line. +The sequence the tables read in comes from [order.txt](order.txt), which is a +list of ids and nothing else. It is advisory and it is optional: an epic it +does not name still appears, alphabetically, at the end of whichever table it +belongs to. So a new epic needs no line in it — add one only when where the +epic sits is worth saying to somebody reading down the list. + ## Status `shipped` — the exit criterion is met. The file stays, because how something @@ -61,6 +67,25 @@ adjacent code is open. Accessibility and copy are like this: they are never done, and pretending otherwise just produces an epic that is permanently 80% complete. +## The tables are generated + +The four tables below are output, not source. `scripts/gen-plan-readme.py` +reads every epic's frontmatter and rewrites what sits between the +`` fences. Everything around them is hand-written and is +passed through untouched, so prose can go anywhere except inside a fence. The +`plan-register` prek hook runs the script with `--check`, which means a row +that disagrees with the file it points at fails the commit. + +Adding an epic is therefore one file and one command: write `plan/.md` +with the frontmatter keys every other epic carries, run +`scripts/gen-plan-readme.py`, and stage both. Nothing here is typed by hand — +that is what stops two branches each adding an epic from conflicting over rows +neither of them cared about. + +Which table an epic lands in follows from the file as well. [complete/](complete/) +is Complete, and outside it the status picks between the other three. Shipping +an epic, or archiving it, is an edit to the epic and nothing else. + ## Complete Nothing open, nothing left to decide. These live in [complete/](complete/) so @@ -69,24 +94,29 @@ deleted — how something was built, and what it cost to learn, is worth more after it works than before — and their ids stay valid commit scopes, because a follow-up fix to a finished epic is still that epic's commit. + | id | title | |---|---| | [sign-in](complete/sign-in.md) | A player signs in with their own ATProto account | | [camo](complete/camo.md) | Player-owned camo, drawn here, stored in their PDS, painted in the match | | [match-startup](complete/match-startup.md) | A slow match start can be attributed instead of guessed at | + ## Shipped, with loose ends The exit criterion is met and something is still open in the file. + | id | title | |---|---| | [match-launch](match-launch.md) | A match runs on Fargate from a manifest we authored | | [lobby](lobby.md) | Several humans get into one container | | [flare](flare.md) | In-match feedback reaches a public board | + ## Open + | id | title | status | |---|---|---| | [match-records](match-records.md) | A finished match is player-owned, checkable data | blocked | @@ -95,20 +125,21 @@ The exit criterion is met and something is still open in the file. | [session-security](session-security.md) | A session is only as alive as the grant behind it | open | | [managed-store](managed-store.md) | Losing the box does not lose login grants | open | | [proxy-split](proxy-split.md) | Deploying the API does not drop every live match | open | -| [security-review](security-review.md) | The boundaries have been looked at deliberately | open | +| [security-review](security-review.md) | The boundaries have been looked at deliberately, not incidentally | open | | [scenarios](scenarios.md) | The scenario catalog is generated and validated, not curated | open | | [user-safety](user-safety.md) | A player has somewhere to go when another player is the problem | open | | [invitations](invitations.md) | Nobody is put in a match without being asked | open | | [player-profile](player-profile.md) | A player has a page that is about them | blocked | | [onboarding](onboarding.md) | Somebody who has never played MegaMek can finish a match | open | | [daily-challenge](daily-challenge.md) | One fight a day, the same for everyone, scored | blocked | -| [cost-model](cost-model.md) | What a match costs is measured | open | +| [cost-model](cost-model.md) | What a match costs is measured, and what a busy day costs is known | open | | [forces](forces.md) | A player brings their own units | open | | [achievements](achievements.md) | A player is awarded something and chooses to show it | open | | [appview](appview.md) | lance.blue shows a match it never hosted | open | | [leaderboard](leaderboard.md) | A rating anybody can recompute | open | | [after-action](after-action.md) | A finished match is worth reading, not just counting | open | | [campaign](campaign.md) | Pilots and forces persist between matches | open | + The middle of that list — security-review through invitations — is where the project stops being played only by people who know each other. It sits ahead of @@ -118,6 +149,7 @@ fix before it has been exercised than after. ## Continuous + | id | title | |---|---| | [site-copy](site-copy.md) | Every user-facing string is written by a human | @@ -127,6 +159,7 @@ fix before it has been exercised than after. | [render-performance](render-performance.md) | Input-to-pixels stays under the tail | | [asset-caching](asset-caching.md) | Static assets are reused across matches | | [arena-image](arena-image.md) | The image is small, current and reproducible | + ## Where the schemas are diff --git a/plan/order.txt b/plan/order.txt new file mode 100644 index 0000000..9dee978 --- /dev/null +++ b/plan/order.txt @@ -0,0 +1,52 @@ +# The order the epics read in, top to bottom, wherever they appear in +# README.md's tables. scripts/gen-plan-readme.py sorts each table by this list +# and puts anything the list does not name at the end of its table, +# alphabetically. +# +# This is not the `order` field that was removed, and reintroducing that field +# is still not on. The difference is where it lives: a rank in every epic's +# frontmatter had to be rewritten across twenty files whenever one moved, so +# two branches inserting an epic conflicted over a number neither cared about. +# One list has one line to move, and being listed here is optional — a new +# epic needs no line at all, so adding one touches no file anybody else is +# editing. Only put an id here when where it sits is worth saying. +# +# It says nothing about status. An epic that ships moves table on its own and +# keeps its place in whatever table it lands in. + +sign-in +camo +match-startup + +match-launch +lobby +flare + +match-records +match-lifecycle +match-control +session-security +managed-store +proxy-split +security-review +scenarios +user-safety +invitations +player-profile +onboarding +daily-challenge +cost-model +forces +achievements +appview +leaderboard +after-action +campaign + +site-copy +site-a11y +web-testing +publishing +render-performance +asset-caching +arena-image diff --git a/prek.toml b/prek.toml index 500e942..53f9d9f 100644 --- a/prek.toml +++ b/prek.toml @@ -76,6 +76,24 @@ entry = "scripts/check-plan.py" pass_filenames = false files = '^plan/.*\.md$' +# plan/README.md's four tables are generated from that same frontmatter, so a +# row cannot quietly stop matching the epic it points at, and adding an epic +# does not mean editing tables every other branch is also editing. A failure +# here means the register is stale: run scripts/gen-plan-readme.py and stage +# plan/README.md. It prints the diff, so the message says which rows moved. +# +# Checks rather than rewrites, unlike the social-card hook below. A commit +# touching plan/ is a human writing prose in these same files, and a hook that +# edited the register underneath them would be changing the thing being +# written rather than reporting on it. +[[repos.hooks]] +id = "plan-register" +name = "plan register" +language = "system" +entry = "scripts/gen-plan-readme.py --check" +pass_filenames = false +files = '^plan/(.*\.md|order\.txt)$' + [[repos.hooks]] id = "cargo-fmt" name = "cargo fmt" diff --git a/scripts/check-commit-scope.sh b/scripts/check-commit-scope.sh index cf21565..212d083 100755 --- a/scripts/check-commit-scope.sh +++ b/scripts/check-commit-scope.sh @@ -6,10 +6,11 @@ # plan/camo.md. This catches the two ways that goes wrong: a typo, and a scope # naming an epic nobody has written down. # -# Valid ids come from two places, because most epics are registered before -# they have a file: the filenames in plan/ (and plan/complete/, where finished -# epics are archived), and the ids in plan/README.md's register tables. Either -# is enough. +# Valid ids are the filenames in plan/ and plan/complete/, where finished +# epics are archived. It used to read plan/README.md's tables as well, for an +# epic that was a row before it was a file; those tables are generated from +# the files now (scripts/gen-plan-readme.py), so a row that no file backs +# cannot exist and reading them would only find the same ids twice. # # It is not a rule that every commit belongs to an epic. A commit with no # scope passes, and so does anything in NON_EPIC_SCOPES — build work, @@ -62,14 +63,6 @@ for f in "$plan_dir"/*.md "$plan_dir"/complete/*.md; do known="$known $id" done -# Epics that are only a register row so far: a table cell holding either -# `id` or [id](id.md). Anything outside a table row is prose, not an id. -if [ -f "$plan_dir/README.md" ]; then - rows="$(grep '^|' "$plan_dir/README.md" || true)" - known="$known $(printf '%s' "$rows" | grep -oP '(?<=`)[a-z0-9-]+(?=`)' || true)" - known="$known $(printf '%s' "$rows" | grep -oP '(?<=\[)[a-z0-9-]+(?=\]\()' || true)" -fi - # Collapse whitespace so the membership test below has single separators. known="$(printf '%s' "$known" | tr -s '[:space:]' ' ')" @@ -95,6 +88,6 @@ done # shellcheck disable=SC2086 printf ' %s\n' $known | sort -u echo - echo "or add the epic to plan/README.md first, or drop the scope." + echo "or write plan/.md for the epic first, or drop the scope." } >&2 exit 1 diff --git a/scripts/check-plan.py b/scripts/check-plan.py index 8fba9dc..f8faacd 100755 --- a/scripts/check-plan.py +++ b/scripts/check-plan.py @@ -126,8 +126,10 @@ def check_frontmatter(path, stem, data, known_ids, errors): f"conflict over a\n" f" number neither cares about. Delete the key. What an epic " f"waits on goes in\n" - f" `dependsOn`; the reading order is the table order in " - f"plan/README.md." + f" `dependsOn`; the reading order is plan/order.txt, which is " + f"one shared list\n" + f" rather than a rank in every file, and which an epic does not " + f"have to appear in." ) missing = [k for k in REQUIRED_KEYS if k not in data] diff --git a/scripts/gen-plan-readme.py b/scripts/gen-plan-readme.py new file mode 100755 index 0000000..b807026 --- /dev/null +++ b/scripts/gen-plan-readme.py @@ -0,0 +1,344 @@ +#!/usr/bin/env python3 +"""Generate the four tables in plan/README.md from the epics' own frontmatter. + +Every row in the register used to repeat an id, a title and a status that the +epic file already carried. That cost two things. Nothing checked the copy, so a +retitled epic kept its old title in the table; and every branch that added an +epic edited the same tables, so two of them conflicted by construction. + +So the tables are output now. Each one sits between an HTML comment fence and +is rewritten from the files; everything outside a fence is hand-written prose +and is passed through untouched. Which table an epic lands in follows from +where it is and what its status says: plan/complete/ is Complete, `shipped` +outside it is Shipped-with-loose-ends, `open` and `blocked` are Open, and +`continuous` is Continuous. + +Row order comes from plan/order.txt, a plain list of ids that is advisory and +optional — an epic it does not name still appears, alphabetically, at the end +of its table. That is the whole reason it can exist without being the `order` +field this project deleted: it is one shared line to move when a reading order +is worth stating, not a rank every file has to carry and every insert has to +renumber. Adding an epic needs no edit here at all. + +No third-party modules, for the same reason check-plan.py has none: this runs +on every commit that touches plan/. The frontmatter parser is that script's, +imported rather than copied, so the two can never disagree about what an epic +says. + +Rewrite the register: scripts/gen-plan-readme.py +Ask whether it is current: scripts/gen-plan-readme.py --check +""" + +import argparse +import difflib +import importlib.util +import os +import re +import sys + +SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__)) +REPO_ROOT = os.path.dirname(SCRIPTS_DIR) +PLAN_DIR = os.path.join(REPO_ROOT, "plan") +COMPLETE_DIR = os.path.join(PLAN_DIR, "complete") +REGISTER = os.path.join(PLAN_DIR, "README.md") +ORDER_FILE = os.path.join(PLAN_DIR, "order.txt") + +FENCE_OPEN_RE = re.compile(r"^$") +FENCE_CLOSE = "" + +# name -> (heading it lives under, does the row carry a status column, +# which epics belong in it). +# +# The status column is only on Open, because that is the only table holding +# more than one status: `blocked` reads as a fact about an open epic, while a +# column of nothing but `continuous` says nothing the heading did not. +TABLES = [ + ( + "complete", + False, + lambda e: e["archived"], + ), + ( + "shipped", + False, + lambda e: not e["archived"] and e["status"] == "shipped", + ), + ( + "open", + True, + lambda e: not e["archived"] and e["status"] in ("open", "blocked"), + ), + ( + "continuous", + False, + lambda e: not e["archived"] and e["status"] == "continuous", + ), +] + + +def load_check_plan(): + """Import scripts/check-plan.py for its frontmatter parser. + + Its filename has a hyphen in it, so `import` cannot reach it by name. Going + the long way round is still better than a second parser: the two scripts + read the same files on the same commit, and a subset-of-YAML parser that + drifts from its twin fails in a way nobody would look for. + + Importing writes a scripts/__pycache__/ unless told not to, and this repo + does not ignore one. Nothing here is imported twice anyway. + """ + path = os.path.join(SCRIPTS_DIR, "check-plan.py") + spec = importlib.util.spec_from_file_location("check_plan", path) + module = importlib.util.module_from_spec(spec) + sys.dont_write_bytecode = True + spec.loader.exec_module(module) + return module + + +def read_epics(check_plan, errors): + """Every epic in plan/ and plan/complete/, with the fields a row needs.""" + epics = [] + for directory, archived in ((PLAN_DIR, False), (COMPLETE_DIR, True)): + if not os.path.isdir(directory): + continue + for name in sorted(os.listdir(directory)): + if not name.endswith(".md") or name == "README.md": + continue + path = os.path.join(directory, name) + rel = os.path.relpath(path, REPO_ROOT) + with open(path, encoding="utf-8") as fh: + lines = fh.read().splitlines() + data = check_plan.parse_frontmatter(rel, lines, errors) + if data is None: + # parse_frontmatter has already said what is wrong with it. + continue + missing = [k for k in ("id", "title", "status") if k not in data] + if missing: + errors.append( + f"{rel}: frontmatter is missing {', '.join(missing)}, so " + f"there is nothing to\n" + f" put in the register. scripts/check-plan.py says what " + f"an epic must carry." + ) + continue + epics.append( + { + "id": data["id"], + "title": data["title"], + "status": data["status"], + "archived": archived, + "link": os.path.relpath(path, PLAN_DIR).replace(os.sep, "/"), + "rel": rel, + } + ) + return epics + + +def read_order(known_ids, errors): + """plan/order.txt as a list of ids. Blank lines and # comments are notes.""" + if not os.path.isfile(ORDER_FILE): + return [] + order = [] + with open(ORDER_FILE, encoding="utf-8") as fh: + for n, raw in enumerate(fh.read().splitlines(), 1): + line = raw.split("#", 1)[0].strip() + if not line: + continue + if line in order: + errors.append( + f"plan/order.txt:{n}: `{line}` is listed twice. " + f"Delete one of them." + ) + continue + if line not in known_ids: + errors.append( + f"plan/order.txt:{n}: `{line}` is not an epic.\n" + f" Every line is the id of a file in plan/ or " + f"plan/complete/. If the epic was\n" + f" renamed or deleted, drop the line — an epic missing " + f"from this file is fine,\n" + f" it just sorts to the end of its table." + ) + continue + order.append(line) + return order + + +def escape_cell(text): + """A pipe in a title would end the cell early; nothing else is special.""" + return text.replace("|", "\\|") + + +def render_table(epics, with_status): + header = ["| id | title | status |", "|---|---|---|"] + if not with_status: + header = ["| id | title |", "|---|---|"] + rows = [] + for epic in epics: + cells = [f"[{epic['id']}]({epic['link']})", escape_cell(epic["title"])] + if with_status: + cells.append(epic["status"]) + rows.append("| " + " | ".join(cells) + " |") + return header + rows + + +def build_blocks(epics, order, errors): + """The generated lines for each fence, keyed by the fence's name.""" + rank = {epic_id: n for n, epic_id in enumerate(order)} + unlisted = len(rank) + + blocks = {} + placed = set() + for name, with_status, belongs in TABLES: + rows = [e for e in epics if belongs(e)] + rows.sort(key=lambda e: (rank.get(e["id"], unlisted), e["id"])) + placed.update(e["id"] for e in rows) + blocks[name] = render_table(rows, with_status) + + for epic in epics: + if epic["id"] not in placed: + errors.append( + f"{epic['rel']}: status `{epic['status']}` belongs to no " + f"table, so the epic\n" + f" would vanish from the register. Use one of: shipped, " + f"open, blocked, continuous." + ) + return blocks + + +def rewrite(text, blocks, errors): + """Replace what is inside each fence. Returns None if the fences are wrong. + + Everything outside a fence — the headings, the paragraphs between the + tables — is hand-written and comes through byte for byte. + """ + lines = text.split("\n") + out = [] + seen = [] + i = 0 + while i < len(lines): + match = FENCE_OPEN_RE.match(lines[i].strip()) + if not match: + out.append(lines[i]) + i += 1 + continue + + name = match.group(1) + if name not in blocks: + errors.append( + f"plan/README.md:{i + 1}: `` names " + f"no table.\n" + f" The generated tables are: " + f"{', '.join(n for n, _, _ in TABLES)}." + ) + return None + if name in seen: + errors.append( + f"plan/README.md:{i + 1}: a second " + f"`` fence.\n" + f" Each table is generated in one place. Delete one of them." + ) + return None + + end = None + for j in range(i + 1, len(lines)): + if lines[j].strip() == FENCE_CLOSE: + end = j + break + if FENCE_OPEN_RE.match(lines[j].strip()): + break + if end is None: + errors.append( + f"plan/README.md:{i + 1}: the `{name}` fence is never closed.\n" + f" Add a `{FENCE_CLOSE}` line after the table." + ) + return None + + seen.append(name) + out.append(lines[i]) + out.extend(blocks[name]) + out.append(lines[end]) + i = end + 1 + + missing = [n for n, _, _ in TABLES if n not in seen] + if missing: + errors.append( + f"plan/README.md: no fence for: {', '.join(missing)}.\n" + f" Each table is written between " + f"`` and `{FENCE_CLOSE}`.\n" + f" Put the pair under the heading the table belongs to." + ) + return None + + return "\n".join(out) + + +def main(): + parser = argparse.ArgumentParser( + description="Generate plan/README.md's tables from epic frontmatter." + ) + parser.add_argument( + "--check", + action="store_true", + help="say whether the register is current, and change nothing", + ) + args = parser.parse_args() + + if not os.path.isfile(REGISTER): + print( + "plan/README.md is missing. It is the register: every epic is " + "linked from it.", + file=sys.stderr, + ) + return 1 + + check_plan = load_check_plan() + errors = [] + + epics = read_epics(check_plan, errors) + order = read_order({e["id"] for e in epics}, errors) + blocks = build_blocks(epics, order, errors) + + with open(REGISTER, encoding="utf-8") as fh: + before = fh.read() + after = rewrite(before, blocks, errors) + + if errors: + print( + f"plan/README.md cannot be generated ({len(errors)} problem(s)):\n", + file=sys.stderr, + ) + for err in errors: + print(err + "\n", file=sys.stderr) + return 1 + + if after == before: + if not args.check: + print("plan/README.md is already current.") + return 0 + + if args.check: + diff = difflib.unified_diff( + before.splitlines(keepends=True), + after.splitlines(keepends=True), + fromfile="plan/README.md (committed)", + tofile="plan/README.md (generated)", + ) + print( + "plan/README.md does not match the epics' frontmatter.\n" + "Run scripts/gen-plan-readme.py and stage the result. What " + "differs:\n", + file=sys.stderr, + ) + sys.stderr.writelines(diff) + print(file=sys.stderr) + return 1 + + with open(REGISTER, "w", encoding="utf-8") as fh: + fh.write(after) + print("plan/README.md: tables rewritten.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())