fix(virtual-packages): restore hermetic lifecycle convergence#2240
Conversation
Compose manifestless skill and synthetic-file lifecycle rows, and restore an empty locked module cache during unchanged updates. apm-spec-waiver: Restores existing locked cache without extending the OpenAPM contract. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8ea838c0-02c0-45e5-a262-d2416f235ca3
There was a problem hiding this comment.
Pull request overview
This PR fixes a lifecycle convergence gap for virtual/manifestless dependencies by teaching apm update to re-materialize an empty apm_modules/ cache even when the resolved refs are unchanged, and it adds a hermetic end-to-end lifecycle matrix to prove install/lock/frozen/update/audit behavior (including CRLF pressure and failure preservation).
Changes:
- Extend the
apm updateno-change plan path to proceed when the lock expects dependencies but the module cache is wholly unmaterialized (rehydrate without prompting or ref changes). - Add unit coverage for the new cache-rehydration predicate and the “no prompt, proceed” behavior.
- Add a new hermetic integration lifecycle matrix test for virtual packages (manifestless Claude skill + synthetic virtual file), and document the observable behavior in CLI docs + packaged guide.
Show a summary per file
| File | Description |
|---|---|
src/apm_cli/commands/update.py |
Adds _module_cache_needs_rehydration and threads a “rehydrate even if plan is unchanged” decision through the plan callback + final reporting. |
tests/unit/commands/test_update_command.py |
Adds focused unit tests for the new rehydration behavior and predicate edge cases (including ignoring resolution staging). |
tests/integration/test_virtual_package_lifecycle_matrix.py |
New hermetic integration test matrix covering virtual/manifestless lifecycle, drift twins, and newline-domain determinism. |
docs/src/content/docs/reference/cli/update.md |
Updates CLI reference to explain same-ref cache repair and consent semantics (including --dry-run remaining read-only). |
packages/apm-guide/.apm/skills/apm-usage/commands.md |
Keeps the packaged usage guide in sync with the updated apm update behavior/consent model. |
Review details
- Files reviewed: 5/5 changed files
- Comments generated: 1
- Review effort level: Low
APM Review Panel:
|
| Persona | B | R | N | Takeaway |
|---|---|---|---|---|
| Python Architect | 0 | 0 | 1 | Bounded fix routes through canonical owners; no new authority introduced. |
| CLI Logging Expert | 0 | 0 | 0 | Schema failure after two retries; no validated finding included. |
| DevX UX Expert | 0 | 0 | 0 | Same-ref repair preserves consent semantics, dry-run behavior, and package-manager UX. |
| Supply Chain Security Expert | 0 | 0 | 1 | Repair is consent-safe and adds no identity or hash authority. |
| OSS Growth Hacker | 0 | 1 | 1 | Cache restoration is a retention signal; broader funnel messaging is later scope. |
| Doc Writer | 0 | 2 | 0 | Two documentation truth corrections should fold now. |
| Test Coverage Expert | 0 | 0 | 1 | Five integration rows and a unit regression trap prove the behavior. |
| Performance Expert | 0 | 0 | 1 | The one-time check is bounded; a lazy loop is a small cleanup. |
B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.
Top 4 follow-ups
- [Doc Writer] Correct dry-run wording in the CLI update reference -- the current sentence promises no-change plan output that the CLI does not render.
- [Doc Writer] Qualify the consumer guide's blanket consent claim -- same-ref cache restoration now proceeds without a prompt, while ref changes still require consent.
- [Test Coverage Expert] Assert the restoration message in the integration row -- the unit test already proves the string; this binds it to the full lifecycle.
- [Performance Expert] Use a lazy single-pass cache-path check -- this avoids canonical path construction after the first warm-cache hit.
Architecture
classDiagram
direction LR
class LockFile {
+dependencies dict
}
class LockedDependency {
+to_dependency_ref() DependencyReference
}
class DependencyReference {
+get_install_path(modules_dir) Path
}
class UpdateCommand {
+_module_cache_needs_rehydration(lockfile, modules_dir) bool
+_plan_callback(plan) bool
}
LockFile *-- LockedDependency : contains
LockedDependency ..> DependencyReference : converts
UpdateCommand ..> LockFile : reads
UpdateCommand ..> DependencyReference : canonical install path
flowchart TD
A["apm update"] --> B["Read lockfile"]
B --> C["Check canonical locked paths"]
C --> D{"Any path materialized"}
D -->|yes| E["Normal no-change exit"]
D -->|no| F["Rehydrate unchanged refs"]
F --> G["Materialize locked tree"]
G --> H["Audit convergence"]
Recommendation
Fold the two documentation truth fixes before freezing the branch. The message assertion and lazy-loop cleanup are safe, bounded polish; quickstart expansion should remain separate.
Full per-persona findings
Python Architect
- [nit] Eager tuple materialization in cache detection at
src/apm_cli/commands/update.py:120
The tuple builds every canonical install path beforeany()can short-circuit. The practical cost is negligible for typical lockfiles, but a single-pass loop is clearer.
Suggested: Return false on the first existing canonical path and otherwise return whether any dependency was seen.
CLI Logging Expert
No validated findings. The final retry returned a legacy schema.
DevX UX Expert
No findings.
Supply Chain Security Expert
- [nit] Consider a verbose pre-materialization cache-repair diagnostic at
src/apm_cli/commands/update.py:631
A verbose-only line could make intentional cache repair easier to distinguish in CI logs. This is not a security gap.
OSS Growth Hacker
- [recommended] Cache-repair behavior is not surfaced in the quickstart funnel at
docs/src/content/docs/getting-started/quick-start.md
This is discoverability work beyond the bounded reference-doc change and should remain a later follow-up. - [nit] Reference wording uses internal cache terminology at
docs/src/content/docs/reference/cli/update.md:104
"Restores your packages" is easier to scan than "rehydrates the cache."
Auth Expert -- inactive
The diff does not change authentication, tokens, credential resolution, or host fallback.
Doc Writer
- [recommended] Make the dry-run sentence match unchanged-cache output at
docs/src/content/docs/reference/cli/update.md:20
The command emits the generic dry-run notice rather than rendering a no-change plan.
Suggested: State that--dry-runremains read-only and does not restore files. - [recommended] Qualify the consumer guide's blanket consent claim at
docs/src/content/docs/consumer/update-and-refresh.mdx:27
Same-ref cache restoration proceeds without consent, so the existing blanket claim conflicts with the updated reference.
Test Coverage Expert
- [nit] The integration row does not assert the cache-restoration success message at
tests/integration/test_virtual_package_lifecycle_matrix.py
The unit test proves the exact string and the integration row proves restored bytes and clean audit. Adding the message assertion tightens the observable contract.
Proof (passed):tests/unit/commands/test_update_command.py::TestUpdateNoChanges::test_locked_empty_module_cache_rehydrates_without_prompt-- proves: an unchanged locked project restores an evicted cache and reports the repair.
Performance Expert
- [nit] Eager tuple prevents short-circuit on the first existing path at
src/apm_cli/commands/update.py:120
A lazy single-pass loop avoids unnecessary canonical path work after the first warm-cache hit.
This panel is advisory. It does not block merge. Re-apply the
panel-review label after addressing feedback to re-run.
Correct consent and dry-run docs, short-circuit cache path checks, align the success symbol, and bind the message at integration tier. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8ea838c0-02c0-45e5-a262-d2416f235ca3
Document the non-interactive unchanged-ref cache repair exception identified by Copilot review. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8ea838c0-02c0-45e5-a262-d2416f235ca3
Add focused unit-test coverage proving that an unchanged, local-only locked dependency (is_local/source="local") whose materialized apm_modules/_local/<name> cache is wholly absent rehydrates automatically without a ref-change consent prompt, and that the rehydration check follows the exact canonical _local path -- including the hashed parent-slot shape used for transitive local deps -- rather than a naive or flattened path that could loop or misclassify. No production behavior changed: the existing _module_cache_needs_rehydration implementation already delegates to DependencyReference.get_install_path, which is correct for local deps. These tests close the coverage gap on the existing focused test surface (test_update_command.py) rather than expanding the 625-line lifecycle matrix. Also add one CHANGELOG [Unreleased]/Fixed entry describing the automatic repair of a wholly absent locked dependency cache without ref changes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 50d2a0e8-6c2f-431a-a4d3-8c8f0c8f3bb6
Prove a missing canonical _local cache restores once without consent, and document the user-visible repair under Unreleased. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8ea838c0-02c0-45e5-a262-d2416f235ca3
Retain one real local-manifest command regression and one Unreleased entry after reconciling the overlapping remote follow-up. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8ea838c0-02c0-45e5-a262-d2416f235ca3
A parallel takeover of this PR (a2594d2, 498e973) landed a truthful local-manifest command regression (test_locked_local_dependency_rehydrates_canonical_cache_once) but its reconciliation pass also dropped two unit-level helper tests that directly exercise _module_cache_needs_rehydration against the canonical _local path shape: - test_module_cache_rehydration_local_dependency_uses_canonical_local_path proves a direct local dependency's absent cache is only satisfied by apm_modules/_local/<name>, not a misclassified/flattened path. - test_module_cache_rehydration_transitive_local_dependency_uses_hashed_parent_slot proves a transitive local dependency (declaring_parent set) requires the hashed apm_modules/_local/<parent_slot>/<name> shape, not the flattened top-level one. Restore both as-is. The command-level regression test and the CHANGELOG Unreleased/Fixed entry from the earlier reconciliation are left untouched -- this is a pure coverage restoration, no production behavior changed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 50d2a0e8-6c2f-431a-a4d3-8c8f0c8f3bb6
fix(virtual-packages): restore and prove the locked lifecycle
TL;DR
This PR composes the virtual-package fixes from #2214, #2217, and #2223 into one hermetic lifecycle matrix: install, lock, frozen replay, update, and audit. The RED contract found one bounded product defect: an unchanged
apm updateskipped materialization after a failed frozen replay leftapm_modules/empty. The fix reuses the canonical lock-to-install-path owner, restores the cache from unchanged refs without prompting, and preserves the prior lock and deployed bytes on failed resolution.Important
This lane does not edit
critical_suite.toml, TM002, or required-CI promotion. Promotion remains a later serialized change.Problem (WHY)
apm updatereturned at the no-change plan gate. The lock and deployed bytes survived, but the dependency cache stayed empty and the next audit could not converge.These failures conflict with P6: "Behavior must be predictable, auditable, and explainable in plain English." The no-change repair also preserves P4's UX floor: "No bug fix, hardening, security patch, or refactor lands if it makes those commands harder, slower, more verbose, or more confusing for a new user."
Approach (WHAT)
claude_skillsubdirectory and a synthetic single-file instruction.apm updateno-change decision so an entirely absent locked cache proceeds through normal materialization without a ref-change prompt.Implementation (HOW)
src/apm_cli/commands/update.pyLockedDependency.to_dependency_ref().get_install_path(), capture that fact before resolution staging, and allow the unchanged plan to rehydrate without consent. No identity or hash authority is added.tests/integration/test_virtual_package_lifecycle_matrix.pytests/unit/commands/test_update_command.py_localmaterialization, and cover direct plus hashed transitive local install paths.CHANGELOG.mddocs/src/content/docs/reference/cli/update.mddocs/src/content/docs/consumer/update-and-refresh.mdxpackages/apm-guide/.apm/skills/apm-usage/commands.mdDiagrams
Legend: the dashed node is the only new product branch; every other node is the composed lifecycle contract.
flowchart LR I[Install] --> L[Lock] L --> F[Frozen replay] F --> U[Update] U --> D{Locked package path exists} D -->|yes| N[No-op] D -->|no| R[Rehydrate unchanged refs] N --> A[Audit] R --> A F -->|integrity failure| G[Preserve last-good lock and deployed bytes] U -->|resolution failure| G G --> A classDef new stroke-dasharray: 5 5; class R new;Trade-offs
LockedDependency.to_dependency_ref().get_install_path(); rejected directory-name heuristics and any second virtual-package identity/path authority.critical_suite.toml, TM002, and CI promotion metadata in this parallel PR.Benefits
install -> lock -> frozen replay -> update -> audit.config-consistency,content-integrity, anddriftaudit checks, including CRLF pressure and cache rehydration.Restored dependency cache without changing refs._localpaths.Validation
uv run --extra dev pytest -q tests/integration/test_virtual_package_lifecycle_matrix.py:uv run --extra dev pytest -q tests/unit/commands/test_update_command.py tests/integration/test_virtual_package_lifecycle_matrix.py tests/integration/test_virtual_skill_audit_e2e.py tests/integration/test_virtual_claude_skill_lock_convergence.py tests/unit/deps/test_github_downloader_single_file_sha.py tests/unit/integration/test_mcp_config_view.py:Full local gate evidence
uv run --extra dev pytest -q tests/unit tests/test_console.py tests/red_team:Quality and authority contracts:
Spec conformance:
uv run apm audit --ci:Canonical lint:
Final exact head:
Mutation evidence
unknown -> 1.0.0Path.write_textcontent-drift-pinned-virtual-filefailed because restored module bytes were absent._local/local-pkgwas still misclassified as absent.All mutations were restored before commit.
Scenario Evidence
tests/integration/test_virtual_package_lifecycle_matrix.py::test_virtual_package_lifecycle_matrix[unchanged-on-disk-cache]tests/integration/test_virtual_package_lifecycle_matrix.py::test_virtual_package_lifecycle_matrix[unchanged-rehydrated-cache]tests/integration/test_virtual_package_lifecycle_matrix.py::test_virtual_package_lifecycle_matrix[source-drift-missing-skill](regression-trap for #2207)tests/integration/test_virtual_package_lifecycle_matrix.py::test_virtual_package_lifecycle_matrix[metadata-drift-invalid-frontmatter](regression-trap for #2206)tests/integration/test_virtual_package_lifecycle_matrix.py::test_virtual_package_lifecycle_matrix[content-drift-pinned-virtual-file](regression-trap for this PR)How to test
content-drift-pinned-virtual-filerow alone and observe frozen replay reject the hash mismatch before update restores the cache.uv run apm audit --cifrom the repository root and observe all 9 checks pass.critical_suite.toml, TM002, and CI promotion files are untouched.Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com