Skip to content

Commit ccdafc4

Browse files
danielmeppielimk1tCopilotDaniel Meppiel
authored
fix(hooks): stabilize root .apm hook source-ids across renames/worktrees (supersedes #1330, closes #1329) (#1392)
* fix: stabilize root hook source ids * test: remove project-specific hook fixture names * fix(hooks): bound dependency hook source discovery * fix(hooks): reject symlinked dependency markers * test(hooks): subprocess-tier coverage + debug logs for #1329 - Add tests/integration/test_hook_root_source_drift_e2e.py: subprocess test that seeds stale .claude/settings.json + apm-hooks.json sidecar with old source-id, runs apm install, asserts entries heal to _local/<manifest-name> and user-owned hook entry survives. - Add two _log.debug lines in hook_integrator.py: - manifest-parse fallback in _get_root_local_package_name - heal count when stale same-content merged entries are removed - Update affected unit tests to read _apm_source from Claude sidecar (post-#1359 schema-strict layout). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(hooks): act on review-panel recommendations for #1329 follow-up Addresses the top recommended-tier follow-ups from the APM review panel on #1392 (microsoft/apm#1392 (comment)). In-PR: * CHANGELOG: add [Unreleased] > Fixed entry with user-outcome framing, enumerating all five affected harnesses (Claude / Codex / Cursor / Gemini / Windsurf) and the explicit parenthetical that Copilot is unaffected because its hooks live in per-file namespaces rather than a shared merged config. (doc-writer + oss-growth-hacker convergence) * Integration coverage: parametrize the e2e heal regression test across all five merged-hook harnesses instead of Claude only. Uses an install-then-corrupt-then-reinstall pattern so the seeded entries match each target's on-disk shape (sidecar for Claude; in-file _apm_source for Codex / Cursor / Gemini / Windsurf), closing the silent-drift gap the test-coverage-expert flagged as highest-signal. * _safe_source_name hardening: collapse runs of 2+ dots to a single dot before stripping edges. No traversal risk today (the marker is JSON metadata), but guards any future caller that passes the name into a Path join. (supply-chain-security recommendation.) * Nits: include exception message (not just class name) in the manifest-unreadable debug log; replace O(n*m) list membership in the heal counter with an id-set; document the project_root parameter on _get_package_name; promote _get_root_local_package_name, _get_hook_source_marker, and _should_remove_prior_merged_entry to @staticmethod (they touch no instance state). The DependencySourceScanner extraction the python-architect proposed is explicitly out of scope for this PR per the panel's own guidance ("does not need to land in this PR but should not slip past the next meaningful touch to hook_integrator.py"). Validation: * uv run --extra dev ruff check src/ tests/ -- clean * uv run --extra dev ruff format --check src/ tests/ -- clean * uv run --extra dev pytest tests/unit/integration/test_hook_integrator.py -- 156 passed * uv run --extra dev pytest tests/integration/test_hook_root_source_drift_e2e.py -- 5 passed (claude, codex, cursor, gemini, windsurf) * uv run --extra dev pytest tests/unit/integration/ tests/integration/test_hook_root_source_drift_e2e.py -- 1364 passed Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(ci): resolve R0801 duplication; expand lint contract to all 7 CI gates CI lint was failing the PR with R0801 (pylint duplicate-code). The duplication was introduced when main was merged into this branch: the recent MCP-client work on main left an identical `_select_best_package` method in both CodexClientAdapter and CopilotClientAdapter, each ~22 lines long. pylint --fail-on=R0801 catches this at the merge commit CI tests, so the PR cannot pass until the dup is resolved. Fix: hoist `_select_best_package` to the `MCPClientAdapter` base class (alongside the existing `_infer_registry_name` it already calls) and delete the duplicates from codex.py and copilot.py. Both call sites use `self._select_best_package(packages)`, so inheritance resolves the lookup transparently. Mirrors the in-flight refactor on `daf99e0f refactor(adapters): hoist _select_remote_with_url and _select_best_package to base` (not yet on main). Also: expand the canonical lint contract at `.apm/instructions/linting.instructions.md` to cover all seven CI Lint steps (ruff check, ruff format, YAML I/O guard, file-length guard, no raw `str(relative_to)`, pylint R0801, auth-signal lint) instead of only ruff. Adds the full `uv run ... && ...` mirror chain and calls out the merge-commit semantics (CI tests HEAD merged with main, so duplication on main can fail a clean PR diff). Apm compile flows the change into the generated AGENTS.md (gitignored at root) and into `.github/copilot-instructions.md`. Validation: * uv run --extra dev ruff check src/ tests/ -- clean * uv run --extra dev ruff format --check src/ tests/ -- clean * uv run --extra dev python -m pylint --disable=all --enable=R0801 \ --min-similarity-lines=10 --fail-on=R0801 src/apm_cli/ -- 10.00/10, exit 0 * bash scripts/lint-auth-signals.sh -- clean * uv run --extra dev pytest tests/unit/integration/test_hook_integrator.py \ tests/integration/test_hook_root_source_drift_e2e.py -- 161 passed Pre-existing main-side test failures in `tests/unit/test_codex_adapter_compatibility.py` (introduced by #1262 / #1277 on main and tracked by the in-flight `32991fb6 fix(adapters): restore _resolve_env_placeholders shim and reject SSE remotes in tests` branch) are out of scope for this PR and will resolve when that follow-up lands on main. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(adapters): restore _resolve_env_placeholders shim and reject SSE remotes in tests PR #1277 removed the _resolve_env_placeholders legacy wrapper from adapters and softened Codex remote-server rejection (https URLs are now accepted as streamable-http), but did not update the phase-3 and compatibility test suites. The result was 6 pre-existing test failures on Build & Test that this PR's Windows-targeted changes inherited. - Restore _resolve_env_placeholders on MCPClientAdapter as a thin delegate to _resolve_variable_placeholders (empty runtime_vars). External callers and 4 test cases rely on the legacy name. - Update test_returns_false_for_remote_only_server in both codex test files to use an SSE-transport remote, which is still the rejection contract post-#1277 (streamable-http is now accepted). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Koichi Takahashi <tkou15.hi@gmail.com> Co-authored-by: Koichi Takahashi <work@k1t.space> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Daniel Meppiel <copilot-rework@github.com>
1 parent 9b36381 commit ccdafc4

11 files changed

Lines changed: 1202 additions & 77 deletions

File tree

.apm/instructions/linting.instructions.md

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,41 @@ report) that claims CI is green.
1010

