roadmap-core 0.2.0__tar.gz → 0.2.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.2.0/roadmap_core.egg-info → roadmap_core-0.2.2}/PKG-INFO +26 -1
- {roadmap_core-0.2.0 → roadmap_core-0.2.2}/README.md +25 -0
- {roadmap_core-0.2.0 → roadmap_core-0.2.2}/pyproject.toml +1 -1
- {roadmap_core-0.2.0 → roadmap_core-0.2.2}/roadmap_core/cli.py +247 -4
- {roadmap_core-0.2.0 → roadmap_core-0.2.2/roadmap_core.egg-info}/PKG-INFO +26 -1
- {roadmap_core-0.2.0 → roadmap_core-0.2.2}/tests/test_adoption.py +62 -0
- {roadmap_core-0.2.0 → roadmap_core-0.2.2}/tests/test_graph.py +52 -0
- {roadmap_core-0.2.0 → roadmap_core-0.2.2}/LICENSE +0 -0
- {roadmap_core-0.2.0 → roadmap_core-0.2.2}/roadmap_core/__init__.py +0 -0
- {roadmap_core-0.2.0 → roadmap_core-0.2.2}/roadmap_core/graph.py +0 -0
- {roadmap_core-0.2.0 → roadmap_core-0.2.2}/roadmap_core/impact.py +0 -0
- {roadmap_core-0.2.0 → roadmap_core-0.2.2}/roadmap_core/store.py +0 -0
- {roadmap_core-0.2.0 → roadmap_core-0.2.2}/roadmap_core/stores.py +0 -0
- {roadmap_core-0.2.0 → roadmap_core-0.2.2}/roadmap_core.egg-info/SOURCES.txt +0 -0
- {roadmap_core-0.2.0 → roadmap_core-0.2.2}/roadmap_core.egg-info/dependency_links.txt +0 -0
- {roadmap_core-0.2.0 → roadmap_core-0.2.2}/roadmap_core.egg-info/entry_points.txt +0 -0
- {roadmap_core-0.2.0 → roadmap_core-0.2.2}/roadmap_core.egg-info/requires.txt +0 -0
- {roadmap_core-0.2.0 → roadmap_core-0.2.2}/roadmap_core.egg-info/top_level.txt +0 -0
- {roadmap_core-0.2.0 → roadmap_core-0.2.2}/setup.cfg +0 -0
- {roadmap_core-0.2.0 → roadmap_core-0.2.2}/templates/roadmap.yml +0 -0
- {roadmap_core-0.2.0 → roadmap_core-0.2.2}/tests/test_arcs.py +0 -0
- {roadmap_core-0.2.0 → roadmap_core-0.2.2}/tests/test_impact.py +0 -0
- {roadmap_core-0.2.0 → roadmap_core-0.2.2}/tests/test_store.py +0 -0
- {roadmap_core-0.2.0 → roadmap_core-0.2.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.2.
|
|
3
|
+
Version: 0.2.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
|
|
@@ -104,6 +104,31 @@ roadmap release first-thing
|
|
|
104
104
|
The store is one SQLite file at `roadmap/roadmap.db`. There is nothing to
|
|
105
105
|
provision and no migration to run: it is created on first open.
|
|
106
106
|
|
|
107
|
+
### When something is off, ask
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
roadmap doctor # is this project's setup actually working?
|
|
111
|
+
roadmap --version # which roadmap-core is this?
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`doctor` exits non-zero when something is genuinely broken and names the remedy.
|
|
115
|
+
Run it first, because **the way this setup fails is by looking fine**: the read
|
|
116
|
+
commands answer from the store, and a store nobody has `push`ed to is empty, so
|
|
117
|
+
`validate` says *"ok — 0 item(s), no problems"* and `ready` says the backlog is
|
|
118
|
+
finished. Both are green, confident and wrong. Same for a command run from
|
|
119
|
+
outside the project: every path still resolves, and `push` reports *"no item
|
|
120
|
+
files to push"*, which reads as an empty backlog rather than as a wrong
|
|
121
|
+
directory.
|
|
122
|
+
|
|
123
|
+
Those are the two failures `tests/test_adoption.py` was written after, and both
|
|
124
|
+
are one line of `doctor` output:
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
FAIL store roadmap/roadmap.db holds 0 items while roadmap/items/ holds 7.
|
|
128
|
+
Every read command will report an empty backlog and call it ok.
|
|
129
|
+
Seed it: `roadmap push`
|
|
130
|
+
```
|
|
131
|
+
|
|
107
132
|
**Two things that are conventions rather than choices**, both found by doing
|
|
108
133
|
this rather than by reading the code:
|
|
109
134
|
|
|
@@ -83,6 +83,31 @@ roadmap release first-thing
|
|
|
83
83
|
The store is one SQLite file at `roadmap/roadmap.db`. There is nothing to
|
|
84
84
|
provision and no migration to run: it is created on first open.
|
|
85
85
|
|
|
86
|
+
### When something is off, ask
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
roadmap doctor # is this project's setup actually working?
|
|
90
|
+
roadmap --version # which roadmap-core is this?
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`doctor` exits non-zero when something is genuinely broken and names the remedy.
|
|
94
|
+
Run it first, because **the way this setup fails is by looking fine**: the read
|
|
95
|
+
commands answer from the store, and a store nobody has `push`ed to is empty, so
|
|
96
|
+
`validate` says *"ok — 0 item(s), no problems"* and `ready` says the backlog is
|
|
97
|
+
finished. Both are green, confident and wrong. Same for a command run from
|
|
98
|
+
outside the project: every path still resolves, and `push` reports *"no item
|
|
99
|
+
files to push"*, which reads as an empty backlog rather than as a wrong
|
|
100
|
+
directory.
|
|
101
|
+
|
|
102
|
+
Those are the two failures `tests/test_adoption.py` was written after, and both
|
|
103
|
+
are one line of `doctor` output:
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
FAIL store roadmap/roadmap.db holds 0 items while roadmap/items/ holds 7.
|
|
107
|
+
Every read command will report an empty backlog and call it ok.
|
|
108
|
+
Seed it: `roadmap push`
|
|
109
|
+
```
|
|
110
|
+
|
|
86
111
|
**Two things that are conventions rather than choices**, both found by doing
|
|
87
112
|
this rather than by reading the code:
|
|
88
113
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "roadmap-core"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.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"
|
|
@@ -34,6 +34,7 @@ Commands
|
|
|
34
34
|
status move an item's status deliberately (the store owns that field)
|
|
35
35
|
release give it back
|
|
36
36
|
validate schema, dangling deps, cycles
|
|
37
|
+
doctor check that this project's setup actually works
|
|
37
38
|
|
|
38
39
|
State fields — ``status`` and the claim — are the store's, and a file's value
|
|
39
40
|
for them is honored when the item is first filed and ignored afterwards. A
|
|
@@ -781,7 +782,7 @@ def cmd_sync(args: argparse.Namespace) -> int:
|
|
|
781
782
|
if current_arcs != rendered_arcs:
|
|
782
783
|
print(
|
|
783
784
|
f"{GENERATED_ARCS_MD.relative_to(REPO_ROOT)} is stale — "
|
|
784
|
-
"run `
|
|
785
|
+
f"run `{graph.CLI} sync`",
|
|
785
786
|
file=sys.stderr,
|
|
786
787
|
)
|
|
787
788
|
return 1
|
|
@@ -792,7 +793,7 @@ def cmd_sync(args: argparse.Namespace) -> int:
|
|
|
792
793
|
if current != rendered:
|
|
793
794
|
print(
|
|
794
795
|
f"{GENERATED_MD.relative_to(REPO_ROOT)} is stale — "
|
|
795
|
-
"run `
|
|
796
|
+
f"run `{graph.CLI} sync`",
|
|
796
797
|
file=sys.stderr,
|
|
797
798
|
)
|
|
798
799
|
return 1
|
|
@@ -1244,7 +1245,7 @@ def cmd_pull(args: argparse.Namespace) -> int:
|
|
|
1244
1245
|
return 0
|
|
1245
1246
|
print(
|
|
1246
1247
|
f"{len(created)} created, {len(updated)} updated — "
|
|
1247
|
-
f"now run `
|
|
1248
|
+
f"now run `{graph.CLI} sync` and commit"
|
|
1248
1249
|
)
|
|
1249
1250
|
return 0
|
|
1250
1251
|
|
|
@@ -1508,7 +1509,7 @@ def cmd_prune(args: argparse.Namespace) -> int:
|
|
|
1508
1509
|
else:
|
|
1509
1510
|
print(f"pruned {key} — store: {outcome}, no file")
|
|
1510
1511
|
_release_dependents(done)
|
|
1511
|
-
print(f"{len(done)} item(s) pruned — now run `
|
|
1512
|
+
print(f"{len(done)} item(s) pruned — now run `{graph.CLI} sync` and commit")
|
|
1512
1513
|
return 0
|
|
1513
1514
|
|
|
1514
1515
|
|
|
@@ -1913,6 +1914,236 @@ def cmd_validate(args: argparse.Namespace) -> int:
|
|
|
1913
1914
|
return 1
|
|
1914
1915
|
|
|
1915
1916
|
|
|
1917
|
+
def installed_version() -> str:
|
|
1918
|
+
"""The version of the distribution this module was imported from.
|
|
1919
|
+
|
|
1920
|
+
A source checkout that was never installed has no distribution metadata,
|
|
1921
|
+
which is a legitimate way to run the CLI and must not raise. It is still
|
|
1922
|
+
worth distinguishing in the output: "which version is this" and "there is no
|
|
1923
|
+
package here, you are running a directory" are different answers to the same
|
|
1924
|
+
question, and only one of them can be compared against a pin.
|
|
1925
|
+
"""
|
|
1926
|
+
try:
|
|
1927
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
1928
|
+
except ImportError: # pragma: no cover - stdlib since 3.8
|
|
1929
|
+
return "unknown"
|
|
1930
|
+
try:
|
|
1931
|
+
return version("roadmap-core")
|
|
1932
|
+
except PackageNotFoundError:
|
|
1933
|
+
return "not installed (running from a source tree)"
|
|
1934
|
+
|
|
1935
|
+
|
|
1936
|
+
class _Check:
|
|
1937
|
+
"""One question `doctor` asks, and what the answer means.
|
|
1938
|
+
|
|
1939
|
+
`fatal=False` for things worth printing but not worth failing on. A doctor
|
|
1940
|
+
that fails on everything imperfect gets run once and then ignored, which
|
|
1941
|
+
leaves an adopter exactly where they started.
|
|
1942
|
+
"""
|
|
1943
|
+
|
|
1944
|
+
__slots__ = ("name", "ok", "detail", "fatal")
|
|
1945
|
+
|
|
1946
|
+
def __init__(self, name: str, ok: bool, detail: str, fatal: bool = True) -> None:
|
|
1947
|
+
self.name, self.ok, self.detail, self.fatal = name, ok, detail, fatal
|
|
1948
|
+
|
|
1949
|
+
|
|
1950
|
+
def _check_yaml() -> _Check:
|
|
1951
|
+
try:
|
|
1952
|
+
import yaml # noqa: F401
|
|
1953
|
+
except ImportError:
|
|
1954
|
+
return _Check(
|
|
1955
|
+
"pyyaml",
|
|
1956
|
+
False,
|
|
1957
|
+
"not installed — `push`, `pull` and `--source files` all fail on this "
|
|
1958
|
+
"one import. Install the extra: pip install 'roadmap-core[files]'",
|
|
1959
|
+
)
|
|
1960
|
+
return _Check("pyyaml", True, "importable")
|
|
1961
|
+
|
|
1962
|
+
|
|
1963
|
+
def _check_repo_root() -> _Check:
|
|
1964
|
+
"""Where the CLI thinks the project is, and how it decided.
|
|
1965
|
+
|
|
1966
|
+
The failure this exists for is silent: pointed one directory too high, every
|
|
1967
|
+
path still resolves, `push` reports "no item files to push", and that reads
|
|
1968
|
+
as an empty backlog rather than as a misconfiguration.
|
|
1969
|
+
"""
|
|
1970
|
+
pinned = os.environ.get("ROADMAP_REPO_ROOT")
|
|
1971
|
+
if pinned:
|
|
1972
|
+
how = "pinned by ROADMAP_REPO_ROOT"
|
|
1973
|
+
elif (REPO_ROOT / "roadmap" / "items").exists():
|
|
1974
|
+
how = "found by walking up from the working directory"
|
|
1975
|
+
else:
|
|
1976
|
+
return _Check(
|
|
1977
|
+
"repo root",
|
|
1978
|
+
False,
|
|
1979
|
+
f"{REPO_ROOT} — no roadmap/items/ above the working directory, so this "
|
|
1980
|
+
"is the working directory itself, chosen as a fallback. Run from "
|
|
1981
|
+
"inside the project, or set ROADMAP_REPO_ROOT.",
|
|
1982
|
+
)
|
|
1983
|
+
return _Check("repo root", True, f"{REPO_ROOT} ({how})")
|
|
1984
|
+
|
|
1985
|
+
|
|
1986
|
+
def _check_items() -> tuple[_Check, int]:
|
|
1987
|
+
if not ITEMS_DIR.is_dir():
|
|
1988
|
+
return (
|
|
1989
|
+
_Check(
|
|
1990
|
+
"items",
|
|
1991
|
+
False,
|
|
1992
|
+
f"{ITEMS_DIR} does not exist. That directory IS the authoring "
|
|
1993
|
+
"format — there is no `roadmap new`, so a project without it has "
|
|
1994
|
+
"nowhere to file work.",
|
|
1995
|
+
),
|
|
1996
|
+
0,
|
|
1997
|
+
)
|
|
1998
|
+
count = len(list(ITEMS_DIR.glob("*.yaml")))
|
|
1999
|
+
return _Check("items", True, f"{count} file(s) in {ITEMS_DIR}", fatal=False), count
|
|
2000
|
+
|
|
2001
|
+
|
|
2002
|
+
def _check_store(source: str, on_disk: int, chosen: bool) -> list[_Check]:
|
|
2003
|
+
"""Whether the store the write commands use agrees that work exists.
|
|
2004
|
+
|
|
2005
|
+
THE CHECK THIS COMMAND WAS WRITTEN FOR. `local` reads a SQLite file that is
|
|
2006
|
+
empty until `push` seeds it, and an empty store answers `validate` with
|
|
2007
|
+
"ok — 0 item(s), no problems" and `ready` with "nothing ready". Both are
|
|
2008
|
+
green, confident, and wrong, and the remedy is one command that nothing
|
|
2009
|
+
suggests because nothing has noticed.
|
|
2010
|
+
"""
|
|
2011
|
+
if source == "local":
|
|
2012
|
+
checks = [_Check("source", True, "local — a SQLite file, no server needed",
|
|
2013
|
+
fatal=False)]
|
|
2014
|
+
path = Path(ROADMAP_STORE_PATH)
|
|
2015
|
+
try:
|
|
2016
|
+
with store_for("local") as store:
|
|
2017
|
+
in_store = len(store.items())
|
|
2018
|
+
except Exception as exc: # noqa: BLE001 - report it, do not raise from a doctor
|
|
2019
|
+
checks.append(_Check("store", False, f"{path} could not be opened: {exc}"))
|
|
2020
|
+
return checks
|
|
2021
|
+
if in_store == 0 and on_disk > 0:
|
|
2022
|
+
checks.append(_Check(
|
|
2023
|
+
"store",
|
|
2024
|
+
False,
|
|
2025
|
+
f"{path} holds 0 items while roadmap/items/ holds {on_disk}. "
|
|
2026
|
+
"Every read command will report an empty backlog and call it ok. "
|
|
2027
|
+
"Seed it: `roadmap push`",
|
|
2028
|
+
))
|
|
2029
|
+
else:
|
|
2030
|
+
checks.append(_Check("store", True, f"{path} — {in_store} item(s)"))
|
|
2031
|
+
return checks
|
|
2032
|
+
|
|
2033
|
+
# `db` is the built-in default for writes, so a project that has configured
|
|
2034
|
+
# nothing lands here without asking to. Saying FAIL twice at that project
|
|
2035
|
+
# would be describing a served store it never wanted; saying nothing would
|
|
2036
|
+
# hide that an unqualified `claim` is aimed somewhere unreachable. So it is
|
|
2037
|
+
# one warning that names the fork, and it becomes a real failure the moment
|
|
2038
|
+
# somebody actually chooses `db`.
|
|
2039
|
+
if not chosen and not DEFAULT_BACKEND:
|
|
2040
|
+
return [_Check(
|
|
2041
|
+
"source",
|
|
2042
|
+
False,
|
|
2043
|
+
"nothing configured, so writes default to the `db` source and would "
|
|
2044
|
+
"need a backend and a token. For the SQLite floor set "
|
|
2045
|
+
"ROADMAP_SOURCE=local (see templates/roadmap.yml); for a served "
|
|
2046
|
+
"store set LUCILLE_BACKEND_URL and LUCILLE_ADMIN_JWT.",
|
|
2047
|
+
fatal=False,
|
|
2048
|
+
)]
|
|
2049
|
+
|
|
2050
|
+
detail = "db — a served store over the admin API"
|
|
2051
|
+
checks = [_Check("source", True, detail, fatal=False)]
|
|
2052
|
+
if not DEFAULT_BACKEND:
|
|
2053
|
+
checks.append(_Check(
|
|
2054
|
+
"backend url",
|
|
2055
|
+
False,
|
|
2056
|
+
"the db source is selected but no backend URL is set. The published "
|
|
2057
|
+
"package ships no default on purpose — a private hostname is not a "
|
|
2058
|
+
"sensible fallback. Set LUCILLE_BACKEND_URL, or use ROADMAP_SOURCE=local.",
|
|
2059
|
+
))
|
|
2060
|
+
else:
|
|
2061
|
+
checks.append(_Check("backend url", True, DEFAULT_BACKEND, fatal=False))
|
|
2062
|
+
if not os.environ.get("LUCILLE_ADMIN_JWT", "").strip():
|
|
2063
|
+
checks.append(_Check(
|
|
2064
|
+
"credential",
|
|
2065
|
+
False,
|
|
2066
|
+
"no admin token in the environment, so every db-source command will "
|
|
2067
|
+
"exit before it reaches the network.",
|
|
2068
|
+
))
|
|
2069
|
+
else:
|
|
2070
|
+
checks.append(_Check("credential", True, "present", fatal=False))
|
|
2071
|
+
return checks
|
|
2072
|
+
|
|
2073
|
+
|
|
2074
|
+
def _check_generated() -> list[_Check]:
|
|
2075
|
+
"""The committed projections. Absent is a real gap — they are what a reader
|
|
2076
|
+
with no install sees — but never fatal: a project mid-adoption has not run
|
|
2077
|
+
`sync` yet, and failing here would make the first doctor run red for a
|
|
2078
|
+
project doing nothing wrong."""
|
|
2079
|
+
out = []
|
|
2080
|
+
for label, path in (("ROADMAP.md", GENERATED_MD), ("ARCS.md", GENERATED_ARCS_MD)):
|
|
2081
|
+
if path.exists():
|
|
2082
|
+
out.append(_Check(label, True, str(path), fatal=False))
|
|
2083
|
+
else:
|
|
2084
|
+
out.append(_Check(
|
|
2085
|
+
label, False, f"{path} not generated yet — run `roadmap sync`",
|
|
2086
|
+
fatal=False,
|
|
2087
|
+
))
|
|
2088
|
+
return out
|
|
2089
|
+
|
|
2090
|
+
|
|
2091
|
+
def cmd_doctor(args: argparse.Namespace) -> int:
|
|
2092
|
+
"""Answer "is this project's roadmap setup working?" in one command.
|
|
2093
|
+
|
|
2094
|
+
Every check here exists because its absence was paid for. The CLI that could
|
|
2095
|
+
not find its graph, the shim at the wrong depth reporting an empty backlog,
|
|
2096
|
+
two installs a major version apart with nothing able to report its own
|
|
2097
|
+
version, a `local` store nobody had seeded — each was found by someone
|
|
2098
|
+
reading a green, confident, wrong answer and believing it.
|
|
2099
|
+
|
|
2100
|
+
Exit code is the product: non-zero when something is actually broken, so a
|
|
2101
|
+
setup step in CI or a bootstrap script can gate on it rather than on a human
|
|
2102
|
+
reading output.
|
|
2103
|
+
"""
|
|
2104
|
+
# "Chosen" means somebody said so — on the command line or in the
|
|
2105
|
+
# environment — as opposed to inheriting the built-in write default. The
|
|
2106
|
+
# distinction changes what an unconfigured db source means, and nothing
|
|
2107
|
+
# downstream can recover it.
|
|
2108
|
+
chosen = bool(_ENV_SOURCE) or "--source" in (sys.argv[1:] or [])
|
|
2109
|
+
source = _write_source(args)
|
|
2110
|
+
|
|
2111
|
+
checks: list[_Check] = [
|
|
2112
|
+
_Check("version", True, f"roadmap-core {installed_version()}", fatal=False),
|
|
2113
|
+
_Check("package", True, str(Path(__file__).resolve().parent), fatal=False),
|
|
2114
|
+
_check_yaml(),
|
|
2115
|
+
_check_repo_root(),
|
|
2116
|
+
]
|
|
2117
|
+
items_check, on_disk = _check_items()
|
|
2118
|
+
checks.append(items_check)
|
|
2119
|
+
checks.append(_Check(
|
|
2120
|
+
"arcs",
|
|
2121
|
+
True,
|
|
2122
|
+
f"{len(list(ARCS_DIR.glob('*.yaml')))} file(s) in {ARCS_DIR}"
|
|
2123
|
+
if ARCS_DIR.is_dir() else f"{ARCS_DIR} does not exist (optional)",
|
|
2124
|
+
fatal=False,
|
|
2125
|
+
))
|
|
2126
|
+
checks += _check_store(source, on_disk, chosen)
|
|
2127
|
+
checks += _check_generated()
|
|
2128
|
+
|
|
2129
|
+
width = max(len(c.name) for c in checks)
|
|
2130
|
+
broken = [c for c in checks if not c.ok and c.fatal]
|
|
2131
|
+
for check in checks:
|
|
2132
|
+
mark = "ok " if check.ok else ("FAIL" if check.fatal else "warn")
|
|
2133
|
+
stream = sys.stderr if not check.ok and check.fatal else sys.stdout
|
|
2134
|
+
print(f"{mark} {check.name.ljust(width)} {check.detail}", file=stream)
|
|
2135
|
+
|
|
2136
|
+
if broken:
|
|
2137
|
+
print(
|
|
2138
|
+
f"\n{len(broken)} problem(s) — the commands above will not behave as "
|
|
2139
|
+
"documented until each is resolved.",
|
|
2140
|
+
file=sys.stderr,
|
|
2141
|
+
)
|
|
2142
|
+
return 1
|
|
2143
|
+
print("\nsetup looks complete.")
|
|
2144
|
+
return 0
|
|
2145
|
+
|
|
2146
|
+
|
|
1916
2147
|
def _add_source(parser: argparse.ArgumentParser) -> None:
|
|
1917
2148
|
parser.add_argument(
|
|
1918
2149
|
"--source", choices=("db", "files", "local"), default=_ENV_SOURCE or "files",
|
|
@@ -1967,6 +2198,12 @@ def _add_compared_source(parser: argparse.ArgumentParser) -> None:
|
|
|
1967
2198
|
def main(argv: list[str] | None = None) -> int:
|
|
1968
2199
|
parser = argparse.ArgumentParser(description=__doc__,
|
|
1969
2200
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
2201
|
+
# Before the subparsers, and deliberately answerable without one: "which
|
|
2202
|
+
# version is this" is asked when two installs are suspected of disagreeing,
|
|
2203
|
+
# and at that moment every subcommand is under suspicion too.
|
|
2204
|
+
parser.add_argument(
|
|
2205
|
+
"--version", action="version", version=f"roadmap-core {installed_version()}"
|
|
2206
|
+
)
|
|
1970
2207
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
1971
2208
|
|
|
1972
2209
|
p_sync = sub.add_parser("sync", help="regenerate roadmap/ROADMAP.md")
|
|
@@ -2053,6 +2290,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
2053
2290
|
_add_source(p_validate)
|
|
2054
2291
|
p_validate.set_defaults(func=cmd_validate)
|
|
2055
2292
|
|
|
2293
|
+
p_doctor = sub.add_parser(
|
|
2294
|
+
"doctor", help="check that this project's roadmap setup actually works"
|
|
2295
|
+
)
|
|
2296
|
+
_add_write_source(p_doctor)
|
|
2297
|
+
p_doctor.set_defaults(func=cmd_doctor)
|
|
2298
|
+
|
|
2056
2299
|
args = parser.parse_args(argv)
|
|
2057
2300
|
return args.func(args)
|
|
2058
2301
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: roadmap-core
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.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
|
|
@@ -104,6 +104,31 @@ roadmap release first-thing
|
|
|
104
104
|
The store is one SQLite file at `roadmap/roadmap.db`. There is nothing to
|
|
105
105
|
provision and no migration to run: it is created on first open.
|
|
106
106
|
|
|
107
|
+
### When something is off, ask
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
roadmap doctor # is this project's setup actually working?
|
|
111
|
+
roadmap --version # which roadmap-core is this?
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`doctor` exits non-zero when something is genuinely broken and names the remedy.
|
|
115
|
+
Run it first, because **the way this setup fails is by looking fine**: the read
|
|
116
|
+
commands answer from the store, and a store nobody has `push`ed to is empty, so
|
|
117
|
+
`validate` says *"ok — 0 item(s), no problems"* and `ready` says the backlog is
|
|
118
|
+
finished. Both are green, confident and wrong. Same for a command run from
|
|
119
|
+
outside the project: every path still resolves, and `push` reports *"no item
|
|
120
|
+
files to push"*, which reads as an empty backlog rather than as a wrong
|
|
121
|
+
directory.
|
|
122
|
+
|
|
123
|
+
Those are the two failures `tests/test_adoption.py` was written after, and both
|
|
124
|
+
are one line of `doctor` output:
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
FAIL store roadmap/roadmap.db holds 0 items while roadmap/items/ holds 7.
|
|
128
|
+
Every read command will report an empty backlog and call it ok.
|
|
129
|
+
Seed it: `roadmap push`
|
|
130
|
+
```
|
|
131
|
+
|
|
107
132
|
**Two things that are conventions rather than choices**, both found by doing
|
|
108
133
|
this rather than by reading the code:
|
|
109
134
|
|
|
@@ -311,3 +311,65 @@ def test_diff_reconciles_against_the_local_store_without_a_credential(project):
|
|
|
311
311
|
f"diff reported agreement:\n{drifted.stdout}\n{drifted.stderr}"
|
|
312
312
|
)
|
|
313
313
|
assert "first-thing" in drifted.stdout + drifted.stderr
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# --- doctor -------------------------------------------------------------------
|
|
317
|
+
#
|
|
318
|
+
# The command that certifies an adoption has to be certified by the adoption
|
|
319
|
+
# test, or it is one more thing whose correctness is assumed. These use the same
|
|
320
|
+
# scratch project as everything above: nothing installed, no server, no token.
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def test_doctor_passes_on_a_project_that_is_set_up(project):
|
|
324
|
+
"""The healthy case, and the only one where a zero exit is meaningful — it is
|
|
325
|
+
meaningful only because the broken cases below are red."""
|
|
326
|
+
run(project, "push")
|
|
327
|
+
result = run(project, "doctor")
|
|
328
|
+
|
|
329
|
+
assert result.returncode == 0, f"{result.stdout}\n{result.stderr}"
|
|
330
|
+
assert "setup looks complete" in result.stdout
|
|
331
|
+
assert "roadmap-core" in result.stdout, "doctor must report the version it is"
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def test_doctor_catches_the_store_nobody_seeded(project):
|
|
335
|
+
"""THE failure this command exists for, and the reason a version of it that
|
|
336
|
+
only reports success would be worse than nothing.
|
|
337
|
+
|
|
338
|
+
A `local` store is empty until `push` seeds it, and every read command
|
|
339
|
+
answers from the store. So `validate` on this exact project says *"ok — 0
|
|
340
|
+
item(s), no problems"* and `ready` says the backlog is finished: green,
|
|
341
|
+
confident, and wrong, with a one-command remedy nothing suggests.
|
|
342
|
+
"""
|
|
343
|
+
# The premise: the other commands really are cheerful about it.
|
|
344
|
+
assert run(project, "validate").returncode == 0
|
|
345
|
+
assert "0 item(s)" in run(project, "validate").stdout
|
|
346
|
+
|
|
347
|
+
result = run(project, "doctor")
|
|
348
|
+
|
|
349
|
+
assert result.returncode == 1, f"doctor passed a broken setup:\n{result.stdout}"
|
|
350
|
+
assert "0 items while roadmap/items/ holds 1" in result.stderr
|
|
351
|
+
assert "roadmap push" in result.stderr, "a diagnosis without a remedy is half of one"
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def test_doctor_catches_being_pointed_at_the_wrong_directory(tmp_path):
|
|
355
|
+
"""The second failure `test_adoption.py`'s own docstring records: run from
|
|
356
|
+
somewhere with no project above it and every path still resolves, `push`
|
|
357
|
+
reports "no item files to push", and that reads as an empty backlog rather
|
|
358
|
+
than as a misconfiguration."""
|
|
359
|
+
elsewhere = tmp_path / "not-a-project"
|
|
360
|
+
elsewhere.mkdir()
|
|
361
|
+
|
|
362
|
+
result = run(elsewhere, "doctor")
|
|
363
|
+
|
|
364
|
+
assert result.returncode == 1
|
|
365
|
+
assert "repo root" in result.stderr
|
|
366
|
+
assert "roadmap/items" in result.stderr
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def test_the_version_is_answerable_without_a_subcommand(project):
|
|
370
|
+
"""Asked when two installs are suspected of disagreeing — a moment when
|
|
371
|
+
every subcommand is under suspicion too, so it must not need one."""
|
|
372
|
+
result = run(project, "--version")
|
|
373
|
+
|
|
374
|
+
assert result.returncode == 0
|
|
375
|
+
assert "roadmap-core" in result.stdout
|
|
@@ -180,3 +180,55 @@ def test_generated_markdown_names_the_console_script_this_package_installs():
|
|
|
180
180
|
arcs, by_key = _one_of_everything()
|
|
181
181
|
for text in (graph.render_markdown(by_key), graph.render_arcs_markdown(arcs, by_key)):
|
|
182
182
|
assert f"`{graph.CLI} sync`" in text
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# --- and the CLI's own messages, which the test above does not reach ---------
|
|
186
|
+
#
|
|
187
|
+
# `graph.CLI` fixed the headers *inside* the generated files. It did not fix the
|
|
188
|
+
# messages the CLI prints when it wants you to regenerate them — and those are
|
|
189
|
+
# strictly more likely to be read, because `sync --check` prints one every time
|
|
190
|
+
# the drift guard fires. An adopter upgrading to the release that removed
|
|
191
|
+
# `scripts/roadmap.py` from the generated files was told, by that same release,
|
|
192
|
+
# to run `python scripts/roadmap.py sync`.
|
|
193
|
+
#
|
|
194
|
+
# Parsed rather than imported: `roadmap_core.cli` is not importable in the
|
|
195
|
+
# isolation job, and the docstrings in that module discuss the extraction repo
|
|
196
|
+
# legitimately. Only strings on their way to a user are checked.
|
|
197
|
+
|
|
198
|
+
def _printed_strings(path):
|
|
199
|
+
"""Every string constant that reaches a `print(...)` call, f-strings included."""
|
|
200
|
+
import ast
|
|
201
|
+
|
|
202
|
+
tree = ast.parse(path.read_text())
|
|
203
|
+
out = []
|
|
204
|
+
|
|
205
|
+
def literals(node):
|
|
206
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
207
|
+
out.append(node.value)
|
|
208
|
+
elif isinstance(node, ast.JoinedStr):
|
|
209
|
+
for part in node.values:
|
|
210
|
+
if isinstance(part, ast.Constant) and isinstance(part.value, str):
|
|
211
|
+
out.append(part.value)
|
|
212
|
+
|
|
213
|
+
for node in ast.walk(tree):
|
|
214
|
+
if isinstance(node, ast.Call) and getattr(node.func, "id", None) == "print":
|
|
215
|
+
for arg in node.args:
|
|
216
|
+
literals(arg)
|
|
217
|
+
for sub in ast.walk(arg):
|
|
218
|
+
literals(sub)
|
|
219
|
+
return out
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_cli_messages_name_no_path_from_the_extraction_repo():
|
|
223
|
+
import importlib.util
|
|
224
|
+
from pathlib import Path
|
|
225
|
+
|
|
226
|
+
cli_path = Path(importlib.util.find_spec("roadmap_core.cli").origin)
|
|
227
|
+
printed = _printed_strings(cli_path)
|
|
228
|
+
assert printed, "parsed no printed strings — the AST walk stopped working"
|
|
229
|
+
for text in printed:
|
|
230
|
+
for bad in _EXTRACTION_REPO_PATHS:
|
|
231
|
+
assert bad not in text, (
|
|
232
|
+
f"cli.py prints {text!r}, naming {bad!r} — a path that exists only in "
|
|
233
|
+
f"the repository this package was extracted from. Use `graph.CLI`."
|
|
234
|
+
)
|
|
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
|