#!/usr/bin/env python3 """Check the shape of plan/ — the epic files and the register that lists them. An epic is a markdown file with YAML frontmatter, and that frontmatter is read by more than a person: scripts/gen-plan-readme.py builds the register's tables out of it, and the id is the Conventional Commits scope a change to that epic is committed under. A malformed file therefore breaks the register rather than the file it is in, which is the wrong place to find out. The structure — a directory of epics, generated tables, order.txt, a Done section that is the exit test — is lifted from lance.blue's headquarters repo, along with these two scripts. The one difference is the frontmatter: `crates` rather than `repos`, because everything here is in one repository and what is worth naming is which parts of the workspace an epic touches. This checks shape only — the keys that must be there, the ids that must match, the links that must resolve, and the `## Done` invariant the register describes. Whether an epic is well scoped or its prose is true is a human's call and is deliberately not checked here. No third-party modules on purpose: this runs on every commit that touches plan/, so it has to start fast and work in a fresh clone. The frontmatter parser below is a subset of YAML, not YAML. Run by prek over plan/, or by hand: scripts/check-plan.py """ import os import re import sys # Every epic carries exactly these, no more and no less. An epic missing one # cannot be put in the register, and an epic with a seventh is writing down # something nothing reads. REQUIRED_KEYS = ["id", "title", "status", "crates", "dependsOn", "exitCriterion"] # Documented in plan/README.md, under "Status". VALID_STATUS = ["shipped", "open", "blocked", "continuous", "declined"] # Files in plan/ that are not epics. Everything else there is one, so a new # hand-written page needs a line here or it fails as a malformed epic. NOT_EPICS = {"README.md", "milestones.md"} LINK_RE = re.compile(r"(?` folded block. Anything else is reported rather than guessed at. """ if not lines or lines[0].rstrip() != "---": errors.append( f"{path}: no frontmatter.\n" f" An epic starts with a --- block holding: " f"{', '.join(REQUIRED_KEYS)}.\n" f" Copy the block from any other file in plan/." ) return None end = None for i in range(1, len(lines)): if lines[i].rstrip() == "---": end = i break if end is None: errors.append( f"{path}: frontmatter is never closed.\n" f" Add a --- line after the last key." ) return None data = {} i = 1 while i < end: raw = lines[i] i += 1 if not raw.strip(): continue m = re.match(r"^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$", raw) if not m: errors.append( f"{path}: frontmatter line {i} is not `key: value`:\n" f" {raw.strip()}\n" f" This file's frontmatter is a small subset of YAML. Keep it " f"to the six keys other epics use." ) return None key, value = m.group(1), m.group(2).strip() if key in data: errors.append( f"{path}: frontmatter sets `{key}` twice. Delete one of them." ) return None if value == ">" or value == "|": # Folded or literal block: the indented lines that follow. block = [] while i < end and (not lines[i].strip() or lines[i][:1] in " \t"): block.append(lines[i].strip()) i += 1 data[key] = " ".join(p for p in block if p) elif value.startswith("[") and value.endswith("]"): inner = value[1:-1].strip() data[key] = [p.strip() for p in inner.split(",") if p.strip()] else: data[key] = value.strip("'\"") return data def check_frontmatter(path, stem, data, known_ids, errors): if "order" in data: errors.append( f"{path}: frontmatter has an `order` key.\n" f" There is deliberately no `order` field: a total order over " f"twenty epics has to\n" f" be rewritten whenever one moves, so two branches adding an " f"epic 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 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] extra = [k for k in data if k not in REQUIRED_KEYS and k != "order"] if missing: errors.append( f"{path}: frontmatter is missing {', '.join(missing)}.\n" f" Every epic carries exactly: {', '.join(REQUIRED_KEYS)}.\n" f" Copy the block from any other file in plan/ and fill it in." ) if extra: errors.append( f"{path}: frontmatter has keys nothing reads: " f"{', '.join(sorted(extra))}.\n" f" Every epic carries exactly: {', '.join(REQUIRED_KEYS)}.\n" f" Put it in the prose instead, or delete it." ) if "id" in data and data["id"] != stem: errors.append( f"{path}: id is `{data['id']}` but the filename says `{stem}`.\n" f" The id is the file's name and the commit scope both, so a " f"change to this epic\n" f" is committed as `feat({stem}): …`. Either set `id: {stem}`, or " f"rename the file\n" f" to {data['id']}.md and update the link in plan/README.md." ) if "status" in data and data["status"] not in VALID_STATUS: errors.append( f"{path}: status is `{data['status']}`. Use one of:\n" f" {', '.join(VALID_STATUS)}\n" f" plan/README.md, under \"Status\", says what each one means." ) depends = data.get("dependsOn", []) if not isinstance(depends, list): errors.append( f"{path}: dependsOn is not a list. Write it as `dependsOn: []` or " f"`dependsOn: [some-epic]`." ) else: for dep in depends: if dep not in known_ids: errors.append( f"{path}: dependsOn names `{dep}`, which is not an epic.\n" f" It has to be the id of a file in plan/ or " f"plan/complete/. Fix the spelling,\n" f" write plan/{dep}.md, or drop it from the list." ) def check_done_section(path, lines, archived, errors): """Exactly one `## Done`, last, with the checkboxes on the right side.""" done_at = None trailing = [] for n, line in enumerate(lines): m = HEADING_RE.match(line) if not m: continue level, text = len(m.group(1)), m.group(2) if level == 2 and text == "Done": if done_at is not None: errors.append( f"{path}: two `## Done` headings (lines {done_at + 1} and " f"{n + 1}).\n" f" An epic has one, at the end. Merge them." ) return done_at = n elif done_at is not None and level <= 2: trailing.append((n + 1, line.strip())) if done_at is None: errors.append( f"{path}: no `## Done` section.\n" f" Every epic ends in one, holding the finished work as `- [x]` " f"items — that is\n" f" what makes \"is this epic finished\" answerable by looking. " f"Add it at the end of\n" f" the file, empty if nothing is done yet." ) return if trailing: line_no, text = trailing[0] errors.append( f"{path}: `## Done` is not the last section — `{text}` follows it " f"on line {line_no}.\n" f" Move that section above `## Done`, so the finished work stays " f"at the bottom." ) for n, line in enumerate(lines): m = CHECKBOX_RE.match(line) if not m: continue checked = m.group(1).lower() == "x" if checked and n < done_at: errors.append( f"{path}:{n + 1}: a `- [x]` item above `## Done`:\n" f" {line.strip()}\n" f" Finished work moves down under `## Done`. Open work stays " f"above it as `- [ ]`." ) elif not checked and n > done_at and not archived: # An archived file gets the sharper message from check_archived # instead: nothing is open in there, wherever it sits. errors.append( f"{path}:{n + 1}: a `- [ ]` item under `## Done`:\n" f" {line.strip()}\n" f" Done holds finished work only. Move it above the heading, " f"or tick it." ) def check_archived(path, lines, errors): """An epic in plan/complete/ has nothing left open.""" for n, line in enumerate(lines): m = CHECKBOX_RE.match(line) if m and m.group(1) == " ": errors.append( f"{path}:{n + 1}: an open `- [ ]` item in an archived epic:\n" f" {line.strip()}\n" f" plan/complete/ is for epics with nothing open. Either " f"finish it and tick it,\n" f" or move the file back up to plan/ until it closes." ) def check_links(path, abs_path, lines, repo_root, errors): """Every relative link resolves, from the linking file's own directory.""" base = os.path.dirname(abs_path) for n, line in enumerate(lines): for target in LINK_RE.findall(line): if re.match(r"^[a-z][a-z0-9+.-]*:", target) or target.startswith("//"): continue target = target.split("#", 1)[0] if not target: continue resolved = os.path.normpath(os.path.join(base, target)) if not os.path.exists(resolved): rel = os.path.relpath(resolved, repo_root) errors.append( f"{path}:{n + 1}: link to `{target}` does not resolve " f"(looked for {rel}).\n" f" Links are relative to the file they are in, so a file " f"in plan/complete/\n" f" reaches the rest of plan/ with `../`. Fix the path, or " f"drop the link." ) def main(): repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) plan_dir = os.path.join(repo_root, "plan") register = os.path.join(plan_dir, "README.md") if not os.path.isdir(plan_dir): print(f"no plan/ directory at {plan_dir}", file=sys.stderr) return 1 epics = [] for directory in (plan_dir, os.path.join(plan_dir, "complete")): if not os.path.isdir(directory): continue for name in sorted(os.listdir(directory)): # README.md is the register and milestones.md is the narrative, # not epics: they carry no frontmatter and no Done section. The # register is checked for its links and for listing everything # else; milestones.md is prose nothing reads. if not name.endswith(".md") or name in NOT_EPICS: continue epics.append(os.path.join(directory, name)) known_ids = {os.path.basename(p)[:-3] for p in epics} errors = [] if not os.path.isfile(register): errors.append( "plan/README.md is missing. It is the register: every epic is " "linked from it." ) register_links = set() else: with open(register, encoding="utf-8") as fh: lines = strip_fences(fh.read().splitlines()) check_links("plan/README.md", register, lines, repo_root, errors) register_links = set() for line in lines: for target in LINK_RE.findall(line): if re.match(r"^[a-z][a-z0-9+.-]*:", target): continue target = target.split("#", 1)[0] if target: register_links.add( os.path.normpath(os.path.join(plan_dir, target)) ) for path in epics: with open(path, encoding="utf-8") as fh: lines = strip_fences(fh.read().splitlines()) stem = os.path.basename(path)[:-3] rel = os.path.relpath(path, repo_root) archived = os.path.dirname(path).endswith(os.sep + "complete") data = parse_frontmatter(rel, lines, errors) if data is not None: check_frontmatter(rel, stem, data, known_ids, errors) check_done_section(rel, lines, archived, errors) check_links(rel, path, lines, repo_root, errors) if archived: check_archived(rel, lines, errors) if os.path.normpath(path) not in register_links: where = os.path.relpath(path, plan_dir) errors.append( f"{rel}: not linked from plan/README.md.\n" f" The register is how an epic is found, and how " f"check-commit-scope.sh knows the\n" f" scope exists. Add a row to the table for its status: " f"`[{stem}]({where})`." ) if errors: print(f"plan/ check failed ({len(errors)} problem(s)):\n", file=sys.stderr) for err in errors: print(err + "\n", file=sys.stderr) return 1 return 0 if __name__ == "__main__": sys.exit(main())