feat: add DiagnosticCollector for structured install diagnostics#267
feat: add DiagnosticCollector for structured install diagnostics#267danielmeppiel merged 2 commits intomainfrom
Conversation
Introduce a DiagnosticCollector pattern that collects warnings and errors during 'apm install' and renders a clean, grouped summary at the end instead of printing inline noise. Changes: - Add DiagnosticCollector (src/apm_cli/utils/diagnostics.py) with category-specific recording (skip, overwrite, warn, error), grouped rendering (normal counts vs verbose details), and thread safety - Update BaseIntegrator.check_collision() to accept optional diagnostics parameter — routes through collector when present, _rich_warning() fallback when absent (backward compatible) - Update all integrator integrate_package_*() methods with optional diagnostics parameter forwarded to check_collision() - Wire DiagnosticCollector into install.py — create at start, pass to all 18 integrator call sites, route exception handlers through collector, render summary before install summary - Add 28 unit tests for DiagnosticCollector covering recording, query helpers, rendering, thread safety, and grouping - Add 7 integrator tests verifying collector vs fallback behavior - Update docs for install diagnostic summary and --verbose behavior Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds a structured “collect then summarize” diagnostics flow for apm install, so collisions/warnings/errors can be recorded during integration and rendered as a grouped summary at the end of the install.
Changes:
- Introduces
DiagnosticCollector/Diagnosticand category constants, plus grouped rendering logic. - Threads an optional
diagnosticsparameter through integrators andBaseIntegrator.check_collision(), and wires a collector into the install engine. - Adds unit/integration test coverage and updates CLI/dependencies documentation to describe the new diagnostic summary behavior.
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/unit/test_install_command.py | Updates mocks for _install_apm_dependencies() to return the new diagnostics object. |
| tests/unit/test_diagnostics.py | Adds unit tests for recording, querying, rendering, grouping, and thread-safety behavior of DiagnosticCollector. |
| tests/unit/integration/test_deployed_files_manifest.py | Updates collision-output assertions and adds tests for BaseIntegrator.check_collision(..., diagnostics=...). |
| src/apm_cli/utils/diagnostics.py | New diagnostics collector implementation (recording, grouping, summary rendering). |
| src/apm_cli/utils/init.py | Exposes diagnostics types/constants via apm_cli.utils. |
| src/apm_cli/integration/skill_integrator.py | Adds optional diagnostics support for sub-skill overwrite and skill-name normalization warnings. |
| src/apm_cli/integration/prompt_integrator.py | Passes diagnostics through collision checks for prompts. |
| src/apm_cli/integration/instruction_integrator.py | Passes diagnostics through collision checks for instructions. |
| src/apm_cli/integration/hook_integrator.py | Passes diagnostics through collision checks for hooks and hook scripts. |
| src/apm_cli/integration/command_integrator.py | Passes diagnostics through collision checks for commands. |
| src/apm_cli/integration/base_integrator.py | Adds diagnostics parameter to check_collision() and routes collision reporting to diagnostics vs _rich_warning. |
| src/apm_cli/integration/agent_integrator.py | Passes diagnostics through collision checks for agents (both .github and .claude). |
| src/apm_cli/commands/install.py | Instantiates a collector, passes it into multiple integrator calls, records install/integration failures into diagnostics, and renders the summary at the end. |
| docs/src/content/docs/reference/cli-commands.md | Updates --verbose description and documents the new diagnostic summary behavior for apm install. |
| docs/src/content/docs/guides/dependencies.md | Adds troubleshooting guidance for file conflicts and clarifies apm install --verbose behavior. |
Comments suppressed due to low confidence (1)
src/apm_cli/commands/install.py:1214
- Same issue on this code path: diagnostics are passed into prompt/agent/etc., but the corresponding
integrate_package_skill()invocation in the same try-block does not receive the collector. Please passdiagnostics=diagnosticsthere as well so skill-related diagnostics are captured consistently.
prompt_integrator.integrate_package_prompts(
package_info, project_root,
force=force, managed_files=managed_files,
diagnostics=diagnostics,
)
)
if prompt_result.files_integrated > 0:
| @@ -75,11 +77,13 @@ def check_collision( | |||
| if force: | |||
| return False | |||
|
|
|||
| print( | |||
| f"\u26a0\ufe0f Skipping {rel_path} \u2014 local file exists (not managed by APM). " | |||
| f"Use 'apm install --force' to overwrite.", | |||
| file=sys.stderr, | |||
| ) | |||
| if diagnostics is not None: | |||
| diagnostics.skip(rel_path) | |||
| else: | |||
| _rich_warning( | |||
| f"Skipping {rel_path} — local file exists (not managed by APM). " | |||
| f"Use 'apm install --force' to overwrite." | |||
| ) | |||
There was a problem hiding this comment.
When routing collisions into diagnostics, the recorded entry does not include the package name. In verbose mode, _render_collision_group() groups by package, so collisions collected via check_collision() will all fall under the empty-package bucket and won’t match the "grouped by package" behavior described in the PR. Consider extending check_collision() to accept an optional package (or package_name) and pass it from integrators (e.g., package_info.package.name).
| diagnostics.warn( | ||
| f"Skill name '{raw_skill_name}' normalized to '{skill_name}' ({error_msg})", | ||
| package=raw_skill_name, |
There was a problem hiding this comment.
The diagnostic is recorded with package=raw_skill_name (the on-disk folder name), which may not match the package identifier used elsewhere in the install diagnostics (typically package_info.package.name / repo key). This will make grouping inconsistent. Consider using the actual package name/key for the package field, and keep the raw/normalized skill name in the message/detail instead.
| diagnostics.warn( | |
| f"Skill name '{raw_skill_name}' normalized to '{skill_name}' ({error_msg})", | |
| package=raw_skill_name, | |
| # Use the canonical package identifier for diagnostic grouping, | |
| # and keep the raw/normalized skill names in the message. | |
| package_name = getattr( | |
| getattr(package_info, "package", None), | |
| "name", | |
| raw_skill_name, | |
| ) | |
| diagnostics.warn( | |
| f"Skill name '{raw_skill_name}' normalized to '{skill_name}' " | |
| f"for package '{package_name}' ({error_msg})", | |
| package=package_name, |
| """Verify collision detection emits warning message to stderr.""" | ||
|
|
||
| def test_prompt_collision_warns_on_stderr(self, tmp_path: Path, capsys): | ||
| """Prompt collision should print warning to stderr.""" | ||
| """Prompt collision should print warning via _rich_warning.""" |
There was a problem hiding this comment.
This test class/docstring still says collision warnings are emitted to stderr, but the assertions now accept stdout or stderr (and _rich_warning typically writes to stdout). Update the docstring/class name to match the new behavior so the test intent stays accurate.
| prompt_integrator.integrate_package_prompts( | ||
| cached_package_info, project_root, | ||
| force=force, managed_files=managed_files, | ||
| diagnostics=diagnostics, | ||
| ) | ||
| ) | ||
| if prompt_result.files_integrated > 0: |
There was a problem hiding this comment.
Diagnostics are being threaded through to the prompt/agent/etc. integrators here, but the nearby skill_integrator.integrate_package_skill(...) call in this same integration block is still invoked without diagnostics=diagnostics. That leaves skill name-normalization warnings and sub-skill overwrite notices outside the end-of-install diagnostic summary and may reintroduce inline noise.
This issue also appears on line 1208 of the same file.
| @property | ||
| def has_diagnostics(self) -> bool: | ||
| """Return True if any diagnostics have been recorded.""" | ||
| return len(self._diagnostics) > 0 | ||
|
|
||
| @property | ||
| def error_count(self) -> int: | ||
| return sum(1 for d in self._diagnostics if d.category == CATEGORY_ERROR) | ||
|
|
||
| def by_category(self) -> Dict[str, List[Diagnostic]]: | ||
| """Return diagnostics grouped by category, preserving insertion order.""" | ||
| groups: Dict[str, List[Diagnostic]] = {} | ||
| for d in self._diagnostics: | ||
| groups.setdefault(d.category, []).append(d) | ||
| return groups |
There was a problem hiding this comment.
DiagnosticCollector claims thread-safety, but query/render paths read self._diagnostics without acquiring the lock (e.g., has_diagnostics, error_count, by_category, and render_summary). This can race with concurrent writers during parallel installs. Consider taking a snapshot of the list under the lock (e.g., with self._lock: diags = list(self._diagnostics)) and base all reads/grouping/rendering on that snapshot, or lock around these reads.
| def _render_collision_group(self, items: List[Diagnostic]) -> None: | ||
| count = len(items) | ||
| noun = "file" if count == 1 else "files" | ||
| _rich_warning( | ||
| f" ⚠ {count} {noun} skipped — local files exist, not managed by APM" | ||
| ) | ||
| _rich_info(" Use 'apm install --force' to overwrite") |
There was a problem hiding this comment.
These renderer messages embed hard-coded status symbols (e.g., "⚠" and "✗"). The codebase already centralizes iconography via STATUS_SYMBOLS and the symbol= parameter on _rich_echo/_rich_warning. To keep output consistent (and avoid future icon changes requiring string edits), consider removing the hard-coded symbols and using _rich_warning(..., symbol='warning') / _rich_echo(..., symbol='cross'|'error') instead.
Summary
Introduces a
DiagnosticCollectorpattern that collects warnings and errors duringapm installand renders a clean, grouped summary at the end — instead of printing inline noise that clutters the install output.What changed
DiagnosticCollectorinsrc/apm_cli/utils/diagnostics.py— category-specific recording (skip, overwrite, warn, error), grouped rendering, thread-safediagnostics=Noneparameter (backward compatible)install.pycreates collector, passes to 18 call sites, routes 3 exception handlers, renders summary before install summaryHow it looks
Normal mode — concise counts:
Verbose mode (
--verbose) — full details grouped by package:Test results
1754 passed (1 pre-existing failure on main excluded)