roadmap-cli 0.2.0__tar.gz → 0.3.0__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 (113) hide show
  1. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/.gitignore +1 -0
  2. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/CHANGELOG.md +32 -0
  3. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/PKG-INFO +6 -3
  4. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/README.md +3 -1
  5. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/pyproject.toml +15 -5
  6. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/__init__.py +2 -2
  7. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/__init__.py +7 -7
  8. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/cleanup.py +62 -32
  9. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/cli_command_helpers.py +51 -1
  10. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/data/commands.py +88 -57
  11. roadmap_cli-0.3.0/roadmap/adapters/inbound/cli/exception_handler.py +37 -0
  12. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/health/commands.py +28 -15
  13. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/init/commands.py +21 -11
  14. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/issues/archive.py +27 -14
  15. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/issues/list.py +55 -14
  16. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/issues/query_presenter.py +44 -29
  17. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/issues/resolution.py +10 -15
  18. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/issues/restore.py +11 -6
  19. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/layout.py +6 -3
  20. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/migrate.py +32 -24
  21. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/milestones/archive.py +21 -16
  22. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/milestones/restore.py +5 -6
  23. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/milestones/view.py +39 -22
  24. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/models/output_models.py +42 -39
  25. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/output_formatter.py +27 -33
  26. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/planning_resolution.py +1 -15
  27. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/projects/archive.py +21 -16
  28. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/projects/list.py +28 -14
  29. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/projects/restore.py +5 -6
  30. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/status.py +60 -55
  31. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/outbound/persistence/canonical.py +43 -78
  32. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/outbound/persistence/configuration.py +47 -19
  33. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/outbound/persistence/diagnostics.py +157 -118
  34. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/outbound/persistence/documents.py +121 -89
  35. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/outbound/persistence/migration.py +167 -127
  36. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/application/ports.py +1 -1
  37. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/application/use_cases/issue_mutations.py +83 -37
  38. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/application/use_cases/issues.py +7 -6
  39. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/application/use_cases/planning.py +116 -40
  40. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/domain/aggregates.py +30 -16
  41. roadmap_cli-0.2.0/roadmap/adapters/inbound/cli/exception_handler.py +0 -94
  42. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/LICENSE.md +0 -0
  43. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/__init__.py +0 -0
  44. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/__init__.py +0 -0
  45. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/analysis/__init__.py +0 -0
  46. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/analysis/commands.py +0 -0
  47. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/analysis/presenter.py +0 -0
  48. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/column_factory.py +0 -0
  49. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/config/__init__.py +0 -0
  50. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/config/commands.py +0 -0
  51. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/console.py +0 -0
  52. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/daily_summary.py +0 -0
  53. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/data/__init__.py +0 -0
  54. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/datetime_parser.py +0 -0
  55. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/decorators.py +0 -0
  56. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/git/__init__.py +0 -0
  57. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/git/commands.py +0 -0
  58. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/health/__init__.py +0 -0
  59. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/init/__init__.py +0 -0
  60. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/issues/__init__.py +0 -0
  61. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/issues/block.py +0 -0
  62. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/issues/close.py +0 -0
  63. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/issues/comment.py +0 -0
  64. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/issues/create.py +0 -0
  65. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/issues/delete.py +0 -0
  66. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/issues/deps.py +0 -0
  67. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/issues/progress.py +0 -0
  68. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/issues/start.py +0 -0
  69. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/issues/unblock.py +0 -0
  70. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/issues/update.py +0 -0
  71. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/issues/view.py +0 -0
  72. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/milestones/__init__.py +0 -0
  73. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/milestones/assign.py +0 -0
  74. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/milestones/close.py +0 -0
  75. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/milestones/create.py +0 -0
  76. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/milestones/delete.py +0 -0
  77. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/milestones/kanban.py +0 -0
  78. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/milestones/list.py +0 -0
  79. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/milestones/recalculate.py +0 -0
  80. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/milestones/update.py +0 -0
  81. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/models/__init__.py +0 -0
  82. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/output_options.py +0 -0
  83. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/projects/__init__.py +0 -0
  84. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/projects/close.py +0 -0
  85. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/projects/create.py +0 -0
  86. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/projects/delete.py +0 -0
  87. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/projects/update.py +0 -0
  88. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/projects/view.py +0 -0
  89. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/styling.py +0 -0
  90. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/inbound/cli/today.py +0 -0
  91. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/outbound/__init__.py +0 -0
  92. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/outbound/git/__init__.py +0 -0
  93. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/outbound/git/local.py +0 -0
  94. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/outbound/persistence/__init__.py +0 -0
  95. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/outbound/persistence/initialization.py +0 -0
  96. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/outbound/persistence/issue_queries.py +0 -0
  97. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/outbound/persistence/projection.py +0 -0
  98. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/adapters/outbound/system.py +0 -0
  99. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/application/__init__.py +0 -0
  100. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/application/configuration.py +0 -0
  101. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/application/contracts.py +0 -0
  102. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/application/failures.py +0 -0
  103. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/application/use_cases/__init__.py +0 -0
  104. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/application/use_cases/health.py +0 -0
  105. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/application/use_cases/local_git.py +0 -0
  106. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/application/use_cases/workspace_initialization.py +0 -0
  107. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/application/use_cases/workspace_migration.py +0 -0
  108. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/bootstrap/__init__.py +0 -0
  109. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/bootstrap/core.py +0 -0
  110. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/domain/__init__.py +0 -0
  111. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/domain/failures.py +0 -0
  112. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/domain/transitions.py +0 -0
  113. {roadmap_cli-0.2.0 → roadmap_cli-0.3.0}/roadmap/domain/types.py +0 -0