1111
## CI-mirror commands
1212

13-
The `Lint` job runs:
13+
The `Lint` job runs (see `.github/workflows/ci.yml`):
1414

15-
- `uv run --extra dev ruff check src/ tests/`
16-
- `uv run --extra dev ruff format --check src/ tests/`
15+
1. `uv run --extra dev ruff check src/ tests/`
16+
2. `uv run --extra dev ruff format --check src/ tests/`
17+
3. YAML I/O safety guard (rejects raw `yaml.dump(..., handle)` outside
18+
`utils/yaml_io.py`; mark approved exceptions with `# yaml-io-exempt`).
19+
4. File length guardrail (no `src/**/*.py` may exceed **2450 lines**).
20+
5. No raw `str(path.relative_to(...))` patterns -- use
21+
`portable_relpath()` from `apm_cli.utils.paths`.
22+
6. **Code duplication guardrail (pylint R0801):**
23+
`uv run --extra dev python -m pylint --disable=all --enable=R0801 \
24+
--min-similarity-lines=10 --fail-on=R0801 src/apm_cli/`
25+
7. Auth-protocol boundary check: `bash scripts/lint-auth-signals.sh`
1726

18-
Both must be silent.
27+
All seven must succeed. CI evaluates these on the **PR merge commit**
28+
(HEAD merged with current `main`), so duplication introduced by a
29+
recent main commit can fail your PR even if your own diff is clean.
30+
Always merge `main` locally before running the mirror.
1931

2032
## Local workflow
2133

2234
- **Auto-fix style+imports:** `uv run --extra dev ruff check src/ tests/ --fix`
2335
- **Apply formatter:** `uv run --extra dev ruff format src/ tests/`
24-
- **Verify (must be silent):** `uv run --extra dev ruff check src/ tests/ && uv run --extra dev ruff format --check src/ tests/`
36+
- **Verify the full Lint job (must all be silent / exit 0):**
37+
```bash
38+
uv run --extra dev ruff check src/ tests/ \
39+
&& uv run --extra dev ruff format --check src/ tests/ \
40+
&& uv run --extra dev python -m pylint --disable=all --enable=R0801 \
41+
--min-similarity-lines=10 --fail-on=R0801 src/apm_cli/ \
42+
&& bash scripts/lint-auth-signals.sh
43+
```
44+
(The YAML, file-length, and `relative_to` guards are pure-grep one-liners
45+
from `ci.yml`; run them directly if you have touched those surfaces.)
2546

26-
Always run the verify pair before `git push` -- the CI Lint job
47+
Always run the verify chain before `git push` -- the CI Lint job
2748
fails on any remaining diagnostic.
2849

2950
## Common surprises
@@ -36,11 +57,13 @@ fails on any remaining diagnostic.
3657
- `F401` / `F841` -- remove unused imports / unused locals.
3758
- `SIM103` -- inline negated returns where the body is one line.
3859
- `I001` -- import sort order (auto-fixable).
60+
- `R0801` -- 10+ identical lines across two files. Extract the shared
61+
block into a base class / helper module instead of disabling.
3962

