mip_graph

mip_graph — generate a Mermaid dependency graph from every MIP's metadata table and write it into docs/MIPs/README.md between two HTML-comment markers. Reads only a machine-readable Blocked by row (comma-separated MIP numbers, or the literal none) — the human-readable Depends on field stays prose-only and is never parsed, because it legitimately carries four different relations (blocking, blocked-by, co-delivery, negation) in one cell that a regex cannot tell apart (verified against all 28 real MIPs in this repo before this script was written; see docs/MIPs/README.md's own history for the specific mis-parses that ruled it out).

scripts/mip_graph.py                    # regenerate the graph block in docs/MIPs/README.md
scripts/mip_graph.py --check             # exit 1 if the checked-in block is stale or missing
scripts/mip_graph.py --parallel 30 31    # can these two MIPs be worked in parallel?
scripts/mip_graph.py --self-test

A MIP with no Blocked by row (most of them, today — this is a new, opt-in field) is drawn as an unconnected node. Nothing here invents an edge from prose.

  1#!/usr/bin/env python3
  2"""mip_graph — generate a Mermaid dependency graph from every MIP's metadata table and write it
  3into docs/MIPs/README.md between two HTML-comment markers. Reads only a machine-readable
  4**Blocked by** row (comma-separated MIP numbers, or the literal `none`) — the human-readable
  5**Depends on** field stays prose-only and is never parsed, because it legitimately carries four
  6different relations (blocking, blocked-by, co-delivery, negation) in one cell that a regex cannot
  7tell apart (verified against all 28 real MIPs in this repo before this script was written; see
  8docs/MIPs/README.md's own history for the specific mis-parses that ruled it out).
  9
 10    scripts/mip_graph.py                    # regenerate the graph block in docs/MIPs/README.md
 11    scripts/mip_graph.py --check             # exit 1 if the checked-in block is stale or missing
 12    scripts/mip_graph.py --parallel 30 31    # can these two MIPs be worked in parallel?
 13    scripts/mip_graph.py --self-test
 14
 15A MIP with no **Blocked by** row (most of them, today — this is a new, opt-in field) is drawn as
 16an unconnected node. Nothing here invents an edge from prose.
 17"""
 18
 19import argparse
 20import re
 21import sys
 22from pathlib import Path
 23
 24ROOT = Path(__file__).resolve().parent.parent
 25MIPS_DIR = ROOT / "docs" / "MIPs"
 26README = MIPS_DIR / "README.md"
 27
 28START_MARK = "<!-- mip-graph:start -->"
 29END_MARK = "<!-- mip-graph:end -->"
 30
 31TITLE_RE = re.compile(r"^#\s*MIP-(\d{4}):\s*(.+?)\s*$")
 32ROW_RE = re.compile(r"^\|\s*\*\*(?P<key>[^*]+)\*\*\s*\|\s*(?P<val>.*?)\s*\|\s*$")
 33MIP_NUM_RE = re.compile(r"\b(\d{4})\b")
 34
 35STATUS_CLASS = {
 36    "draft": "draft",
 37    "accepted": "accepted",
 38    "implemented": "implemented",
 39    "rejected": "rejected",
 40    "superseded": "rejected",
 41}
 42
 43
 44class Mip:
 45    def __init__(self, num, title, status, blocked_by):
 46        self.num = num
 47        self.title = title
 48        self.status = status
 49        self.blocked_by = blocked_by  # list[int]
 50
 51    def node_id(self):
 52        return f"M{self.num:04d}"
 53
 54    def status_class(self):
 55        s = self.status.lower()
 56        for key, cls in STATUS_CLASS.items():
 57            if s.startswith(key):
 58                return cls
 59        return "draft"
 60
 61
 62def parse_mip_file(path):
 63    lines = path.read_text(encoding="utf-8").splitlines()
 64    num = None
 65    title = path.stem
 66    status = "Draft"
 67    blocked_by = []
 68    for line in lines[:3]:
 69        m = TITLE_RE.match(line)
 70        if m:
 71            num = int(m.group(1))
 72            title = m.group(2)
 73            break
 74    if num is None:
 75        return None
 76    for line in lines:
 77        m = ROW_RE.match(line)
 78        if not m:
 79            continue
 80        key = m.group("key").strip().lower()
 81        val = m.group("val").strip()
 82        if key == "status":
 83            # first word/clause only — "Implemented, ultrareview-verified..." -> "Implemented"
 84            status = re.split(r"[,(]", val, maxsplit=1)[0].strip() or "Draft"
 85        elif key == "blocked by":
 86            if val.lower() != "none":
 87                blocked_by = [int(n) for n in MIP_NUM_RE.findall(val)]
 88    return Mip(num, title, status, blocked_by)
 89
 90
 91def load_mips():
 92    mips = {}
 93    for path in sorted(MIPS_DIR.glob("MIP-*.md")):
 94        if path.name.endswith(".tasks.md"):
 95            continue
 96        mip = parse_mip_file(path)
 97        if mip:
 98            mips[mip.num] = mip
 99    return mips
