roadmap-core 0.3.0__tar.gz → 0.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. {roadmap_core-0.3.0/roadmap_core.egg-info → roadmap_core-0.3.2}/PKG-INFO +1 -1
  2. {roadmap_core-0.3.0 → roadmap_core-0.3.2}/pyproject.toml +1 -1
  3. {roadmap_core-0.3.0 → roadmap_core-0.3.2}/roadmap_core/cli.py +171 -18
  4. {roadmap_core-0.3.0 → roadmap_core-0.3.2}/roadmap_core/graph.py +115 -4
  5. {roadmap_core-0.3.0 → roadmap_core-0.3.2/roadmap_core.egg-info}/PKG-INFO +1 -1
  6. {roadmap_core-0.3.0 → roadmap_core-0.3.2}/roadmap_core.egg-info/SOURCES.txt +2 -0
  7. {roadmap_core-0.3.0 → roadmap_core-0.3.2}/tests/test_graph.py +164 -0
  8. roadmap_core-0.3.2/tests/test_pull_projects_status.py +195 -0
  9. roadmap_core-0.3.2/tests/test_renderers.py +358 -0
  10. {roadmap_core-0.3.0 → roadmap_core-0.3.2}/LICENSE +0 -0
  11. {roadmap_core-0.3.0 → roadmap_core-0.3.2}/README.md +0 -0
  12. {roadmap_core-0.3.0 → roadmap_core-0.3.2}/roadmap_core/__init__.py +0 -0
  13. {roadmap_core-0.3.0 → roadmap_core-0.3.2}/roadmap_core/impact.py +0 -0
  14. {roadmap_core-0.3.0 → roadmap_core-0.3.2}/roadmap_core/mcp_server.py +0 -0
  15. {roadmap_core-0.3.0 → roadmap_core-0.3.2}/roadmap_core/store.py +0 -0
  16. {roadmap_core-0.3.0 → roadmap_core-0.3.2}/roadmap_core/stores.py +0 -0
  17. {roadmap_core-0.3.0 → roadmap_core-0.3.2}/roadmap_core.egg-info/dependency_links.txt +0 -0
  18. {roadmap_core-0.3.0 → roadmap_core-0.3.2}/roadmap_core.egg-info/entry_points.txt +0 -0
  19. {roadmap_core-0.3.0 → roadmap_core-0.3.2}/roadmap_core.egg-info/requires.txt +0 -0
  20. {roadmap_core-0.3.0 → roadmap_core-0.3.2}/roadmap_core.egg-info/top_level.txt +0 -0
  21. {roadmap_core-0.3.0 → roadmap_core-0.3.2}/setup.cfg +0 -0
  22. {roadmap_core-0.3.0 → roadmap_core-0.3.2}/templates/roadmap.yml +0 -0
  23. {roadmap_core-0.3.0 → roadmap_core-0.3.2}/tests/test_adoption.py +0 -0
  24. {roadmap_core-0.3.0 → roadmap_core-0.3.2}/tests/test_arcs.py +0 -0
  25. {roadmap_core-0.3.0 → roadmap_core-0.3.2}/tests/test_impact.py +0 -0
  26. {roadmap_core-0.3.0 → roadmap_core-0.3.2}/tests/test_mcp_server.py +0 -0
  27. {roadmap_core-0.3.0 → roadmap_core-0.3.2}/tests/test_store.py +0 -0
  28. {roadmap_core-0.3.0 → roadmap_core-0.3.2}/tests/test_stores.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: roadmap-core
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: The roadmap work-item and arc graph: status derivation, validation, and markdown rendering. Stdlib-only, so any repo can adopt it without adopting a backend.
5
5
  License: MIT
6
6
  Project-URL: Source, https://github.com/gald33/roadmap-core
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "roadmap-core"
3
- version = "0.3.0"
3
+ version = "0.3.2"
4
4
  description = "The roadmap work-item and arc graph: status derivation, validation, and markdown rendering. Stdlib-only, so any repo can adopt it without adopting a backend."
5
5
  requires-python = ">=3.11"
6
6
  readme = "README.md"
@@ -1067,13 +1067,40 @@ def compare_sources(
1067
1067
  )
1068
1068
  for key in sorted(set(db) & set(files)):
1069
1069
  d, f = db[key], files[key]
1070
- if (d.get("status") == "done") != (f.get("status") == "done"):
1070
+ done_differs = (d.get("status") == "done") != (f.get("status") == "done")
1071
+ if done_differs:
1071
1072
  finished, stale = ("files", "DB") if f.get("status") == "done" else ("DB", "files")
1072
1073
  problems.append(
1073
1074
  f"{key}: done in the {finished} but not in the {stale} — `done` is the "
1074
1075
  f"one status the graph cannot derive, so the {stale} will keep offering "
1075
1076
  f"finished work as startable"
1076
1077
  )
1078
+ # `verifying` is underivable for exactly the same reason, and
1079
+ # `derive_status` says so: `done`, `verifying` and an active claim are
1080
+ # the three facts the graph cannot work out for itself. This compared
1081
+ # two of the three. So a store holding `claimed` (or `ready`) against a
1082
+ # file holding `verifying` read as AGREEMENT, and the committed
1083
+ # ROADMAP.md kept offering shipped work as startable — the exact harm
1084
+ # the `done` message above describes, reached by the one door left open.
1085
+ #
1086
+ # Measured in Lucille 2026-09-14: four items differed this way while
1087
+ # `diff` reported "db and files agree", and the two committed artifacts
1088
+ # disagreed with each other — ROADMAP.md listed
1089
+ # `user-timezone-is-never-asked-only-defaulted` as `now` and startable
1090
+ # while its own item file said `status: verifying`.
1091
+ #
1092
+ # Suppressed when `done` already differs: a db=`verifying` /
1093
+ # files=`done` pair is ONE disagreement, and reporting it twice would
1094
+ # make the commonest real case read as two problems.
1095
+ elif (d.get("status") == "verifying") != (f.get("status") == "verifying"):
1096
+ shipped, stale = (
1097
+ ("files", "DB") if f.get("status") == "verifying" else ("DB", "files")
1098
+ )
1099
+ problems.append(
1100
+ f"{key}: verifying in the {shipped} but not in the {stale} — `verifying` "
1101
+ f"is underivable too, so the {stale} will keep offering shipped work as "
1102
+ f"startable and its claim will read as an ordinary hold"
1103
+ )
1077
1104
  if (d.get("claimed_by") or None) != (f.get("claimed_by") or None):
