roadmap-core 0.2.2__tar.gz → 0.2.3__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.2.2/roadmap_core.egg-info → roadmap_core-0.2.3}/PKG-INFO +12 -1
- {roadmap_core-0.2.2 → roadmap_core-0.2.3}/README.md +11 -0
- {roadmap_core-0.2.2 → roadmap_core-0.2.3}/pyproject.toml +1 -1
- {roadmap_core-0.2.2 → roadmap_core-0.2.3}/roadmap_core/cli.py +53 -12
- {roadmap_core-0.2.2 → roadmap_core-0.2.3}/roadmap_core/stores.py +35 -5
- {roadmap_core-0.2.2 → roadmap_core-0.2.3/roadmap_core.egg-info}/PKG-INFO +12 -1
- {roadmap_core-0.2.2 → roadmap_core-0.2.3}/tests/test_adoption.py +121 -0
- {roadmap_core-0.2.2 → roadmap_core-0.2.3}/tests/test_stores.py +82 -0
- {roadmap_core-0.2.2 → roadmap_core-0.2.3}/LICENSE +0 -0
- {roadmap_core-0.2.2 → roadmap_core-0.2.3}/roadmap_core/__init__.py +0 -0
- {roadmap_core-0.2.2 → roadmap_core-0.2.3}/roadmap_core/graph.py +0 -0
- {roadmap_core-0.2.2 → roadmap_core-0.2.3}/roadmap_core/impact.py +0 -0
- {roadmap_core-0.2.2 → roadmap_core-0.2.3}/roadmap_core/store.py +0 -0
- {roadmap_core-0.2.2 → roadmap_core-0.2.3}/roadmap_core.egg-info/SOURCES.txt +0 -0
- {roadmap_core-0.2.2 → roadmap_core-0.2.3}/roadmap_core.egg-info/dependency_links.txt +0 -0
- {roadmap_core-0.2.2 → roadmap_core-0.2.3}/roadmap_core.egg-info/entry_points.txt +0 -0
- {roadmap_core-0.2.2 → roadmap_core-0.2.3}/roadmap_core.egg-info/requires.txt +0 -0
- {roadmap_core-0.2.2 → roadmap_core-0.2.3}/roadmap_core.egg-info/top_level.txt +0 -0
- {roadmap_core-0.2.2 → roadmap_core-0.2.3}/setup.cfg +0 -0
- {roadmap_core-0.2.2 → roadmap_core-0.2.3}/templates/roadmap.yml +0 -0
- {roadmap_core-0.2.2 → roadmap_core-0.2.3}/tests/test_arcs.py +0 -0
- {roadmap_core-0.2.2 → roadmap_core-0.2.3}/tests/test_graph.py +0 -0
- {roadmap_core-0.2.2 → roadmap_core-0.2.3}/tests/test_impact.py +0 -0
- {roadmap_core-0.2.2 → roadmap_core-0.2.3}/tests/test_store.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: roadmap-core
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
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
|
|
@@ -129,6 +129,17 @@ FAIL store roadmap/roadmap.db holds 0 items while roadmap/items/ holds 7.
|
|
|
129
129
|
Seed it: `roadmap push`
|
|
130
130
|
```
|
|
131
131
|
|
|
132
|
+
**Who owns a claim depends on which store you have.** On the SQLite floor the
|
|
133
|
+
file is authoritative: CI rebuilds the store from `roadmap/items/*.yaml` every
|
|
134
|
+
run, so `push` takes a file's `claim` when it CREATES the item — otherwise a
|
|
135
|
+
held item renders as `ready` and `sync --check` fails for as long as anybody is
|
|
136
|
+
working. Against a served store the file is a *projection* of a store that
|
|
137
|
+
outlives the checkout, so a claim in a file is never pushed: a stale clone would
|
|
138
|
+
recreate one the store had already released. Either way `push` ignores it on
|
|
139
|
+
UPDATE, because the store is the live record of who holds what.
|
|
140
|
+
|
|
141
|
+
Not guessable from the field's name, which is why it is written down here.
|
|
142
|
+
|
|
132
143
|
**Two things that are conventions rather than choices**, both found by doing
|
|
133
144
|
this rather than by reading the code:
|
|
134
145
|
|
|
@@ -108,6 +108,17 @@ FAIL store roadmap/roadmap.db holds 0 items while roadmap/items/ holds 7.
|
|
|
108
108
|
Seed it: `roadmap push`
|
|
109
109
|
```
|
|
110
110
|
|
|
111
|
+
**Who owns a claim depends on which store you have.** On the SQLite floor the
|
|
112
|
+
file is authoritative: CI rebuilds the store from `roadmap/items/*.yaml` every
|
|
113
|
+
run, so `push` takes a file's `claim` when it CREATES the item — otherwise a
|
|
114
|
+
held item renders as `ready` and `sync --check` fails for as long as anybody is
|
|
115
|
+
working. Against a served store the file is a *projection* of a store that
|
|
116
|
+
outlives the checkout, so a claim in a file is never pushed: a stale clone would
|
|
117
|
+
recreate one the store had already released. Either way `push` ignores it on
|
|
118
|
+
UPDATE, because the store is the live record of who holds what.
|
|
119
|
+
|
|
120
|
+
Not guessable from the field's name, which is why it is written down here.
|
|
121
|
+
|
|
111
122
|
**Two things that are conventions rather than choices**, both found by doing
|
|
112
123
|
this rather than by reading the code:
|
|
113
124
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "roadmap-core"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.3"
|
|
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"
|
|
@@ -899,7 +899,16 @@ def cmd_push(args: argparse.Namespace) -> int:
|
|
|
899
899
|
}
|
|
900
900
|
try:
|
|
901
901
|
if local is not None:
|
|
902
|
-
|
|
902
|
+
# The claim goes to the LOCAL store only. Its store is ephemeral
|
|
903
|
+
# — CI rebuilds it from these files every run — so the file is
|
|
904
|
+
# the only durable record a claim has. The served store outlives
|
|
905
|
+
# the checkout, where the same move would recreate a claim it had
|
|
906
|
+
# already released. See `LocalStore`'s "claim on the floor".
|
|
907
|
+
local.upsert_item(dict(
|
|
908
|
+
payload,
|
|
909
|
+
claimed_by=item.get("claimed_by"),
|
|
910
|
+
claimed_at=item.get("claimed_at"),
|
|
911
|
+
))
|
|
903
912
|
else:
|
|
904
913
|
_api("PUT", f"/admin/roadmap/{key}", payload)
|
|
905
914
|
except stores.Pruned:
|
|
@@ -1000,7 +1009,15 @@ def cmd_status(args: argparse.Namespace) -> int:
|
|
|
1000
1009
|
file=sys.stderr,
|
|
1001
1010
|
)
|
|
1002
1011
|
if args.status == "done":
|
|
1003
|
-
|
|
1012
|
+
# The store drops the hold; the FILE has to lose it too, or the next
|
|
1013
|
+
# push puts it straight back. On the floor that is not a cosmetic lag —
|
|
1014
|
+
# the file is where a claim durably lives there, so a stale block in it
|
|
1015
|
+
# is the claim, and `sync --check` goes red on an item nobody holds.
|
|
1016
|
+
# Projected here rather than left to `pull`, which is a served-store
|
|
1017
|
+
# command a floor project never runs.
|
|
1018
|
+
with switchboard_write_lock(args.key):
|
|
1019
|
+
_claim_projected(args.key, None)
|
|
1020
|
+
print("done also drops the claim — `roadmap prune` when you are ready to clear it")
|
|
1004
1021
|
# No lease to drop here any more. This used to release the long-lived
|
|
1005
1022
|
# mirror, which the store's `done` side effect would otherwise leave
|
|
1006
1023
|
# reading as "claimed and alive" for up to four hours after the roadmap
|
|
@@ -1915,22 +1932,46 @@ def cmd_validate(args: argparse.Namespace) -> int:
|
|
|
1915
1932
|
|
|
1916
1933
|
|
|
1917
1934
|
def installed_version() -> str:
|
|
1918
|
-
"""The version of the
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1935
|
+
"""The version of the code that is actually running.
|
|
1936
|
+
|
|
1937
|
+
Not simply `metadata.version("roadmap-core")`, which reports the version of
|
|
1938
|
+
the INSTALLED DISTRIBUTION whether or not that is what got imported. Those
|
|
1939
|
+
diverge whenever a source tree shadows the install — `PYTHONPATH=.` in a
|
|
1940
|
+
checkout, an editable install left pointing at a directory that moved, a
|
|
1941
|
+
`sys.path` entry a wrapper script prepended. In every one of those the
|
|
1942
|
+
distribution's metadata is still perfectly readable and describes a copy of
|
|
1943
|
+
the code nobody is executing.
|
|
1944
|
+
|
|
1945
|
+
That is a small inaccuracy in most commands and a disqualifying one here.
|
|
1946
|
+
This function exists because two installs at different versions could not be
|
|
1947
|
+
told apart; a version string that can name the wrong one reintroduces the
|
|
1948
|
+
problem it was added to solve. Measured 2026-08-22 in this repository:
|
|
1949
|
+
`doctor` reported `0.2.1` while running 0.2.2 from a checkout.
|
|
1950
|
+
|
|
1951
|
+
So the location is compared, and a mismatch is stated rather than resolved —
|
|
1952
|
+
which one is "right" depends on what the caller meant, and printing both is
|
|
1953
|
+
the only answer that cannot be wrong.
|
|
1925
1954
|
"""
|
|
1926
1955
|
try:
|
|
1927
|
-
from importlib.metadata import PackageNotFoundError,
|
|
1956
|
+
from importlib.metadata import PackageNotFoundError, distribution
|
|
1928
1957
|
except ImportError: # pragma: no cover - stdlib since 3.8
|
|
1929
1958
|
return "unknown"
|
|
1959
|
+
|
|
1960
|
+
running_from = Path(__file__).resolve().parent
|
|
1930
1961
|
try:
|
|
1931
|
-
|
|
1962
|
+
dist = distribution("roadmap-core")
|
|
1932
1963
|
except PackageNotFoundError:
|
|
1933
|
-
return "not installed (running from
|
|
1964
|
+
return f"not installed (running from {running_from})"
|
|
1965
|
+
|
|
1966
|
+
declared = dist.version
|
|
1967
|
+
try:
|
|
1968
|
+
installed_at = Path(str(dist.locate_file("roadmap_core"))).resolve()
|
|
1969
|
+
except Exception: # noqa: BLE001 - a path we cannot resolve is a mismatch we cannot rule out
|
|
1970
|
+
return declared
|
|
1971
|
+
|
|
1972
|
+
if installed_at == running_from:
|
|
1973
|
+
return declared
|
|
1974
|
+
return f"{declared} installed, but running from {running_from}"
|
|
1934
1975
|
|
|
1935
1976
|
|
|
1936
1977
|
class _Check:
|
|
@@ -172,6 +172,20 @@ def _row_to_arc(row: sqlite3.Row) -> dict[str, Any]:
|
|
|
172
172
|
class LocalStore:
|
|
173
173
|
"""The roadmap in one SQLite file, with no server anywhere.
|
|
174
174
|
|
|
175
|
+
**Claim on the floor.** This store takes a file's `claimed_by`/`claimed_at`
|
|
176
|
+
when it CREATES an item, and ignores them on update. `ApiStore` never does.
|
|
177
|
+
That asymmetry is deliberate and is the ruling recorded in
|
|
178
|
+
`a-claim-cannot-survive-the-floors-ci`: here the store is EPHEMERAL — CI
|
|
179
|
+
deletes it and rebuilds it from `roadmap/items/*.yaml` every run — so the
|
|
180
|
+
file is the only durable record a claim has, and dropping it made a held item
|
|
181
|
+
render as `ready` and `sync --check` fail for as long as anybody was working.
|
|
182
|
+
Against a SERVED store the file is a projection of a store that outlives it,
|
|
183
|
+
and honouring a claim from a stale checkout would recreate one the store had
|
|
184
|
+
already released — the resurrection class `roadmap_prunes` exists to prevent.
|
|
185
|
+
|
|
186
|
+
One field, two meanings, decided by which store you are holding. Stated here
|
|
187
|
+
because it is not guessable from the field's name.
|
|
188
|
+
|
|
175
189
|
Owns its connection and closes it, so a caller can use it as a context
|
|
176
190
|
manager and a test does not leak file handles into the next test.
|
|
177
191
|
"""
|
|
@@ -260,6 +274,10 @@ class LocalStore:
|
|
|
260
274
|
payload["id"] = store.new_id()
|
|
261
275
|
payload["status"] = item.get("status") or "ready"
|
|
262
276
|
payload["created_at"] = now
|
|
277
|
+
# The claim, on CREATE only, for the same reason as `status` and with
|
|
278
|
+
# the same asymmetry — see the class docstring's "claim on the floor".
|
|
279
|
+
payload["claimed_by"] = item.get("claimed_by")
|
|
280
|
+
payload["claimed_at"] = item.get("claimed_at")
|
|
263
281
|
names = ", ".join(payload)
|
|
264
282
|
marks = ", ".join("?" for _ in payload)
|
|
265
283
|
self._conn.execute(
|
|
@@ -326,11 +344,23 @@ class LocalStore:
|
|
|
326
344
|
raise StoreError(f"no such item: {key}")
|
|
327
345
|
now = _utcnow_iso()
|
|
328
346
|
done_at = row["done_at"] or (now if status == "done" else None)
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
"
|
|
332
|
-
|
|
333
|
-
|
|
347
|
+
if status == "done":
|
|
348
|
+
# Finishing drops the hold, matching the served path — and matching
|
|
349
|
+
# what the CLI already prints ("done also drops the claim"), which it
|
|
350
|
+
# printed here while nothing happened. Finishing is the one moment a
|
|
351
|
+
# hold is definitely over, and releasing separately is the step that
|
|
352
|
+
# never happens because nothing prompts it.
|
|
353
|
+
self._conn.execute(
|
|
354
|
+
"UPDATE roadmap_items SET status = ?, done_at = ?, updated_at = ?,"
|
|
355
|
+
" claimed_by = NULL, claimed_at = NULL WHERE key = ?",
|
|
356
|
+
(status, done_at, now, key),
|
|
357
|
+
)
|
|
358
|
+
else:
|
|
359
|
+
self._conn.execute(
|
|
360
|
+
"UPDATE roadmap_items SET status = ?, done_at = ?, updated_at = ?"
|
|
361
|
+
" WHERE key = ?",
|
|
362
|
+
(status, done_at, now, key),
|
|
363
|
+
)
|
|
334
364
|
return self.get_item(key) or {}
|
|
335
365
|
|
|
336
366
|
def delete_item(self, key: str) -> bool:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: roadmap-core
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
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
|
|
@@ -129,6 +129,17 @@ FAIL store roadmap/roadmap.db holds 0 items while roadmap/items/ holds 7.
|
|
|
129
129
|
Seed it: `roadmap push`
|
|
130
130
|
```
|
|
131
131
|
|
|
132
|
+
**Who owns a claim depends on which store you have.** On the SQLite floor the
|
|
133
|
+
file is authoritative: CI rebuilds the store from `roadmap/items/*.yaml` every
|
|
134
|
+
run, so `push` takes a file's `claim` when it CREATES the item — otherwise a
|
|
135
|
+
held item renders as `ready` and `sync --check` fails for as long as anybody is
|
|
136
|
+
working. Against a served store the file is a *projection* of a store that
|
|
137
|
+
outlives the checkout, so a claim in a file is never pushed: a stale clone would
|
|
138
|
+
recreate one the store had already released. Either way `push` ignores it on
|
|
139
|
+
UPDATE, because the store is the live record of who holds what.
|
|
140
|
+
|
|
141
|
+
Not guessable from the field's name, which is why it is written down here.
|
|
142
|
+
|
|
132
143
|
**Two things that are conventions rather than choices**, both found by doing
|
|
133
144
|
this rather than by reading the code:
|
|
134
145
|
|
|
@@ -29,7 +29,9 @@ empty backlog for an afternoon.
|
|
|
29
29
|
|
|
30
30
|
from __future__ import annotations
|
|
31
31
|
|
|
32
|
+
import argparse
|
|
32
33
|
import os
|
|
34
|
+
import shutil
|
|
33
35
|
import subprocess
|
|
34
36
|
import sys
|
|
35
37
|
from pathlib import Path
|
|
@@ -373,3 +375,122 @@ def test_the_version_is_answerable_without_a_subcommand(project):
|
|
|
373
375
|
|
|
374
376
|
assert result.returncode == 0
|
|
375
377
|
assert "roadmap-core" in result.stdout
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def test_the_version_names_the_code_that_is_running_not_the_one_installed(
|
|
381
|
+
project, tmp_path
|
|
382
|
+
):
|
|
383
|
+
"""A shadowed install must not be reported as the running one.
|
|
384
|
+
|
|
385
|
+
`metadata.version()` reads the INSTALLED distribution whether or not that is
|
|
386
|
+
what got imported, and the two diverge on `PYTHONPATH=.` in a checkout, an
|
|
387
|
+
editable install pointing at a moved directory, or any wrapper that prepends
|
|
388
|
+
to `sys.path`. Measured in this repository on 2026-08-22: `doctor` printed
|
|
389
|
+
`0.2.1` while running 0.2.2 from a checkout.
|
|
390
|
+
|
|
391
|
+
This is the one command where that is disqualifying rather than untidy — it
|
|
392
|
+
exists BECAUSE two installs at different versions could not be told apart, so
|
|
393
|
+
a version string able to name the wrong one reintroduces its own subject.
|
|
394
|
+
|
|
395
|
+
Done by really shadowing the package rather than by patching `metadata`: a
|
|
396
|
+
copy at a path nobody would mistake for the install, imported by putting it
|
|
397
|
+
first on `PYTHONPATH`.
|
|
398
|
+
"""
|
|
399
|
+
shadow = tmp_path / "shadow"
|
|
400
|
+
shutil.copytree(PACKAGE_ROOT / "roadmap_core", shadow / "roadmap_core")
|
|
401
|
+
|
|
402
|
+
env = dict(os.environ)
|
|
403
|
+
env["PYTHONPATH"] = str(shadow)
|
|
404
|
+
env["ROADMAP_SOURCE"] = "local"
|
|
405
|
+
result = subprocess.run(
|
|
406
|
+
[sys.executable, "-m", "roadmap_core.cli", "--version"],
|
|
407
|
+
cwd=project, env=env, capture_output=True, text=True, timeout=60,
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
assert result.returncode == 0, result.stderr
|
|
411
|
+
assert str(shadow / "roadmap_core") in result.stdout, (
|
|
412
|
+
"the version must name where the running code came from when that is not "
|
|
413
|
+
f"the installed distribution — got: {result.stdout!r}"
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
# --- claiming, on the floor, end to end ---------------------------------------
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def test_a_floor_project_can_claim_and_still_pass_its_own_ci(project):
|
|
421
|
+
"""The failure `a-claim-cannot-survive-the-floors-ci` describes, run whole.
|
|
422
|
+
|
|
423
|
+
Claim an item, commit what the CLI tells you to commit, then let CI rebuild
|
|
424
|
+
the store from the files — which is what `.github/workflows/roadmap.yml`
|
|
425
|
+
does on every run, because on the floor the store is a SQLite file that does
|
|
426
|
+
not exist until `push` creates it.
|
|
427
|
+
|
|
428
|
+
Before the fix this ended in `ARCS.md is stale — run 'roadmap sync'`, and
|
|
429
|
+
`sync` did not fix it: the file said `claimed`, a fresh push rendered
|
|
430
|
+
`ready`, and the two could not agree while anybody held anything. The floor's
|
|
431
|
+
CI was red for exactly as long as its coordination feature was in use.
|
|
432
|
+
|
|
433
|
+
The store is deleted between the two halves ON PURPOSE. Checking against the
|
|
434
|
+
warm store the claim was made in is what hid this — it agrees with itself.
|
|
435
|
+
"""
|
|
436
|
+
assert run(project, "push").returncode == 0
|
|
437
|
+
# `--by` explicitly: the scratch project is not a git repository, so
|
|
438
|
+
# `current_branch()` has nothing to read and the holder would be the literal
|
|
439
|
+
# string "unknown" — which would still pass a test that only checked the
|
|
440
|
+
# claim survived, while proving nothing about the name travelling with it.
|
|
441
|
+
assert run(project, "claim", "first-thing", "--by", "claude/holder").returncode == 0
|
|
442
|
+
assert run(project, "sync").returncode == 0
|
|
443
|
+
|
|
444
|
+
(project / "roadmap" / "roadmap.db").unlink() # as CI starts
|
|
445
|
+
assert run(project, "push").returncode == 0
|
|
446
|
+
assert run(project, "validate").returncode == 0
|
|
447
|
+
|
|
448
|
+
checked = run(project, "sync", "--check")
|
|
449
|
+
assert checked.returncode == 0, (
|
|
450
|
+
"a claimed item makes the committed markdown unreproducible:\n"
|
|
451
|
+
f"{checked.stdout}\n{checked.stderr}"
|
|
452
|
+
)
|
|
453
|
+
# And the holder survived, not merely the status: a rebuild that kept
|
|
454
|
+
# `claimed` while losing WHO holds it would still leave the next session
|
|
455
|
+
# unable to tell whether the item is theirs.
|
|
456
|
+
assert "CLAIMED" in run(project, "list").stdout
|
|
457
|
+
assert "claude/holder" in run(project, "show", "first-thing").stdout
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def test_the_served_path_still_refuses_to_carry_a_claim(project, monkeypatch):
|
|
461
|
+
"""Acceptance 2, asserted rather than assumed.
|
|
462
|
+
|
|
463
|
+
The same move against a SERVED store is the resurrection class: its store
|
|
464
|
+
outlives the checkout, so a push from a stale clone would recreate a claim
|
|
465
|
+
that store had already released. The fix must be visible on the floor and
|
|
466
|
+
absent over the API, and the only way to know is to look at what `push`
|
|
467
|
+
actually sends.
|
|
468
|
+
"""
|
|
469
|
+
import importlib.util
|
|
470
|
+
|
|
471
|
+
spec = importlib.util.spec_from_file_location(
|
|
472
|
+
"roadmap_core.cli", PACKAGE_ROOT / "roadmap_core" / "cli.py"
|
|
473
|
+
)
|
|
474
|
+
cli = importlib.util.module_from_spec(spec)
|
|
475
|
+
spec.loader.exec_module(cli)
|
|
476
|
+
|
|
477
|
+
monkeypatch.setattr(cli, "REPO_ROOT", project)
|
|
478
|
+
monkeypatch.setattr(cli, "ITEMS_DIR", project / "roadmap" / "items")
|
|
479
|
+
monkeypatch.setattr(cli, "ARCS_DIR", project / "roadmap" / "arcs")
|
|
480
|
+
(project / "roadmap" / "items" / "first-thing.yaml").write_text(
|
|
481
|
+
ITEM + "claim:\n by: claude/stale-checkout\n at: '2026-01-01T00:00:00Z'\n"
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
sent: list[dict] = []
|
|
485
|
+
monkeypatch.setattr(cli, "_api", lambda m, p, payload=None: sent.append(payload or {}))
|
|
486
|
+
monkeypatch.setattr(cli, "load_from_db", dict)
|
|
487
|
+
|
|
488
|
+
cli.cmd_push(argparse.Namespace(source="db"))
|
|
489
|
+
|
|
490
|
+
item_payloads = [p for p in sent if "evidence" in p]
|
|
491
|
+
assert item_payloads, "push sent no item at all"
|
|
492
|
+
for payload in item_payloads:
|
|
493
|
+
assert "claimed_by" not in payload, (
|
|
494
|
+
"the served store must not be handed a claim from a file — a stale "
|
|
495
|
+
f"checkout would resurrect a released one: {payload}"
|
|
496
|
+
)
|
|
@@ -452,3 +452,85 @@ def test_json_columns_named_by_the_schema_are_the_ones_decoded(db):
|
|
|
452
452
|
item = db.items()["alpha"]
|
|
453
453
|
for column in store.JSON_COLUMNS["roadmap_items"]:
|
|
454
454
|
assert not isinstance(item[column], str), f"{column} came back as raw JSON text"
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
# --- claim on the floor -------------------------------------------------------
|
|
458
|
+
#
|
|
459
|
+
# The store here is EPHEMERAL: CI deletes it and rebuilds it from the committed
|
|
460
|
+
# YAML every run. So a claim that a file records has to survive being pushed, or
|
|
461
|
+
# a held item renders as `ready` and the committed markdown can never match what
|
|
462
|
+
# CI generates. That is `a-claim-cannot-survive-the-floors-ci`, and these pin the
|
|
463
|
+
# ruling: authoritative on the floor, CREATE only, exactly like `status`.
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def test_a_claim_in_the_file_survives_a_rebuild_of_the_store(db):
|
|
467
|
+
db.upsert_item({
|
|
468
|
+
"key": "alpha",
|
|
469
|
+
"title": "Alpha",
|
|
470
|
+
"status": "claimed",
|
|
471
|
+
"claimed_by": "claude/some-branch",
|
|
472
|
+
"claimed_at": "2026-08-22T10:00:00+00:00",
|
|
473
|
+
})
|
|
474
|
+
item = db.items()["alpha"]
|
|
475
|
+
assert item["claimed_by"] == "claude/some-branch"
|
|
476
|
+
assert item["claimed_at"] == "2026-08-22T10:00:00+00:00"
|
|
477
|
+
assert item["status"] == "claimed"
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def test_an_update_cannot_move_a_claim(db):
|
|
481
|
+
"""CREATE only, for the same reason `status` is.
|
|
482
|
+
|
|
483
|
+
On update the store is the live record of who holds what — a push from a
|
|
484
|
+
checkout that predates a release would otherwise hand the item back to a
|
|
485
|
+
session that has already let go.
|
|
486
|
+
"""
|
|
487
|
+
db.upsert_item({"key": "alpha", "title": "Alpha"})
|
|
488
|
+
db.claim("alpha", by="claude/live")
|
|
489
|
+
|
|
490
|
+
db.upsert_item({
|
|
491
|
+
"key": "alpha",
|
|
492
|
+
"title": "Alpha",
|
|
493
|
+
"claimed_by": "claude/stale-checkout",
|
|
494
|
+
"claimed_at": "2026-01-01T00:00:00+00:00",
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
assert db.items()["alpha"]["claimed_by"] == "claude/live"
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def test_an_unclaimed_file_creates_an_unclaimed_item(db):
|
|
501
|
+
"""The ordinary case: no claim block, no claim. Asserted because the create
|
|
502
|
+
path now writes those columns explicitly rather than leaving them to the
|
|
503
|
+
schema default, and `None` is the value that has to arrive."""
|
|
504
|
+
db.upsert_item({"key": "alpha", "title": "Alpha"})
|
|
505
|
+
item = db.items()["alpha"]
|
|
506
|
+
assert item["claimed_by"] is None
|
|
507
|
+
assert item["claimed_at"] is None
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def test_done_drops_the_claim(db):
|
|
511
|
+
"""The CLI has always printed "done also drops the claim". On the floor it
|
|
512
|
+
did not — the served path clears the hold and this one did not, and nobody
|
|
513
|
+
could see the difference because a claim never survived a rebuild anyway.
|
|
514
|
+
|
|
515
|
+
Fixing claims-survive-a-rebuild is what made this visible: the notice about
|
|
516
|
+
an unreleased hold started firing on an item that had just been finished.
|
|
517
|
+
"""
|
|
518
|
+
db.upsert_item({"key": "alpha", "title": "Alpha"})
|
|
519
|
+
db.claim("alpha", by="claude/holder")
|
|
520
|
+
|
|
521
|
+
row = db.set_status("alpha", "done")
|
|
522
|
+
|
|
523
|
+
assert row["status"] == "done"
|
|
524
|
+
assert row["claimed_by"] is None
|
|
525
|
+
assert row["claimed_at"] is None
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def test_a_status_that_is_not_done_leaves_the_claim_alone(db):
|
|
529
|
+
"""`verifying` explicitly does NOT drop it: the branch that shipped the work
|
|
530
|
+
still owns confirming it landed."""
|
|
531
|
+
db.upsert_item({"key": "alpha", "title": "Alpha"})
|
|
532
|
+
db.claim("alpha", by="claude/holder")
|
|
533
|
+
|
|
534
|
+
row = db.set_status("alpha", "verifying")
|
|
535
|
+
|
|
536
|
+
assert row["claimed_by"] == "claude/holder"
|
|
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
|