Skip to content

fix(install): exclude self-entry from dry-run orphan preview#2069

Open
mia106dev wants to merge 1 commit into
microsoft:mainfrom
mia106dev:fix/dry-run-self-include-orphan-overreport
Open

fix(install): exclude self-entry from dry-run orphan preview#2069
mia106dev wants to merge 1 commit into
microsoft:mainfrom
mia106dev:fix/dry-run-self-include-orphan-overreport

Conversation

@mia106dev

Copy link
Copy Markdown
Contributor

TL;DR

apm install --dry-run falsely lists the project's own includes: auto
self-managed files under "Files that would be removed (packages no longer in
apm.yml)"
. The real install never removes them — this is a dry-run preview
bug only. The fix makes the orphan preview skip the synthesized lockfile
self-entry, so the preview matches actual install behaviour.

Problem

For a project using includes: auto, LockFile.from_yaml synthesizes a
virtual dependency keyed by _SELF_KEY (".") from the flat
local_deployed_files field, to unify traversal across real dependencies and
the project's own local content (src/apm_cli/deps/lockfile.py, from_yaml).

detect_orphans (src/apm_cli/drift.py) walks every entry in
existing_lockfile.dependencies and flags any key not present in the
manifest-derived intended_dep_keys as orphaned:

for dep_key, dep in existing_lockfile.dependencies.items():
    if dep_key not in intended_dep_keys:
        orphaned.update(dep.deployed_files)

The synthesized _SELF_KEY (".") can never appear in intended_dep_keys
(it is not a manifest dependency), so on a full install every self-include was
reported as an orphan to be removed.

This is preview-only over-reporting. The real paths are already correct and
were never affected:

  • Replay guards the self-entry to re-deploy it, not remove it —
    src/apm_cli/install/drift.py:481,495 (if lock_dep.local_path == _SELF_KEY).
  • The cleanup phase independently skips it —
    src/apm_cli/install/phases/cleanup.py:79 (if _orphan_key == _SELF_KEY).

detect_orphans' only non-test caller is the dry-run preview renderer
(src/apm_cli/install/presentation/dry_run.py:70), which confirms the blast
radius is the preview text alone.

Approach

Skip _SELF_KEY inside detect_orphans. Orphan-ness is a property the
function itself owns, so the guard belongs here — next to the sibling guards
already present in the real cleanup/replay paths — rather than filtered at the
single caller. Synthesis stays in from_yaml because other consumers rely on
the self-entry existing.

Implementation

+    from apm_cli.deps.lockfile import _SELF_KEY
+
     orphaned: builtins.set = builtins.set()
     if only_packages or not existing_lockfile:
         return orphaned
     for dep_key, dep in existing_lockfile.dependencies.items():
+        # The synthesized self-entry (key ".") holds the project's own
+        # includes:auto content, which the real install always re-deploys
+        # (never removes). It can never appear in the manifest-derived
+        # intended set, so skip it to avoid over-counting self-includes as
+        # removals in the dry-run preview.
+        if dep_key == _SELF_KEY:
+            continue
         if dep_key not in intended_dep_keys:
             orphaned.update(dep.deployed_files)
     return orphaned

The _SELF_KEY import is function-scoped, matching the codebase's established
lazy-import idiom for the driftlockfile cycle.

Validation

  • New unit tests in tests/unit/test_drift_detection.py:
    • test_self_entry_never_orphaned — a stub lockfile with a self-entry plus a
      genuinely dropped package: only the dropped package's files are returned.
    • test_self_include_survives_real_from_yaml_roundtrip — exercises the
      real LockFile.from_yaml synthesis → detect_orphans path, so a future
      change to either the self-key value or the synthesis shape that reintroduces
      the false alarm fails here.
  • Red→green confirmed: test_self_entry_never_orphaned fails on main
    (self_a.md/self_b.md wrongly returned), passes with the fix.
  • End-to-end check against the real parse path: with the manifest emptied, only
    a genuinely dropped package's file is reported; self-includes
    (.claude/skills/...) no longer appear.
  • Blast-radius unit suites green (test_drift_detection, test_install_update,
    tests/unit/install/): 1831 passed.
  • Lint clean: ruff check + ruff format --check on the changed files.