@@ -254,6 +254,7 @@ test_sync_init/*.lock
254
254
  /tmp/test_check_sync/*.tmp
255
255
  /tmp/test_check_sync/*.lock
256
256
  sync_git_state.txt
257
+ /output.txt
257
258
 
258
259
  # design notes, AI prompts, etc.
259
260
 
@@ -6,8 +6,40 @@ All notable changes to Roadmap CLI are recorded here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.0] - 2026-09-02
10
+
11
+ ### Changed
12
+
13
+ - Migrated static type checking from Pyright-only to `ty` running alongside
14
+ Pyright, both enforced in CI and pre-commit; aligned their configured
15
+ Python-version floor with the declared `>=3.12` support range.
16
+ - Reduced cyclomatic complexity across the codebase and enforced a
17
+ xenon B-or-better gate (absolute, per-module, and average) in CI and
18
+ pre-commit.
19
+ - Raised the enforced test coverage floor from 78% to 85%, backed by a
20
+ targeted test-coverage sweep (CLI output formatting and issue query
21
+ presentation) that reached 85.70%.
22
+ - Reduced DRY violations identified during the complexity-reduction pass.
23
+ - Enabled GitHub's native Dependabot vulnerability alerts for the repository.
24
+
25
+ ### Fixed
26
+
27
+ - Removed the stale, inaccurate `.env.production` file (unverifiable "0 known
28
+ CVEs" claim, an obsolete `poetry install` reference, and unused
29
+ environment variables that nothing in the codebase reads).
30
+ - Removed the stale, tracked `output.txt` pytest-output artifact from version
31
+ control.
32
+ - Fixed a broken `SECURITY.md` reference to a nonexistent developer-notes
33
+ file; it now points to the ADR that documents credential handling.
34
+
35
+ ### Removed
36
+
37
+ - Removed `.env.production` and `output.txt` from version control (see
38
+ Fixed, above).
39
+
9
40
  ## [0.2.0] - 2026-08-27
10
41
 
42
+
11
43
  ### Added
12
44
 
13
45
  - Added explicit `roadmap migrate` preflight, dry-run, confirmed execution,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: roadmap-cli
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Git-native command line tool for local project roadmap management
5
5
  Project-URL: Homepage, https://github.com/shanewilkins/roadmap
6
6
  Project-URL: Repository, https://github.com/shanewilkins/roadmap
@@ -43,7 +43,8 @@ Requires-Dist: pytest-mock>=3.15.1; extra == 'dev'
43
43
  Requires-Dist: pytest-xdist>=3.8.0; extra == 'dev'
44
44
  Requires-Dist: pytest>=9.0.3; extra == 'dev'
45
45
  Requires-Dist: radon<7.0.0,>=6.0.1; extra == 'dev'
46
- Requires-Dist: ruff==0.16.4; extra == 'dev'
46
+ Requires-Dist: ruff==0.16.5; extra == 'dev'
47
+ Requires-Dist: ty>=0.0.77; extra == 'dev'
47
48
  Requires-Dist: xenon<1.0.0,>=0.9.3; extra == 'dev'
48
49
  Description-Content-Type: text/markdown
49
50
 
@@ -54,9 +55,11 @@ their planning data in reviewable Markdown and YAML files rather than a hosted
54
55
  service. It supports issues, dependencies, comments, projects, milestones,
55
56
  daily views, health checks, and machine-readable exports.
56
57
 
57
- The current release is 0.2.0. Its deliberately contracted behavior is defined
58
+ The current release is 0.3.0. Its deliberately contracted behavior is defined
58
59
  by an explicit
59
60
  [public compatibility contract](docs/architecture/public-contract-0.2.md).
61
+ 0.3 is an internal quality-hardening release; the 0.2 CLI and data contract is
62
+ unchanged.
60
63
 
61
64
  ## What it does
62
65
 
@@ -5,9 +5,11 @@ their planning data in reviewable Markdown and YAML files rather than a hosted
5
5
  service. It supports issues, dependencies, comments, projects, milestones,
6
6
  daily views, health checks, and machine-readable exports.
7
7
 
8
- The current release is 0.2.0. Its deliberately contracted behavior is defined
8
+ The current release is 0.3.0. Its deliberately contracted behavior is defined
9
9
  by an explicit
10
10
  [public compatibility contract](docs/architecture/public-contract-0.2.md).
11
+ 0.3 is an internal quality-hardening release; the 0.2 CLI and data contract is
12
+ unchanged.
11
13
 
12
14
  ## What it does
13
15
 
@@ -13,7 +13,7 @@ build-backend = "hatchling.build"
13
13
  # Installs the locked runtime and development dependency sets.
14
14
  # ============================================================================
15
15
  name = "roadmap-cli"
16
- version = "0.2.0"
16
+ version = "0.3.0"
17
17
  description = "Git-native command line tool for local project roadmap management"
18
18
  readme = "README.md"
19
19
  requires-python = ">=3.12,<4.0"
@@ -61,7 +61,7 @@ dev = [
61
61
  "pytest-cov>=7.1.0",
62
62
  "pytest-asyncio>=1.3.0",
63
63
  "pytest-aiohttp>=1.1.0",
64
- "ruff==0.16.4",
64
+ "ruff==0.16.5",
65
65
  "pre-commit>=4.6.0",
66
66
  "bandit[toml]>=1.9.4",
67
67
  "radon>=6.0.1,<7.0.0",
@@ -70,6 +70,7 @@ dev = [
70
70
  "pytest-benchmark>=5.2.3",
71
71
  "pyright>=1.1.411",
72
72
  "pytest-mock>=3.15.1",
73
+ "ty>=0.0.77",
73
74
  ]
74
75
 
75
76
  [project.urls]
@@ -112,10 +113,19 @@ include = [
112
113
  # - ruff.toml: Linting and formatting rules
113
114
  # - bandit.toml: Security scanning configuration
114
115
  # - .coveragerc: Test coverage thresholds and exclusions
115
- # - pyrightconfig.json: Type checking configuration
116
+ # - pyrightconfig.json: Type checking configuration (current)
116
117
 
117
- # Type checking is now handled by Pyright
118
- # Configuration is in pyrightconfig.json
118
+ # ty runs alongside Pyright and is enforced in CI and pre-commit (0.3 quality
119
+ # cleanup, see notes/path_to_1_point_0.md).
120
+ [tool.ty.src]
121
+ include = ["roadmap", "tests"]
122
+ # These fixture trees are synthetic "roadmap" packages used only for the
123
+ # architecture checker's AST scan; they are never actually imported, so
124
+ # ty's import resolution has nothing real to check them against.
125
+ exclude = ["tests/policy/fixtures/architecture"]
126
+
127
+ [tool.ty.environment]
128
+ python-version = "3.12"
119
129
 
120
130
  [tool.pytest.ini_options]
121
131
  testpaths = ["tests"]
@@ -4,10 +4,10 @@ from importlib.metadata import PackageNotFoundError, version
4
4
 
5
5
  # Installed artifacts read their distribution metadata; source checkouts use the
6
6
  # matching fallback without importing the executable adapter as a side effect.
7
- __version__ = "0.2.0"
7
+ __version__ = "0.3.0"
8
8
  try:
9
9
  __version__ = version("roadmap-cli")
10
10
  except PackageNotFoundError:
11
- __version__ = "0.2.0"
11
+ __version__ = "0.3.0"
12
12
 
13
13
  __all__ = ["__version__"]
@@ -96,21 +96,21 @@ class RoadmapClickGroup(click.Group):
96
96
  with formatter.section("Commands"):
97
97
  formatter.write_dl(rows)
98
98
 
99
- def get_command(self, ctx: click.Context, name: str) -> click.Command | None:
99
+ def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
100
100
  """Load one registered command only when Click requests it."""
101
- if (attached := super().get_command(ctx, name)) is not None:
101
+ if (attached := super().get_command(ctx, cmd_name)) is not None:
102
102
  return attached
103
- if name in self._command_cache:
104
- return self._command_cache[name]
105
- location = self._command_registry.get(name)
103
+ if cmd_name in self._command_cache:
104
+ return self._command_cache[cmd_name]
105
+ location = self._command_registry.get(cmd_name)
106
106
  if location is None:
107
107
  return None
108
108
  module_path, attribute, _help = location
109
109
  try:
110
- self._command_cache[name] = command = getattr(importlib.import_module(module_path), attribute) # fmt: skip
110
+ self._command_cache[cmd_name] = command = getattr(importlib.import_module(module_path), attribute) # fmt: skip
111
111
  except Exception as error:
112
112
  self._console_factory().print(
113
- f"⚠️ Failed to load command '{name}': {error}", style="yellow"
113
+ f"⚠️ Failed to load command '{cmd_name}': {error}", style="yellow"
114
114
  )
115
115
  return None
116
116
  return command
@@ -11,25 +11,40 @@ import click
11
11
  from roadmap.adapters.inbound.cli.cli_command_helpers import require_initialized
12
12
 
13
13
 
14
- def _candidates(
15
- backups_dir: Path, keep: int, days: int | None, now: datetime
16
- ) -> tuple[Path, ...]:
14
+ def _validate_retention_options(keep: int, days: int | None) -> None:
17
15
  if keep < 0:
18
16
  raise click.BadParameter("keep must be non-negative", param_hint="--keep")
19
17
  if days is not None and days < 0:
20
18
  raise click.BadParameter("days must be non-negative", param_hint="--days")
19
+
20
+
21
+ def _group_backups(backups_dir: Path) -> dict[str, list[Path]]:
21
22
  grouped: dict[str, list[Path]] = defaultdict(list)
22
23
  for path in backups_dir.glob("*.backup.md") if backups_dir.exists() else ():
23
24
  parts = path.stem.split("_")
24
25
  grouped["_".join(parts[:-1])].append(path)
26
+ return grouped
27
+
28
+
29
+ def _stale_paths(paths: list[Path], keep: int, cutoff: datetime | None) -> list[Path]:
30
+ ordered = sorted(paths, key=lambda path: (-path.stat().st_mtime, path.name))
31
+ stale: list[Path] = []
32
+ for index, path in enumerate(ordered):
33
+ modified = datetime.fromtimestamp(path.stat().st_mtime, tz=UTC)
34
+ if index >= keep or (cutoff is not None and modified < cutoff):
35
+ stale.append(path)
36
+ return stale
37
+
38
+
39
+ def _candidates(
40
+ backups_dir: Path, keep: int, days: int | None, now: datetime
41
+ ) -> tuple[Path, ...]:
42
+ _validate_retention_options(keep, days)
43
+ grouped = _group_backups(backups_dir)
25
44
  cutoff = now - timedelta(days=days) if days is not None else None
26
45
  selected: list[Path] = []
27
46
  for paths in grouped.values():
28
- ordered = sorted(paths, key=lambda path: (-path.stat().st_mtime, path.name))
29
- for index, path in enumerate(ordered):
30
- modified = datetime.fromtimestamp(path.stat().st_mtime, tz=UTC)
31
- if index >= keep or (cutoff is not None and modified < cutoff):
32
- selected.append(path)
47
+ selected.extend(_stale_paths(paths, keep, cutoff))
33
48
  return tuple(sorted(selected))
34
49
 
35
50
 
@@ -40,6 +55,39 @@ def _diagnostic_matches(core, finding_id: str) -> tuple[str, ...]:
40
55
  )
41
56
 
42
57
 
58
+ def _selected_diagnostic_check(
59
+ check_folders: bool, check_duplicates: bool, check_malformed: bool
60
+ ) -> str | None:
61
+ checks = {
62
+ "canonical.noncanonical-path": check_folders,
63
+ "canonical.duplicate-id": check_duplicates,
64
+ "canonical.invalid": check_malformed,
65
+ }
66
+ selected = [name for name, enabled in checks.items() if enabled]
67
+ if len(selected) > 1:
68
+ raise click.UsageError("select only one --check-* option")
69
+ return selected[0] if selected else None
70
+
71
+
72
+ def _report_diagnostic_matches(core, finding_id: str) -> None:
73
+ matches = _diagnostic_matches(core, finding_id)
74
+ click.echo("\n".join(matches) + ("\n" if matches else "No findings.\n"), nl=False)
75
+
76
+
77
+ def _remove_backups(candidates: tuple[Path, ...], relative: tuple[str, ...]) -> None:
78
+ failures: list[str] = []
79
+ for path, label in zip(candidates, relative, strict=True):
80
+ try:
81
+ path.unlink()
82
+ except OSError as error:
83
+ failures.append(f"{label}: {error}")
84
+ if failures:
85
+ raise click.ClickException(
86
+ "Backup cleanup was incomplete:\n" + "\n".join(failures)
87
+ )
88
+ click.echo(f"Removed {len(candidates)} backup file(s).")
89
+
90
+
43
91
  @click.command()
44
92
  @click.option("--keep", type=int, default=10, show_default=True)
45
93
  @click.option("--days", type=int)
@@ -66,19 +114,11 @@ def cleanup(
66
114
  ) -> None:
67
115
  """Preview or remove only retention-qualified legacy backup files."""
68
116
  del backups_only, verbose
69
- checks = {
70
- "canonical.noncanonical-path": check_folders,
71
- "canonical.duplicate-id": check_duplicates,
72
- "canonical.invalid": check_malformed,
73
- }
74
- selected_checks = [name for name, enabled in checks.items() if enabled]
75
- if len(selected_checks) > 1:
76
- raise click.UsageError("select only one --check-* option")
77
- if selected_checks:
78
- matches = _diagnostic_matches(ctx.obj["core"], selected_checks[0])
79
- click.echo(
80
- "\n".join(matches) + ("\n" if matches else "No findings.\n"), nl=False
81
- )
117
+ diagnostic_check = _selected_diagnostic_check(
118
+ check_folders, check_duplicates, check_malformed
119
+ )
120
+ if diagnostic_check is not None:
121
+ _report_diagnostic_matches(ctx.obj["core"], diagnostic_check)
82
122
  return
83
123
  roadmap_dir = ctx.obj["core"].roadmap_dir
84
124
  candidates = _candidates(roadmap_dir / "backups", keep, days, datetime.now(UTC))
@@ -94,14 +134,4 @@ def cleanup(
94
134
  return
95
135
  if not force:
96
136
  click.confirm("Remove exactly these backup files?", abort=True, default=False)
97
- failures: list[str] = []
98
- for path, label in zip(candidates, relative, strict=True):
99
- try:
100
- path.unlink()
101
- except OSError as error:
102
- failures.append(f"{label}: {error}")
103
- if failures:
104
- raise click.ClickException(
105
- "Backup cleanup was incomplete:\n" + "\n".join(failures)
106
- )
107
- click.echo(f"Removed {len(candidates)} backup file(s).")
137
+ _remove_backups(candidates, relative)
@@ -9,17 +9,67 @@ Provides decorators and functions to reduce duplication in CLI commands:
9
9
 
10
10
  import functools
11
11
  import sys
12
- from collections.abc import Callable
12
+ from collections.abc import Callable, Sequence
13
13
  from typing import Any, TypeVar
14
14
 
15
15
  import click # type: ignore[import-not-found]
16
16
  from rich.console import Console # type: ignore[import-not-found]
17
17
 
18
+ from roadmap.application.failures import ApplicationFailure
19
+ from roadmap.domain.failures import DomainFailure
20
+
18
21
  console = Console()
19
22
 
20
23
  F = TypeVar("F", bound=Callable[..., Any])
21
24
 
22
25
 
26
+ def invoke(operation: Callable[[], Any]) -> Any:
27
+ """Translate stable application/domain failures into Click failures."""
28
+ try:
29
+ return operation()
30
+ except (ApplicationFailure, DomainFailure, ValueError) as error:
31
+ raise click.ClickException(str(error)) from error
32
+
33
+
34
+ def projection_warning(result: Any) -> None:
35
+ if getattr(result, "projection_stale", False):
36
+ click.echo("Warning: SQLite projection is stale; canonical Markdown was saved.")
37
+
38
+
39
+ def echo_batch_result(
40
+ noun: str,
41
+ items: Sequence[Any],
42
+ dry_run: bool,
43
+ *,
44
+ action: str,
45
+ label: Callable[[Any], str] = lambda item: str(item.name),
46
+ ) -> None:
47
+ """Echo the "<verb> <noun> <id>: <label>" summary shared by batch
48
+ archive/restore commands. ``action`` is the base verb (e.g. "archive",
49
+ "restore"); both currently used verbs conjugate regularly to their past
50
+ tense for the non-dry-run wording.
51
+ """
52
+ past_tense = action + ("d" if action.endswith("e") else "ed")
53
+ verb = f"Would {action}" if dry_run else past_tense.capitalize()
54
+ for item in items:
55
+ click.echo(f"{verb} {noun} {item.id}: {label(item)}")
56
+ if not items:
57
+ click.echo(f"No matching {noun}s.")
58
+
59
+
60
+ def echo_archived_list(
61
+ noun: str,
62
+ values: Sequence[Any],
63
+ label: Callable[[Any], str] = lambda item: str(item.name),
64
+ ) -> None:
65
+ """Echo the archived-entity listing shared by the "--list" branch of
66
+ the project/milestone archive commands."""
67
+ if not values:
68
+ click.echo(f"No archived {noun}s.")
69
+ for item in values:
70
+ click.echo(f"{item.id} {label(item)}")
71
+
72
+
23
73
  def require_initialized(func: Callable) -> Callable:
24
74
  """Require roadmap to be initialized before command execution.
25
75
 
@@ -74,40 +74,62 @@ def export(
74
74
  click.echo(f"Exported {len(records)} issues to {output}", err=True)
75
75
 
76
76
 
77
- def _query(value: str | None) -> IssueListQuery:
78
- if value is None:
79
- return IssueListQuery(scope=IssueScope.ALL)
77
+ def _validate_filter_string(value: str) -> tuple[str, str]:
80
78
  if "=" not in value:
81
79
  raise click.BadParameter("filter must use key=value", param_hint="--filter")
82
80
  key, expected = (part.strip() for part in value.split("=", 1))
83
81
  if not key or not expected:
84
82
  raise click.BadParameter("filter must use key=value", param_hint="--filter")
85
- if key == "status":
86
- return IssueListQuery(
87
- scope=IssueScope.ALL,
88
- open_only=expected == "open",
89
- status=None if expected == "open" else expected,
90
- )
91
- if key == "priority":
92
- return IssueListQuery(scope=IssueScope.ALL, priority=expected)
93
- if key == "issue_type":
94
- return IssueListQuery(scope=IssueScope.ALL, issue_type=expected)
95
- if key == "assignee":
96
- return IssueListQuery(scope=IssueScope.ALL, assignee=expected)
97
- if key == "retention":
98
- try:
99
- return IssueListQuery(scope=IssueScope(expected))
100
- except ValueError as error:
101
- raise click.BadParameter(
102
- "retention must be visible, closed, archived, or all",
103
- param_hint="--filter",
104
- ) from error
105
- raise click.BadParameter(
106
- "supported filter keys are status, priority, issue_type, assignee, and retention",
107
- param_hint="--filter",
83
+ return key, expected
84
+
85
+
86
+ def _retention_query(expected: str) -> IssueListQuery:
87
+ try:
88
+ return IssueListQuery(scope=IssueScope(expected))
89
+ except ValueError as error:
90
+ raise click.BadParameter(
91
+ "retention must be visible, closed, archived, or all",
92
+ param_hint="--filter",
93
+ ) from error
94
+
95
+
96
+ def _status_query(expected: str) -> IssueListQuery:
97
+ return IssueListQuery(
98
+ scope=IssueScope.ALL,
99
+ open_only=expected == "open",
100
+ status=None if expected == "open" else expected,
108
101
  )
109
102
 
110
103
 
104
+ _FILTER_BUILDERS = {
105
+ "status": _status_query,
106
+ "priority": lambda expected: IssueListQuery(
107
+ scope=IssueScope.ALL, priority=expected
108
+ ),
109
+ "issue_type": lambda expected: IssueListQuery(
110
+ scope=IssueScope.ALL, issue_type=expected
111
+ ),
112
+ "assignee": lambda expected: IssueListQuery(
113
+ scope=IssueScope.ALL, assignee=expected
114
+ ),
115
+ "retention": _retention_query,
116
+ }
117
+
118
+
119
+ def _query(value: str | None) -> IssueListQuery:
120
+ if value is None:
121
+ return IssueListQuery(scope=IssueScope.ALL)
122
+ key, expected = _validate_filter_string(value)
123
+ builder = _FILTER_BUILDERS.get(key)
124
+ if builder is None:
125
+ raise click.BadParameter(
126
+ "supported filter keys are status, priority, issue_type, assignee, "
127
+ "and retention",
128
+ param_hint="--filter",
129
+ )
130
+ return builder(expected)
131
+
132
+
111
133
  def _timestamp(value: Any) -> str | None:
112
134
  return None if value is None else value.value.isoformat()
113
135
 
@@ -157,38 +179,39 @@ def _issue_record(record: IssueQueryRecord) -> dict[str, Any]:
157
179
  }
158
180
 
159
181
 
160
- def _render(records: tuple[IssueQueryRecord, ...], format_name: str) -> str:
161
- rows = [_issue_record(record) for record in records]
162
- if format_name == "json":
163
- return (
164
- json.dumps(
165
- {
166
- "schema_version": EXPORT_SCHEMA_VERSION,
167
- "kind": "roadmap.issue-export",
168
- "issues": rows,
169
- },
170
- ensure_ascii=False,
171
- indent=2,
172
- sort_keys=True,
173
- )
174
- + "\n"
182
+ def _render_json(rows: list[dict[str, Any]]) -> str:
183
+ return (
184
+ json.dumps(
185
+ {
186
+ "schema_version": EXPORT_SCHEMA_VERSION,
187
+ "kind": "roadmap.issue-export",
188
+ "issues": rows,
189
+ },
190
+ ensure_ascii=False,
191
+ indent=2,
192
+ sort_keys=True,
193
+ )
194
+ + "\n"
195
+ )
196
+
197
+
198
+ def _render_csv(rows: list[dict[str, Any]]) -> str:
199
+ stream = io.StringIO(newline="")
200
+ writer = csv.DictWriter(stream, fieldnames=CSV_FIELDS, lineterminator="\n")
201
+ writer.writeheader()
202
+ for row in rows:
203
+ writer.writerow(
204
+ {
205
+ field: json.dumps(row[field], ensure_ascii=False, separators=(",", ":"))
206
+ if field in {"labels", "depends_on", "blocks"}
207
+ else row[field]
208
+ for field in CSV_FIELDS
209
+ }
175
210
  )
176
- if format_name == "csv":
177
- stream = io.StringIO(newline="")
178
- writer = csv.DictWriter(stream, fieldnames=CSV_FIELDS, lineterminator="\n")
179
- writer.writeheader()
180
- for row in rows:
181
- writer.writerow(
182
- {
183
- field: json.dumps(
184
- row[field], ensure_ascii=False, separators=(",", ":")
185
- )
186
- if field in {"labels", "depends_on", "blocks"}
187
- else row[field]
188
- for field in CSV_FIELDS
189
- }
190
- )
191
- return stream.getvalue()
211
+ return stream.getvalue()
212
+
213
+
214
+ def _render_markdown(rows: list[dict[str, Any]]) -> str:
192
215
  lines = [
193
216
  "---",
194
217
  f"schema_version: {EXPORT_SCHEMA_VERSION}",
@@ -211,5 +234,13 @@ def _render(records: tuple[IssueQueryRecord, ...], format_name: str) -> str:
211
234
  return "\n".join(lines) + "\n"
212
235
 
213
236
 
237
+ _RENDERERS = {"json": _render_json, "csv": _render_csv, "markdown": _render_markdown}
238
+
239
+
240
+ def _render(records: tuple[IssueQueryRecord, ...], format_name: str) -> str:
241
+ rows = [_issue_record(record) for record in records]
242
+ return _RENDERERS[format_name](rows)
243
+
244
+
214
245
  def _markdown(value: object) -> str:
215
246
  return str(value).replace("|", "\\|").replace("\n", "<br>")
@@ -0,0 +1,37 @@
1
+ """Centralized exception handling for CLI commands.
2
+
3
+ This module provides error handling and formatting for all exceptions
4
+ raised by CLI commands, ensuring consistent user-facing error messages
5
+ and proper stderr output with correct exit codes.
6
+ """
7
+
8
+ import click
9
+
10
+ from roadmap.adapters.inbound.cli.console import get_console_stderr
11
+ from roadmap.application.failures import ApplicationFailure
12
+ from roadmap.domain.failures import DomainFailure
13
+
14
+
15
+ def handle_cli_exception(
16
+ ctx: click.Context, error: Exception, show_traceback: bool = False
17
+ ) -> None:
18
+ """Centralized exception handler for CLI commands.
19
+
20
+ Catches RoadmapException instances and formats them for user output,
21
+ directing errors to stderr with proper exit codes. Other exceptions
22
+ are re-raised or logged as needed.
23
+
24
+ Args:
25
+ ctx: Click context
26
+ error: The exception to handle
27
+ show_traceback: Whether to show full traceback (for debugging)
28
+ """
29
+ stderr_console = get_console_stderr()
30
+
31
+ if isinstance(error, ApplicationFailure | DomainFailure | ValueError):
32
+ stderr_console.print(f"Error: {error}")
33
+ else:
34
+ stderr_console.print(f"Error: {error}")
35
+ if show_traceback:
36
+ stderr_console.print_exception()
37
+ ctx.exit(1)
@@ -62,7 +62,7 @@ def _render(
62
62
  writer = csv.DictWriter(stream, fieldnames=fields, lineterminator="\n")
63
63
  writer.writeheader()
64
64
  if not summary_only:
65
- writer.writerows(payload["findings"]) # type: ignore[arg-type]
65
+ writer.writerows(payload["findings"]) # type: ignore[arg-type, ty:invalid-argument-type]
66
66
  return stream.getvalue()
67
67
  lines = [f"Roadmap health: {payload['status']}"]
68
68
  if summary_only:
@@ -79,6 +79,30 @@ def _render(
79
79
  return "\n".join(lines) + "\n"
80
80
 
81
81
 
82
+ def _filter_by_entity(findings: tuple, entities: tuple[str, ...]) -> tuple:
83
+ if not entities:
84
+ return findings
85
+ selected = {value.casefold() for value in entities}
86
+ return tuple(
87
+ item for item in findings if item.scope.split("/", 1)[0].rstrip("s") in selected
88
+ )
89
+
90
+
91
+ def _filter_by_severity(findings: tuple, severities: tuple[str, ...]) -> tuple:
92
+ if not severities:
93
+ return findings
94
+ selected = {value.casefold() for value in severities}
95
+ return tuple(item for item in findings if item.severity.value in selected)
96
+
97
+
98
+ def _filter_dependencies(findings: tuple, *, dependencies: bool) -> tuple:
99
+ if dependencies:
100
+ return findings
101
+ return tuple(
102
+ item for item in findings if item.finding_id != "canonical.broken-reference"
103
+ )
104
+
105
+
82
106
  def _filtered(
83
107
  report: HealthReport,
84
108
  entities: tuple[str, ...] = (),
@@ -87,20 +111,9 @@ def _filtered(
87
111
  dependencies: bool = True,
88
112
  ) -> HealthReport:
89
113
  findings = report.findings
90
- if entities:
91
- selected = {value.casefold() for value in entities}
92
- findings = tuple(
93
- item
94
- for item in findings
95
- if item.scope.split("/", 1)[0].rstrip("s") in selected
96
- )
97
- if severities:
98
- selected = {value.casefold() for value in severities}
99
- findings = tuple(item for item in findings if item.severity.value in selected)
100
- if not dependencies:
101
- findings = tuple(
102
- item for item in findings if item.finding_id != "canonical.broken-reference"
103
- )
114
+ findings = _filter_by_entity(findings, entities)
115
+ findings = _filter_by_severity(findings, severities)
116
+ findings = _filter_dependencies(findings, dependencies=dependencies)
104
117
  return HealthReport(findings)
105
118
 
106
119