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.
Files changed (24) hide show
  1. {roadmap_core-0.2.0/roadmap_core.egg-info → roadmap_core-0.2.2}/PKG-INFO +26 -1
  2. {roadmap_core-0.2.0 → roadmap_core-0.2.2}/README.md +25 -0
  3. {roadmap_core-0.2.0 → roadmap_core-0.2.2}/pyproject.toml +1 -1
  4. {roadmap_core-0.2.0 → roadmap_core-0.2.2}/roadmap_core/cli.py +247 -4
  5. {roadmap_core-0.2.0 → roadmap_core-0.2.2/roadmap_core.egg-info}/PKG-INFO +26 -1
  6. {roadmap_core-0.2.0 → roadmap_core-0.2.2}/tests/test_adoption.py +62 -0
  7. {roadmap_core-0.2.0 → roadmap_core-0.2.2}/tests/test_graph.py +52 -0
  8. {roadmap_core-0.2.0 → roadmap_core-0.2.2}/LICENSE +0 -0
  9. {roadmap_core-0.2.0 → roadmap_core-0.2.2}/roadmap_core/__init__.py +0 -0
  10. {roadmap_core-0.2.0 → roadmap_core-0.2.2}/roadmap_core/graph.py +0 -0
  11. {roadmap_core-0.2.0 → roadmap_core-0.2.2}/roadmap_core/impact.py +0 -0
  12. {roadmap_core-0.2.0 → roadmap_core-0.2.2}/roadmap_core/store.py +0 -0
  13. {roadmap_core-0.2.0 → roadmap_core-0.2.2}/roadmap_core/stores.py +0 -0
  14. {roadmap_core-0.2.0 → roadmap_core-0.2.2}/roadmap_core.egg-info/SOURCES.txt +0 -0
  15. {roadmap_core-0.2.0 → roadmap_core-0.2.2}/roadmap_core.egg-info/dependency_links.txt +0 -0
  16. {roadmap_core-0.2.0 → roadmap_core-0.2.2}/roadmap_core.egg-info/entry_points.txt +0 -0
  17. {roadmap_core-0.2.0 → roadmap_core-0.2.2}/roadmap_core.egg-info/requires.txt +0 -0
  18. {roadmap_core-0.2.0 → roadmap_core-0.2.2}/roadmap_core.egg-info/top_level.txt +0 -0
  19. {roadmap_core-0.2.0 → roadmap_core-0.2.2}/setup.cfg +0 -0
  20. {roadmap_core-0.2.0 → roadmap_core-0.2.2}/templates/roadmap.yml +0 -0
  21. {roadmap_core-0.2.0 → roadmap_core-0.2.2}/tests/test_arcs.py +0 -0
  22. {roadmap_core-0.2.0 → roadmap_core-0.2.2}/tests/test_impact.py +0 -0
  23. {roadmap_core-0.2.0 → roadmap_core-0.2.2}/tests/test_store.py +0 -0
  24. {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.0
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.0"
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 `python scripts/roadmap.py sync`",
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 `python scripts/roadmap.py sync`",
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 `python scripts/roadmap.py sync` and commit"
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 `python scripts/roadmap.py sync` and commit")
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.0
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