How to test

uv run pytest tests/unit/test_drift_detection.py -q

Or reproduce the original symptom: in a project that uses includes: auto with
a committed apm.lock.yaml, run apm install --dry-run — before this change
the project's own deployed files are listed under "Files that would be
removed"; after, they are not.

Scope notes

  • src/apm_cli/drift.py is not under the OpenAPM Mode-B critical-path allowlist
    (tests/spec_conformance/critical_paths.txt), so no spec anchor/manifest/
    marker or apm-spec-waiver: is required.
  • Out of scope (noted by review): the dep_key == _SELF_KEY idiom now appears
    in several consumers; a LockFile.real_dependencies() accessor to DRY it is a
    separate refactor.

Copilot AI review requested due to automatic review settings July 8, 2026 09:58

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a dry-run-only drift preview bug where apm install --dry-run incorrectly reported the project’s own includes: auto self-managed files as “orphans” (removals), by excluding the synthesized lockfile self-entry (_SELF_KEY, ".") from orphan detection.

Changes:

  • Update detect_orphans() to skip the synthesized self dependency key (".") so dry-run orphan previews match real install behavior.
  • Add unit tests covering both a stubbed lockfile case and a real LockFile.from_yaml() synthesis roundtrip.
  • Add a changelog entry for the fix.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
src/apm_cli/drift.py Skips _SELF_KEY during orphan detection to avoid false removals in dry-run preview.
tests/unit/test_drift_detection.py Adds regression tests ensuring the self-entry is never treated as orphaned, including a real from_yaml parse path.
CHANGELOG.md Documents the dry-run orphan preview fix under Unreleased/Fixed.

