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.
- {roadmap_core-0.3.0/roadmap_core.egg-info → roadmap_core-0.3.2}/PKG-INFO +1 -1
- {roadmap_core-0.3.0 → roadmap_core-0.3.2}/pyproject.toml +1 -1
- {roadmap_core-0.3.0 → roadmap_core-0.3.2}/roadmap_core/cli.py +171 -18
- {roadmap_core-0.3.0 → roadmap_core-0.3.2}/roadmap_core/graph.py +115 -4
- {roadmap_core-0.3.0 → roadmap_core-0.3.2/roadmap_core.egg-info}/PKG-INFO +1 -1
- {roadmap_core-0.3.0 → roadmap_core-0.3.2}/roadmap_core.egg-info/SOURCES.txt +2 -0
- {roadmap_core-0.3.0 → roadmap_core-0.3.2}/tests/test_graph.py +164 -0
- roadmap_core-0.3.2/tests/test_pull_projects_status.py +195 -0
- roadmap_core-0.3.2/tests/test_renderers.py +358 -0
- {roadmap_core-0.3.0 → roadmap_core-0.3.2}/LICENSE +0 -0
- {roadmap_core-0.3.0 → roadmap_core-0.3.2}/README.md +0 -0
- {roadmap_core-0.3.0 → roadmap_core-0.3.2}/roadmap_core/__init__.py +0 -0
- {roadmap_core-0.3.0 → roadmap_core-0.3.2}/roadmap_core/impact.py +0 -0
- {roadmap_core-0.3.0 → roadmap_core-0.3.2}/roadmap_core/mcp_server.py +0 -0
- {roadmap_core-0.3.0 → roadmap_core-0.3.2}/roadmap_core/store.py +0 -0
- {roadmap_core-0.3.0 → roadmap_core-0.3.2}/roadmap_core/stores.py +0 -0
- {roadmap_core-0.3.0 → roadmap_core-0.3.2}/roadmap_core.egg-info/dependency_links.txt +0 -0
- {roadmap_core-0.3.0 → roadmap_core-0.3.2}/roadmap_core.egg-info/entry_points.txt +0 -0
- {roadmap_core-0.3.0 → roadmap_core-0.3.2}/roadmap_core.egg-info/requires.txt +0 -0
- {roadmap_core-0.3.0 → roadmap_core-0.3.2}/roadmap_core.egg-info/top_level.txt +0 -0
- {roadmap_core-0.3.0 → roadmap_core-0.3.2}/setup.cfg +0 -0
- {roadmap_core-0.3.0 → roadmap_core-0.3.2}/templates/roadmap.yml +0 -0
- {roadmap_core-0.3.0 → roadmap_core-0.3.2}/tests/test_adoption.py +0 -0
- {roadmap_core-0.3.0 → roadmap_core-0.3.2}/tests/test_arcs.py +0 -0
- {roadmap_core-0.3.0 → roadmap_core-0.3.2}/tests/test_impact.py +0 -0
- {roadmap_core-0.3.0 → roadmap_core-0.3.2}/tests/test_mcp_server.py +0 -0
- {roadmap_core-0.3.0 → roadmap_core-0.3.2}/tests/test_store.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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 `
|
|
1499
|
-
#
|
|
1500
|
-
#
|
|
1501
|
-
#
|
|
1502
|
-
#
|
|
1503
|
-
#
|
|
1504
|
-
|
|
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
|
-
"
|
|
1509
|
-
"
|
|
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.
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|