roadmap-core 0.3.1__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.1/roadmap_core.egg-info → roadmap_core-0.3.2}/PKG-INFO +1 -1
  2. {roadmap_core-0.3.1 → roadmap_core-0.3.2}/pyproject.toml +1 -1
  3. {roadmap_core-0.3.1 → roadmap_core-0.3.2}/roadmap_core/cli.py +41 -3
  4. {roadmap_core-0.3.1 → roadmap_core-0.3.2/roadmap_core.egg-info}/PKG-INFO +1 -1
  5. {roadmap_core-0.3.1 → roadmap_core-0.3.2}/roadmap_core.egg-info/SOURCES.txt +1 -0
  6. roadmap_core-0.3.2/tests/test_pull_projects_status.py +195 -0
  7. {roadmap_core-0.3.1 → roadmap_core-0.3.2}/LICENSE +0 -0
  8. {roadmap_core-0.3.1 → roadmap_core-0.3.2}/README.md +0 -0
  9. {roadmap_core-0.3.1 → roadmap_core-0.3.2}/roadmap_core/__init__.py +0 -0
  10. {roadmap_core-0.3.1 → roadmap_core-0.3.2}/roadmap_core/graph.py +0 -0
  11. {roadmap_core-0.3.1 → roadmap_core-0.3.2}/roadmap_core/impact.py +0 -0
  12. {roadmap_core-0.3.1 → roadmap_core-0.3.2}/roadmap_core/mcp_server.py +0 -0
  13. {roadmap_core-0.3.1 → roadmap_core-0.3.2}/roadmap_core/store.py +0 -0
  14. {roadmap_core-0.3.1 → roadmap_core-0.3.2}/roadmap_core/stores.py +0 -0
  15. {roadmap_core-0.3.1 → roadmap_core-0.3.2}/roadmap_core.egg-info/dependency_links.txt +0 -0
  16. {roadmap_core-0.3.1 → roadmap_core-0.3.2}/roadmap_core.egg-info/entry_points.txt +0 -0
  17. {roadmap_core-0.3.1 → roadmap_core-0.3.2}/roadmap_core.egg-info/requires.txt +0 -0
  18. {roadmap_core-0.3.1 → roadmap_core-0.3.2}/roadmap_core.egg-info/top_level.txt +0 -0
  19. {roadmap_core-0.3.1 → roadmap_core-0.3.2}/setup.cfg +0 -0
  20. {roadmap_core-0.3.1 → roadmap_core-0.3.2}/templates/roadmap.yml +0 -0
  21. {roadmap_core-0.3.1 → roadmap_core-0.3.2}/tests/test_adoption.py +0 -0
  22. {roadmap_core-0.3.1 → roadmap_core-0.3.2}/tests/test_arcs.py +0 -0
  23. {roadmap_core-0.3.1 → roadmap_core-0.3.2}/tests/test_graph.py +0 -0
  24. {roadmap_core-0.3.1 → roadmap_core-0.3.2}/tests/test_impact.py +0 -0
  25. {roadmap_core-0.3.1 → roadmap_core-0.3.2}/tests/test_mcp_server.py +0 -0
  26. {roadmap_core-0.3.1 → roadmap_core-0.3.2}/tests/test_renderers.py +0 -0
  27. {roadmap_core-0.3.1 → roadmap_core-0.3.2}/tests/test_store.py +0 -0
  28. {roadmap_core-0.3.1 → 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.1
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.1"
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"
@@ -1198,6 +1198,23 @@ def cmd_diff(args: argparse.Namespace) -> int:
1198
1198
  return 1
1199
1199
 
1200
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
+
1201
1218
  def cmd_pull(args: argparse.Namespace) -> int:
1202
1219
  """The inverse of ``push``: bring the store's reality back into the checkout.
1203
1220
 
@@ -1209,6 +1226,7 @@ def cmd_pull(args: argparse.Namespace) -> int:
1209
1226
  db = load(source)
1210
1227
  files = load_from_files()
1211
1228
  created, updated = [], []
1229
+ projected: dict[str, str] = {}
1212
1230
  ITEMS_DIR.mkdir(parents=True, exist_ok=True)
1213
1231
  for key in sorted(db):
1214
1232
  item = db[key]
@@ -1225,11 +1243,27 @@ def cmd_pull(args: argparse.Namespace) -> int:
1225
1243
  else None
1226
1244
  )
1227
1245
  changed = write_claim_to_file(key, claim) or changed
1228
- 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:
1229
1260
  path = item_path(key)
1230
1261
  path.write_text(
1231
- _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
+ )
1232
1265
  )
1266
+ projected[key] = f"{local.get('status') or 'unset'} -> {item['status']}"
1233
1267
  changed = True
1234
1268
  # A priority set straight against the API (by an operator with no
1235
1269
  # checkout — the case that motivated the field) changes no file, so
@@ -1281,7 +1315,11 @@ def cmd_pull(args: argparse.Namespace) -> int:
1281
1315
  for key in created:
1282
1316
  print(f"created roadmap/items/{key}.yaml")
1283
1317
  for key in updated:
1284
- 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}")
1285
1323
  for key in arcs_created:
1286
1324
  print(f"created roadmap/arcs/{key}.yaml")
1287
1325
  if not created and not updated and not arcs_created:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: roadmap-core
3
- Version: 0.3.1
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,6 +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
23
24
  tests/test_renderers.py
24
25
  tests/test_store.py
25
26
  tests/test_stores.py
@@ -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
+ )
File without changes
File without changes
File without changes