100
101
102NO_EDGES_PLACEHOLDER = (
103    "_No MIP currently declares a **Blocked by** relationship, so there is nothing to graph yet "
104    "— add that field to a MIP's metadata table and run `just mip-graph` again._"
105)
106
107
108def render_mermaid(mips):
109    edges = []
110    for num in sorted(mips):
111        mip = mips[num]
112        for blocker in mip.blocked_by:
113            if blocker in mips:
114                edges.append((blocker, num))
115    edges = sorted(set(edges))
116    if not edges:
117        return NO_EDGES_PLACEHOLDER
118
119    # Only the MIPs that actually participate in an edge get drawn — an orphan MIP (no declared
120    # Blocked-by relationship in either direction) adds a disconnected box that tells the reader
121    # nothing the index table above doesn't already say, and 20+ of them turned the graph into an
122    # unreadable wall (verified against a real run: 29 MIPs, 0 edges, every node carrying its full
123    # title — the exact complaint that got this rewritten).
124    connected = {a for a, _ in edges} | {b for _, b in edges}
125
126    lines = ["```mermaid", "flowchart TD"]
127    # Dark fills: the site is slate only and injects white label text (MIP-0068 §5.2).
128    lines.append("  classDef draft fill:#1e2129,stroke:#8fa3b0,stroke-dasharray:3 3;")
129    lines.append("  classDef accepted fill:#0b4261,stroke:#1ac5da;")
130    lines.append("  classDef implemented fill:#0f4a2e,stroke:#3ecf6e;")
131    lines.append("  classDef rejected fill:#2a2d35,stroke:#666,color:#999;")
132    for num in sorted(connected):
133        mip = mips[num]
134        # Bare "MIP-NNNN" only — the full title is already one line up in the index table, and a
135        # long label per node is exactly what made the graph unreadable before this rewrite.
136        lines.append(f'  {mip.node_id()}["MIP-{num:04d}"]:::{mip.status_class()}')
137    for a, b in edges:
138        lines.append(f"  {mips[a].node_id()} --> {mips[b].node_id()}")
139    lines.append("```")
140    orphans = sorted(set(mips) - connected)
141    if orphans:
142        orphan_list = ", ".join(f"MIP-{n:04d}" for n in orphans)
143        lines.append("")
144        lines.append(
145            f"_{len(orphans)} MIP(s) with no declared Blocked-by relationship, not graphed: "
146            f"{orphan_list}._"
147        )
148    return "\n".join(lines)
149
150
151def graph_block(mips):
152    return f"{START_MARK}\n{render_mermaid(mips)}\n{END_MARK}"
153
154
155def reachable(mips, start, blocked_direction=True):
156    """Every MIP transitively blocking (or blocked by, if blocked_direction=False) `start`."""
157    seen = set()
158    stack = [start]
159    while stack:
160        cur = stack.pop()
161        if cur not in mips:
162            continue
163        neighbors = (
164            mips[cur].blocked_by
165            if blocked_direction
166            else [m.num for m in mips.values() if cur in m.blocked_by]
167        )
168        for n in neighbors:
169            if n not in seen:
170                seen.add(n)
171                stack.append(n)
172    return seen
173
174
175def source_paths(mip_path):
176    """Backticked paths under a known top-level source dir, from a MIP's §5 Design section."""
177    text = mip_path.read_text(encoding="utf-8")
178    return set(
179        re.findall(
180            r"`((?:core|local|cli|site|scripts)/[A-Za-z0-9_./-]+)`",
181            text,
182        )
183    )
184
185
186def cmd_parallel(a, b):
187    mips = load_mips()
188    if a not in mips or b not in mips:
189        missing = [n for n in (a, b) if n not in mips]
190        print(f"mip_graph --parallel: MIP-{missing[0]:04d} not found")
191        return 1
192    blockers_of_a = reachable(mips, a)
193    blockers_of_b = reachable(mips, b)
194    dep_conflict = b in blockers_of_a or a in blockers_of_b
195    candidates_a = list(MIPS_DIR.glob(f"MIP-{a:04d}-*.md"))
196    candidates_b = list(MIPS_DIR.glob(f"MIP-{b:04d}-*.md"))
197    paths_a = source_paths(candidates_a[0]) if candidates_a else set()
198    paths_b = source_paths(candidates_b[0]) if candidates_b else set()
199    overlap = paths_a & paths_b
200
201    print(f"MIP-{a:04d} vs MIP-{b:04d}:")
202    print(f"  dependency conflict (Blocked by graph): {'yes' if dep_conflict else 'no'}")
203    if overlap:
204        print(f"  §5 source-path overlap: yes — {', '.join(sorted(overlap))}")
205    else:
206        print("  §5 source-path overlap: no (or no backticked source paths found in one/both)")
207    if not dep_conflict and not overlap:
208        print("  -> parallel-safe on both checks")
209        return 0
210    print("  -> NOT confirmed parallel-safe")
211    return 1
212
213
214def cmd_generate(check):
215    if not README.exists():
216        print(f"mip_graph: {README} not found", file=sys.stderr)
217        return 1
218    mips = load_mips()
219    new_block = graph_block(mips)
220    text = README.read_text(encoding="utf-8")
221    if START_MARK in text and END_MARK in text:
222        pre, rest = text.split(START_MARK, 1)
223        _, post = rest.split(END_MARK, 1)
224        new_text = pre + new_block + post
225    else:
226        sep = "" if text.endswith("\n") else "\n"
227        new_text = text + sep + "\n" + new_block + "\n"
228    if check:
229        if new_text != text:
230            print(
231                "mip_graph --check: docs/MIPs/README.md's graph block is stale or missing — "
232                "run `just mip-graph` and commit the result"
233            )
234            return 1
235        print("mip_graph --check: graph block is current")
236        return 0
237    if new_text != text:
238        README.write_text(new_text, encoding="utf-8")
239        print(f"mip_graph: wrote graph block to {README} ({len(mips)} MIPs)")
240    else:
241        print("mip_graph: graph block already current, nothing written")
242    return 0
243
244
245def self_test():
246    fails = 0
247
248    def ok(cond, label):
249        nonlocal fails
250        if cond:
251            print("  ok   " + label)
252        else:
253            print("  FAIL " + label)
254            fails += 1
255
256    sample = """# MIP-0099: A sample title
257
258| | |
259|---|---|
260| **Status** | Draft |
261| **Blocked by** | MIP-0010, MIP-0025 |
262"""
263    tmp = Path("/tmp/mip_graph_selftest_0099.md")
264    tmp.write_text(sample, encoding="utf-8")
265    mip = parse_mip_file(tmp)
266    ok(mip is not None, "parses a real MIP header")
267    ok(mip.num == 99, "extracts the MIP number")
268    ok(mip.title == "A sample title", "extracts the title")
269    ok(mip.blocked_by == [10, 25], "parses a comma-separated Blocked by list")
270    tmp.unlink()
271
272    none_sample = "# MIP-0098: Another\n\n| | |\n|---|---|\n| **Blocked by** | none |\n"
273    tmp2 = Path("/tmp/mip_graph_selftest_0098.md")
274    tmp2.write_text(none_sample, encoding="utf-8")
275    mip2 = parse_mip_file(tmp2)
276    ok(mip2.blocked_by == [], "'none' produces an empty blocker list, not a parsed '0'")
277    tmp2.unlink()
278
279    no_row = "# MIP-0097: No blocked-by row at all\n\n| | |\n|---|---|\n| **Status** | Draft |\n"
280    tmp3 = Path("/tmp/mip_graph_selftest_0097.md")
281    tmp3.write_text(no_row, encoding="utf-8")
282    mip3 = parse_mip_file(tmp3)
283    ok(mip3.blocked_by == [], "a MIP with no Blocked by row at all is unconnected, not an error")
284    tmp3.unlink()
285
286    mips = {
287        10: Mip(10, "Ledger", "Implemented", []),
288        32: Mip(32, "Benchmark", "Draft", [10]),
289    }
290    block = graph_block(mips)
291    ok(block.startswith(START_MARK) and block.endswith(END_MARK), "block is wrapped in markers")
292    ok("M0010 --> M0032" in block, "an edge renders blocker --> blocked")
293    ok(":::implemented" in block and ":::draft" in block, "status classes render per node")
294    ok('"MIP-0010"' in block, "a connected node's label is bare MIP-NNNN, not its title")
295    ok(
296        "Ledger" not in block and "Benchmark" not in block,
297        "a MIP's title never appears in the graph",
298    )
299
300    r = reachable(mips, 32)
301    ok(r == {10}, "reachable() walks the Blocked-by chain")
302
303    zero_edge_mips = {1: Mip(1, "A", "Draft", []), 2: Mip(2, "B", "Draft", [])}
304    zero_block = graph_block(zero_edge_mips)
305    ok("```mermaid" not in zero_block, "zero Blocked-by edges renders no mermaid block at all")
306    ok("nothing to graph yet" in zero_block, "zero edges renders the placeholder text instead")
307
308    orphan_mips = {
309        1: Mip(1, "Orphan", "Draft", []),
310        10: Mip(10, "Ledger", "Implemented", []),
311        32: Mip(32, "Benchmark", "Draft", [10]),
312    }
313    orphan_block = graph_block(orphan_mips)
314    ok("M0001" not in orphan_block, "a MIP with no edge in either direction is not drawn as a node")
315    ok("MIP-0001" in orphan_block, "an excluded orphan is still named in the not-graphed list")
316
317    stale_before = "before\n<!-- mip-graph:start -->\nold\n<!-- mip-graph:end -->\nafter\n"
318    tmp_readme = Path("/tmp/mip_graph_selftest_readme.md")
319    tmp_readme.write_text(stale_before, encoding="utf-8")
320    global README
321    real_readme = README
322    README = tmp_readme
323    try:
324        rc = cmd_generate(check=True)
325        ok(rc == 1, "--check reports stale when the block content differs")
326        cmd_generate(check=False)
327        rc2 = cmd_generate(check=True)
328        ok(rc2 == 0, "--check passes immediately after a regenerate")
329        ok(
330            "before\n" in tmp_readme.read_text() and "after\n" in tmp_readme.read_text(),
331            "content outside the markers is untouched",
332        )
333    finally:
334        README = real_readme
335        tmp_readme.unlink()
336
337    print(f"mip_graph self-test: {'ok' if fails == 0 else f'{fails} FAILED'}")
338    return 0 if fails == 0 else 1
339
340
341def main():
342    ap = argparse.ArgumentParser()
343    ap.add_argument("--check", action="store_true")
344    ap.add_argument("--parallel", nargs=2, type=int, metavar=("MIP_A", "MIP_B"))
345    ap.add_argument("--self-test", action="store_true")
346    args = ap.parse_args()
347
348    if args.self_test:
349        sys.exit(self_test())
350    if args.parallel:
351        sys.exit(cmd_parallel(*args.parallel))
352    sys.exit(cmd_generate(check=args.check))
353
354
355if __name__ == "__main__":
356    main()
ROOT = PosixPath('/home/runner/work/marola/marola')
MIPS_DIR = PosixPath('/home/runner/work/marola/marola/docs/MIPs')
README = PosixPath('/home/runner/work/marola/marola/docs/MIPs/README.md')
START_MARK = '<!-- mip-graph:start -->'
END_MARK = '<!-- mip-graph:end -->'
TITLE_RE = re.compile('^#\\s*MIP-(\\d{4}):\\s*(.+?)\\s*$')
ROW_RE = re.compile('^\\|\\s*\\*\\*(?P<key>[^*]+)\\*\\*\\s*\\|\\s*(?P<val>.*?)\\s*\\|\\s*$')
MIP_NUM_RE = re.compile('\\b(\\d{4})\\b')
STATUS_CLASS = {'draft': 'draft', 'accepted': 'accepted', 'implemented': 'implemented', 'rejected': 'rejected', 'superseded': 'rejected'}
class Mip:
45class Mip:
46    def __init__(self, num, title, status, blocked_by):
47        self.num = num
48        self.title = title
49        self.status = status
50        self.blocked_by = blocked_by  # list[int]
51
52    def node_id(self):
53        return f"M{self.num:04d}"
54
55    def status_class(self):
56        s = self.status.lower()
57        for key, cls in STATUS_CLASS.items():
58            if s.startswith(key):
59                return cls
60        return "draft"
Mip(num, title, status, blocked_by)
46    def __init__(self, num, title, status, blocked_by):
47        self.num = num
48        self.title = title
49        self.status = status
50        self.blocked_by = blocked_by  # list[int]
num
title
status
blocked_by
def node_id(self):
52    def node_id(self):
53        return f"M{self.num:04d}"
def status_class(self):
55    def status_class(self):
56        s = self.status.lower()
57        for key, cls in STATUS_CLASS.items():
58            if s.startswith(key):
59                return cls
60        return "draft"
def parse_mip_file(path):
63def parse_mip_file(path):
64    lines = path.read_text(encoding="utf-8").splitlines()
65    num = None
66    title = path.stem
67    status = "Draft"
68    blocked_by = []
69    for line in lines[:3]:
70        m = TITLE_RE.match(line)
71        if m:
72            num = int(m.group(1))
73            title = m.group(2)
74            break
75    if num is None:
76        return None
77    for line in lines:
78        m = ROW_RE.match(line)
79        if not m:
80            continue
81        key = m.group("key").strip().lower()
82        val = m.group("val").strip()
83        if key == "status":
84            # first word/clause only — "Implemented, ultrareview-verified..." -> "Implemented"
85            status = re.split(r"[,(]", val, maxsplit=1)[0].strip() or "Draft"
86        elif key == "blocked by":
87            if val.lower() != "none":
88                blocked_by = [int(n) for n in MIP_NUM_RE.findall(val)]
89    return Mip(num, title, status, blocked_by)
def load_mips():
 92def load_mips():
 93    mips = {}
 94    for path in sorted(MIPS_DIR.glob("MIP-*.md")):
 95        if path.name.endswith(".tasks.md"):
 96            continue
 97        mip = parse_mip_file(path)
 98        if mip:
 99            mips[mip.num] = mip
