refactor: unify integration dispatch, result types, and hook dedup#562
refactor: unify integration dispatch, result types, and hook dedup#562danielmeppiel merged 13 commits intomainfrom
Conversation
The three merge-based hook methods (Claude, Cursor, Codex) shared ~90% identical code differing only in config file path and opt-in behavior. - Add _MergeHookConfig dataclass + _MERGE_HOOK_TARGETS registry - Extract shared logic into _integrate_merged_hooks() - integrate_hooks_for_target() now dispatches via registry lookup - Original per-target methods kept as deprecated delegates - Net -111 lines; zero behavioral change (all 3553 tests pass) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Five architectural improvements to the targeting system: 1. Single Result Type: merge HookIntegrationResult into IntegrationResult with optional fields (scripts_copied, sub_skills_promoted, skill_created). Hooks now return the same type as all other integrators. 2. Primitive Coverage Assertion: import-time check (via test fixture) that every primitive in KNOWN_TARGETS has a handler in the dispatch table. Catches silent wiring omissions during development. 3. Deprecate Legacy Methods: mark ~28 per-target wrapper methods across 5 integrators as DEPRECATED with clear guidance to use *_for_target(). 4. Unified Dispatch Table: new dispatch.py with PrimitiveDispatch dataclass. Install and uninstall both import from one source of truth. Hooks join the table. Adding a new primitive = 1 dict entry instead of 5 edits. 5. Hook Merge Strategy Extraction: 3 copy-pasted JSON-merge methods (Claude/Cursor/Codex) replaced with _integrate_merged_hooks() + _MERGE_HOOK_TARGETS config dict. -111 lines, zero behavioral changes. Closes #561 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Refactors APM's integration targeting system to use a single primitive dispatch registry and a unified IntegrationResult, while reducing duplication in hook integration and adding automated coverage checks to prevent dispatch/target drift.
Changes:
- Introduces
src/apm_cli/integration/dispatch.pyas the single source of truth for primitive integration/sync dispatch (used by install + uninstall). - Unifies hook results into
IntegrationResultand deduplicates Claude/Cursor/Codex hook-merge logic via a shared implementation + config map. - Adds primitive coverage validation (
coverage.py) and wires it into the unit test session to fail fast whenKNOWN_TARGETSand dispatch drift.
Reviewed changes
Copilot reviewed 17 out of 18 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
src/apm_cli/integration/dispatch.py |
New unified primitive dispatch registry with lazy initialization. |
src/apm_cli/integration/coverage.py |
New validation helper to ensure every KNOWN_TARGETS primitive has a dispatch handler. |
src/apm_cli/commands/install.py |
Replaces local primitive mapping with unified dispatch table + shared counters/logging path. |
src/apm_cli/commands/uninstall/engine.py |
Uses unified dispatch for phase-1 sync and phase-2 best-effort re-integration. |
src/apm_cli/integration/base_integrator.py |
Expands IntegrationResult to include optional hook/skill fields. |
src/apm_cli/integration/hook_integrator.py |
Replaces HookIntegrationResult dataclass with IntegrationResult, adds merged-hook implementation shared across targets. |
src/apm_cli/integration/skill_integrator.py |
Documents SkillIntegrationResult as deprecated in favor of IntegrationResult for new code. |
src/apm_cli/integration/{prompt,command,instruction,agent}_integrator.py |
Adds/clarifies DEPRECATED legacy per-target wrapper guidance. |
src/apm_cli/integration/__init__.py |
Exposes new dispatch/coverage utilities from the integration package. |
tests/conftest.py |
Adds session-scoped autouse primitive coverage validation. |
tests/unit/integration/test_*.py |
Updates tests to use files_integrated and adds coverage/dispatch table validation tests. |
…hitecture Add Post-Refactor section documenting the PRIMITIVE_DISPATCH table, unified IntegrationResult, _MERGE_HOOK_TARGETS hook dedup, coverage assertion, and apm deps update coverage. Update How-to walkthroughs to reference dispatch.py instead of manually editing install.py and engine.py. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…cope - HookIntegrationResult is now a proper subclass (not just alias) that accepts hooks_integrated= kwarg and exposes a .hooks_integrated property, so external consumers constructing or reading via the old API continue to work. - Remove unused _user_scope variable from _integrate_package_primitives (scope guards were removed in the for_scope() refactor). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- HookIntegrationResult shim: add target_paths=[] default so old-style construction HookIntegrationResult(hooks_integrated=0, scripts_copied=0) works without specifying target_paths - coverage.py: add reverse check (dead dispatch entries) and method existence validation - 5 new tests: dead entry detection, bidirectional check, shim construction (old-style, old-style-no-paths, new-style) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
apm deps update -g silently ignored scope, causing user-scope updates to resolve targets against Path.cwd() instead of Path.home(). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…5 targets 31 new tests verifying scope-resolved targeting actually deploys files to the correct paths and cleans them up on uninstall. Uses REAL integrators against temp directories -- no mocks. test_scope_integration.py (16 tests): - for_scope resolution for Copilot, Claude, OpenCode, Codex - auto_create guard behavior - resolve_targets consistency (roots, filtered primitives) - Skill deployment at user scope test_scope_install_uninstall.py (15 tests): - Full install-then-uninstall cycles for all 5 targets - Project scope AND user scope for each target - Verifies files exist at right path AND absent at wrong path - Posix path format for managed_files (lockfile simulation) - Codex exclusion at user scope - OpenCode .config/opencode multi-level root cycles 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
This PR refactors APM's integration layer to make primitive integration and uninstall routing table-driven from a single shared dispatch registry, while unifying integrator result types and deduplicating hook merge logic across targets.
Changes:
- Introduces a single primitive dispatch table (
get_dispatch_table()) used by both install and uninstall flows, plus a coverage check to prevent missing wiring. - Unifies integrator result reporting on
IntegrationResult(with optional hook/skill fields) and updates tests accordingly. - Deduplicates merged-hook targets (Claude/Cursor/Codex) via a shared
_integrate_merged_hooks()implementation driven by a config dict.
Show a summary per file
| File | Description |
|---|---|
| WIP/targeting-architecture.md | New architecture write-up documenting the target/primitive model and unified dispatch approach. |
| tests/conftest.py | Adds a session-scoped autouse fixture to fail fast when primitive coverage is incomplete. |
| tests/unit/integration/test_scope_integration.py | New integration tests validating scope-resolved target behavior with real integrators. |
| tests/unit/integration/test_scope_install_uninstall.py | New install-then-uninstall cycle tests across targets/scopes (currently uses a monkeypatch workaround). |
| tests/unit/integration/test_hook_integrator.py | Updates assertions from hooks_integrated to unified files_integrated. |
| tests/unit/integration/test_deployed_files_manifest.py | Updates hook result assertions to unified files_integrated. |
| tests/unit/integration/test_data_driven_dispatch.py | Updates exhaustiveness checks to use unified dispatch and adds coverage/dispatch-table validation tests. |
| tests/unit/integration/test_command_integrator.py | Updates mock result shape to match unified IntegrationResult. |
| src/apm_cli/integration/base_integrator.py | Extends IntegrationResult with optional hook/skill fields and updates its docstring. |
| src/apm_cli/integration/hook_integrator.py | Adds _MERGE_HOOK_TARGETS + _integrate_merged_hooks() and introduces a backward-compat HookIntegrationResult shim. |
| src/apm_cli/integration/dispatch.py | New unified primitive dispatch registry with lazy initialization. |
| src/apm_cli/integration/coverage.py | New bidirectional primitive coverage validation helper. |
| src/apm_cli/integration/init.py | Exposes dispatch/coverage utilities at the integration package level. |
| src/apm_cli/integration/prompt_integrator.py | Clarifies deprecated legacy per-target APIs vs target-driven API. |
| src/apm_cli/integration/agent_integrator.py | Clarifies deprecated legacy per-target APIs vs target-driven API. |
| src/apm_cli/integration/instruction_integrator.py | Clarifies deprecated legacy per-target APIs vs target-driven API. |
| src/apm_cli/integration/command_integrator.py | Clarifies deprecated legacy per-target APIs vs target-driven API. |
| src/apm_cli/integration/skill_integrator.py | Marks legacy SkillIntegrationResult as deprecated in favor of IntegrationResult. |
| src/apm_cli/commands/install.py | Replaces local primitive dispatch logic with get_dispatch_table() driven dispatch. |
| src/apm_cli/commands/uninstall/engine.py | Replaces sync/reintegration dispatch tables with unified dispatch-table driven logic. |
| src/apm_cli/commands/deps/cli.py | Fixes apm deps update -g by passing scope through to the install path. |
| CHANGELOG.md | Adds Unreleased entries describing the refactor and the deps update -g scope fix. |
Copilot's findings
Comments suppressed due to low confidence (2)
src/apm_cli/integration/hook_integrator.py:551
- The deprecated per-target methods (integrate_package_hooks_claude/cursor/codex) declare they return HookIntegrationResult, but they delegate to _integrate_merged_hooks() which returns IntegrationResult. That means callers using the legacy hooks_integrated field/property will break even though these APIs are marked as backward-compatible. Wrap the returned IntegrationResult in HookIntegrationResult (or have _integrate_merged_hooks optionally produce the shim) so deprecated methods preserve the old surface area.
def integrate_package_hooks_claude(self, package_info, project_root: Path,
force: bool = False,
managed_files: set = None,
diagnostics=None) -> HookIntegrationResult:
"""Integrate hooks into .claude/settings.json.
.. deprecated:: Use :meth:`integrate_hooks_for_target` instead.
"""
return self._integrate_merged_hooks(
_MERGE_HOOK_TARGETS["claude"], package_info, project_root,
force=force, managed_files=managed_files,
diagnostics=diagnostics,
)
src/apm_cli/integration/hook_integrator.py:602
- integrate_hooks_for_target() is annotated to return HookIntegrationResult, but in practice it returns IntegrationResult (from integrate_package_hooks(), _integrate_merged_hooks(), and the final fallback). Update the return type to IntegrationResult (unified result type) or consistently return the HookIntegrationResult shim if you want the legacy alias to remain available.
def integrate_hooks_for_target(
self,
target,
package_info,
project_root: Path,
*,
force: bool = False,
managed_files: set = None,
diagnostics=None,
) -> "HookIntegrationResult":
"""Integrate hooks for a single *target*.
Copilot uses individual JSON files (genuinely different pattern).
All other merge-based targets are dispatched via the
``_MERGE_HOOK_TARGETS`` registry.
"""
if target.name == "copilot":
return self.integrate_package_hooks(
- Files reviewed: 22/23 changed files
- Comments generated: 6
| hook_files = self.find_hook_files(package_info.install_path) | ||
|
|
||
| if not hook_files: | ||
| return HookIntegrationResult( | ||
| hooks_integrated=0, | ||
| scripts_copied=0, | ||
| return IntegrationResult( | ||
| files_integrated=0, files_updated=0, | ||
| files_skipped=0, target_paths=[], | ||
| ) |
There was a problem hiding this comment.
integrate_package_hooks() is still annotated to return HookIntegrationResult, but the no-hook early return now returns an IntegrationResult. If external consumers still call this legacy API and expect the hooks_integrated alias/property, this is a breaking change. Consider returning HookIntegrationResult here (wrapping the unified IntegrationResult), or update the method signature/docstring to reflect the new return type and explicitly document the breaking change/deprecation behavior.
This issue also appears in the following locations of the same file:
- line 539
- line 585
| _prim_name, _target.name | ||
| ) | ||
| _managed_subset = _buckets.get(_bucket_key, set()) | ||
| result = _integrator.sync_for_target( | ||
| result = _integrators[_prim_name].sync_for_target( | ||
| _target, apm_package, project_root, | ||
| managed_files=_managed_subset, | ||
| ) | ||
| counts[_counter_key] += result.get("files_removed", 0) | ||
| counts[_entry.counter_key] += result.get("files_removed", 0) |
There was a problem hiding this comment.
In Phase 1 user-scope uninstall, sync_for_target() ultimately calls BaseIntegrator.sync_remove_files(), which calls validate_deploy_path(rel_path, project_root) without passing scope-resolved targets/allowed_prefixes. That validation falls back to KNOWN_TARGETS prefixes (project-scope roots) and will reject user-scope paths like .copilot/... and .config/opencode/..., so those files will be silently skipped and left behind. Please plumb the resolved targets (or an allowlist derived from them) through the sync_remove_files/validate_deploy_path call chain so user-scope managed files can be removed without test monkeypatching.
src/apm_cli/commands/install.py
Outdated
| from apm_cli.core.scope import InstallScope | ||
|
|
There was a problem hiding this comment.
_integrate_package_primitives() imports InstallScope but no longer uses it (the previous _user_scope computation was removed). This will fail linting/unused-import checks; please remove the unused import (and consider dropping the now-unused scope parameter if it's truly no longer needed).
| from apm_cli.core.scope import InstallScope |
| # User-scope roots (.copilot/, .config/opencode/) are not in the default | ||
| # allowed-prefix list returned by get_integration_prefixes() when called | ||
| # without targets. sync_remove_files() calls validate_deploy_path() | ||
| # without targets, so uninstall at user scope for copilot/opencode would | ||
| # be silently rejected. This fixture extends the prefix list so cycle |
There was a problem hiding this comment.
This test monkeypatches BaseIntegrator._get_integration_prefixes to allow user-scope prefixes, which indicates uninstall cleanup is currently dependent on the allowlist produced from KNOWN_TARGETS (project-scope roots). This makes the cycle tests diverge from production behavior. Prefer fixing the production sync/validate_deploy_path plumbing so user-scope uninstall works without monkeypatching, then remove this fixture and the hardcoded _USER_SCOPE_PREFIXES.
| ### Changed | ||
|
|
||
| - Scope resolution now happens once via `TargetProfile.for_scope()` and `resolve_targets()` -- integrators no longer need scope-aware parameters (#562) | ||
| - Unified integration dispatch table in `dispatch.py` -- both install and uninstall import from one source of truth (#562) | ||
| - Hook merge logic deduplicated: three copy-pasted JSON-merge methods replaced with `_integrate_merged_hooks()` + config dict (#562) |
There was a problem hiding this comment.
This PR fixes apm deps update -g scope behavior, but the user-facing docs still don't list a global flag for apm deps update (e.g., docs/src/content/docs/reference/cli-commands.md and packages/apm-guide/.apm/skills/apm-usage/commands.md only list verbose/force/target/parallel-downloads). Please update the Starlight docs pages and the apm-guide command reference to document the global/user-scope behavior for deps update (and any related scope semantics impacted by the unified dispatch change).
| @@ -268,27 +266,12 @@ def _sync_integrations_after_uninstall(apm_package, project_root, all_deployed_f | |||
| counts = {"prompts": 0, "agents": 0, "skills": 0, "commands": 0, "hooks": 0, "instructions": 0} | |||
There was a problem hiding this comment.
counts is hardcoded to the current primitive set. One of the PR goals is that adding a new primitive is a single dispatch-table change; with this hardcoded dict, adding a new dispatch entry/primitive will cause KeyError or missing accounting unless this is manually updated too. Consider deriving the counters dict from _dispatch (e.g., {entry.counter_key: 0 for entry in _dispatch.values()}) to keep uninstall aligned with the unified registry.
| counts = {"prompts": 0, "agents": 0, "skills": 0, "commands": 0, "hooks": 0, "instructions": 0} | |
| counts = {entry.counter_key: 0 for entry in _dispatch.values()} |
- Thread targets= through sync_remove_files -> validate_deploy_path so user-scope paths (.copilot/, .config/opencode/) pass validation during uninstall cleanup (fixes silent skip bug) - All 4 integrator sync_for_target() methods pass targets=[target] - integrate_package_hooks returns HookIntegrationResult (not bare IntegrationResult) matching the annotated return type - Remove unused InstallScope import from install.py - Derive uninstall counts dict from dispatch table (not hardcoded) - Remove test monkeypatch fixture (no longer needed after prod fix) - Document -g/--global flag for deps update in cli-commands.md and dependencies.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ves prefixes from targets The synthetic target test patched validate_deploy_path to accept .newcode/ prefix. Now that sync_for_target threads targets= through sync_remove_files -> validate_deploy_path -> _get_integration_prefixes, the prefix is derived from the actual TargetProfile automatically. Zero monkeypatches remain that mask validation behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…guard Rework of PR microsoft#566 on top of post-microsoft#562 architecture: - integrate_package_hooks: accept target=, use target.root_dir instead of hardcoded .github for Copilot hook deployment path - _integrate_merged_hooks: use target.root_dir instead of f'.{config.target_key}' for Claude/Cursor/Codex JSON merge path - _rewrite_command_for_target / _rewrite_hooks_data: accept root_dir= override so script paths match the scope-resolved directory - sync_integration: derive hook prefixes dynamically from KNOWN_TARGETS instead of hardcoded .github/.claude/.cursor/.codex paths; accept targets= parameter - install.py: gate directory creation on auto_create=True to prevent creating dirs for targets that don't want them (e.g. Claude, Cursor at project scope) - 6 new tests covering scope-resolved deploy, script rewrite, sync cleanup, and auto_create guard Fixes microsoft#565 Addresses microsoft#576 (P1-1 sync, P1-4 auto_create) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…guard (#566) Rework of PR #566 on top of post-#562 architecture: - integrate_package_hooks: accept target=, use target.root_dir instead of hardcoded .github for Copilot hook deployment path - _integrate_merged_hooks: use target.root_dir instead of f'.{config.target_key}' for Claude/Cursor/Codex JSON merge path - _rewrite_command_for_target / _rewrite_hooks_data: accept root_dir= override so script paths match the scope-resolved directory - sync_integration: derive hook prefixes dynamically from KNOWN_TARGETS instead of hardcoded .github/.claude/.cursor/.codex paths; accept targets= parameter - install.py: gate directory creation on auto_create=True to prevent creating dirs for targets that don't want them (e.g. Claude, Cursor at project scope) - 6 new tests covering scope-resolved deploy, script rewrite, sync cleanup, and auto_create guard Fixes #565 Addresses #576 (P1-1 sync, P1-4 auto_create) Co-authored-by: danielmeppiel <dmeppiel@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Motivation
APM's integration layer had primitive-to-integrator mappings scattered across
three separate dispatch tables in
install.pyandengine.py, hooks excludedfrom all of them, and three different result types that callers had to
destructure with type-specific field names. Every new primitive or hook target
required edits in 3+ locations, and forgetting one silently broke uninstall.
This refactor consolidates everything into a single dispatch table with a
unified result type so that adding a primitive is one dict entry and the test
suite catches omissions automatically.
Before / After Summary
_PRIMITIVE_INTEGRATORS,_SYNC_DISPATCH,_REINT_DISPATCH)PRIMITIVE_DISPATCHindispatch.py)if target.namebranches (3 copies)_MERGE_HOOK_TARGETSconfig dictIntegrationResult,HookIntegrationResult,SkillIntegrationResult)IntegrationResultwith optional fields)dispatch.py_MERGE_HOOK_TARGETScheck_primitive_coverage()auto-runs in test suiteBefore: 3-Way Dispatch
flowchart TD A[install.py] --> B{primitive type?} B -->|prompts/agents/cmds/instr| C[_PRIMITIVE_INTEGRATORS table] B -->|hooks| D[Inline if branch] B -->|skills| E[Separate call block] C --> F[IntegrationResult] D --> G[HookIntegrationResult] E --> H[SkillIntegrationResult] F --> I[Type-specific field access] G --> I H --> IAfter: Unified Dispatch
flowchart TD A["install.py / engine.py"] --> B["get_dispatch_table()"] B --> C["PRIMITIVE_DISPATCH (dispatch.py)"] C --> D{multi_target?} D -->|No| E["Per-target loop: integrator.*_for_target()"] D -->|Yes skills| F["All-target: integrate_package_skill()"] E --> G["IntegrationResult (unified)"] F --> G G --> H["Uniform field access: files_integrated, target_paths"]Improvement Details
1. Single dispatch table (
dispatch.py)A new
PrimitiveDispatchfrozen dataclass maps each primitive name to itsintegrator class, install method, uninstall method, counter key, and a
multi_targetflag. Theget_dispatch_table()function builds the table lazilyon first access (deferred imports avoid circular dependencies). Both
install.pyand
engine.pyimport and iterate the same table.2. Unified
IntegrationResultThe three result dataclasses (
IntegrationResult,HookIntegrationResult,SkillIntegrationResult) are replaced by a singleIntegrationResultwithshared fields (
files_integrated,target_paths) and optional fields thatdefault to zero (
scripts_copied,sub_skills_promoted,skill_created).Callers no longer need type-specific destructuring.
3. Hooks in the dispatch table
Hooks now have a
PrimitiveDispatchentry like every other primitive. Theper-target merge logic (Claude/Cursor/Codex) is consolidated into
_integrate_merged_hooks(), driven by a_MERGE_HOOK_TARGETSconfig dict.Adding a new JSON-merge hook target (e.g., Windsurf) is one dict entry -- no
method duplication required.
4.
_MERGE_HOOK_TARGETSconfig dictEach merge-hook target is described by a
_MergeHookConfig(config filename,target key, require_dir flag). The shared
_integrate_merged_hooks()methodhandles file discovery, JSON merging,
_apm_sourcetagging, and script copyingfor all targets uniformly. The three near-identical per-target methods now
delegate to this single implementation.
5.
check_primitive_coverage()safety netA new
coverage.pymodule exposescheck_primitive_coverage(), which comparesKNOWN_TARGETSprimitives against dispatch table keys. Aconftest.pysession-scoped fixture runs this check at test startup, so a missing dispatch
entry fails the entire test suite immediately -- no silent breakage.
Command Coverage
apm installapm uninstallsync_methodfrom same dispatch entriesapm deps update_install_apm_dependencies(update_refs=True), same loopapm deps listapm deps treeapm deps infoTesting
check_primitive_coverage()runs as a session-scopedautouse fixture in
conftest.py. Tests a happy path (all primitives covered)and a negative path (missing primitive raises
RuntimeError).test_data_driven_dispatch.pyvalidates thatevery entry in the dispatch table has valid method names, correct counter keys,
and that
multi_targetflags are consistent.test_hook_integrator.pycovers Claude, Cursor, andCodex merge paths through
_integrate_merged_hooks(), including_apm_sourcetagging, user-hook preservation, and require_dir skip logic.
integrators across the test suite.
Stats
dispatch.py(72 lines),coverage.py(40 lines)