Comment thread CHANGELOG.md Outdated
Comment on lines +12 to +15
- `apm install --dry-run` no longer lists the project's own `includes: auto`
self-managed files under "Files that would be removed"; the orphan preview
now excludes the synthesized lockfile self-entry, matching the real install
which never removes them. — by @mia106dev (#XXXX)
@sergio-sisternes-epam

Copy link
Copy Markdown
Collaborator

APM Review Panel: ship_with_followups

Clean trust-repair fix for dry-run orphan false-positive; ship now, fold CHANGELOG nits, defer accessor refactor.

cc @mia106dev @danielmeppiel @sergio-sisternes-epam -- a fresh advisory pass is ready for your review.

All six active panelists converge: the fix is correct, minimal, and well-tested. No specialist disagreements to resolve. The python-architect's real_dependencies() accessor recommendation is the only architecturally substantive finding, and the PR author already called it out as deferred scope -- this is the right call for a community contributor's bugfix PR; we do not pile refactors onto external contributions.

The doc-writer caught two nits on the CHANGELOG entry (attribution format and em-dash encoding) that should be folded before merge -- they're mechanical, break established conventions, and take 30 seconds to fix. The test-coverage-expert validated that the new from_yaml roundtrip test is the right shape for a regression trap (9 passed, 0.21s, integration-with-fixtures tier). The missing e2e variant requires GITHUB_APM_PAT and is correctly scoped as a follow-up, not a gate.

Strategically, this is a low-risk, high-trust fix. Dry-run fidelity is load-bearing for adoption: users who see false warnings in preview mode lose confidence in the tool before they ever run a real install. Shipping this quickly signals that we treat preview accuracy as a first-class contract.

Aligned with: Pragmatic as npm -- Dry-run telling the truth is table-stakes for any package manager; this fix closes a gap that npm/pip users would never tolerate. OSS community-driven -- Community contributor fix, cleanly scoped, no refactor burden imposed. Exactly the kind of PR that should merge fast to keep the contributor loop tight.

Growth signal. The oss-growth-hacker's note is worth amplifying at release time: '--dry-run now tells the truth' is a concrete trust signal. Every false positive in a preview surface has outsized negative adoption impact because it poisons the user's mental model before real usage begins. Consider tracking dry-run accuracy as a quality metric in release notes -- it reinforces APM's 'predictable by default' positioning against tools where preview modes are an afterthought.

Panel summary

Persona B R N Takeaway
Python Architect 0 1 1 Fix is correct and minimal; the scattered _SELF_KEY guard (now 10+ sites) is the real architectural debt worth a follow-up accessor.
CLI Logging Expert 0 0 0 Fix correctly silences false orphan noise in dry-run; output parity with real install restored, no output contract violations.
DevX UX Expert 0 0 1 Fix correctly restores dry-run fidelity for includes:auto projects; CHANGELOG entry is clear; no UX regressions.
Supply Chain Security Expert 0 0 0 Preview-only orphan guard; no file-deletion, integrity, or provenance paths affected. No security regression.
OSS Growth Hacker 0 0 1 Good trust-repair fix for dry-run; CHANGELOG entry is user-facing and clear -- minor story-angle opportunity in release notes.
Doc Writer 0 0 3 CHANGELOG entry is accurate and scoped correctly; two nits on attribution format and em-dash encoding; no doc-site pages need updating.
Test Coverage Expert 0 2 1 Two new unit tests defend the _SELF_KEY guard well; from_yaml roundtrip test is near-integration tier. No e2e gap blocks ship.

B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.

Top 5 follow-ups

  1. [Doc Writer] Fix CHANGELOG attribution format to match established (by @author, #NNNN) pattern and drop the em-dash. -- Consistency with every other CHANGELOG entry; ASCII-only encoding rule.
  2. [Python Architect] Add LockFile.real_dependencies accessor to centralize the _SELF_KEY guard across 10+ call sites. -- Eliminates the entire class of 'forgot to skip self-entry' bugs; each new consumer today must independently remember the guard.
  3. [Test Coverage Expert] Add an includes:auto variant to the e2e dry-run integration test. -- Certifies the full CLI path end-to-end; currently the e2e fixture does not exercise self-entry synthesis so this class of regression would not be caught at the integration tier.
  4. [Test Coverage Expert] Add parametrize case for lockfile with only a self-entry and zero real deps. -- Trivial edge case that makes the empty-set return explicit; one line of test code.
  5. [DevX UX Expert] Document dry-run fidelity contract in CLI reference or --help text. -- The promise that --dry-run output exactly matches real install behavior is enforced by code but never stated to users.

Architecture

classDiagram
    direction LR
    class LockFile {
      ``Dataclass``
      +dependencies: dict~str, LockedDependency~
      +from_yaml(text) LockFile
      +to_yaml() str
    }
    class LockedDependency {
      ``Dataclass``
      +repo_url: str
      +deployed_files: list~str~
      +local_path: str
    }
    class detect_orphans {
      ``Function``
      +detect_orphans(lockfile, intended, only_packages) set
    }
    class DryRunPresenter {
      ``Module: dry_run.py``
      +present_dry_run_preview()
    }
    class CleanupPhase {
      ``Module: cleanup.py``
      +run_orphan_cleanup()
    }
    class InstallDrift {
      ``Module: install/drift.py``
      +replay_drift()
    }
    LockFile *-- LockedDependency : contains
    DryRunPresenter ..> detect_orphans : calls
    detect_orphans ..> LockFile : reads dependencies
    CleanupPhase ..> LockFile : iterates dependencies
    InstallDrift ..> LockFile : iterates dependencies
    note for LockFile "_SELF_KEY guard duplicated in every consumer -- 10+ sites"
    class detect_orphans:::touched
    classDef touched fill:#fff3b0,stroke:#d47600
Loading
flowchart TD
    A["apm.lock.yaml on disk"] -->|LockFile.from_yaml| B["LockFile.dependencies\ndicts incl. _SELF_KEY='.'"]
    B --> C{"dry-run mode?"}
    C -->|Yes| D["dry_run.py:70\npresent_dry_run_preview()"]
    D -->|calls| E["detect_orphans()"]
    E -->|NEW: skip _SELF_KEY| F["Return orphaned file set\n(self-includes excluded)"]
    F --> G["logger.progress()\npreview removed files"]
    C -->|No: real install| H["cleanup.py:74\nrun_orphan_cleanup()"]
    H -->|existing guard: skip _SELF_KEY| I["remove_stale_deployed_files()"]
    style E fill:#fff3b0,stroke:#d47600
    style F fill:#fff3b0,stroke:#d47600
Loading
sequenceDiagram
    participant User
    participant CLI as apm install --dry-run
    participant DryRun as dry_run.py
    participant Detect as drift.detect_orphans
    participant LF as LockFile
    User->>CLI: apm install --dry-run
    CLI->>DryRun: present_dry_run_preview()
    DryRun->>LF: from_yaml(apm.lock.yaml)
    LF-->>DryRun: LockFile with synthesized self-entry
    DryRun->>Detect: detect_orphans(lockfile, intended_keys)
    loop each dep_key in lockfile.dependencies
        alt dep_key == _SELF_KEY
            Detect-->>Detect: skip (NEW guard)
        else dep_key not in intended
            Detect-->>Detect: add to orphaned set
        end
    end
    Detect-->>DryRun: orphaned files (no self-includes)
    DryRun-->>User: preview: N files would be removed
Loading

Recommendation

Ship after folding the two CHANGELOG nits (attribution format + em-dash). The fix is correct, the tests are solid including a real from_yaml roundtrip regression trap, no security or UX regressions, and every panelist agrees. The accessor refactor and e2e gap are legitimate follow-ups but gating a community contributor's clean two-line fix on them would be exactly the kind of scope creep that loses contributors. Merge fast, file the follow-ups, and credit @mia106dev in the release notes.


Full per-persona findings

Python Architect

  • [recommended] LockFile should expose a real_dependencies() accessor to centralize the _SELF_KEY guard at src/apm_cli/deps/lockfile.py
    _SELF_KEY is imported and guarded against in 10+ call sites across 7 files. Each consumer independently does if key == _SELF_KEY: continue. A LockFile.real_dependencies property would eliminate the entire class of bugs this PR fixes.
    Suggested: @property def real_dependencies(self) -> dict[str, LockedDependency]: return {k: v for k, v in self.dependencies.items() if k != _SELF_KEY}

  • [nit] detect_orphans docstring should mention self-entry exclusion at src/apm_cli/drift.py:200
    The Returns section should note that the synthesized self-entry (key ".") is always excluded from the returned set.

CLI Logging Expert

No findings.

DevX UX Expert

  • [nit] The dry-run fidelity contract (that --dry-run output exactly matches real install) is enforced by code but never stated in CLI reference docs or --help text.

Supply Chain Security Expert

No findings.

OSS Growth Hacker

  • [nit] CHANGELOG entry misses the emotional hook. Suggested rephrasing: "Fixed a false-positive in apm install --dry-run that incorrectly warned includes: auto projects their own files would be removed -- the real install was never affected, but the preview was misleading."

Auth Expert -- inactive

PR touches only src/apm_cli/drift.py, tests/unit/test_drift_detection.py, and CHANGELOG.md -- none of which involve token management, credential resolution, or remote-host authentication.

Doc Writer

  • [nit] Attribution format -- by @mia106dev (#2069) breaks the established (by @author, #NNNN) pattern. Change to (by @mia106dev, #2069).

  • [nit] The separator before attribution is a Unicode em-dash (U+2014) which violates the ASCII-only encoding rule. Switching to parenthesised attribution resolves this.

  • [nit] CHANGELOG sentence is slightly overloaded with internal mechanism detail ("synthesized lockfile self-entry"). Optional: trim to user-visible behaviour only.

Test Coverage Expert

  • [nit] Missing edge case: lockfile with ONLY a self-entry (no other deps) should return empty set. One parametrize entry would make this explicit.
    Proof (missing): tests/unit/test_drift_detection.py::test_self_entry_only_lockfile_returns_empty -- proves: A project with includes:auto but zero deps does not report self-includes as orphans

  • [recommended] test_self_include_survives_real_from_yaml_roundtrip uses real LockFile.from_yaml() -- effectively an integration-with-fixtures test. RIGHT shape for a regression trap.
    Proof (passed, integration-with-fixtures): tests/unit/test_drift_detection.py::test_self_include_survives_real_from_yaml_roundtrip -- proves: LockFile.from_yaml synthesis + detect_orphans guard agree on _SELF_KEY value. Run: 9 passed, 0.21s.
    self.assertEqual(result, {".apm/x.md"})

  • [recommended] E2E dry-run test (tests/integration/test_install_dry_run_e2e.py) does not use includes:auto in its fixture. Adding a variant would certify the full CLI path; requires GITHUB_APM_PAT so this is a follow-up.
    Proof (missing, e2e): tests/integration/test_install_dry_run_e2e.py::test_install_dry_run_includes_auto_no_false_orphans

Performance Expert -- inactive

The change adds a trivial guard in detect_orphans() which runs only during --dry-run preview -- not in any hot path.

This panel is advisory. It does not block merge. Re-apply the
panel-review label after addressing feedback to re-run.

@sergio-sisternes-epam sergio-sisternes-epam added the panel-review Trigger the apm-review-panel gh-aw workflow label Jul 9, 2026
@sergio-sisternes-epam

Copy link
Copy Markdown
Collaborator

@mia106dev thanks for the clean fix! One pre-merge ask before we queue this:

The CHANGELOG entry uses a Unicode em-dash (U+2014) and a non-standard attribution format. Please change line 15 from:

  which never removes them. — by @mia106dev (#2069)

to:

  which never removes them. (by @mia106dev, #2069)

This matches the established pattern in every other CHANGELOG entry and satisfies the ASCII-only encoding rule. Everything else looks great -- once that's pushed the PR is ready to merge.

@sergio-sisternes-epam sergio-sisternes-epam removed the panel-review Trigger the apm-review-panel gh-aw workflow label Jul 9, 2026
@github-actions github-actions Bot mentioned this pull request Jul 9, 2026
@mia106dev

Copy link
Copy Markdown
Contributor Author

Thanks for the review! Fixed in 61f07df — line 15 now reads (by @mia106dev, #2069), matching every other entry and staying ASCII-only. Ready to merge.

`apm install --dry-run` over-reported the project's own includes:auto
self-managed files under "Files that would be removed". LockFile.from_yaml
synthesizes a virtual _SELF_KEY (".") dependency from local_deployed_files,
and detect_orphans flagged every dependency key absent from the manifest-
derived intended set as orphaned -- including "." which can never appear
there. The real install path already guards _SELF_KEY on replay/cleanup and
never removes these files, so this was a dry-run-preview-only false alarm.

Skip _SELF_KEY in detect_orphans. Add unit tests covering both the stub
and the real LockFile.from_yaml synthesis path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@mia106dev mia106dev force-pushed the fix/dry-run-self-include-orphan-overreport branch from 61f07df to cdc1bad Compare July 14, 2026 05:49
@danielmeppiel danielmeppiel added the panel-review Trigger the apm-review-panel gh-aw workflow label Jul 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

panel-review Trigger the apm-review-panel gh-aw workflow

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants