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.
- {roadmap_core-0.3.1/roadmap_core.egg-info → roadmap_core-0.3.2}/PKG-INFO +1 -1
- {roadmap_core-0.3.1 → roadmap_core-0.3.2}/pyproject.toml +1 -1
- {roadmap_core-0.3.1 → roadmap_core-0.3.2}/roadmap_core/cli.py +41 -3
- {roadmap_core-0.3.1 → roadmap_core-0.3.2/roadmap_core.egg-info}/PKG-INFO +1 -1
- {roadmap_core-0.3.1 → roadmap_core-0.3.2}/roadmap_core.egg-info/SOURCES.txt +1 -0
- roadmap_core-0.3.2/tests/test_pull_projects_status.py +195 -0
- {roadmap_core-0.3.1 → roadmap_core-0.3.2}/LICENSE +0 -0
- {roadmap_core-0.3.1 → roadmap_core-0.3.2}/README.md +0 -0
- {roadmap_core-0.3.1 → roadmap_core-0.3.2}/roadmap_core/__init__.py +0 -0
- {roadmap_core-0.3.1 → roadmap_core-0.3.2}/roadmap_core/graph.py +0 -0
- {roadmap_core-0.3.1 → roadmap_core-0.3.2}/roadmap_core/impact.py +0 -0
- {roadmap_core-0.3.1 → roadmap_core-0.3.2}/roadmap_core/mcp_server.py +0 -0
- {roadmap_core-0.3.1 → roadmap_core-0.3.2}/roadmap_core/store.py +0 -0
- {roadmap_core-0.3.1 → roadmap_core-0.3.2}/roadmap_core/stores.py +0 -0
- {roadmap_core-0.3.1 → roadmap_core-0.3.2}/roadmap_core.egg-info/dependency_links.txt +0 -0
- {roadmap_core-0.3.1 → roadmap_core-0.3.2}/roadmap_core.egg-info/entry_points.txt +0 -0
- {roadmap_core-0.3.1 → roadmap_core-0.3.2}/roadmap_core.egg-info/requires.txt +0 -0
- {roadmap_core-0.3.1 → roadmap_core-0.3.2}/roadmap_core.egg-info/top_level.txt +0 -0
- {roadmap_core-0.3.1 → roadmap_core-0.3.2}/setup.cfg +0 -0
- {roadmap_core-0.3.1 → roadmap_core-0.3.2}/templates/roadmap.yml +0 -0
- {roadmap_core-0.3.1 → roadmap_core-0.3.2}/tests/test_adoption.py +0 -0
- {roadmap_core-0.3.1 → roadmap_core-0.3.2}/tests/test_arcs.py +0 -0
- {roadmap_core-0.3.1 → roadmap_core-0.3.2}/tests/test_graph.py +0 -0
- {roadmap_core-0.3.1 → roadmap_core-0.3.2}/tests/test_impact.py +0 -0
- {roadmap_core-0.3.1 → roadmap_core-0.3.2}/tests/test_mcp_server.py +0 -0
- {roadmap_core-0.3.1 → roadmap_core-0.3.2}/tests/test_renderers.py +0 -0
- {roadmap_core-0.3.1 → roadmap_core-0.3.2}/tests/test_store.py +0 -0
- {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.
|
|
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"
|
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
|
@@ -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
|
|
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
|