4063
## Lifecycle binding
4164

4265
This is the canonical lint contract for the repo. Skills that
4366
produce artifacts asserting green CI -- notably `pr-description-skill`
4467
(whose "Validation evidence" row covers CI checks) -- inherit this
45-
gate transitively. Do NOT redefine ruff commands inside individual
46-
skills; honor this instruction before invoking them.
68+
gate transitively. Do NOT redefine ruff or pylint commands inside
69+
individual skills; honor this instruction before invoking them.

.github/copilot-instructions.md

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<!-- Generated by APM CLI from .apm/ primitives -->
2-
<!-- Build ID: a3144f4613b2 -->
2+
<!-- Build ID: 9a566a78e962 -->
33
<!-- APM Version: 0.14.1 -->
44

55
<!-- Source: .apm/instructions/linting.instructions.md -->
@@ -11,20 +11,41 @@ report) that claims CI is green.
1111

1212
## CI-mirror commands
1313

14-
The `Lint` job runs:
14+
The `Lint` job runs (see `.github/workflows/ci.yml`):
1515

16-
- `uv run --extra dev ruff check src/ tests/`
17-
- `uv run --extra dev ruff format --check src/ tests/`
16+
1. `uv run --extra dev ruff check src/ tests/`
17+
2. `uv run --extra dev ruff format --check src/ tests/`
18+
3. YAML I/O safety guard (rejects raw `yaml.dump(..., handle)` outside
19+
`utils/yaml_io.py`; mark approved exceptions with `# yaml-io-exempt`).
20+
4. File length guardrail (no `src/**/*.py` may exceed **2450 lines**).
21+
5. No raw `str(path.relative_to(...))` patterns -- use
22+
`portable_relpath()` from `apm_cli.utils.paths`.
23+
6. **Code duplication guardrail (pylint R0801):**
24+
`uv run --extra dev python -m pylint --disable=all --enable=R0801 \
25+
--min-similarity-lines=10 --fail-on=R0801 src/apm_cli/`
26+
7. Auth-protocol boundary check: `bash scripts/lint-auth-signals.sh`
1827

19-
Both must be silent.
28+
All seven must succeed. CI evaluates these on the **PR merge commit**
29+
(HEAD merged with current `main`), so duplication introduced by a
30+
recent main commit can fail your PR even if your own diff is clean.
31+
Always merge `main` locally before running the mirror.
2032

2133
## Local workflow
2234

2335
- **Auto-fix style+imports:** `uv run --extra dev ruff check src/ tests/ --fix`
2436
- **Apply formatter:** `uv run --extra dev ruff format src/ tests/`
25-
- **Verify (must be silent):** `uv run --extra dev ruff check src/ tests/ && uv run --extra dev ruff format --check src/ tests/`
37+
- **Verify the full Lint job (must all be silent / exit 0):**
38+
```bash
39+
uv run --extra dev ruff check src/ tests/ \
40+
&& uv run --extra dev ruff format --check src/ tests/ \
41+
&& uv run --extra dev python -m pylint --disable=all --enable=R0801 \
42+
--min-similarity-lines=10 --fail-on=R0801 src/apm_cli/ \
43+
&& bash scripts/lint-auth-signals.sh
44+
```
45+
(The YAML, file-length, and `relative_to` guards are pure-grep one-liners
46+
from `ci.yml`; run them directly if you have touched those surfaces.)
2647

27-
Always run the verify pair before `git push` -- the CI Lint job
48+
Always run the verify chain before `git push` -- the CI Lint job
2849
fails on any remaining diagnostic.
2950

3051
## Common surprises
@@ -37,14 +58,16 @@ fails on any remaining diagnostic.
3758
- `F401` / `F841` -- remove unused imports / unused locals.
3859
- `SIM103` -- inline negated returns where the body is one line.
3960
- `I001` -- import sort order (auto-fixable).
61+
- `R0801` -- 10+ identical lines across two files. Extract the shared
62+
block into a base class / helper module instead of disabling.
4063

4164
## Lifecycle binding
4265

4366
This is the canonical lint contract for the repo. Skills that
4467
produce artifacts asserting green CI -- notably `pr-description-skill`
4568
(whose "Validation evidence" row covers CI checks) -- inherit this
46-
gate transitively. Do NOT redefine ruff commands inside individual
47-
skills; honor this instruction before invoking them.
69+
gate transitively. Do NOT redefine ruff or pylint commands inside
70+
individual skills; honor this instruction before invoking them.
4871
<!-- End source: .apm/instructions/linting.instructions.md -->
4972