100    return mips
NO_EDGES_PLACEHOLDER = "_No MIP currently declares a **Blocked by** relationship, so there is nothing to graph yet — add that field to a MIP's metadata table and run `just mip-graph` again._"
def render_mermaid(mips):
109def render_mermaid(mips):
110    edges = []
111    for num in sorted(mips):
112        mip = mips[num]
113        for blocker in mip.blocked_by:
114            if blocker in mips:
115                edges.append((blocker, num))
116    edges = sorted(set(edges))
117    if not edges:
118        return NO_EDGES_PLACEHOLDER
119
120    # Only the MIPs that actually participate in an edge get drawn — an orphan MIP (no declared
121    # Blocked-by relationship in either direction) adds a disconnected box that tells the reader
122    # nothing the index table above doesn't already say, and 20+ of them turned the graph into an
123    # unreadable wall (verified against a real run: 29 MIPs, 0 edges, every node carrying its full
124    # title — the exact complaint that got this rewritten).
125    connected = {a for a, _ in edges} | {b for _, b in edges}
126
127    lines = ["```mermaid", "flowchart TD"]
128    # Dark fills: the site is slate only and injects white label text (MIP-0068 §5.2).
129    lines.append("  classDef draft fill:#1e2129,stroke:#8fa3b0,stroke-dasharray:3 3;")
130    lines.append("  classDef accepted fill:#0b4261,stroke:#1ac5da;")
131    lines.append("  classDef implemented fill:#0f4a2e,stroke:#3ecf6e;")
132    lines.append("  classDef rejected fill:#2a2d35,stroke:#666,color:#999;")
133    for num in sorted(connected):
134        mip = mips[num]
135        # Bare "MIP-NNNN" only — the full title is already one line up in the index table, and a
136        # long label per node is exactly what made the graph unreadable before this rewrite.
137        lines.append(f'  {mip.node_id()}["MIP-{num:04d}"]:::{mip.status_class()}')
138    for a, b in edges:
139        lines.append(f"  {mips[a].node_id()} --> {mips[b].node_id()}")
140    lines.append("```")
141    orphans = sorted(set(mips) - connected)
142    if orphans:
143        orphan_list = ", ".join(f"MIP-{n:04d}" for n in orphans)
144        lines.append("")
145        lines.append(
146            f"_{len(orphans)} MIP(s) with no declared Blocked-by relationship, not graphed: "
147            f"{orphan_list}._"
148        )
149    return "\n".join(lines)
def graph_block(mips):
152def graph_block(mips):
153    return f"{START_MARK}\n{render_mermaid(mips)}\n{END_MARK}"
def reachable(mips, start, blocked_direction=True):
156def reachable(mips, start, blocked_direction=True):
157    """Every MIP transitively blocking (or blocked by, if blocked_direction=False) `start`."""
158    seen = set()
159    stack = [start]
160    while stack:
161        cur = stack.pop()
162        if cur not in mips:
163            continue
164        neighbors = (
165            mips[cur].blocked_by
166            if blocked_direction
167            else [m.num for m in mips.values() if cur in m.blocked_by]
168        )
169        for n in neighbors:
170            if n not in seen:
171                seen.add(n)
172                stack.append(n)
173    return seen