1078
1105
  problems.append(
1079
1106
  f"{key}: claim differs — DB={d.get('claimed_by') or 'none'!r}, "
@@ -1171,6 +1198,23 @@ def cmd_diff(args: argparse.Namespace) -> int:
1171
1198
  return 1
1172
1199
 
1173
1200
 
1201
+ # How settled each of the two underivable statuses is. `derive_status` recomputes
1202
+ # every other status from the edges, so these are the only two a checkout cannot
1203
+ # work out for itself — and therefore the only two `pull` has to carry.
1204
+ #
1205
+ # THE ORDER IS THE DESIGN: `pull` moves a file UP this scale and never down. The
1206
+ # store's silence about `verifying` is IGNORANCE, NOT DENIAL — the backend honors
1207
+ # `status` on INSERT and ignores it on UPDATE, so a file's `verifying` has no path
1208
+ # into an existing store row at all. Projecting the store's `ready` back over it
1209
+ # would delete the only record that the work shipped and hand it to the next
1210
+ # session as startable, which is the very harm this projection exists to stop.
1211
+ #
1212
+ # Measured in Lucille from the two committed artifacts, 2026-09-17: 1 item where
1213
+ # the store holds the underivable status and the file does not, against 17 the
1214
+ # other way, 8 of them at store-status `ready`.
1215
+ _SETTLEMENT = {"verifying": 1, "done": 2}
1216
+
1217
+
1174
1218
  def cmd_pull(args: argparse.Namespace) -> int:
1175
1219
  """The inverse of ``push``: bring the store's reality back into the checkout.
1176
1220
 
@@ -1182,6 +1226,7 @@ def cmd_pull(args: argparse.Namespace) -> int:
1182
1226
  db = load(source)
1183
1227
  files = load_from_files()
1184
1228
  created, updated = [], []
1229
+ projected: dict[str, str] = {}
1185
1230
  ITEMS_DIR.mkdir(parents=True, exist_ok=True)
1186
1231
  for key in sorted(db):
1187
1232
  item = db[key]
@@ -1198,11 +1243,27 @@ def cmd_pull(args: argparse.Namespace) -> int:
1198
1243
  else None
1199
1244
  )
1200
1245
  changed = write_claim_to_file(key, claim) or changed
1201
- if item.get("status") == "done" and local.get("status") != "done":
1246
+ # This projected `done` and nothing else. `verifying` post-dates it and
1247
+ # was never added — not a considered rejection, just a gap — so a store
1248
+ # that knew an item had shipped could not tell a checkout, and every
1249
+ # tokenless session (which is how dispatch runs BY DESIGN) read `ready`
1250
+ # for work that merged days earlier. `pull` would print "the files
1251
+ # already match the store" while `diff` reported the divergence in the
1252
+ # same second, and the sync workflow asserting `diff` stayed red with no
1253
+ # automatic remedy.
1254
+ #
1255
+ # Strictly increasing, per `_SETTLEMENT`: add what the store knows, never
1256
+ # remove what only the file knows.
1257
+ db_rank = _SETTLEMENT.get(item.get("status"), 0)
1258
+ local_rank = _SETTLEMENT.get(local.get("status"), 0)
1259
+ if db_rank > local_rank:
1202
1260
  path = item_path(key)
1203
1261
  path.write_text(
1204
- _replace_top_level_key(path.read_text(), "status", ["status: done"])
1262
+ _replace_top_level_key(
1263
+ path.read_text(), "status", [f"status: {item['status']}"]
1264
+ )
1205
1265
  )
1266
+ projected[key] = f"{local.get('status') or 'unset'} -> {item['status']}"
1206
1267
  changed = True
1207
1268
  # A priority set straight against the API (by an operator with no
1208
1269
  # checkout — the case that motivated the field) changes no file, so
@@ -1254,7 +1315,11 @@ def cmd_pull(args: argparse.Namespace) -> int:
1254
1315
  for key in created:
1255
1316
  print(f"created roadmap/items/{key}.yaml")
1256
1317
  for key in updated:
1257
- print(f"updated roadmap/items/{key}.yaml")
1318
+ # Name the transition. A bare "updated <file>" does not say a status
1319
+ # moved, and a repair that leaves no trace hides the next regression
1320
+ # exactly as this one hid itself.
1321
+ note = f" (status: {projected[key]})" if key in projected else ""
1322
+ print(f"updated roadmap/items/{key}.yaml{note}")
1258
1323
  for key in arcs_created:
1259
1324
  print(f"created roadmap/arcs/{key}.yaml")
1260
1325
  if not created and not updated and not arcs_created:
@@ -1295,20 +1360,37 @@ def _stale_claim_notice(by_key: dict[str, dict[str, Any]]) -> None:
1295
1360
  if not stale:
1296
1361
  return
1297
1362
  print(
1298
- f"\nnote: {len(stale)} claim(s) held longer than {graph.STALE_CLAIM_DAYS}d — "
1363
+ f"\nnote: {len(stale)} claim(s) held longer than expected — "
1299
1364
  f"likely finished sessions that never released:",
1300
1365
  file=sys.stderr,
1301
1366
  )
1302
1367
  for item in stale:
1368
+ # Name the bar this item was actually judged against. A flat
1369
+ # "longer than 3d" header was already wrong for `verifying`, which is
1370
+ # held to a longer one; printing one number for a mixed list just moves
1371
+ # that error rather than fixing it.
1372
+ limit = item.get("claim_threshold_days", graph.STALE_CLAIM_DAYS)
1373
+ status = str(item.get("status") or "")
1303
1374
  print(
1304
1375
  f" {item['key']} — {item.get('claimed_by')} "
1305
- f"({item['claim_age_days']:.1f}d ago)",
1376
+ f"({item['claim_age_days']:.1f}d ago, over {limit:g}d"
1377
+ + (f", {status}" if status else "")
1378
+ + ")",
1306
1379
  file=sys.stderr,
1307
1380
  )
1308
1381
  print(
1309
1382
  " Verify the branch is merged or gone, then `roadmap.py release <key>`.",
1310
1383
  file=sys.stderr,
1311
1384
  )
1385
+ if any(item.get("status") == "verifying" for item in stale):
1386
+ # `release` is the WRONG first move for one of these: the item shipped
1387
+ # and is waiting on an observation, so the usual outcome is that the
1388
+ # effect landed and nobody said so.
1389
+ print(
1390
+ " For a `verifying` item, `roadmap.py status <key> done` if the effect "
1391
+ "has landed — release only if the watch was abandoned.",
1392
+ file=sys.stderr,
1393
+ )
1312
1394
 
1313
1395
 
1314
1396
  def _own_claims_notice(by_key: dict[str, dict[str, Any]], *, exclude: str = "") -> None:
@@ -1457,6 +1539,13 @@ def cmd_prune(args: argparse.Namespace) -> int:
1457
1539
  carrying forward should already be in ``ARCS.md`` or the design doc — an item
1458
1540
  is a work ticket, not an archive.
1459
1541
 
1542
+ WITH ONE EXCEPTION: an item carrying ``tickets`` is withheld. That field is
1543
+ the only copy of the edge from shipped work back to the person who reported
1544
+ it, and unlike the prose above it has no second home in git history that
1545
+ anything queries. ``--include-ticket-linked`` overrides, for an operator who
1546
+ has checked those loops are closed. See the comment at the withholding for
1547
+ why the predicate here is broader than the rule it implements.
1548
+
1460
1549
  DB first, then the file: if the store delete fails the file stays, so a
1461
1550
  partial run is simply re-runnable rather than leaving an item that exists in
1462
1551
  a checkout and nowhere else.
@@ -1485,32 +1574,74 @@ def cmd_prune(args: argparse.Namespace) -> int:
1485
1574
  for line in blockers:
1486
1575
  print(f" {line}", file=sys.stderr)
1487
1576
  files = load_from_files()
1577
+ stored: dict[str, dict[str, Any]] = {}
1488
1578
  done = sorted(k for k, i in files.items() if i.get("status") == "done")
1489
1579
  try:
1580
+ stored = load_from_db()
1490
1581
  done += sorted(
1491
- k for k, i in load_from_db().items() if i.get("status") == "done" and k not in done
1582
+ k for k, i in stored.items() if i.get("status") == "done" and k not in done
1492
1583
  )
1493
1584
  except SystemExit as exc: # no JWT / API down — the file side still prunes
1494
1585
  print(f"warning: could not read the store ({exc}); pruning files only", file=sys.stderr)
1495
1586
  if not done:
1496
1587
  print("no done items — nothing to prune")
1497
1588
  return 0
1498
- # A pruned item takes its `done_at`, `done_version` and ticket links with it,
1499
- # and those are exactly the fields `impact` reads — the answer to "is anybody
1500
- # still complaining about this after we fixed it" only exists while the item
1501
- # does. Warned, not blocked: rule 4 says do not accumulate a done pile, and
1502
- # silently refusing to prune would rebuild the pile this command exists to
1503
- # clear. Run `impact` first if the answer still matters.
1504
- linked = {k: files.get(k, {}).get("tickets") or [] for k in done}
1589
+ # HYGIENE LOSES TO EVIDENCE. A pruned item takes its `tickets` with it, and
1590
+ # that field is the ONLY copy of the link from shipped work back to the
1591
+ # person who reported it — one writer, no second copy anywhere. `impact`
1592
+ # reads it to answer "is anybody still complaining about this after we
1593
+ # fixed it", and Lucille's ticket lifecycle walks it the other way to move
1594
+ # a report to `handled`, the one status its reporter ever sees. Destroying
1595
+ # it to tidy a done pile trades something irreplaceable for something
1596
+ # cosmetic.
1597
+ #
1598
+ # So this used to warn and prune anyway. It now withholds them, which is
1599
+ # the CEO ruling of 2026-09-14 (gald33/Lucille#1429).
1600
+ #
1601
+ # ⚠️ THE RULING IS NARROWER THAN WHAT IS IMPLEMENTED HERE, and the gap is
1602
+ # deliberate rather than forgotten. The rule is "skip an item whose
1603
+ # `tickets` is non-empty AND WHOSE LOOP IS STILL OPEN" — once the linked
1604
+ # ticket reaches `handled` the edge has done its job and the item should
1605
+ # prune normally. The predicate is "is this edge still load-bearing", not
1606
+ # "does this field exist". This package cannot evaluate the first half:
1607
+ # ticket status lives in the consumer's `feedback_tickets` table, and
1608
+ # reaching for it would make a stdlib-only library depend on one specific
1609
+ # backend — the one thing `pyproject.toml` says it must never do. So the
1610
+ # conservative half is implemented, it is LOUD about being conservative,
1611
+ # and `--include-ticket-linked` is the operator's way to say "I checked,
1612
+ # the loops are closed" — the judgement a human can make and this code
1613
+ # cannot.
1614
+ linked = {k: ((files.get(k) or stored.get(k) or {}).get("tickets") or []) for k in done}
1505
1615
  linked = {k: v for k, v in linked.items() if v}
1506
- if linked:
1616
+ if linked and not args.include_ticket_linked:
1617
+ done = [k for k in done if k not in linked]
1507
1618
  print(
1508
- "warning: these carry ticket links; `impact` cannot answer for them "
1509
- "once pruned:",
1619
+ f"withholding {len(linked)} item(s): they carry ticket links, which "
1620
+ "are the only record of who reported the work.",
1621
+ file=sys.stderr,
1622
+ )
1623
+ for key, ids in sorted(linked.items()):
1624
+ print(f" {key} ({len(ids)} ticket(s): {', '.join(ids)})", file=sys.stderr)
1625
+ print(
1626
+ "This tool cannot see ticket status — that lives in the consuming\n"
1627
+ "application, not in roadmap-core — so it cannot tell a loop that is\n"
1628
+ "still open from one already closed, and withholds both. Check\n"
1629
+ f"whether those tickets are handled (`{graph.CLI} impact <key>` shows\n"
1630
+ "what the links answer); if they are, re-run with\n"
1631
+ "--include-ticket-linked to prune them too.",
1632
+ file=sys.stderr,
1633
+ )
1634
+ elif linked:
1635
+ print(
1636
+ f"--include-ticket-linked: pruning {len(linked)} item(s) WITH ticket "
1637
+ "links; their link to the reporter is destroyed:",
1510
1638
  file=sys.stderr,
1511
1639
  )
1512
1640
  for key, ids in sorted(linked.items()):
1513
1641
  print(f" {key} ({len(ids)} ticket(s))", file=sys.stderr)
1642
+ if not done:
1643
+ print("nothing to prune — every done item is withheld for its ticket links")
1644
+ return 0
1514
1645
 
1515
1646
  if not args.yes:
1516
1647
  print("would prune (re-run with --yes):")
@@ -1668,8 +1799,23 @@ def cmd_list(args: argparse.Namespace) -> int:
1668
1799
  suffix += f" ⚠️ held {age:.1f}d"
1669
1800
  elif unmet := graph.unmet_deps(item, by_key):
1670
1801
  suffix = f" ← {', '.join(unmet)}"
1802
+ # The status token is ON THE LINE, not only in the band header
1803
+ # above it. A header is read once and then scrolled past: a grep, a
1804
+ # truncated read, a line quoted into a dispatch message, or plain
1805
+ # scrollback all deliver the line WITHOUT it, and the line then
1806
+ # says nothing about whether the work is open. Measured 2026-09-14
1807
+ # in Lucille: 10 of its 18 `done` items carried a `now`/`next`
1808
+ # priority, so they rendered as `now <key> <present-tense
1809
+ # defect>` — byte-identical in shape to the repo's highest-priority
1810
+ # open work. A session read one, believed it, and dispatched ~150k
1811
+ # tokens re-fixing already-shipped work.
1812
+ #
1813
+ # Every item gets one, never only the finished ones: a marker that
1814
+ # appears conditionally makes ABSENCE carry meaning, which is the
1815
+ # exact shape of the bug — an unmarked line read as open work.
1671
1816
  print(
1672
- f" {graph.priority_of(item) or '':<6} "
1817
+ f" {statename:<{graph.STATUS_WIDTH}} "
1818
+ f"{graph.priority_of(item) or '':<6} "
1673
1819
  f"{item['key']:<38} {item['title']}{suffix}"
1674
1820
  )
1675
1821
  _stale_claim_notice(by_key)
@@ -2277,6 +2423,13 @@ def main(argv: list[str] | None = None) -> int:
2277
2423
  help="prune even though roadmap/items/ disagrees with origin/main "
2278
2424
  "(you have read the difference and it is deliberate)",
2279
2425
  )
2426
+ p_prune.add_argument(
2427
+ "--include-ticket-linked",
2428
+ action="store_true",
2429
+ help="also prune done items that carry `tickets` links (withheld by "
2430
+ "default: that field is the only record of who reported the work, "
2431
+ "and this tool cannot see whether their loop is closed)",
2432
+ )
2280
2433
  p_prune.set_defaults(func=cmd_prune)
2281
2434
 
2282
2435
  p_refile = sub.add_parser(
@@ -20,6 +20,14 @@ from typing import Any
20
20
 
21
21
  STATUSES = ("ready", "deferred", "blocked", "claimed", "verifying", "done")
22
22
 
23
+ #: Column width for a status token printed alongside other columns.
24
+ #:
25
+ #: Derived from ``STATUSES`` rather than written out, so adding a longer status
26
+ #: cannot silently unalign every surface that prints one. Every renderer that
27
+ #: puts a status in a column uses this, which is what makes ``roadmap list``
28
+ #: line up with anything else that grows one later.
29
+ STATUS_WIDTH = max(len(s) for s in STATUSES)
30
+
23
31
  #: How the generated markdown tells a reader to re-run this tool.
24
32
  #:
25
33
  #: The name of the console script this package installs, and DELIBERATELY A
@@ -90,6 +98,48 @@ _UNKNOWN_PRIORITY_RANK = _PRIORITY_RANK[None]
90
98
  #: the next reader, who can check the branch and decide.
91
99
  STALE_CLAIM_DAYS = 3
92
100
 
101
+ #: How long a ``verifying`` claim may stand before it reads as suspect.
102
+ #:
103
+ #: The threshold above rests on "an agent claims, works, opens a PR and merges,
104
+ #: usually inside a day". ``verifying`` is the one status where that is FALSE BY
105
+ #: DESIGN, and this package says so itself — setting the status prints "verifying
106
+ #: does NOT drop the claim — the branch that shipped this still owns confirming
107
+ #: it. Move to `done` once the effect is actually observed". Observing a prod
108
+ #: effect takes as long as it takes; a post-merge check can sit inside a 14-day
109
+ #: window before it has anything to report.
110
+ #:
111
+ #: So the flat 3-day read made this package contradict itself between two of its
112
+ #: own commands. Measured in Lucille 2026-09-14: ``ready`` reported
113
+ #: ``closure-tool-fires-but-nothing-is-written`` as a "likely finished session
114
+ #: that never released" and advised releasing it, while that repo's own claim
115
+ #: audit reported the same item the same day as "'verifying', which is the one
116
+ #: status that deliberately keeps its claim ... Nothing to decide." Releasing it
117
+ #: would have stripped the claim from the branch that owns the watch — so the
118
+ #: advice was not merely noisy, it was wrong.
119
+ #:
120
+ #: Still a surfacing threshold and never an expiry: a ``verifying`` claim CAN be
121
+ #: abandoned, by a session that shipped and died before the effect landed. It is
122
+ #: reported later, not never.
123
+ VERIFYING_CLAIM_DAYS = 14
124
+
125
+ #: Statuses whose claim is expected to outlive a session. Anything absent here
126
+ #: uses ``STALE_CLAIM_DAYS``; a status is listed only when holding is the
127
+ #: DOCUMENTED behaviour, never to quiet a noisy report.
128
+ _CLAIM_THRESHOLD_DAYS: dict[str, float] = {"verifying": VERIFYING_CLAIM_DAYS}
129
+
130
+
131
+ def claim_threshold_days(
132
+ item: dict[str, Any], *, threshold_days: float = STALE_CLAIM_DAYS
133
+ ) -> float:
134
+ """How long *this* item's claim may stand before it reads as suspect.
135
+
136
+ ``max`` rather than a plain override, so a caller that deliberately widens
137
+ the base threshold widens this one with it. Narrowing it does not narrow
138
+ this one — a status that holds on purpose still holds on purpose.
139
+ """
140
+ override = _CLAIM_THRESHOLD_DAYS.get(str(item.get("status") or ""))
141
+ return threshold_days if override is None else max(override, threshold_days)
142
+
93
143
  #: The second edge type, and the only non-blocking one.
94
144
  #:
95
145
  #: ``blocked_on`` says "do not start X until Y is done" — a claim about
@@ -334,12 +384,24 @@ def stale_claims(
334
384
  Copies rather than mutating, with ``claim_age_days`` added, so the CLI, the
335
385
  API and the generated markdown all report the same age from the same
336
386
  computation instead of each re-deriving it against its own clock.
387
+
388
+ The threshold is per-status (see ``claim_threshold_days``): ``verifying``
389
+ keeps its claim on purpose, and reporting that as an abandoned hold made
390
+ this package advise undoing something it had just told the caller to do.
391
+ ``claim_threshold_days`` is carried on each returned item so a caller can
392
+ say what bar was applied instead of assuming the base one.
337
393
  """
338
394
  aged = []
339
395
  for key in sorted(by_key):
340
- age = claim_age_days(by_key[key], now=now)
341
- if age is not None and age >= threshold_days:
342
- aged.append({**by_key[key], "claim_age_days": round(age, 1)})
396
+ item = by_key[key]
397
+ age = claim_age_days(item, now=now)
398
+ if age is None:
399
+ continue
400
+ limit = claim_threshold_days(item, threshold_days=threshold_days)
401
+ if age >= limit:
402
+ aged.append(
403
+ {**item, "claim_age_days": round(age, 1), "claim_threshold_days": limit}
404
+ )
343
405
  return sorted(aged, key=lambda item: item["claim_age_days"], reverse=True)
344
406
 
345
407
 
@@ -572,6 +634,25 @@ def _node(key: str) -> str:
572
634
  return key.replace("-", "_")
573
635
 
574
636
 
637
+ #: How each derived status is drawn in the dependency graph.
638
+ #:
639
+ #: One entry per ``STATUSES`` member — asserted by the tests, so a new status
640
+ #: cannot be added without deciding how it looks, which is the failure mode
641
+ #: where an unstyled node silently renders as the default and reads as open.
642
+ #:
643
+ #: Stroke properties only. This block is rendered on GitHub in both a light and
644
+ #: a dark theme, and a hardcoded ``fill`` or text ``color`` that reads well on
645
+ #: one is unreadable on the other.
646
+ _MERMAID_STATUS_STYLE = {
647
+ "ready": "stroke:#2da44e,stroke-width:2px",
648
+ "deferred": "stroke:#9a6700,stroke-width:1px,stroke-dasharray:4 3",
649
+ "blocked": "stroke:#cf222e,stroke-width:2px",
650
+ "claimed": "stroke:#8250df,stroke-width:2px",
651
+ "verifying": "stroke:#0969da,stroke-width:2px,stroke-dasharray:6 3",
652
+ "done": "stroke:#8c959f,stroke-width:1px,stroke-dasharray:5 4",
653
+ }
654
+
655
+
575
656
  def render_markdown(by_key: dict[str, dict[str, Any]]) -> str:
576
657
  """The agent-facing projection of the graph.
577
658
 
@@ -762,9 +843,39 @@ def render_markdown(by_key: dict[str, dict[str, Any]]) -> str:
762
843
  add("")
763
844
  add("```mermaid")
764
845
  add("graph TD")
846
+ # Six constant lines. They are byte-identical on every branch, so they
847
+ # add no conflict surface at all — unlike a `class a,b,c done` roll-up,
848
+ # which would be a graph-wide aggregate on ONE line and would conflict
849
+ # between any two branches that finished different items. That is the
850
+ # #1103 shape this function's docstring refuses, arriving through
851
+ # styling instead of through a count. Strokes only, no fill and no text
852
+ # colour, because this renders on both a light and a dark background
853
+ # and a hardcoded fill is unreadable on one of them.
854
+ for statename in STATUSES:
855
+ add(f" classDef {statename} {_MERMAID_STATUS_STYLE[statename]}")
765
856
  for key in sorted(derived):
857
+ status = derived[key]["status"]
766
858
  label = derived[key]["title"].replace('"', "'")
767
- add(f' {_node(key)}["{label}"]')
859
+ # Every node carries its status, and it is carried BY THE NODE'S OWN
860
+ # LINE. Before this the 82 nodes of Lucille's committed graph
861
+ # rendered identically whether `ready` or `done`, in the one
862
+ # artifact a session reads to decide what to work on.
863
+ #
864
+ # Two markers, because the two readers of this block are different.
865
+ # `:::status` is for whoever reads the SOURCE — a grep, a diff, an
866
+ # agent reading the checked-in markdown — and it survives being
867
+ # quoted a line at a time. The `✓ ` prefix is for whoever reads the
868
+ # PICTURE, where a stroke colour is a distinction that can be
869
+ # missed and a glyph in the label cannot be. A `classDef` alone
870
+ # would have been invisible to the first reader and easy to miss
871
+ # for the second.
872
+ #
873
+ # `:::status` goes on every node, never only the done ones, for the
874
+ # reason `cmd_list` prints a status on every row: a conditional
875
+ # marker makes absence carry meaning, and "no marker" reading as
876
+ # "open" is the bug itself.
877
+ prefix = "✓ " if status == "done" else ""
878
+ add(f' {_node(key)}["{prefix}{label}"]:::{status}')
768
879
  for key in sorted(derived):
769
880
  for dep in derived[key].get("blocked_on") or []:
770
881
  add(f" {_node(dep)} --> {_node(key)}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: roadmap-core
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: The roadmap work-item and arc graph: status derivation, validation, and markdown rendering. Stdlib-only, so any repo can adopt it without adopting a backend.
5
5
  License: MIT
6
6
  Project-URL: Source, https://github.com/gald33/roadmap-core
@@ -20,5 +20,7 @@ tests/test_arcs.py
20
20
  tests/test_graph.py
21
21
  tests/test_impact.py
22
22
  tests/test_mcp_server.py
23
+ tests/test_pull_projects_status.py
24
+ tests/test_renderers.py
23
25
  tests/test_store.py
24
26
  tests/test_stores.py
@@ -232,3 +232,167 @@ def test_cli_messages_name_no_path_from_the_extraction_repo():
232
232
  f"cli.py prints {text!r}, naming {bad!r} — a path that exists only in "
233
233
  f"the repository this package was extracted from. Use `graph.CLI`."
234
234
  )
235
+
236
+
237
+ # --- a claim that is held on purpose is not a claim that was forgotten -------
238
+
239
+
240
+ def _held(key, *, status, days_ago, now):
241
+ """An item claimed `days_ago` by a branch, in `status`."""
242
+ import datetime as _dt
243
+
244
+ return {
245
+ "key": key,
246
+ "title": key,
247
+ "status": status,
248
+ "blocked_on": [],
249
+ "evidence": "x",
250
+ "claimed_by": f"claude/{key}",
251
+ "claimed_at": (now - _dt.timedelta(days=days_ago)).isoformat(),
252
+ }
253
+
254
+
255
+ def _now():
256
+ import datetime as _dt
257
+
258
+ return _dt.datetime(2026, 9, 14, 12, 0, tzinfo=_dt.timezone.utc)
259
+
260
+
261
+ def test_a_verifying_claim_is_not_stale_at_the_base_threshold():
262
+ """The bug this package had against itself.
263
+
264
+ Setting `verifying` PRINTS "verifying does NOT drop the claim — the branch
265
+ that shipped this still owns confirming it". `ready` then reported that
266
+ same claim as a "likely finished session that never released" and advised
267
+ releasing it. Measured in Lucille 2026-09-14 on
268
+ `closure-tool-fires-but-nothing-is-written`, held 7.8 days; its own claim
269
+ audit said "Nothing to decide" about the identical row the same day.
270
+ Releasing it would have stripped the claim from the branch that owns the
271
+ watch — wrong advice, not merely noisy.
272
+ """
273
+ now = _now()
274
+ by_key = {
275
+ "claimed": _held("claimed", status="claimed", days_ago=7.8, now=now),
276
+ "verifying": _held("verifying", status="verifying", days_ago=7.8, now=now),
277
+ }
278
+ stale = graph.stale_claims(by_key, now=now)
279
+ assert [item["key"] for item in stale] == ["claimed"], (
280
+ "a `verifying` claim held under the verifying threshold must not be "
281
+ "reported as an abandoned hold"
282
+ )
283
+
284
+
285
+ def test_a_verifying_claim_is_still_reported_once_it_outlives_its_own_window():
286
+ """Surfacing threshold, never an expiry — reported later, not never.
287
+
288
+ A session can ship and die before the effect lands, and that claim is just
289
+ as abandoned as any other. Excluding `verifying` outright would trade a
290
+ wrong report for a missing one.
291
+ """
292
+ now = _now()
293
+ by_key = {"v": _held("v", status="verifying", days_ago=20.0, now=now)}
294
+ stale = graph.stale_claims(by_key, now=now)
295
+ assert [item["key"] for item in stale] == ["v"]
296
+ assert stale[0]["claim_threshold_days"] == graph.VERIFYING_CLAIM_DAYS
297
+
298
+
299
+ def test_the_verifying_window_is_longer_than_the_base_one():
300
+ """If these ever cross, the override silently becomes a no-op."""
301
+ assert graph.VERIFYING_CLAIM_DAYS > graph.STALE_CLAIM_DAYS
302
+
303
+
304
+ def test_a_widened_threshold_widens_verifying_too():
305
+ """`max`, not override: a caller asking for a laxer bar gets it everywhere.
306
+
307
+ Otherwise `threshold_days=30` would report a 20-day `verifying` hold while
308
+ ignoring a 20-day `claimed` one — the laxer request making the stricter
309
+ answer.
310
+ """
311
+ now = _now()
312
+ by_key = {"v": _held("v", status="verifying", days_ago=20.0, now=now)}
313
+ assert graph.stale_claims(by_key, now=now, threshold_days=30) == []
314
+ assert graph.claim_threshold_days(by_key["v"], threshold_days=30) == 30
315
+
316
+
317
+ def test_a_narrowed_threshold_does_not_narrow_verifying():
318
+ """A status that holds on purpose still holds on purpose."""
319
+ now = _now()
320
+ by_key = {"v": _held("v", status="verifying", days_ago=7.8, now=now)}
321
+ assert graph.stale_claims(by_key, now=now, threshold_days=0.1) == []
322
+
323
+
324
+ def test_every_other_status_keeps_the_base_threshold():
325
+ """The override is a named exception, not a general loosening."""
326
+ now = _now()
327
+ for status in graph.STATUSES:
328
+ if status == "verifying":
329
+ continue
330
+ item = _held("k", status=status, days_ago=4.0, now=now)
331
+ assert graph.claim_threshold_days(item) == graph.STALE_CLAIM_DAYS, status
332
+ assert graph.stale_claims({"k": item}, now=now), status
333
+
334
+
335
+ def test_an_unknown_status_keeps_the_base_threshold():
336
+ """A typo must not silently buy an item a two-week hold."""
337
+ now = _now()
338
+ item = _held("k", status="verifyng", days_ago=4.0, now=now)
339
+ assert graph.claim_threshold_days(item) == graph.STALE_CLAIM_DAYS
340
+ assert graph.stale_claims({"k": item}, now=now)
341
+
342
+
343
+ # --- the drift detector compared two of the three underivable facts ---------
344
+
345
+
346
+ def _pair(db_status, file_status, *, db_claim=None, file_claim=None):
347
+ from roadmap_core import cli
348
+
349
+ db = {"k": {"key": "k", "status": db_status, "claimed_by": db_claim}}
350
+ files = {"k": {"key": "k", "status": file_status, "claimed_by": file_claim}}
351
+ return cli.compare_sources(db, files)
352
+
353
+
354
+ def test_a_verifying_disagreement_is_a_divergence():
355
+ """`derive_status` names three facts the graph cannot derive — `done`,
356
+ `verifying` and an active claim. `compare_sources` checked two of them.
357
+
358
+ So a store holding `claimed` against a file holding `verifying` read as
359
+ AGREEMENT. Measured in Lucille 2026-09-14: four items differed this way
360
+ while `diff` reported "db and files agree", and the two committed artifacts
361
+ contradicted each other — ROADMAP.md offered
362
+ `user-timezone-is-never-asked-only-defaulted` as `now` and startable while
363
+ its own item file said `status: verifying`.
364
+ """
365
+ problems = _pair("claimed", "verifying", db_claim="claude/x", file_claim="claude/x")
366
+ assert any("verifying" in p for p in problems), (
367
+ "a store/file disagreement on `verifying` must be reported; it is as "
368
+ "underivable as `done` and decides whether work is offered"
369
+ )
370
+
371
+
372
+ def test_a_ready_store_against_a_verifying_file_is_a_divergence():
373
+ """The shape that actually offers shipped work as startable."""
374
+ assert any("verifying" in p for p in _pair("ready", "verifying"))
375
+
376
+
377
+ def test_agreement_on_verifying_is_not_a_divergence():
378
+ """It must not cry wolf on the normal case."""
379
+ assert _pair("verifying", "verifying") == []
380
+
381
+
382
+ def test_a_done_verifying_pair_is_reported_once():
383
+ """One disagreement, one line.
384
+
385
+ db=`verifying` / files=`done` is the commonest real case — a session marked
386
+ an item done in the checkout and the store has not caught up. Reporting it
387
+ under both the `done` and the `verifying` heading would make one problem
388
+ read as two.
389
+ """
390
+ problems = _pair("verifying", "done")
391
+ assert len(problems) == 1, problems
392
+ assert "done" in problems[0]
393
+
394
+
395
+ def test_neither_status_underivable_is_still_quiet():
396
+ """`claimed` vs `ready` is derived from the claim, not stored — comparing it
397
+ would fire on every item whose claim the two sources already agree on."""
398
+ assert _pair("claimed", "ready", db_claim="claude/x", file_claim="claude/x") == []
@@ -0,0 +1,195 @@
1
+ """`pull` must carry the statuses the graph cannot derive — and only add them.
2
+
3
+ `derive_status` names four facts the edges cannot produce: ``done``,
4
+ ``verifying``, an active claim and a deferral. `pull` carried two of them (the
5
+ claim and ``done``); ``verifying`` post-dates it and was never added. So a
6
+ checkout could read ``ready`` for work that had already shipped, and `pull`
7
+ would print "the files already match the store" while `diff` — the assertion
8
+ the sync workflow is built on — reported the divergence in the same second.
9
+
10
+ The second half of this file is the harder half, and it is why the projection
11
+ is DELIBERATELY ONE-DIRECTIONAL. The store's silence about ``verifying`` is
12
+ IGNORANCE, NOT DENIAL: the backend honors ``status`` on INSERT and ignores it
13
+ on UPDATE, so a file's ``verifying`` has no path into the store at all. A
14
+ symmetric "the store wins" projection would therefore erase every such mark.
15
+
16
+ Measured in Lucille from the two committed artifacts, 2026-09-17: ONE item
17
+ where the store holds the underivable status and the file does not (which this
18
+ fix heals), against SEVENTEEN where the file holds it and the store does not —
19
+ eight of them sitting at store-status ``ready``. Projecting the store over the
20
+ files would have deleted seventeen records and put eight shipped items back on
21
+ the startable queue, which is the exact harm this item was filed about,
22
+ manufactured by its own fix.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import os
28
+ import subprocess
29
+ import sys
30
+ from pathlib import Path
31
+
32
+ import pytest
33
+
34
+ pytest.importorskip("yaml", reason="pull reads and writes roadmap/items/*.yaml")
35
+
36
+ PACKAGE_ROOT = Path(__file__).resolve().parents[1]
37
+
38
+ ITEM = """\
39
+ id: first-thing
40
+ title: Try the roadmap in a project that is not Lucille
41
+ status: ready
42
+ evidence: |
43
+ Adopting should take a checkout and nothing else.
44
+ """
45
+
46
+
47
+ @pytest.fixture
48
+ def project(tmp_path):
49
+ root = tmp_path / "myproject"
50
+ (root / "roadmap" / "items").mkdir(parents=True)
51
+ (root / "roadmap" / "items" / "first-thing.yaml").write_text(ITEM)
52
+ return root
53
+
54
+
55
+ def run(cwd: Path, *args: str) -> subprocess.CompletedProcess:
56
+ env = dict(os.environ)
57
+ env["PYTHONPATH"] = str(PACKAGE_ROOT)
58
+ env["ROADMAP_SOURCE"] = "local"
59
+ env.pop("LUCILLE_ADMIN_JWT", None)
60
+ env.pop("BACKEND_URL", None)
61
+ return subprocess.run(
62
+ [sys.executable, "-m", "roadmap_core.cli", *args],
63
+ cwd=cwd, env=env, capture_output=True, text=True, timeout=60,
64
+ )
65
+
66
+
67
+ def status_in_file(project: Path, key: str = "first-thing") -> str | None:
68
+ for line in (project / "roadmap" / "items" / f"{key}.yaml").read_text().splitlines():
69
+ if line.startswith("status:"):
70
+ return line.split(":", 1)[1].strip()
71
+ return None
72
+
73
+
74
+ def set_status_in_file(project: Path, value: str, key: str = "first-thing") -> None:
75
+ path = project / "roadmap" / "items" / f"{key}.yaml"
76
+ path.write_text(
77
+ "\n".join(
78
+ f"status: {value}" if line.startswith("status:") else line
79
+ for line in path.read_text().splitlines()
80
+ )
81
+ + "\n"
82
+ )
83
+
84
+
85
+ # --- the defect ---------------------------------------------------------------
86
+
87
+
88
+ def test_pull_projects_verifying_from_the_store_onto_the_file(project):
89
+ """The whole item. A store that knows an item is `verifying` must be able to
90
+ tell a checkout, or every tokenless session — which is how dispatch runs BY
91
+ DESIGN — reads `ready` for work that shipped days ago."""
92
+ assert run(project, "push").returncode == 0
93
+ assert run(project, "status", "first-thing", "verifying").returncode == 0
94
+
95
+ # `status` writes the file as well as the store, so undo that half: this is
96
+ # the state a checkout reaches whenever the store moved and the file did not
97
+ # — an item set `verifying` from another session, or straight against the API.
98
+ set_status_in_file(project, "ready")
99
+ assert status_in_file(project) == "ready"
100
+
101
+ pulled = run(project, "pull")
102
+ assert pulled.returncode == 0, pulled.stderr
103
+ assert status_in_file(project) == "verifying", (
104
+ "the store holds `verifying`, the file still reads `ready`, and `pull` — "
105
+ "the command whose entire job is to bring the store's reality back into "
106
+ f"the checkout — left it there:\n{pulled.stdout}{pulled.stderr}"
107
+ )
108
+
109
+
110
+ def test_pull_does_not_claim_agreement_that_diff_denies(project):
111
+ """`pull` and `diff` are two halves of one workflow: roadmap-sync runs `pull`
112
+ to heal, then `diff` to assert the heal worked. When `pull` prints "the files
113
+ already match the store" and `diff` reports a divergence on the same two
114
+ sources seconds later, one of them is lying and the workflow is red forever
115
+ with no automatic remedy."""
116
+ assert run(project, "push").returncode == 0
117
+ assert run(project, "status", "first-thing", "verifying").returncode == 0
118
+ set_status_in_file(project, "ready")
119
+
120
+ pulled = run(project, "pull")
121
+ diffed = run(project, "diff")
122
+
123
+ assert not (
124
+ "already match the store" in pulled.stdout and diffed.returncode == 1
125
+ ), (
126
+ "`pull` asserted the files already match the store, and `diff` "
127
+ "immediately reported a real divergence:\n"
128
+ f"pull : {pulled.stdout.strip()}\n"
129
+ f"diff : {diffed.stdout.strip()}"
130
+ )
131
+
132
+
133
+ # --- the direction it must NOT go ---------------------------------------------
134
+
135
+
136
+ def test_pull_does_not_erase_a_verifying_the_store_never_heard_of(project):
137
+ """THE GUARD ON THE FIX, and the reason it is one-directional.
138
+
139
+ The backend honors `status` on INSERT and ignores it on UPDATE, so a file's
140
+ `verifying` cannot reach an existing item in the store — the store's `ready`
141
+ is ignorance, not a denial. Projecting it back over the file would delete the
142
+ only record that the work shipped, and hand the item to the next session as
143
+ startable. In Lucille that is 17 items, 8 of them store-`ready`."""
144
+ assert run(project, "push").returncode == 0
145
+ set_status_in_file(project, "verifying")
146
+
147
+ pulled = run(project, "pull")
148
+ assert pulled.returncode == 0, pulled.stderr
149
+ assert status_in_file(project) == "verifying", (
150
+ "`pull` overwrote a file's `verifying` with the store's `ready`. The "
151
+ "store cannot be TOLD about `verifying` (honored on INSERT, ignored on "
152
+ "UPDATE), so this deletes the record and re-offers shipped work:\n"
153
+ f"{pulled.stdout}{pulled.stderr}"
154
+ )
155
+
156
+
157
+ def test_pull_does_not_downgrade_a_done_file_to_verifying(project):
158
+ """Same rule one step further along. `done` is more settled than `verifying`;
159
+ a store that has only heard `verifying` must not walk a file's `done` back."""
160
+ assert run(project, "push").returncode == 0
161
+ assert run(project, "status", "first-thing", "verifying").returncode == 0
162
+ set_status_in_file(project, "done")
163
+
164
+ pulled = run(project, "pull")
165
+ assert pulled.returncode == 0, pulled.stderr
166
+ assert status_in_file(project) == "done", (
167
+ f"`pull` walked a `done` back to `verifying`:\n{pulled.stdout}{pulled.stderr}"
168
+ )
169
+
170
+
171
+ def test_pull_still_projects_done(project):
172
+ """The behaviour that already existed, asserted so the generalisation cannot
173
+ quietly drop it."""
174
+ assert run(project, "push").returncode == 0
175
+ assert run(project, "status", "first-thing", "done").returncode == 0
176
+ set_status_in_file(project, "ready")
177
+
178
+ pulled = run(project, "pull")
179
+ assert pulled.returncode == 0, pulled.stderr
180
+ assert status_in_file(project) == "done", pulled.stdout + pulled.stderr
181
+
182
+
183
+ def test_pull_says_which_status_it_projected(project):
184
+ """A repair that leaves no trace hides the next regression exactly as this one
185
+ hid itself. `updated <file>` alone does not say a status moved, so the one
186
+ line a reader gets must name the transition."""
187
+ assert run(project, "push").returncode == 0
188
+ assert run(project, "status", "first-thing", "verifying").returncode == 0
189
+ set_status_in_file(project, "ready")
190
+
191
+ pulled = run(project, "pull")
192
+ assert "ready -> verifying" in pulled.stdout, (
193
+ "`pull` healed a status and reported only that the file was updated:\n"
194
+ f"{pulled.stdout}"
195
+ )
@@ -0,0 +1,358 @@
1
+ """What the rendered surfaces say about an item's status — and what `prune`
2
+ refuses to destroy on the way out.
3
+
4
+ These three assertions exist because of one incident, on 2026-09-14, in the
5
+ repository that consumes this package. A session ran `roadmap list`, read a line
6
+ reading `now engine-topic-id-populate Populate topic_id on engine rows`, and
7
+ dispatched a worker session to do the work. The item had shipped weeks earlier.
8
+ ~150k tokens went into re-fixing it.
9
+
10
+ Nothing was broken in the graph: `derive_status` returned `done`, the band header
11
+ said `DONE (18)`, and the committed `ROADMAP.md` listed it under Items with
12
+ `- **status:** done`. The defect was entirely in the *rendering* — status lived
13
+ somewhere the reader's eye and every mechanical reader (a grep, a truncated read,
14
+ a line pasted into a dispatch message) did not have to pass through.
15
+
16
+ So these are tests of rendered output, asserted as strings, which is normally a
17
+ smell. Here it is the point: the thing that failed was the string. A test of
18
+ `derive_status` would have been green throughout.
19
+
20
+ Synthetic fixtures only, per the rule the rest of the suite follows — no
21
+ committed `roadmap/items/`, no YAML, no store, nothing from any consumer.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+
28
+ import pytest
29
+
30
+ from roadmap_core import cli, graph
31
+
32
+
33
+ def _item(key: str, **over):
34
+ base = {"key": key, "title": f"Title of {key}", "status": "ready",
35
+ "blocked_on": [], "evidence": "x"}
36
+ base.update(over)
37
+ return base
38
+
39
+
40
+ # --- 1. `roadmap list`: status on the row, not only in the band header -------
41
+
42
+
43
+ @pytest.fixture
44
+ def listed(monkeypatch, capsys):
45
+ """Run `cmd_list` over a fixture and hand back its stdout lines."""
46
+
47
+ def run(by_key):
48
+ monkeypatch.setattr(cli, "load", lambda source: by_key)
49
+ monkeypatch.setattr(cli, "_offline_notice", lambda args: None)
50
+ monkeypatch.setattr(cli, "_stale_claim_notice", lambda by_key: None)
51
+ capsys.readouterr()
52
+ cli.cmd_list(argparse.Namespace(source="files", json=False))
53
+ return capsys.readouterr().out.splitlines()
54
+
55
+ return run
56
+
57
+
58
+ def _row_for(lines: list[str], key: str) -> str:
59
+ """The one output row for ``key``.
60
+
61
+ Matched on the title rather than the key, because a key also appears in
62
+ *another* item's row as the `← <dep>` blocked-on suffix.
63
+ """
64
+ rows = [ln for ln in lines if f"Title of {key}" in ln]
65
+ assert len(rows) == 1, f"expected exactly one row for {key}, got {rows}"
66
+ return rows[0]
67
+
68
+
69
+ def test_a_list_row_states_its_own_status(listed):
70
+ """THE INCIDENT, as an assertion.
71
+
72
+ The row for a done item must carry the word `done`. Band headers are read
73
+ once and scrolled past; this line has to survive being read alone, because
74
+ that is how it reached the session that acted on it.
75
+ """
76
+ shipped = _item("shipped", status="done", priority="now")
77
+ lines = listed({"shipped": shipped})
78
+ assert "done" in _row_for(lines, "shipped")
79
+
80
+
81
+ def test_a_done_row_is_not_confusable_with_the_top_of_the_queue(listed):
82
+ """The specific collision: a `done` item and a `now` item rendered the same.
83
+
84
+ 10 of the consumer's 18 done items carried a `now`/`next` priority, so they
85
+ rendered as `now <key> <present-tense defect title>` — the exact shape of
86
+ the highest-priority startable work. Strip the keys and titles, which differ
87
+ for unrelated reasons, and what is left must still differ.
88
+ """
89
+ by_key = {
90
+ "shipped": _item("shipped", status="done", priority="now"),
91
+ "startable": _item("startable", priority="now"),
92
+ }
93
+ lines = listed(by_key)
94
+ done_row = _row_for(lines, "shipped")
95
+ ready_row = _row_for(lines, "startable")
96
+
97
+ def shape(row: str, key: str) -> str:
98
+ return row.replace(key, "").replace(f"Title of {key}", "")
99
+
100
+ assert shape(done_row, "shipped") != shape(ready_row, "startable"), (
101
+ "a done row and a top-priority ready row are distinguishable only by "
102
+ "their key and title — which is what let a session act on a finished "
103
+ "item"
104
+ )
105
+
106
+
107
+ def test_every_row_carries_a_status_not_only_the_finished_ones(listed):
108
+ """A marker that appears conditionally makes ABSENCE carry meaning.
109
+
110
+ That is the bug's own shape: an unmarked line read as open work. So the
111
+ token is on every row, for every status the graph can derive.
112
+ """
113
+ by_key = {
114
+ "ready-one": _item("ready-one"),
115
+ "deferred-one": _item("deferred-one", defer_reason="too early"),
116
+ "blocked-one": _item("blocked-one", blocked_on=["ready-one"]),
117
+ "claimed-one": _item("claimed-one", claimed_by="claude/x"),
118
+ "verifying-one": _item("verifying-one", status="verifying"),
119
+ "done-one": _item("done-one", status="done"),
120
+ }
121
+ lines = listed(by_key)
122
+ for key in by_key:
123
+ statename = graph.derive_status(by_key[key], by_key)
124
+ assert statename in _row_for(lines, key), (
125
+ f"the row for {key} does not state its status ({statename})"
126
+ )
127
+
128
+
129
+ def test_the_status_column_is_wide_enough_for_every_status():
130
+ """`STATUS_WIDTH` is derived, so a longer status added later cannot silently
131
+ unalign every surface that prints one."""
132
+ for statename in graph.STATUSES:
133
+ assert len(statename) <= graph.STATUS_WIDTH
134
+
135
+
136
+ # --- 2. the mermaid graph: a node states its own status ----------------------
137
+
138
+
139
+ def _mermaid(by_key) -> list[str]:
140
+ block = graph.render_markdown(by_key).split("```mermaid")[1].split("```")[0]
141
+ return [ln.strip() for ln in block.splitlines() if ln.strip()]
142
+
143
+
144
+ def _node_line(lines: list[str], key: str) -> str:
145
+ node = key.replace("-", "_")
146
+ rows = [ln for ln in lines if ln.startswith(f"{node}[")]
147
+ assert len(rows) == 1, f"expected one node line for {key}, got {rows}"
148
+ return rows[0]
149
+
150
+
151
+ def test_a_done_node_is_not_drawn_like_an_open_one():
152
+ """Before this, all 82 nodes of the consumer's committed graph rendered
153
+ identically whether `ready` or `done` — in the one artifact a session reads
154
+ to decide what to pick up."""
155
+ by_key = {
156
+ "shipped": _item("shipped", status="done"),
157
+ "startable": _item("startable"),
158
+ }
159
+ lines = _mermaid(by_key)
160
+ done_line = _node_line(lines, "shipped")
161
+ ready_line = _node_line(lines, "startable")
162
+
163
+ def shape(line: str, key: str) -> str:
164
+ return line.replace(key.replace("-", "_"), "").replace(f"Title of {key}", "")
165
+
166
+ assert shape(done_line, "shipped") != shape(ready_line, "startable")
167
+ assert "done" in done_line, "the done node's own line does not say so"
168
+
169
+
170
+ def test_every_node_line_carries_its_derived_status():
171
+ """On the node's own line, so a grep or a one-line quote keeps it — the same
172
+ property `cmd_list` needs, for the same reason."""
173
+ by_key = {
174
+ "ready-one": _item("ready-one"),
175
+ "deferred-one": _item("deferred-one", defer_reason="too early"),
176
+ "blocked-one": _item("blocked-one", blocked_on=["ready-one"]),
177
+ "claimed-one": _item("claimed-one", claimed_by="claude/x"),
178
+ "verifying-one": _item("verifying-one", status="verifying"),
179
+ "done-one": _item("done-one", status="done"),
180
+ }
181
+ lines = _mermaid(by_key)
182
+ for key in by_key:
183
+ statename = graph.derive_status(by_key[key], by_key)
184
+ assert _node_line(lines, key).endswith(f":::{statename}"), (
185
+ f"the node line for {key} does not carry its status"
186
+ )
187
+
188
+
189
+ def test_the_status_marking_is_readable_in_the_picture_too():
190
+ """`:::done` serves whoever reads the source. The rendered diagram needs its
191
+ own marker, because a stroke colour is a distinction a reader can miss and a
192
+ glyph in the label is not."""
193
+ lines = _mermaid({"shipped": _item("shipped", status="done")})
194
+ assert "✓" in _node_line(lines, "shipped")
195
+
196
+
197
+ def test_every_status_has_a_style():
198
+ """An unstyled node renders as the default, which reads as open — the same
199
+ failure with a new status name on it."""
200
+ lines = _mermaid({"a": _item("a")})
201
+ for statename in graph.STATUSES:
202
+ assert any(ln.startswith(f"classDef {statename} ") for ln in lines), (
203
+ f"no classDef for {statename}"
204
+ )
205
+
206
+
207
+ def test_status_marking_adds_no_clock_and_no_graph_wide_aggregate():
208
+ """``render_markdown``'s standing constraint, checked against the thing just
209
+ added to it.
210
+
211
+ The file is regenerated wholesale and committed, so any line derived from a
212
+ total differs between two branches that finished different items and
213
+ conflicts on merge while saying nothing about what either changed. That is
214
+ why the classes are attached PER NODE and the `classDef` lines are constant,
215
+ rather than a single `class a,b,c done` roll-up — which would have been
216
+ exactly that aggregate.
217
+
218
+ Asserted by rendering two graphs that differ by one added item and checking
219
+ that no line about styling moved.
220
+ """
221
+ base = {
222
+ "one": _item("one", status="done"),
223
+ "two": _item("two"),
224
+ }
225
+ grown = dict(base, three=_item("three", status="done"))
226
+
227
+ def styling_lines(by_key):
228
+ return [ln for ln in _mermaid(by_key) if ln.startswith(("classDef ", "class "))]
229
+
230
+ assert styling_lines(base) == styling_lines(grown), (
231
+ "a styling line changed when an unrelated item was added — that line is "
232
+ "a graph-wide aggregate and will conflict between branches"
233
+ )
234
+ # And nothing anywhere in the block is a roll-up naming several nodes.
235
+ assert not [ln for ln in _mermaid(base) if ln.startswith("class ")], (
236
+ "a `class a,b,... done` roll-up is a single line that every branch "
237
+ "finishing an item has to edit"
238
+ )
239
+
240
+
241
+ # --- 3. `prune` withholds the ticket edge ------------------------------------
242
+
243
+
244
+ @pytest.fixture
245
+ def pruner(monkeypatch, capsys):
246
+ """Run `cmd_prune` over a fixture, with everything that touches a store, a
247
+ checkout or the network stubbed out. Returns (deleted keys, stdout, stderr).
248
+ """
249
+
250
+ def run(files, *, yes=True, include_ticket_linked=False):
251
+ deleted: list[str] = []
252
+ monkeypatch.setattr(cli, "prune_staleness_blockers", lambda: [])
253
+ monkeypatch.setattr(cli, "load_from_files", lambda: {k: dict(v) for k, v in files.items()})
254
+ monkeypatch.setattr(cli, "load_from_db", dict)
255
+ monkeypatch.setattr(cli, "_api_delete_tolerant", lambda key: deleted.append(key) or "ok")
256
+ monkeypatch.setattr(cli, "item_path", lambda key: _MissingPath())
257
+ monkeypatch.setattr(cli, "_release_dependents", lambda pruned: None)
258
+ capsys.readouterr()
259
+ cli.cmd_prune(argparse.Namespace(
260
+ yes=yes, allow_stale=False, include_ticket_linked=include_ticket_linked
261
+ ))
262
+ captured = capsys.readouterr()
263
+ return deleted, captured.out, captured.err
264
+
265
+ return run
266
+
267
+
268
+ class _MissingPath:
269
+ """Stands in for an item file that is not on disk, so the prune never
270
+ unlinks anything real."""
271
+
272
+ def exists(self) -> bool:
273
+ return False
274
+
275
+
276
+ def test_prune_withholds_an_item_that_carries_ticket_links(pruner):
277
+ """The ticket edge has ONE writer and no second copy.
278
+
279
+ It is the only record of the link from shipped work back to the person who
280
+ reported it — `impact` reads it to ask whether anyone is still complaining,
281
+ and the consumer's lifecycle walks it the other way to tell that person the
282
+ thing they reported is handled. Hygiene loses to evidence: the done pile is
283
+ cosmetic, this is not recoverable.
284
+ """
285
+ deleted, out, err = pruner({
286
+ "plain": _item("plain", status="done"),
287
+ "reported": _item("reported", status="done",
288
+ tickets=["6f1b0f6e-0000-4000-8000-000000000001"]),
289
+ })
290
+ assert deleted == ["plain"]
291
+ assert "reported" not in deleted
292
+
293
+
294
+ def test_the_withholding_is_loud_and_says_it_is_conservative(pruner):
295
+ """A silent skip is how an operator ends up believing the pile was cleared.
296
+
297
+ It must also admit WHY it withheld: this package cannot see ticket status —
298
+ that lives in the consuming application — so it cannot tell a loop that is
299
+ still open from one already closed, and holds both. Saying so is what keeps
300
+ the gap from reading as the rule.
301
+ """
302
+ _, _, err = pruner({
303
+ "reported": _item("reported", status="done",
304
+ tickets=["6f1b0f6e-0000-4000-8000-000000000001"]),
305
+ })
306
+ assert "reported" in err
307
+ assert "6f1b0f6e-0000-4000-8000-000000000001" in err, "name the tickets, not just a count"
308
+ assert "--include-ticket-linked" in err, "an operator needs the way past it"
309
+ assert "cannot see ticket status" in err, (
310
+ "the skip must state that it is broader than the rule it implements, or "
311
+ "the gap reads as the rule"
312
+ )
313
+
314
+
315
+ def test_the_operator_can_override_once_they_have_checked(pruner):
316
+ """The judgement this code cannot make, a human can. Withholding forever
317
+ would rebuild the done pile `prune` exists to clear."""
318
+ deleted, _, err = pruner(
319
+ {"reported": _item("reported", status="done",
320
+ tickets=["6f1b0f6e-0000-4000-8000-000000000001"])},
321
+ include_ticket_linked=True,
322
+ )
323
+ assert deleted == ["reported"]
324
+ assert "reported" in err, "destroying the edge deliberately is still worth saying out loud"
325
+
326
+
327
+ def test_an_empty_tickets_list_is_not_a_link(pruner):
328
+ """The predicate is "is this edge load-bearing", and a field somebody emptied
329
+ carries no edge. Treating `[]` as a link would withhold every item a schema
330
+ default touched."""
331
+ deleted, _, _ = pruner({"empty": _item("empty", status="done", tickets=[])})
332
+ assert deleted == ["empty"]
333
+
334
+
335
+ def test_nothing_left_to_prune_reports_why(pruner):
336
+ """`no done items — nothing to prune` would be a lie when there are done
337
+ items and all of them were withheld."""
338
+ deleted, out, _ = pruner({
339
+ "reported": _item("reported", status="done",
340
+ tickets=["6f1b0f6e-0000-4000-8000-000000000001"]),
341
+ })
342
+ assert deleted == []
343
+ assert "withheld" in out
344
+
345
+
346
+ def test_a_withheld_item_is_absent_from_the_dry_run_too(pruner):
347
+ """The dry run is what an operator reads before passing `--yes`. Listing an
348
+ item there that the real run will not touch teaches them to distrust it."""
349
+ _, out, _ = pruner(
350
+ {
351
+ "plain": _item("plain", status="done"),
352
+ "reported": _item("reported", status="done",
353
+ tickets=["6f1b0f6e-0000-4000-8000-000000000001"]),
354
+ },
355
+ yes=False,
356
+ )
357
+ assert "plain" in out
358
+ assert "reported" not in out
File without changes
File without changes
File without changes