5073
---

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- Unit test coverage raised to 88% (gate: `fail_under = 80`); integration test coverage raised to 71% with first CI gate at 55%. (#1402)
1313

14+
### Fixed
15+
16+
- Root `.apm` hooks no longer duplicate after renaming the project directory or using git worktrees; Claude, Codex, Cursor, Gemini, and Windsurf hook configs stay idempotent across checkouts. The hook source-id is now derived from `apm.yml`'s `name` field instead of `install_path.name`, and `apm install` silently heals stale same-content entries from prior checkout basenames. Copilot is unaffected (its hooks live in per-file namespaces under `.github/hooks/`, not a shared merged config). (#1392, closes #1329)
17+
1418
## [0.14.1] - 2026-05-20
1519

1620
### Added

src/apm_cli/adapters/client/base.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,30 @@ def _infer_registry_name(package):
248248

249249
return ""
250250

251+
@classmethod
252+
def _select_best_package(cls, packages):
253+
"""Select the best package for installation from available packages.
254+
255+
Prioritizes packages in order: npm, docker, pypi, homebrew, others.
256+
Uses ``_infer_registry_name`` so selection works even when the
257+
registry API returns empty ``registry_name``.
258+
259+
Args:
260+
packages (list): List of package dictionaries.
261+
262+
Returns:
263+
dict: Best package to use, or None if no suitable package found.
264+
"""
265+
priority_order = ["npm", "docker", "pypi", "homebrew"]
266+
267+
for target in priority_order:
268+
for package in packages:
269+
if cls._infer_registry_name(package) == target:
270+
return package
271+
272+
# If no priority package found, return the first one
273+
return packages[0] if packages else None
274+
251275
@staticmethod
252276
def _warn_input_variables(mapping, server_name, runtime_label):
253277
"""Emit a warning for each ``${input:...}`` reference found in *mapping*.
@@ -542,6 +566,16 @@ def _replace_runtime(match):
542566

543567
return processed
544568

569+
def _resolve_env_placeholders(self, value, resolved_env):
570+
"""Legacy thin wrapper for backward compatibility.
571+
572+
Kept because external callers and the phase-3 test suite invoke
573+
the pre-#1277 name. Delegates to ``_resolve_variable_placeholders``
574+
with an empty ``runtime_vars`` map. New code should call
575+
``_resolve_variable_placeholders`` directly.
576+
"""
577+
return self._resolve_variable_placeholders(value, resolved_env, {})
578+
545579
# ------------------------------------------------------------------
546580
# Shared server-info helpers (used by all adapter subclasses)
547581
# ------------------------------------------------------------------

src/apm_cli/adapters/client/codex.py

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -548,26 +548,3 @@ def _select_remote_with_url(remotes):
548548
if url:
549549
return remote
550550
return None
551-
552-
def _select_best_package(self, packages):
553-
"""Select the best package for installation from available packages.
554-
555-
Prioritizes packages in order: npm, docker, pypi, homebrew, others.
556-
Uses ``_infer_registry_name`` so selection works even when the
557-
registry API returns empty ``registry_name``.
558-
559-
Args:
560-
packages (list): List of package dictionaries.
561-
562-
Returns:
563-
dict: Best package to use, or None if no suitable package found.
564-
"""
565-
priority_order = ["npm", "docker", "pypi", "homebrew"]
566-
567-
for target in priority_order:
568-
for package in packages:
569-
if self._infer_registry_name(package) == target:
570-
return package
571-
572-
# If no priority package found, return the first one
573-
return packages[0] if packages else None

src/apm_cli/adapters/client/copilot.py

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1007,29 +1007,6 @@ def _select_remote_with_url(remotes):
10071007
return remote
10081008
return None
10091009

1010-
def _select_best_package(self, packages):
1011-
"""Select the best package for installation from available packages.
1012-
1013-
Prioritizes packages in order: npm, docker, pypi, homebrew, others.
1014-
Uses ``_infer_registry_name`` so selection works even when the
1015-
registry API returns empty ``registry_name``.
1016-
1017-
Args:
1018-
packages (list): List of package dictionaries.
1019-
1020-
Returns:
1021-
dict: Best package to use, or None if no suitable package found.
1022-
"""
1023-
priority_order = ["npm", "docker", "pypi", "homebrew"]
1024-
1025-
for target in priority_order:
1026-
for package in packages:
1027-
if self._infer_registry_name(package) == target:
1028-
return package
1029-
1030-
# If no priority package found, return the first one
1031-
return packages[0] if packages else None
1032-
10331010
def _is_github_server(self, server_name, url):
10341011
"""Securely determine if a server is a GitHub MCP server.
10351012

0 commit comments

Comments
 (0)