Every MIP transitively blocking (or blocked by, if blocked_direction=False) start.

def source_paths(mip_path):
176def source_paths(mip_path):
177    """Backticked paths under a known top-level source dir, from a MIP's §5 Design section."""
178    text = mip_path.read_text(encoding="utf-8")
179    return set(
180        re.findall(
181            r"`((?:core|local|cli|site|scripts)/[A-Za-z0-9_./-]+)`",
182            text,
183        )
184    )

Backticked paths under a known top-level source dir, from a MIP's §5 Design section.

def cmd_parallel(a, b):
187def cmd_parallel(a, b):
188    mips = load_mips()
189    if a not in mips or b not in mips:
190        missing = [n for n in (a, b) if n not in mips]
191        print(f"mip_graph --parallel: MIP-{missing[0]:04d} not found")
192        return 1
193    blockers_of_a = reachable(mips, a)
194    blockers_of_b = reachable(mips, b)
195    dep_conflict = b in blockers_of_a or a in blockers_of_b
196    candidates_a = list(MIPS_DIR.glob(f"MIP-{a:04d}-*.md"))
197    candidates_b = list(MIPS_DIR.glob(f"MIP-{b:04d}-*.md"))
198    paths_a = source_paths(candidates_a[0]) if candidates_a else set()
199    paths_b = source_paths(candidates_b[0]) if candidates_b else set()
200    overlap = paths_a & paths_b
201
202    print(f"MIP-{a:04d} vs MIP-{b:04d}:")
203    print(f"  dependency conflict (Blocked by graph): {'yes' if dep_conflict else 'no'}")
204    if overlap:
205        print(f"  §5 source-path overlap: yes — {', '.join(sorted(overlap))}")
206    else:
207        print("  §5 source-path overlap: no (or no backticked source paths found in one/both)")
208    if not dep_conflict and not overlap:
209        print("  -> parallel-safe on both checks")
210        return 0
211    print("  -> NOT confirmed parallel-safe")
212    return 1
def cmd_generate(check):
215def cmd_generate(check):
216    if not README.exists():
217        print(f"mip_graph: {README} not found", file=sys.stderr)
218        return 1
219    mips = load_mips()
220    new_block = graph_block(mips)
221    text = README.read_text(encoding="utf-8")
222    if START_MARK in text and END_MARK in text:
223        pre, rest = text.split(START_MARK, 1)
224        _, post = rest.split(END_MARK, 1)
225        new_text = pre + new_block + post
226    else:
227        sep = "" if text.endswith("\n") else "\n"
228        new_text = text + sep + "\n" + new_block + "\n"
229    if check:
230        if new_text != text:
231            print(
232                "mip_graph --check: docs/MIPs/README.md's graph block is stale or missing — "
233                "run `just mip-graph` and commit the result"
234            )
235            return 1
236        print("mip_graph --check: graph block is current")
237        return 0
238    if new_text != text:
239        README.write_text(new_text, encoding="utf-8")
240        print(f"mip_graph: wrote graph block to {README} ({len(mips)} MIPs)")
241    else:
242        print("mip_graph: graph block already current, nothing written")
243    return 0
def self_test():
246def self_test():
247    fails = 0
248
249    def ok(cond, label):
250        nonlocal fails
251        if cond:
252            print("  ok   " + label)
253        else:
254            print("  FAIL " + label)
255            fails += 1
256
257    sample = """# MIP-0099: A sample title
258
259| | |
260|---|---|
261| **Status** | Draft |
262| **Blocked by** | MIP-0010, MIP-0025 |
263"""
264    tmp = Path("/tmp/mip_graph_selftest_0099.md")
265    tmp.write_text(sample, encoding="utf-8")
266    mip = parse_mip_file(tmp)
267    ok(mip is not None, "parses a real MIP header")
268    ok(mip.num == 99, "extracts the MIP number")
269    ok(mip.title == "A sample title", "extracts the title")
270    ok(mip.blocked_by == [10, 25], "parses a comma-separated Blocked by list")
271    tmp.unlink()
272
273    none_sample = "# MIP-0098: Another\n\n| | |\n|---|---|\n| **Blocked by** | none |\n"
274    tmp2 = Path("/tmp/mip_graph_selftest_0098.md")
275    tmp2.write_text(none_sample, encoding="utf-8")
276    mip2 = parse_mip_file(tmp2)
277    ok(mip2.blocked_by == [], "'none' produces an empty blocker list, not a parsed '0'")
278    tmp2.unlink()
279
280    no_row = "# MIP-0097: No blocked-by row at all\n\n| | |\n|---|---|\n| **Status** | Draft |\n"
281    tmp3 = Path("/tmp/mip_graph_selftest_0097.md")
282    tmp3.write_text(no_row, encoding="utf-8")
283    mip3 = parse_mip_file(tmp3)
284    ok(mip3.blocked_by == [], "a MIP with no Blocked by row at all is unconnected, not an error")
285    tmp3.unlink()
286
287    mips = {
288        10: Mip(10, "Ledger", "Implemented", []),
289        32: Mip(32, "Benchmark", "Draft", [10]),
290    }
291    block = graph_block(mips)
292    ok(block.startswith(START_MARK) and block.endswith(END_MARK), "block is wrapped in markers")
293    ok("M0010 --> M0032" in block, "an edge renders blocker --> blocked")
294    ok(":::implemented" in block and ":::draft" in block, "status classes render per node")
295    ok('"MIP-0010"' in block, "a connected node's label is bare MIP-NNNN, not its title")
296    ok(
297        "Ledger" not in block and "Benchmark" not in block,
298        "a MIP's title never appears in the graph",
299    )
300
301    r = reachable(mips, 32)
302    ok(r == {10}, "reachable() walks the Blocked-by chain")
303
304    zero_edge_mips = {1: Mip(1, "A", "Draft", []), 2: Mip(2, "B", "Draft", [])}
305    zero_block = graph_block(zero_edge_mips)
306    ok("```mermaid" not in zero_block, "zero Blocked-by edges renders no mermaid block at all")
307    ok("nothing to graph yet" in zero_block, "zero edges renders the placeholder text instead")
308
309    orphan_mips = {
310        1: Mip(1, "Orphan", "Draft", []),
311        10: Mip(10, "Ledger", "Implemented", []),
312        32: Mip(32, "Benchmark", "Draft", [10]),
313    }
314    orphan_block = graph_block(orphan_mips)
315    ok("M0001" not in orphan_block, "a MIP with no edge in either direction is not drawn as a node")
316    ok("MIP-0001" in orphan_block, "an excluded orphan is still named in the not-graphed list")
317
318    stale_before = "before\n<!-- mip-graph:start -->\nold\n<!-- mip-graph:end -->\nafter\n"
319    tmp_readme = Path("/tmp/mip_graph_selftest_readme.md")
320    tmp_readme.write_text(stale_before, encoding="utf-8")
321    global README
322    real_readme = README
323    README = tmp_readme
324    try:
325        rc = cmd_generate(check=True)
326        ok(rc == 1, "--check reports stale when the block content differs")
327        cmd_generate(check=False)
328        rc2 = cmd_generate(check=True)
329        ok(rc2 == 0, "--check passes immediately after a regenerate")
330        ok(
331            "before\n" in tmp_readme.read_text() and "after\n" in tmp_readme.read_text(),
332            "content outside the markers is untouched",
333        )
334    finally:
335        README = real_readme
336        tmp_readme.unlink()
337
338    print(f"mip_graph self-test: {'ok' if fails == 0 else f'{fails} FAILED'}")
339    return 0 if fails == 0 else 1
def main():
342def main():
343    ap = argparse.ArgumentParser()
344    ap.add_argument("--check", action="store_true")
345    ap.add_argument("--parallel", nargs=2, type=int, metavar=("MIP_A", "MIP_B"))
346    ap.add_argument("--self-test", action="store_true")
347    args = ap.parse_args()
348
349    if args.self_test:
350        sys.exit(self_test())
351    if args.parallel:
352        sys.exit(cmd_parallel(*args.parallel))
353    sys.exit(cmd_generate(check=args.check))