perf(resolver): memoize NpmRange→pubgrub::Ranges conversion#4
Merged
Conversation
`NpmRange::to_pubgrub_ranges(&available_versions)` is O(N) in version
count and runs on every `DependencyProvider::get_dependencies` call —
the hot path PubGrub hits for every edge during resolution AND
re-hits during backtracking. Nothing cached it.
This is the latent cost that surfaced in Phase 41: adding 9 extra
packages to the metadata cache regressed pubgrub_core_ms by +962 ms
without this memoization. Phase 40 P4 split-context deduplication
had masked the issue on today's decision-gate fixture by
collapsing the resolver's walk to near-linear, so on clean fixtures
there's no backtracking to deduplicate — but pathological inputs
(conflicting peer deps, heavy overrides, projects with deep version
ranges) will hit it, and the future Phase 43 speculation cache
raises CPU pressure across the board.
Fix. Add `range_cache: RefCell<HashMap<(ResolverPackage, String),
Ranges<NpmVersion>>>` on `LpmDependencyProvider`. Keyed on the
package identity + raw range string. `to_pubgrub_ranges_cached`
wraps the O(N) conversion behind the cache; the two production
call sites in `get_dependencies` (root + transitive) go through
the wrapper. Heuristic fallback (when `available_versions` is
empty) stays uncached because its cost is bounded by range bounds,
not version count.
Correctness. Safe within a provider instance because
`available_versions(pkg)` is fixed once `ensure_cached(pkg)` runs.
The metadata cache is append-only per resolve pass and platform
filtering is a pure function of the (also fixed) platform map.
Keyed on `ResolverPackage` (including split context) so future
per-split behaviour differences can't silently share stale cells.
NOT transferred across provider instances by `with_cache` /
`with_prefetched_metadata` — the metadata cache transfers, the
range cache rebuilds. Keeps the correctness invariant local.
Two failing-test-first regression tests encode the contract:
- `to_pubgrub_ranges_cached_hits_on_repeated_query` —
identical `(pkg, range)` query populates exactly one cache
entry and the hit returns structurally equal `Ranges`; a
different range keys a new entry.
- `to_pubgrub_ranges_cached_distinguishes_split_packages` —
plain and split variants of the same canonical name stay in
separate cells even with identical range + available set.
Both fail to compile before the fix (unknown method +
unknown field), pass after.
## Measured effect on decision-gate — null result
Same-session 3×3 interleaved A/B vs Phase 42 main, cold cache each
run, medians:
metric | Phase 42 main | this PR | delta
--------------------|--------------:|------------:|--------------
total_ms | 6 914 | 6 635 | -279 (noise)
resolve_ms | 5 595 | 5 479 | -116 (noise)
pubgrub_ms | 2 411 | 2 435 | +24 (noise)
pubgrub_core_ms | 782 | 851 | +69 (noise)
followup_rpc_ms | 1 624 | 1 563 | -61 (noise)
All deltas sit inside run-to-run variance. Post-Phase-40-P4 the
decision-gate walk barely backtracks, so every `get_dependencies`
call is a compulsory cache miss — the cache has nothing to reuse.
That's the condition Gemini's Phase 41 prediction of ~700 ms of
the 962 ms regression would have been recoverable via this exact
cache under; without that metadata pressure, the cache is dormant.
## Why ship anyway
1. Algorithmic correctness, not feature YAGNI. An uncached O(N)
inside a backtracking search algorithm is a latent perf bug.
The "it doesn't hit today" argument evaporates the first time
a user's tree backtracks — which we can't predict from benches
alone.
2. Zero overhead on fixtures that don't exercise it. Cache is a
RefCell<HashMap> paid once per (pkg, range) pair; measured
runtime overhead is below the noise floor on decision-gate.
Memory: ~16 KB on a 100-package resolve.
3. Phase 43 readiness. The speculation-cache layer (fetch at T=0
from lockfile, verify resolve in parallel) puts more CPU
pressure on the resolver side. Cleaning the range-conversion
hot path now means the next round of measurements isn't
polluted by this known-uncached O(N).
4. Second-opinion framing (Gemini, post-fix): "algorithmic
integrity ≠ feature YAGNI." Agreed — different bar for fixing
a primitive's complexity than for shipping speculative
architecture. This PR is the former.
## CI gate locally green
- cargo clippy --workspace -- -D warnings
- cargo fmt --check
- fancy-regex ban
- cargo nextest run --workspace --exclude lpm-integration-tests
(3643 passed, 7 skipped, 0 failed)
- cargo test -p lpm-auth x2 (43 passed both, parallel-deterministic)
- 51-pkg + 280-pkg + decision-gate cold installs vs lpm.dev:
no regressions, zero WARN
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tolgaergin
added a commit
that referenced
this pull request
Apr 17, 2026
Phase 42 — resolver correctness + perf fix + algorithmic insurance. Since v0.19.1: - **fix(registry)** — Phase 42 P0/P1 NDJSON parse loop — O(n²) scan + wall-clock timeout (#3). Decision-gate install 46 s → 6.7 s (7× speedup) when the registry streams large batch-metadata responses. The O(n²) scan was the dominant cost in NDJSON parsing for large packuments; a rolling-offset rewrite brings it to O(n). Wall-clock timeout defends against slow-emit registries stalling the resolver. - **fix(install)** — Phase 40 P4 split-context dedupe (#2). When two sibling parents produced the same grandchild under different split contexts, the grandchild was duplicated in the fetch/link plan. Dedupe on canonical `(name, version)` before fetch dispatch. No user-visible lockfile change. - **perf(resolver)** — Phase 42 P2 `NpmRange → pubgrub::Ranges` memoization (#4). Null-result in benchmarks but shipped as algorithmic insurance: the conversion is O(m) per call and was re-computed on every PubGrub visit. Memoized table eliminates redundant work. Neutral on current fixtures, protective against pathological cases. - **chore(ci)** — Node.js 24 opt-in for JavaScript actions ahead of GitHub's 2026-06-02 forced-default. No breaking changes. Lockfile compatibility unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tolgaergin
added a commit
that referenced
this pull request
Apr 24, 2026
Second sub-slice of the per-package capability lane. Extends the
approval record to carry a `capability_hash` field and adds the
match method `CapabilitySet::is_approved_by`. Deliberately does
NOT wire enforcement — that's sub-slice 6c. This commit is
purely about storage shape + match semantics.
# Invariant this commit protects
**An absent `capabilityHash` field MUST mean "Phase 46 legacy
approval, baseline only" — NEVER silent widening.** Every
existing approval in every user's `package.json` predates this
sub-slice and must load into the new struct unchanged. A
malicious repo cannot exploit the schema migration to have an
old approval authorize a newly-added `passEnv` / `readProject =
"full"` / above-ceiling `sandboxLimits`.
Serde testing pins this from the storage side; the new
`is_approved_by` method pins it from the match side; together
they form a two-sided contract the enforcement slice (6c) can
rely on.
# What changes in lpm-workspace
New field on [`TrustedDependencyBinding`]:
```rust
#[serde(default, rename = "capabilityHash", skip_serializing_if = "Option::is_none")]
pub capability_hash: Option<String>,
```
Same non-breaking pattern the sibling Option fields use
(`provenanceAtApproval`, `behavioralTagsHash`, etc.): absent in
the JSON → `None` in the struct → omitted on re-serialization.
Two existing construction sites (the `<name>@*` migration
sentinel and `approve_with_metadata`) set `capability_hash:
None` with comments explaining WHY: the migration sentinel
carries no grant; `approve_with_metadata` plumbs no hash yet
(sub-slice 6d wires it up once `ApprovalMetadata` grows the
field).
# What changes in lpm-cli
New method on [`CapabilitySet`]:
```rust
pub fn is_approved_by(&self, binding: &TrustedDependencyBinding) -> bool {
match &binding.capability_hash {
None => self.is_at_baseline(),
Some(stored) => stored == &self.canonical_hash(),
}
}
```
The method lives on `CapabilitySet` (not on the binding) because
lpm-workspace can't import the hash primitive without a dep
cycle. The enforcement path in sub-slice 6c consumes this
method exclusively; direct comparison of `binding.capability_hash`
in runtime code is explicitly forbidden by the field's
rustdoc (the semantic "None = baseline only" isn't evident
from the field type alone).
# Clippy: TrustMatch::BindingDrift.stored boxed
Adding `capability_hash: Option<String>` pushed the binding
past `clippy::large_enum_variant`'s threshold (24-byte Option
on top of the existing five Options). The reviewed fix per
clippy's help text: box the drift variant's stored binding:
```rust
BindingDrift {
stored: Box<TrustedDependencyBinding>, // was TrustedDependencyBinding
}
```
Construction site updated to `Box::new(stored.clone())`. The
single caller that dereferences `stored.script_hash` works
unchanged via `Box`'s auto-deref — no deep API churn. All 2133-
style `{ .. }` consumers are unaffected (they don't look at
the field). The rustdoc on the variant now names the 6b reason
so a future field-addition knows the box is deliberate, not
accidental.
# Test coverage (14 new tests, all green)
**Storage-side (lpm-workspace):**
1. `binding_without_capability_hash_loads_as_legacy_approval`
— three shapes of pre-6b JSON (empty, bare integrity+script,
full P7 with provenance + behavioral tags) all deserialize
with `capability_hash = None`.
2. `binding_with_capability_hash_round_trips` — serialize emits
`"capabilityHash":"..."`, deserialize restores the value.
3. `binding_with_none_capability_hash_omits_key_in_json` — None
serializes as absent key, not `null` (matches sibling fields).
4. `legacy_binding_stays_legacy_after_round_trip_on_new_code` —
old JSON → new struct → re-serialize MUST NOT introduce a
`capabilityHash` key. Pins the "we never silently upgrade
legacy to bound-capability" invariant.
5. `default_binding_has_no_capability_hash` — `Default` impl
pins `capability_hash = None` from the Default side.
**Match-side (lpm-cli):**
6. `legacy_binding_approves_baseline_request` — reviewer's #2.
7. `legacy_binding_rejects_widened_pass_env` — reviewer's #3a.
8. `legacy_binding_rejects_full_read_project` — reviewer's #3b.
9. `legacy_binding_rejects_non_empty_sandbox_limits` —
reviewer's #3c. Any non-empty sandbox_limits invalidates a
legacy approval at the storage-match level; enforcement-time
under-ceiling vs. above-ceiling distinctions belong to 6c.
10. `binding_with_matching_hash_approves_set` — reviewer's #4.
11. `binding_with_mismatched_hash_rejects_non_baseline` —
reviewer's #5 (drift).
12. `binding_with_mismatched_hash_rejects_baseline` — the
critical distinction: `Some(non-baseline-hash)` does NOT
approve a baseline request (even though the request is
"smaller" than what was approved), because the approved
set changed. Different semantic than legacy-None, same
return value — the diagnostic UX in 6d surfaces the
difference to users.
13. `drift_invalidates_previously_approved_set` — scenario
test: approve specific set, then mutate pass_env /
readProject / sandbox_limits individually, each mutation
invalidates.
14. `baseline_hash_storage_approves_baseline_request` — pins
that Some(baseline_hash) and None both approve baseline
requests, even though the storage forms differ.
# Not yet on this branch
Remaining sub-slices for the per-package capability lane:
- **6c:** wire `CapabilitySet::is_approved_by` into
`evaluate_trust` + rebuild/install. Add a `loosens_beyond`
helper that compares a capability set against the user's
configured floor/ceiling; under-bound auto-applies, above-
bound requires matching approval, drift invalidates trust.
- **6d:** `lpm approve-scripts` UX + write-path. Surface the
requested capability delta in the review prompt. Extend
`ApprovalMetadata` with `capability_hash: Option<String>` and
plumb it through `approve_with_metadata` so new approvals
serialize with the granted hash.
# Verification
- cargo clippy --workspace -- -D warnings: clean (required the
Box fix described above)
- cargo fmt --check: clean
- cargo nextest run --workspace --exclude lpm-integration-tests
--no-fail-fast: 4293 passed, 7 skipped, 0 failed (14 new
tests added across lpm-cli and lpm-workspace; pre-slice
baseline was 4279).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tolgaergin
added a commit
that referenced
this pull request
Apr 30, 2026
…le (Phase 61.2)
Three things land together because they all touch `prepare_live_package_dir`:
D8a — store-fallback hard-error. Pre-Phase-61 the function returned
`Ok(store_path)` whenever the live probe fell through, letting the
caller chdir into canonical store bytes for a lifecycle script. On
macOS (clonefile, CoW) that was silent corruption on first write; on
Linux (hardlinks) the early `if !live.starts_with(store_root)` branch
skipped detach so the script ran against shared inodes. Either way, a
soundness violation. Post-fix the function returns `Err("...not linked
into project — refusing to run lifecycle script inside the store...")`
so failures are loud, actionable, and never corrupt the store.
Audit fix #4 — wrapper-segment shape. `live_package_dir` now takes a
`wrapper_id: Option<&str>` and computes the wrapper segment via
`LayoutPaths::wrapper_segment(name, version, wrapper_id)`. The same
helper `LinkTarget::wrapper_segment` delegates to (single source of
truth across the linker / rebuild / future doctor code paths). Pre-fix
the inline `format!("{safe}@{version}")` silently missed every
non-Registry source: a Directory / Link / Tarball / Git dep with a
lifecycle script had its wrapper probe fail and fall through to the
store. Post-fix `ScriptablePackage` carries the `wrapper_id` derived
from `lp.source` via `Source::source_id()`.
Audit fix #5 — test inversion. The pre-existing
`prepare_live_package_dir_does_not_detach_when_path_is_under_store_root`
test pinned the silent-fallback contract D8a inverts. Replaced with
`prepare_live_package_dir_errors_when_unlinked` asserting the new
`Err("...not linked into project...")` shape; canary-bytes-intact
assertion preserved.
Adjacent fix in `p6_triage_autoexec_reference.rs`: the test seeded
the store but not the wrapper, relying on the silent-fallback hole to
run lifecycle scripts. Added a `seed_wrapper` helper that materializes
`<project>/.lpm/wrappers/<seg>/node_modules/<name>/` from the store —
mirroring real post-install state. Pre-D8a the same fixture passed by
accident; the new state captures the actual contract.
`LayoutPaths::wrapper_segment` is the new cross-crate helper.
`LinkTarget::wrapper_segment` delegates to it so the two cannot drift.
4949 workspace tests pass; clippy --workspace -D warnings clean;
cargo fmt clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
11 tasks
tolgaergin
added a commit
that referenced
this pull request
Apr 30, 2026
* feat(lpm-runtime): RuntimeStatus carries resolved managed-runtime bin
`Ready` and `Installed` now carry a `bin_dir: PathBuf` field — the
managed-runtime bin path that `node_bin_dir(&version)` already resolves
inside `ensure_runtime` and would otherwise discard. Downstream callers
(the PATH builder in `lpm-runner/bin_path`) can consume this hint to
skip a redundant `detect_node_version` + `list_installed` pass per
`lpm run` invocation.
For the `Installed` branch, defensively re-stat after install — if the
freshly-installed bin dir vanished mid-call (race / external tampering),
degrade to `NotInstalled` rather than panic.
This is the data-shape change that the rest of Phase 61 Tier 1 builds on.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(lpm-runner): 3-state ManagedRuntimeHint + pre-resolved PATH builder
Adds `ManagedRuntimeHint { Bin(PathBuf) | Absent | Unknown }` plus
`build_path_with_bins_pre_resolved(start_dir, hint)`. The existing
public `build_path_with_bins` becomes a thin wrapper that passes
`Unknown` — preserving the silent-detect contract for callers that
don't go through `ensure_runtime` first (rebuild, dlx, hooks,
tools.rs, doctor, orchestrator).
Why three states, not `Option<PathBuf>`:
- `Bin(path)` — caller resolved the managed runtime: use it directly.
- `Absent` — caller called `ensure_runtime` and confirmed there
is no managed runtime to use. PATH builder skips the
silent re-detect entirely (the win on unpinned projects).
- `Unknown` — caller hasn't checked. Falls back to silent detect
(current pre-Phase-61 behavior).
Collapsing `Absent` and `Unknown` into one nullable would force the
silent re-detect on the unpinned-project path — the most common shape.
Two deterministic unit tests cover the contract: `_uses_hinted_bin`
asserts the produced PATH is exactly [nm_bin, hint_bin, ...inherited]
when `Bin(...)` is supplied (uses a non-existent fake path so any
re-stat would fail-loud); `_absent_skips_runtime` asserts the PATH is
exactly [nm_bin, ...inherited]. Both assert full structure rather than
substring presence/absence so they're robust to whatever managed-
runtime fragments the developer's PATH happens to contain.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(lpm-runner/script): thread bin_hint through script/command entrypoints
Extends every `pub fn run_*` in the script runner with a
`bin_hint: &ManagedRuntimeHint` parameter, routing each internal
PATH-build through `build_path_with_bins_pre_resolved` instead of the
silent-detect wrapper. Eight entrypoints touched:
- run_script, run_script_with_envs, run_script_captured
- run_script_buffered, run_script_prefixed
- run_command, run_command_captured, run_command_buffered,
run_command_prefixed
No backwards-compatibility shims — per CLAUDE.md "no `// removed`
comments, no shims, no parallel slow-path wrappers." Tests pass
`&ManagedRuntimeHint::Unknown` (imported as `Unknown` at the top of
the test mod for brevity).
Public API surface change is mechanical (one extra parameter); the
sole external consumer is `lpm-cli`, migrated in the next commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(lpm-cli): consume bin_hint, collapse cache-config reads, delete dead wrappers
Threads the `ManagedRuntimeHint` from `commands::run::ensure_runtime`
through the script-execution chain so the downstream PATH builder
doesn't redo `detect_node_version` + `list_installed` on every
`lpm run` invocation.
Signature changes:
- `commands::run::ensure_runtime` now returns `ManagedRuntimeHint`
(`Bin(bin_dir)` for Ready/Installed; `Absent` for NotInstalled and
NoRequirement).
- `run`, `run_multi`, `run_workspace`, `run_watch`, `exec`,
`run_tasks_sequential`, `run_tasks_parallel`, `run_task`, and
`run_task_captured` all gain a `bin_hint` parameter.
Caller migration:
- `main.rs:3102` (watch path) and `main.rs:3527` (External script
shortcut) capture the hint before calling `run_watch` / `run`.
- `dev.rs` captures `runtime_hint` via the existing `tokio::join!`
block instead of discarding it; threads to the dev script invocation.
- `migrate.rs::run_verification` resolves the hint once and reuses
it across the build + test verification scripts.
Caller contract: every callsite of `run` / `run_multi` / `run_watch`
/ `exec` MUST invoke `ensure_runtime` first — that's where the
user-visible "Using node X" notice + auto-install fire. Documented
on `pub async fn run` so future callers don't bypass it accidentally.
Cache-context dedup (Tier 1.4.2):
- `run` reads `lpm.json` once at the top instead of twice (cache-hit
check + caching-enabled check both used to read).
- Migrates the simple-script path to use the existing
`try_cache_hit_with_config` and `is_task_cached_with_config`
helpers — the no-config wrappers were only used by this one
callsite.
Dead-code removal (CLAUDE.md "no shims"):
- Delete `is_task_cached`, `try_cache_hit`, `try_cache_store_with_output`
— every other call site already used the `_with_config` variants.
- Delete the `is_task_cached_false_without_lpm_json` test that
exclusively exercised the deleted wrapper; the equivalent contract
is exercised by `is_task_cached_with_config_*` tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* perf(lpm-cli/run): Tier 1 follow-ups — workspace pin inheritance, parallel Arc reuse, is_meta_task plumbing
Three follow-ups that landed during the M/L review pass on top of the
base hint threading:
L1 — `is_meta_task` no longer reads `package.json` per call.
Caller (`run_multi`, `run_workspace_package`) extracts `pkg.scripts`
once and threads it down through `run_tasks_sequential` /
`run_tasks_parallel` / `is_meta_task`. The dependsOn-but-no-command
case previously paid one `package.json` read per task in the
parallel loop; now zero. The `is_meta_task_from_config` alias
collapses into the single `is_meta_task` since the helper is
filesystem-free now.
L2 — `run_tasks_parallel` wraps shared per-call state in `Arc`.
Pre-Tier-1: each spawned thread did a full `clone` of the hint,
the tasks `HashMap`, the `LpmJsonConfig`, and (post-L1) the
`pkg_scripts` `HashMap`. Post-Tier-1: each is `Arc::new`'d once
before the loop, threads do a refcount bump. Negligible per-thread
but avoids quadratic-feeling allocations on wide parallel levels.
L3 — workspace per-member calls inherit the root hint when the
member has no own pin.
`run_workspace_package` probes the member dir via
`lpm_runtime::detect::detect_node_version` (single-dir, no walk).
If the member has its own .nvmrc / engines / lpm.json runtime,
pass `Unknown` so the silent detect resolves the member-level
pin. If not, inherit the root hint. Matches user intuition that
the workspace-root pin governs the whole workspace (like nvm
walking parent dirs).
Small behavior change: a workspace member with NO own Node pin
now uses the root-resolved managed runtime instead of falling back
to system Node. Arguably a bug fix — pre-Tier-1 behavior was
inconsistent (root auto-installed Node 22 but member silently
ran on whatever `node` happened to be on PATH).
Plus the M/L review fixes batched in:
- M1: doc note on `pub async fn run` documenting the
`ensure_runtime`-must-be-called-first contract.
- M2/M3: `bin_path` test assertions tightened to compare the full
PATH segment list, not substring presence/absence (robust to
whatever managed-runtime fragments the developer's PATH happens
to contain).
- Style: `Default for ManagedRuntimeHint` returning `Unknown`; test
mods import `ManagedRuntimeHint::Unknown` so call sites read
`&Unknown` instead of `&ManagedRuntimeHint::Unknown`.
Measurement (n=101, time.perf_counter_ns(), M5 Mac, load avg ~3):
- Managed-runtime fixture (.nvmrc + 7 entries): ~150 µs / lpm run.
- No-managed-runtime fixture: ~60 µs / lpm run.
- bench/run.sh script-overhead (1ms resolution, n=21): within noise.
Sub-perceptible at ms resolution; preparatory plumbing for Tier 2
warm-path relayout. See preplan v3 status block for full numbers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(lpm-linker): introduce LayoutPaths utility (Phase 61.0.5, no behavior change)
Centralizes wrapper / metadata / health-check path construction. Every
production callsite that built `node_modules/.lpm/...` paths inline now
goes through `LayoutPaths::for_project(project_dir).{isolated,hoisted}_*`.
61.0.5 contract: every helper returns the legacy path
(`node_modules/.lpm/`). No observable behavior change. 61.1 will flip
`isolated_*` to `<project>/.lpm/wrappers/...` as a single source-of-truth
edit; consumers migrate transparently.
Production migrations in this commit:
- `lpm-linker::cleanup_stale_entries`: wrapper-root construction
- `lpm-linker::link_one_package`: pkg-entry-dir + .linked marker
- `lpm-linker::link_finalize`: wrapper-root for bin link traversal
- `lpm-linker::link_packages_hoisted`: metadata path + nested-root (via
`hoisted_*` helpers, intentionally still scoped to `node_modules/`)
- `lpm-cli::commands::rebuild::live_package_dir`: isolated probe
`doctor.rs` predicate is intentionally NOT migrated here — its semantic
change (handling hoisted-no-conflicts via `install_appears_healthy()`)
lands in 61.4.
Adds `crates/lpm-linker/src/layout.rs` with 13 unit tests covering all
helpers including the 5 `InstallHealth` variants and the
`needs_layout_migration` invariant in 61.0.5.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(lpm-linker): flip isolated wrapper root to <project>/.lpm/wrappers/ (Phase 61.1)
The big lever — the isolated linker's per-package wrapper tree moves
out of `node_modules/.lpm/` to `<project>/.lpm/wrappers/`. After the
relayout, `rm -rf node_modules` no longer wipes the entire incremental
linker cache, so the warm-install bench (and the user pattern Phase 57.2
surfaced — wiping node_modules after a teammate's lockfile change)
actually exercises the incremental linker.
Symlink-target shape changes (audit fix #1, v3):
- Phase 3 root symlinks (canonical + aliases) gain one extra `..`
segment and route through `<project>/.lpm/wrappers/<seg>/...`.
Centralized in `LayoutPaths::root_symlink_target()` so the depth
math (link-depth + 1) is computed in one place.
- Phase 3.5 self-references unchanged — they target the project root,
which doesn't move under Tier 2.
- Phase 2 internal sibling-wrapper symlinks unchanged — both endpoints
live inside `.lpm/wrappers/` so the relative `../../` shape is
preserved.
Drive-by audit fixes folded in:
- #3 (bin-shim wrapper segment): `create_bin_links` now uses
`pkg.wrapper_segment()` instead of hardcoding
`format!("{safe}@{version}")`. Pre-fix, local-source deps with a
`bin` field produced shims pointing at non-existent wrapper paths.
- #7 (Windows junction `..` normalization): added a lexical-clean
helper inside `create_symlink_or_junction`'s Windows arm so the
`../.lpm/wrappers/...` shape doesn't embed an unresolved `..`
segment in the path handed to `cmd /c mklink /J`.
`cleanup_stale_entries` updates:
- Explicitly creates `node_modules/` (pre-Tier-2 the wrapper-root
`create_dir_all` covered both via parent recursion; now they're
disjoint paths).
- Skips dotfile entries (e.g., the new `.version` schema-tag) when
sweeping stale wrappers.
- Writes `<wrapper-root>/.version` (D6) for forward-compat shape
detection.
Test fixtures migrated to use `LayoutPaths` so they track production
semantics on any future shape change. 4949 workspace tests pass;
clippy --workspace -D warnings clean; cargo fmt clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(lpm-cli): rebuild.rs uses LayoutPaths + closes store-fallback hole (Phase 61.2)
Three things land together because they all touch `prepare_live_package_dir`:
D8a — store-fallback hard-error. Pre-Phase-61 the function returned
`Ok(store_path)` whenever the live probe fell through, letting the
caller chdir into canonical store bytes for a lifecycle script. On
macOS (clonefile, CoW) that was silent corruption on first write; on
Linux (hardlinks) the early `if !live.starts_with(store_root)` branch
skipped detach so the script ran against shared inodes. Either way, a
soundness violation. Post-fix the function returns `Err("...not linked
into project — refusing to run lifecycle script inside the store...")`
so failures are loud, actionable, and never corrupt the store.
Audit fix #4 — wrapper-segment shape. `live_package_dir` now takes a
`wrapper_id: Option<&str>` and computes the wrapper segment via
`LayoutPaths::wrapper_segment(name, version, wrapper_id)`. The same
helper `LinkTarget::wrapper_segment` delegates to (single source of
truth across the linker / rebuild / future doctor code paths). Pre-fix
the inline `format!("{safe}@{version}")` silently missed every
non-Registry source: a Directory / Link / Tarball / Git dep with a
lifecycle script had its wrapper probe fail and fall through to the
store. Post-fix `ScriptablePackage` carries the `wrapper_id` derived
from `lp.source` via `Source::source_id()`.
Audit fix #5 — test inversion. The pre-existing
`prepare_live_package_dir_does_not_detach_when_path_is_under_store_root`
test pinned the silent-fallback contract D8a inverts. Replaced with
`prepare_live_package_dir_errors_when_unlinked` asserting the new
`Err("...not linked into project...")` shape; canary-bytes-intact
assertion preserved.
Adjacent fix in `p6_triage_autoexec_reference.rs`: the test seeded
the store but not the wrapper, relying on the silent-fallback hole to
run lifecycle scripts. Added a `seed_wrapper` helper that materializes
`<project>/.lpm/wrappers/<seg>/node_modules/<name>/` from the store —
mirroring real post-install state. Pre-D8a the same fixture passed by
accident; the new state captures the actual contract.
`LayoutPaths::wrapper_segment` is the new cross-crate helper.
`LinkTarget::wrapper_segment` delegates to it so the two cannot drift.
4949 workspace tests pass; clippy --workspace -D warnings clean;
cargo fmt clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(lpm-cli): layout-aware install_state + wrapper-layout migration (Phase 61.3)
Two pieces, both load-bearing per the v3 audit fix #2 / D8c:
1. Layout-aware freshness gate. `check_install_state` AND
`try_mtime_fast_path` now consult
`LayoutPaths::needs_layout_migration()` and force `up_to_date = false`
when a populated legacy `node_modules/.lpm/` coexists with an empty
`<project>/.lpm/wrappers/`. Without this gate, an upgrade-in-place
user (binary upgraded but `node_modules/` not wiped) hash-matches
on the install-hash check, the top-of-`main` fast lane
short-circuits, and the migration code path never runs — they stay
silently on the legacy layout until something else invalidates the
hash.
2. Migration code path inside `lpm install`. Right after the fast-exit
guard returns false, `migrate_legacy_wrapper_layout` checks the
same predicate and (when true) wipes `node_modules/.lpm/` so the
subsequent `cleanup_stale_entries` rebuilds at the new wrapper-root
location. No rename-first attempt — cross-FS rename hazards
(Linux containers, network FS, EXDEV) outweigh the saved relink
cost, which Phase 61 makes faster anyway. Best-effort wipe; legacy-
state quirks don't abort the install.
D9 — migration notice modes. Human-pretty mode prints a one-line
"migrating wrapper layout" notice via `output::info`; JSON / `--quiet`
/ non-TTY remain silent.
Tests added:
- `legacy_layout_present_forces_install_via_full_read` — hash matches
but migration is owed → `up_to_date = false`.
- `legacy_layout_present_forces_install_via_mtime_fast_path` — same
but with v2 mtime line; the mtime fast path bails to slow path.
- `empty_legacy_dir_does_not_force_install` — empty `.lpm/` doesn't
count as legacy.
- `populated_new_layout_does_not_force_install` — both populated →
migration considered complete; gate stops firing.
- `migrate_legacy_wrapper_layout_wipes_legacy_state` — happy path.
- `migrate_legacy_wrapper_layout_noop_when_not_owed` — no-op
on a fresh project (doesn't synthesize directories).
- `migrate_legacy_wrapper_layout_noop_when_both_populated` —
doesn't wipe on a mid-migration mixed state (real convergence
happens via the next normal install).
4956 workspace tests pass; clippy --workspace -D warnings clean;
cargo fmt clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(lpm-cli): doctor + gitignore + sandbox comment refresh (Phase 61.4 + 61.5 + 61.7)
61.4 — `lpm doctor` predicate becomes layout-aware. The legacy
`nm.exists() && nm.join(".lpm").exists()` probe is replaced with
`LayoutPaths::install_appears_healthy()` plus a `needs_layout_migration()`
gate. The doctor now distinguishes:
- Healthy { Isolated } → "exists with .lpm/wrappers store"
- Healthy { Hoisted } → "exists with hoisted layout"
- Healthy { Mixed } → warn + remediation
- NodeModulesPresentButNoStore → warn (existing message preserved)
- NoNodeModules → fail (existing message preserved)
- legacy layout detected (migration owed) → warn pointing the user
at `lpm install` to converge
The hoisted-no-conflicts case (which the legacy predicate misreported
as "no .lpm store") now correctly classifies as healthy.
61.5 — `ensure_lpm_wrappers_gitignore` runtime helper. Mirrors
`ensure_skills_gitignore` (and the lpm-vault / npmrc siblings):
runtime "ensure once" pattern, idempotent, OpenOptions-append to
narrow the TOCTOU window. Marker is `.lpm/wrappers/`. Wired into the
install entry point alongside `migrate_legacy_wrapper_layout`.
61.7 — sandbox comment refresh. `landlock_rules.rs` explanatory
comment referenced `{project}/node_modules/.lpm/`; updated to mention
the post-Phase-61.1 `<project>/.lpm/wrappers/` location. The actual
ReadWrite rule at line 103 already grants `<project>/.lpm` so the
post-relayout location was already covered — comment-only change,
no functional impact.
Tests added:
- `ensure_lpm_wrappers_gitignore_appends_entry`
- `ensure_lpm_wrappers_gitignore_no_duplicate`
- `ensure_lpm_wrappers_gitignore_creates_when_no_gitignore`
4959 workspace tests pass; clippy --workspace -D warnings clean;
cargo fmt clean; no fancy-regex.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(lpm-linker): retarget legacy root symlinks + dotfile-aware layout predicates
Two audit fixes (round 2 of Phase 61 review):
CRITICAL — legacy root-symlink retarget. Pre-fix, the 61.3 migration
wiped `node_modules/.lpm/` but never touched root symlinks at
`node_modules/<pkg>` whose targets pointed into the legacy
wrapper-root shape. Phase 3's `if root_link.exists()` guard skipped
recreation, so an upgrade-in-place install left dangling symlinks —
the wrapper tree was wiped, but `node_modules/<pkg>` still pointed at
the old location and stayed broken.
Fix: `cleanup_stale_entries`'s root-symlink sweep gains a second
predicate. Beyond the existing "not in `direct_names`" stale-name
removal, it now ALSO removes any root symlink whose target traverses
a `.lpm/` segment NOT followed by `wrappers/` (legacy shape). Phase 3
recreates with the correct new target. Walks `Path::components()`
so the predicate is robust to path-separator style and to whether
the relative target leads with `.lpm/` (unscoped) or `../.lpm/`
(scoped). Self-refs (target = `..`, no `.lpm`) and workspace-member
symlinks (target outside `.lpm/`) are unaffected.
5 new tests:
- `cleanup_stale_entries_removes_legacy_shape_root_symlink`
- `cleanup_stale_entries_preserves_new_shape_root_symlink`
- `cleanup_stale_entries_preserves_workspace_member_symlink`
- `cleanup_stale_entries_preserves_self_reference_symlink`
- `link_finalize_retargets_legacy_root_symlink_after_migration`
(end-to-end: post-migration install produces a working symlink
resolving to a real `package.json`)
MEDIUM — `.version` schema-tag must not mask migration. The 61.1
`.version` write at the wrapper root happens BEFORE any wrapper is
materialized; pre-fix, `dir_is_nonempty` counted `.version` as
evidence of a populated layout, so a half-completed install (or any
state where the new root has only `.version`) would silently mask a
needed migration AND make `lpm doctor` report a healthy isolated
install when no wrappers actually existed. Both
`needs_layout_migration` and `install_appears_healthy` consume the
helper.
Fix: `dir_is_nonempty` now skips entries whose name starts with `.`.
Wrapper segments from `LayoutPaths::wrapper_segment` cannot produce
a leading-dot name (path-separator sanitizer is `replace('/', '+')`,
never `.`), so the dotfile filter cannot miss a real wrapper.
2 new tests:
- `needs_layout_migration_true_when_new_root_has_only_version_file`
- `install_appears_healthy_metadata_only_root_is_not_isolated`
4966 workspace tests pass; clippy --workspace -D warnings clean;
cargo fmt clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(lpm-linker): scoped legacy-symlink retarget belt-and-braces
Audit follow-up: the scoped-name branch (`@scope/pkg`) of
`cleanup_stale_entries`'s root-symlink sweep traverses a separate
code path from the unscoped branch. The retarget fix in the prior
commit applies to both, but the existing test only exercised the
unscoped case. This test adds the scoped equivalent so a future
refactor that drops the legacy-shape predicate from the scoped
branch fails loud.
Setup: a `node_modules/@types/node` symlink whose target is the
pre-Phase-61.1 scoped shape (`../.lpm/<seg>/node_modules/@types/node`,
no `wrappers/` segment). After cleanup the legacy symlink must be
removed so Phase 3 recreates it pointing at the new
`../../.lpm/wrappers/<seg>/...` two-level shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tolgaergin
added a commit
that referenced
this pull request
May 4, 2026
Round 1 — `lpm rebuild --no-sandbox` pairing hardening (Phase 64 #4 + #38): - Parser test pinning the clap `requires = "unsafe_full_env"` constraint - Defense-in-depth `debug_assert!` in `run_under_store_lock` - Command-level `--help` expanded to enumerate executed phases (preinstall/install/postinstall) and recognized-but-not-executed phases (prepare/prepublishOnly), the sandbox-on-by-default contract, and the `--unsafe-full-env --no-sandbox` partner pairing - `prepare` correction across rebuild / install / approve-scripts / glossary / npm-compatibility docs Round 2 — `lpm test --watch` silent-drop fix (Phase 64 #14): - Detect watch flags in forwarded args; rewrite the vitest base from `vitest run` to `vitest` so `--watch` is honored. Pre-fix, vitest silently dropped `--watch` under the `run` subcommand. Jest / mocha unchanged. `lpm bench` unaffected (vitest's `bench` subcommand respects `--watch` natively). Round 3 — `lpm add` source-package dep flow rewrite (Phase 64 #9 / #9.1 / #9.2 / #9.3 / #9.4): #9 — drop the @lpm.dev/* filter that silently lost dep entries declared in source packages. Registry-agnostic dep collection now; shared `collect_source_pkg_deps` helper drives both install and preview / skip-count surfaces. Tightened legacy-fallback gate so a declared-but-unmatched `dependencies` block opts out of the fallback. #9.1 — preserve author-pinned `name@range` specs verbatim; bare names caret-resolve via the registry per Phase 33 save policy. Per-package routing through `.npmrc` so `@corp/ui` from a private registry works the same as a bare npm name. Fail-fast posture: unresolvable bare/dist-tag entries error before `package.json` is mutated. #9.2 — wrap the manifest mutation + trailing install in a `ManifestTransaction`. Snapshot includes the selected PM's lockfile (package-lock.json / pnpm-lock.yaml / yarn.lock / bun.lock+lockb) so external-PM partial writes don't create manifest/lockfile split-brain. All four `--pm` dispatch arms now error-and-rollback instead of warn-and-continue. #9.3 — extend the snapshot to include source-file dest paths. Step 8 file copies roll back too. Required splitting `resolve_safe_dest` into pure validate + mkdir/canonicalize phases, then composing canonical- pinned final dest paths before the snapshot opens (so snapshot path == write path under intermediate-symlink resolution). #9.4 — preflight gate: hard-error before any side effects when a deps-declaring source package would land in a project with no `package.json`. Remediation message points at `lpm init` / `npm init -y`. `--no-install-deps` escape hatch preserved. Plus composed integration tests at `tests/workflows/tests/add.rs` exercising the real CLI binary end-to-end (happy path / preflight / rollback) — closes the test-depth gap audited across the #9.x chain. Schema: `lpm.config.json#dependencies` entries now accept `name@range` syntax alongside bare names. Author docs and JSON Schema description updated in lockstep with the public mirror; drift-guard test pins the parity. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tolgaergin
added a commit
that referenced
this pull request
May 7, 2026
…ng + GraphKey disambiguation Closes the three load-bearing 4d (default-flip) blockers from Phase 66 4b's deferred-items list as a single coordinated change. Without these, every line of Phase 4c would build on top of an empty-peers GraphKey assumption and a `(name, version)`-only key map — exactly the cheap-now / refactor-later trap the user called out. #9 — peer-context threading (resolver → install → linker): - `lpm_resolver::ResolvedPackage` gains `peers: Vec<(String, String)>`, populated from `CachedPackageInfo.peer_deps[version]` intersected against the resolved-versions lookup the resolver already builds for `format_solution` / greedy `into_resolved_packages`. Sorted by peer_name for deterministic GraphKey hashing. Both resolver arms (pubgrub + greedy) populate symmetrically through `compute_resolved_peers` (pubgrub) / inline lookup (greedy, since the node table is the lookup). - `InstallPackage.peers` carries the resolver's output verbatim through `resolved_to_install_packages`. Source-kind paths (Tarball / Directory / Link / lockfile fast-path) populate empty for now; the v2 linker's `ensure_peer_context` re-derives from the just-extracted `package.json` when the field arrives empty, keeping cold-resolve and warm-fast-path producing the same GraphKeys. - `LinkTarget.peers` propagates from `InstallPackage.peers` at every install→link conversion site. v1 ignores the field; hoisted-mode v1 wanting cross-project sharing later can fold it in without further plumbing. #4 — fold peers into the GraphKey: - `lpm_store::v2::GraphKeyInputs::with_peers` now receives the resolved `PeerEntry` list from `LinkTarget.peers` instead of the empty `Vec<PeerEntry>::new()` placeholder. The hash field contract was already in place (`peers` slot in `derive`); we just stop passing nothing into it. - New `with_wrapper_id` setter folds the source-identity disambiguator into the hash so `Source::Registry { foo@1.0.0 }` and `Source::Tarball { foo@1.0.0 from URL X }` produce distinct keys. Empty `wrapper_id` (registry default) preserves the pre-Phase-66 hash so existing v2 store entries don't get invalidated by this addition. #8 — multi-source-same-coords disambiguation in v2 linker key map: - New `KeyMap` type with two indexes — `by_triple` keyed on `(name, version, wrapper_id)` for the consumer's own key lookup, `by_coords` keyed on `(name, version)` for dep / peer edge lookups (which carry only coords today). At construction time, a `(name, version)` collision across distinct `wrapper_id`s surfaces a hard `LpmError::Store` rather than silently aliasing the second target onto the first. Audit- fixtures don't exercise multi-source-same-coords today, so the error is reachable only via a malformed install set; lifting the constraint requires threading wrapper_id through dep edges, a Phase 4 follow-up. v2 linker behavior changes: - `augment_with_peer_edges` renamed to `ensure_peer_context` and rewritten to populate `LinkTarget.peers` (separate Vec) instead of mutating `LinkTarget.dependencies`. The fixed-point closure loop is gone — each consumer's resolved peers is a single per-package fact (the resolver / package.json intersection), not a transitive graph property. Transitive resolution flows through the per-target loop: when peer B is also a LinkTarget, ITS link entry gets ITS own peer siblings. - `populate_one` synthesizes peer-edge sibling symlinks ALONGSIDE dep-edge siblings (peers were previously folded into `dependencies`; now they're a separate pass with explicit dedupe against already-declared deps). - `peerDependenciesMeta.optional` controls trace verbosity for missing peers — required-but-missing emits a debug log pointing at the upstream `check_unmet_peers` gap; optional-missing is silent (npm-compat). Tests: - `link_packages_v2_distinct_keys_for_peer_divergent_projects`: same consumer + same edge graph + DIFFERENT resolved peer versions across two projects must produce distinct GraphKeys (no silent cross-project sharing under peer-pinning divergence). - `link_packages_v2_shares_keys_for_peer_identical_projects`: same consumer + same edge graph + SAME resolved peer version across two projects must produce the same GraphKey (cross- project sharing actually works under peer-pinning agreement — this is the win the v2 rewrite is supposed to unlock). - `link_packages_v2_errors_on_multi_source_same_coords`: malformed install set with two LinkTargets at the same `(name, version)` distinct `wrapper_id` produces a clear `multi-source LinkTarget collision` error rather than aliasing. Pre-merge gate green: - cargo clippy --workspace --all-targets -- -D warnings ✓ - cargo fmt --check ✓ - cargo nextest run --workspace --exclude lpm-integration-tests ✓ (5711/5711 pass; one transient lpm-inspect sqlite-races-under-load flake on first run — rerun clean) - cargo test -p lpm-auth (2× parallel-deterministic) ✓ - audit-fixtures: 17 PASS / 1 SKIP / 0 mixed under both default v1 and `LPM_STORE_VERSION=v2`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tolgaergin
added a commit
that referenced
this pull request
May 8, 2026
…Maps Phase 66 perf followup #4, samply-driven (2026-05-08). `hashbrown::raw::RawTable::reserve_rehash` was the second-largest active hotspot at ~6.7 % of cold-install CPU on `bench/fixture-large`, mostly attributable to the greedy resolver's `inflight` HashSet and `parked` HashMap growing from 0 to ~266 entries during a fresh resolve. Each rehash copies every existing entry into a new larger table — for the default-sized `HashMap::new()` starting at capacity 0, the table doubles ~5-7 times growing to 266, paying ~total-of- each-rehash-pass in copies. Fix: pre-size both maps to `npm_fanout` (the metadata-fetch semaphore size, default 256). It's the closest proxy we have for "how many manifests this resolver might track simultaneously" without threading a dependency-count estimate through the resolver constructor. Slight over-allocation is strictly cheaper than rehashing on the hot path. Bench delta (paired n=10 cold/clean on fixture-large): - not directly visible in the JSON stage breakdown (resolve_ms was already going down via separate Phase 66 wins) but pure CPU savings; expected ~10-30 ms wall. 3-line change with zero behavior delta. Long tail will surface as proportionally more wins for larger projects (1000+ deps where default-sized maps would rehash 9-10 times). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tolgaergin
added a commit
that referenced
this pull request
May 8, 2026
Three small, verified wins on cold-install CPU + wall, identified by symbolicated samply on bench/fixture-large after fixes b03d051 already landed. #1 extractor: archive.set_preserve_mtime(false) Pre-fix flame attributed 2.0% of active CPU to filetime::set_file_- handle_times → fsetattrlist (100% from extract_tarball). mtime is meaningless for content-addressable store bytes — require() doesn't read it; lpm doctor doesn't use it. tar 0.4.45's preserve_mtime defaulted to true; flipping it eliminates the syscall entirely. #4 extractor: stream_entry_to_disk replaces entry.unpack() for files Even with preserve_permissions(false) tar 0.4.45's _set_perms still unconditionally calls fs::set_permissions (entry.rs:814 — the flag only controls SUID-bit retention). 1.7% of active CPU was __fchmod from 100% extract_tarball. New helper does File::create + io::copy only — same minimal write semantics as the existing write_buffered_entry path. #2 store: LinkMeta::write_to_unpublished skips inner tmp+rename populate_into stages the sidecar inside an unpublished tmp_dir (links/<key>.tmp.<pid>.<tid>/) that is published via a single outer atomic rename. The inner tmp+rename in write_to was redundant: no observer can ever see a half-written sidecar inside an unpublished dir. New write_to_unpublished writes the JSON directly. Saves one rename syscall per link entry × N packages. Verification — paired A/B median over 8 iters (worst dropped): Stage | Pre-fix | Post-fix | Δ total | 998 ms | 937 ms | −61 ms (−6.1%) fetch | 355 | 304 | −51 ms (#1 + #4) link | 138 | 132 | −6 ms (#2) Flame profile confirms target syscalls eliminated: set_perms_ownerships: 1.7% → 0 set_file_handle_times: 2.0% → 0 __fchmod: 1.7% → 0 __rename: 10.2% → 7.7% LinkMeta::write_to → write_to_unpublished: 4.2% → 0.3% Tests: cargo nextest run -p lpm-extractor -p lpm-store — 134/134 pass. Clippy: clean across workspace. Followups still open (separate tranche): - #5 fuse extract+analyze (drop redundant 2nd-pass walk, ~10% on-CPU) - #6 restore event-driven link/fetch overlap (~50-100 ms wall) - #3 lazy warm-hit sidecar touch (warm-install-only) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 tasks
tolgaergin
added a commit
that referenced
this pull request
May 9, 2026
* fix(resolver): wire root-level npm: aliases on greedy/fused arms
The legacy pubgrub arm's `provider.rs::1311-1328` parses
`"local": "npm:target@range"` root deps, keys the canonical on the
target, and records `local → target` in `root_aliases`. The
greedy/fused arm's `seed_root_edges` shipped a TODO ("W4 wires that")
and the field was hardcoded to `HashMap::new()` at both arm tails —
so any root npm: alias on the default resolver path failed with
"failed to parse range for root dep <name>: invalid range
'npm:<target>@<range>'".
Surfaced via the Phase 66 confidence-followup §2c audit fixture
(`source-kind/npm-aliases`); legacy pubgrub passed, greedy failed
both modes.
- Add `root_aliases: HashMap<String, String>` to `ResolveState`.
- In `seed_root_edges`, parse `npm:` aliases before `NpmRange::parse`
so the canonical is keyed on the target and `local_name` preserves
the alias key for the install pipeline.
- Drain `state.root_aliases` into `ResolveResult.root_aliases` at
both arm tails (`resolve_greedy` and `resolve_greedy_fused`).
- New test `seed_root_edges_rewrites_npm_alias_root_dep` mirrors
`resolve.rs::resolve_with_prefetch_handles_root_npm_alias`'s
contract — verifies canonical-on-target, range parse, alias-as-
local-name, and `root_aliases` population in one pass.
Gates green: clippy --workspace --all-targets -D warnings, fmt
check, 5756 workspace tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(audit-fixtures): Phase 66 confidence-followup §2 — 8 edge-shape fixtures
Closes the followup queue's §2a-h (cheap mechanical fills). Suite
grows 18→26 fixtures; final state 23 PASS / 1 SKIP / 2 FAIL with
zero asymmetric outcomes (run-all.sh CI gate stays clean).
New fixtures:
- peer-heavy/optional-peers — react-redux@9 with `peerDependenciesMeta.optional`
(redux + @types/react absent). PASS/PASS.
- workspace/cyclic-deps — A↔B require cycle across two workspace
members; exercises Node's CommonJS partial-export semantic +
workspace symlink wiring. PASS/PASS.
- workspace/large-monorepo — 10-member DAG with multi-level cross-
deps; smoke pins exact 21-visit traversal counts so a layout bug
trips a specific assertion rather than a generic "wrong output".
PASS/PASS.
- source-kind/self-referential — lpm-linker Phase 3.5
`node_modules/<self>` symlink; root + sub-path require()s round-trip
through it. PASS/PASS.
- source-kind/npm-aliases — three aliases of lodash + one scoped-target
alias (@babel/code-frame); identity + functional + scoped-`@`-split
coverage. PASS/PASS (gated on the greedy resolver root-alias fix).
- source-kind/bundle-deps — npm@11.14.0's 65 bundled deps survive
extraction + linking under both modes; npm in devDependencies to
avoid `require('npm')`'s intentional throw. PASS/PASS.
- source-kind/postinstall-introspects-layout — synthetic walker
exercising both INIT_CWD-style (project root) and from-package-cwd
introspection patterns. PASS/PASS.
- source-kind/patches — `lpm.patchedDependencies` declared against
ms@2.1.3 with originalIntegrity SRI; smoke verifies patched
`lpmPatchProof` export survives to runtime. **FAIL/FAIL** —
documents adjacent issue: `patch_engine::apply_patch` reads
baseline from `PackageStore::package_dir()` which is hardcoded
to v1 layout while v2 is the active backend. Fixture is ready-
state — once the v1→v2 patch wiring lands, this fixture validates
it without modification.
Adjacent issue (pre-existing) surfaced separately:
`native/esbuild-prebuilt` regressed PASS→FAIL between Phase 2.7 and
this run; v2 store extractor does not preserve executable bit on
package binaries (EACCES on darwin-arm64 esbuild bin). Reproduces
with the pre-fix binary too — not caused by this branch's resolver
fix. To be addressed in a follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(extractor): preserve executable bit during v2 store extraction
Pre-fix the v2 store extractor stripped every file's mode to the
process umask (typically 0644). Phase 66 perf followup #4
(`stream_entry_to_disk`) bypassed `tar::Entry::unpack`'s
chmod/chown/utimes epilogue for the syscall savings — but it also
discarded the tar header's mode field entirely. Bin scripts shipped
in tarballs at 0755 (esbuild's `darwin-arm64` binary, every package's
`.bin/` shell wrappers) landed non-executable, causing `EACCES` when
Node's lifecycle runner tried to spawn them.
Caught by `bench/audit-fixtures/native/esbuild-prebuilt` regressing
PASS→FAIL between Phase 2.7 (2026-05-07) and Phase 4b's v2-default
flip; documented as confidence-followup §S1.
- Capture `entry.header().mode() & 0o111` BEFORE any read; the tar
crate parses the header up-front so this is free.
- After the write, `set_permissions(0o644 | exec_bits)` only when
exec_bits is non-zero — most files (0644 source) skip the
`set_permissions` syscall entirely. Bin scripts (0755) get a
single restore call.
- Honors user/group/other exec bits independently (a tarball
declaring 0o744 — user-only exec — keeps user-X without granting
group/other-X).
- SUID/SGID/sticky bits stay dropped — same security posture as the
pre-fix `set_preserve_permissions(false)`.
- `#[cfg(unix)]`-gated; Windows skips the chmod (NTFS dispatches by
extension, not the X bit).
New unit test `extract_preserves_executable_bit_for_bin_files` pins
the contract: 0644 → no exec bits, 0755 → all three exec bits, 0744
→ user-only exec. All 22 lpm-extractor tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(patch): wire lpm patch + patch_engine for the v2 virtual store
`patch_engine::apply_patch` and `lpm patch <name>@<version>` both
read the package baseline (source bytes + integrity SRI) from
`PackageStore::package_dir()` (v1 layout) and `read_stored_integrity`
(v1 `.integrity` sentinel). Phase 4b made v2 the active default, and
v2 stores baselines under `<store>/v2/links/<key>/node_modules/<name>/`
with the SRI in the link sidecar's `meta.source_sri` — neither path
the v1 helpers know how to read. Result: every `lpm patch <pkg>` and
every install with `lpm.patchedDependencies` errored on v2-installed
packages with "not in the global store" / "missing .integrity",
even though the package was fully linked in `node_modules/`.
Caught by `bench/audit-fixtures/source-kind/patches` (FAIL/FAIL
under symmetric "store entry missing .integrity") and tracked as
confidence-followup §S2+§S3.
Changes:
- `lpm-store::find_installed_package_baseline(lpm_root, name, version)`
— new free function. Resolves the package's source dir + integrity
preferring v2 (iterates `Store::iter_link_entries`, returns the
matching link's package dir + `meta.source_sri`); falls back to
v1 (`package_dir` + `.integrity`). Returns
`InstalledPackageBaseline { package_dir, integrity, layout }`.
`PackageStore::lpm_root()` derives an `LpmRoot` from the store's
parent so callers can keep the `&PackageStore` API while the
helper internally consults v2.
- `patch_engine::verify_original_integrity` and
`patch_engine::apply_patch` route through the new helper. The
`&PackageStore` parameter is preserved so external callers and
tests keep the same signature; the v2 lookup happens internally.
- `lpm patch` (`run_patch_inner`) and `lpm patch-commit`
(`run_patch_commit_inner`) likewise route through
`find_installed_package_baseline`. The integrity is reused from
the same lookup result, so commit no longer needs a separate
`read_stored_integrity` probe.
Tests:
- New `verify_integrity_passes_on_v2_link_entry` — materializes a
v2 `links/<key>/node_modules/<name>/` plus `LinkMeta` sidecar and
asserts both pass-on-match and drift-error-cites-v2-baseline.
- Existing `verify_integrity_fails_when_integrity_file_missing`
renamed and reworded — the v2-aware lookup folds "v1 dir present
but `.integrity` absent" into the same "no resolvable baseline"
outcome as "store entirely empty"; both surface a clearer error
pointing the user at `lpm install`.
- `bench/audit-fixtures/source-kind/patches` flips FAIL/FAIL → PASS/PASS.
Gates green: clippy --workspace --all-targets -D warnings, fmt
check, 5758 workspace tests pass, full audit suite at 25 PASS / 1
SKIP / 0 FAIL with zero asymmetric outcomes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(resolver): apply lpm.overrides on the default greedy/fused arm
R1 from the Phase 66 confidence-followup parallel audit. Pre-fix, both
resolver arms parsed `OverrideSet` from package.json but only the
legacy pubgrub arm's `LpmDependencyProvider::choose_version`
(provider.rs:1185-1207) applied overrides; the default greedy/fused
arm accepted `_overrides` (underscored = unused) and shipped
`applied_overrides: Vec::new()`. Every user-declared `lpm.overrides`
or `package.json > overrides` silently dropped on the default path —
version pins, selective downgrades, all no-ops.
`process_edge` now consults `OverrideSet::find_match` against the
natural pick (parent-context-aware), applies `OverrideTarget` against
the consumer range (PinnedVersion satisfies-range gate; Range
intersects ×consumer ×platform), records an `OverrideHit` post-
allocation, and drains via `take_hits` into `applied_overrides`. The
zero-overrides hot path is preserved with one `OverrideSet::is_empty`
check (single-bool indirection, zero allocs) so installs without
overrides keep their pre-Phase-32-P5 cost model. With overrides,
exact-version dedupe on the override-forced version replaces the
default reuse-on-range-satisfies behavior so two parents forcing
distinct versions split into independent nodes.
Tests added (5):
- process_edge_applies_name_selector_override
- process_edge_range_target_picks_newest_in_intersection
- process_edge_irreconcilable_override_falls_through_to_natural
- process_edge_path_selector_splits_two_parents
- process_edge_zero_overrides_takes_hot_path_unchanged
Audit fixture added (`source-kind/overrides`):
`lpm.overrides.lodash = "4.17.20"` against `dependencies.lodash =
"^4.0.0"`. Pre-R1, the install would resolve to lodash@4.18.1 (newest
4.x) on the default resolver and the smoke test would fail; post-R1,
both linker modes correctly land 4.17.20. Symmetric-FAIL signal —
both linker modes fail the same way pre-fix because both share the
same resolver arm. Audit suite final state: 26 PASS / 1 SKIP / 0 FAIL
across 27 fixtures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(rebuild,approve-scripts): route post-install pipeline through find_installed_package_baseline
S5a + S5b from the Phase 66 confidence-followup parallel audit. Pre-
fix, multiple post-install commands read package.json + lifecycle
scripts via the v1-only `PackageStore::package_dir`. Under the
v2-default install pipeline (Phase 66 4b), the v1 path doesn't exist
for any install, so these commands silently dropped every scripted
package:
- `lpm approve --show-scripts` emitted no script body diff
(S5a, approve_scripts.rs:938-941, 1226).
- `lpm rebuild` skipped lifecycle-script execution for v2-installed
packages — Phase 61.2's "linked + scripted" gate tripped the
silent-skip branch (S5b, rebuild.rs:227).
- `show_install_build_hint` reported zero scripted packages even
when prisma / esbuild / sharp were waiting (S5b, rebuild.rs:1808).
- `all_scripted_packages_trusted` returned false for every v2
install with unbuilt-but-trusted scripts, suppressing the auto-
build path entirely (S5b, rebuild.rs:1997).
All four sites now route through `lpm-store::find_installed_package_baseline`
(v2-first via `Store::iter_link_entries` + `meta.source_sri`, v1
fallback via `read_stored_integrity`). A new private
`package_baseline_dir` helper in rebuild.rs wraps the lookup and
returns `Option<PathBuf>` — `None` preserves the legacy silent-skip
semantic for workspace/file/link sources that don't materialize into
either store.
API surface: `scriptable_package_rows`, `show_install_build_hint`,
and `all_scripted_packages_trusted` all switched from
`store: &PackageStore` → `lpm_root: &lpm_common::LpmRoot`.
`install.rs::run_with_options` and `run_link_and_finish` lift
`LpmRoot::from_env()` to function scope and pass it through.
`PackageStore` is no longer used in production rebuild code; gated
behind `#[cfg(test)]` for the test fixtures that synthesize v1-only
state directly.
Test fixtures (`write_store_package`, `write_p6_pkg`) now write a
fake `.integrity` sentinel so the v1 fallback in
`find_installed_package_baseline` finds them. Without this, the
helper returned None for every test-synthesized package and the
helper calls silently skipped every fixture entry.
`approve_scripts.rs::print_full_script` now hard-warns rather than
silently degrades when the package is missing from BOTH stores —
the v1-only fallback's "could not read package.json" message would
have misled v2 users who expected the script body to render.
Plus cargo fmt cleanups in patch.rs / patch_engine.rs picked up
during the workspace gate.
Tests: 78 existing rebuild tests + 1 patch_engine test pass against
the new shape. Workspace nextest 5765/5765 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(store): walk both v1 and v2 entries in lpm store verify
S4 from the Phase 66 confidence-followup parallel audit. Pre-fix,
`lpm store verify` walked `~/.lpm/store/v1/` only via
`list_store_verify_entries`. Under the v2-default install pipeline
(Phase 66 4b), the v1 directory doesn't exist for any install, so the
command silently reported zero packages even when hundreds of links
were materialized in `~/.lpm/store/v2/links/`. A user-facing broken
state — visible across both `--json` and human output paths.
Refactored `list_store_verify_entries` into two walkers:
- `list_v1_verify_entries` (existing logic, refactored to return
the unified `StoreVerifyEntry` shape).
- `list_v2_verify_entries` (new) — enumerates link entries via
`Store::iter_link_entries`, lifts `(name, version, source_sri)`
from each `.lpm-link-meta.json` sidecar, and resolves the
materialized package dir at `<link>/node_modules/<name>/`.
Multi-source-same-coords (Phase 66 §2.2) yields one entry per
link; sidecar-less links are silently skipped (matches
`iter_link_entries`'s graceful-degradation contract).
The unified verify loop reads integrity from `inline_integrity`
(v2 sidecar's `source_sri`) when present, else falls back to
`read_stored_integrity` against `<dir>/.integrity` (v1 sentinel).
Both layouts share the rest of the loop — directory existence,
`package.json` validation, security analysis cross-check.
`run_verify` signature: gained `lpm_root: &LpmRoot` so the v2 walker
can construct a `Store` handle. The 4 existing test sites pass
`LpmRoot::from_dir(dir.path())` alongside the existing
`PackageStore::at(...)` — for tests that don't populate v2, the
walker returns an empty vec.
Tests added (2):
- verify_walks_v2_link_entries — seeds a single v2 link entry
(sidecar + node_modules dir + package.json) and asserts both the
enumerator and the end-to-end run_verify walk it.
- verify_walks_v1_and_v2_entries_concurrently — pins the mid-
migration case where some packages are v1 + others v2; both walked
side by side without one shadowing the other.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(audit,query): route store.package_dir lookups through find_installed_package_baseline
S5c from the Phase 66 confidence-followup parallel audit. Three
sites in `lpm audit` and `lpm query` looked up cached behavioral
analysis via the v1-only `PackageStore::package_dir`. Under v2 the
v1 path doesn't exist, so every lookup missed and the analysis
flow fell through to the slower project_cache path on every run —
"degraded but correct" was the documented diagnosis, but it meant
v2 users got noticeably slower audits than v1 users with no
underlying reason for the regression.
The clonefile that materializes a v2 link entry copies the
`.lpm-security.json` sidecar from the object dir into the link's
`node_modules/<pkg>/`, so reading the analysis from the baseline-
resolved path picks up the pre-computed result written at install
time.
Sites updated:
- `commands/audit/mod.rs:642` — main audit cache lookup.
- `commands/audit/inventory.rs:92` — `lpm audit --inventory` cache
lookup.
- `commands/query.rs:78-91` — `lpm query` lifecycle-script + build
marker probe (also picks up the v2 path for the `is_built` /
`has_scripts` per-package shape).
All three now route through `lpm-store::find_installed_package_baseline`
(v2-first, v1-fallback). No new tests — this is a perf fix, not a
contract change; the existing audit/query tests cover the behavior
unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(resolver): close R3+R4+R5 — workspace guard, bundleDeps tracking, optional peers
Phase 66 confidence-followup completeness pass. Three small but
visible gaps in the greedy/fused resolver, batched because they
share `CachedPackageInfo` schema changes and the same
`parse_metadata_to_cache_info` extraction site.
R3 — `workspace:` defense-in-depth at resolver entry.
`Specifier::Workspace` parses but is meant to be rewritten by
`lpm-workspace` upstream before the resolver runs. If a raw
`workspace:<rest>` slips through (future refactor drops the
upstream layer, hand-edited manifest, malformed cache),
`NpmRange::parse("workspace:*")` produced an opaque semver-error
surface. Now `is_workspace_specifier` detects the prefix at both
entry points (`seed_root_edges`, `enqueue_child_deps`) and emits
a specific error/warning pointing at the actual cause. Root
edges hard-fail (internal bug); transitive edges skip with a
warn (registry-published packages should never declare
workspace: deps; npm rejects them at publish time).
R4 — `bundleDependencies` field tracking.
Registers the `bundleDependencies` / `bundledDependencies` (npm
alias) field in `VersionMetadata` with a custom deserializer
that handles both list-of-strings AND the historical
`bundleDependencies: true` shape. Names are collected per-version
into `CachedPackageInfo.bundled_dep_names` and consumed at
enqueue time: in `greedy::enqueue_child_deps` and the pubgrub
arm's `get_dependencies` constraint loop, bundled names are
stripped from the dep set so the resolver doesn't fetch a
registry copy that the linker may then shadow over the bundled
one. The extractor preserves the in-tarball `node_modules/<bundled>/`
subtree implicitly; the resolver's job is just to NOT introduce
a redundant fetch. Today's `bundle-deps` audit fixture (npm@11
with 65 vendored deps) continues to PASS, with the resolver
now correctly avoiding 65 redundant registry RPCs per install.
R5 — `peerDependenciesMeta.optional` flag.
Adds a `PeerDependencyMeta` shape (today reading only the
`optional` flag; future npm spec keys flow through verbatim
without breaking parse). Optional peer names are collected
per-version into `CachedPackageInfo.optional_peer_names` and
consumed in `check_unmet_peers`: gated ONLY on the missing-peer
branch. Optional peers that ARE present but at the wrong version
still warrant a warning — the user opted into having a peer,
just at an incompatible version.
Real-world impact: every user of react-redux, ESLint plugins
(`eslint-plugin-jsx-a11y`, `eslint-plugin-import`, etc.) gets
noisy "unmet peer" warnings without this gate. pnpm + yarn +
npm v7+ all honor the flag; lpm now matches.
Tests added:
- greedy: 3 R3 tests (root reject, transitive skip, helper
contract) + 2 R4 tests (bundled-names skip, no-bundling
baseline).
- resolve: 2 R5 tests (optional peer missing → no warning,
optional peer present-but-wrong-version → warning).
- Existing `bundle-deps` audit fixture exercises R4 end-to-end.
`install.rs` swift fixture struct-init updated for the new
`peer_dependencies_meta` + `bundle_dependencies` fields on
`VersionMetadata`.
CI gate: workspace clippy clean, fmt clean, nextest 5772/5772
(5765 baseline + 7 new). Audit suite 26 PASS / 1 SKIP / 0 FAIL
across 27 fixtures.
Still queued (intentionally — this is the final completeness pass):
R2 (eager peer install / W3) — bun-parity design call, deserves
a separate session.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(resolver,install,lockfile): R2 — eager peer auto-install (bun-parity)
Required peerDependencies not otherwise satisfied by the resolved
tree are auto-installed at root scope. Default on; opt-out via
`lpm.autoInstallPeers = false` in package.json (or
`auto-install-peers = false` in `~/.lpm/config.toml`). Implemented
on the greedy/fused resolver arms only — pubgrub stays warn-only
and surfaces a stderr warning at install time on mismatch.
## Resolver
- `PeerRequirement` worklist on `ResolveState`, populated during
`enqueue_child_deps` with alias rewrite, workspace-protocol
guard, optional-flag propagation, and alphabetic ordering for
determinism. Peers are NEVER routed as `n.children` edges; they
go on a separate worklist consumed by R2.2's drain.
- Post-main-loop drain pass (`drain_peer_requirements_one_pass`)
groups by canonical, checks satisfaction against
`state.resolved`, synthesizes root-scoped Edges with
exact-version pins for unsatisfied required groups. Returns
`ResolveError::PeerConflict` when no version threads every
consumer's range and at least one consumer is required.
- Speculative peer-manifest prefetch (Phase A2 in the fused arm)
overlaps peer fetches with the regular dep dispatch on the
existing 256-permit metadata semaphore. New
`peer_prefetch_count` counter on `StageTiming` exposes the
parallelism gain via `--json`.
- `ResolveResult.ambient_peer_installs: Vec<String>` carries
synthesized canonicals to the install pipeline.
- `B1` fix: `process_edge_inner` consults
`OverrideSet::split_targets` so path-selector overrides don't
leak across non-matching siblings via range-satisfies dedupe.
- `B2` fix: pubgrub arm gains the same `workspace:` defense the
greedy arm already had at root + transitive dep sites.
## Install pipeline
- `resolved_to_install_packages` unions ambient peers into
`root_link_map` so the linker materializes top-level
`node_modules/<peer>/` symlinks. Ambient peers stay
`is_direct = false` — script gating is unchanged for
user-undeclared installs.
- `LpmConfig.auto_install_peers: Option<bool>` plumbed from
`package.json > lpm > autoInstallPeers` through
`~/.lpm/config.toml` to the resolver.
- Pubgrub-mismatch warning hoisted above the empty-deps short-
circuit and emitted on stderr regardless of `--json` so
CI/tooling consumers see the semantic-divergence signal.
## Lockfile (LOCKFILE_VERSION 1 → 2)
- New top-level `ambient-peer-installs: Vec<String>`.
- New per-package `peers: Vec<String>` (`<name>@<version>`).
- Cold-resolve writer persists both; warm-fast-path reader
rebuilds them. Per-package peers feed v2 graph-key derivation
so cold install and `rm -rf node_modules && lpm install` from
the lockfile produce byte-identical link-entry digests
(verified empirically on the new audit fixture).
- Binary mmap fast path falls back to TOML when either field is
non-empty (binary schema doesn't encode the new state).
- v1 lockfile + auto_install_peers=true forces fresh resolve so
pre-R2.5 lockfiles (resolver-only R2.x-R2.4 builds wrote v1
with neither field) re-derive ambient peer state. One
re-resolve, then back on the fast path.
## Audit fixture
`peer-heavy/auto-install-peer` — declares react-redux without
root react. Pre-R2.x: install completes but
`require('react-redux')` hard-fails (MODULE_NOT_FOUND on react).
Post-R2: smoke passes in both isolated and hoisted linker modes.
Warm-reinstall test: `lockfile-version`, `ambient-peer-installs`,
and per-package `peers` all round-trip; v2 graph-key digests
match cold install byte-for-byte.
## Tests
- 9 R2.1 collection tests (peer-requirement shape + contract).
- 9 R2.2 drain tests (satisfied/skipped/conflict/synthesize).
- 5 R2.3 ambient-install threading + GraphKey peer-isolation
tests.
- 9 R2.4 prefetch picker + counter tests.
- 7 R2.5 lockfile schema round-trip + binary fallback tests.
- 5 R2.5 repair-gate truth-table tests.
- 3 R2.5 pubgrub-warning workflow tests (visibility under
`--json`).
- 3 B1 + B2 correctness tests (split_targets gate, workspace
defenses).
Workspace nextest: 5822 / 5822 pass. Audit suite: 27 PASS / 1
SKIP / 0 FAIL across 28 fixtures, +1 fixture, zero regressions.
Workspace clippy `-D warnings` clean; fmt clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(install): R2.5 — extend pre-R2.5 lockfile repair gate to --offline
The R2.5 repair gate that drops the lockfile fast path when a v1
lockfile is loaded under `auto_install_peers = true` only fired
on the online fast-path branch. The `--offline` branch took its
own path through `try_lockfile_fast_path` → linker without ever
consulting the gate, so a pre-R2.5 v1 lockfile produced by an
R2.2-R2.4 buggy writer would silently replay the broken tree
under `lpm install --offline` (no top-level `node_modules/<peer>/`
symlink → `require()` fails at runtime).
Offline can't fall back to a fresh resolve, so the gate becomes a
hard error rather than a silent re-resolve. Error message names
the cause and points at the remediation (run online once to
upgrade to v2) plus the bypass (`lpm.autoInstallPeers = false` to
accept warn-only peer semantics). Mirrors the offline-mode hard-
error pattern for `--overrides-changed` / `--patches-changed`.
Pre-existing workflow test fixtures hardcoded `lockfile-version
= 1` because they predate R2.5. Bumped to v2 in 8 files —
they aren't testing pre-R2.5 behavior, just using whatever the
current version was. The two intentional v1-fixture tests in
install.rs (`install_offline_refuses_pre_r25_v1_lockfile_under_auto_install_peers`
and its inverse `install_offline_accepts_pre_r25_v1_lockfile_when_auto_install_peers_off`)
keep `lockfile-version = 1` to exercise the gate.
Workspace nextest: 5824 / 5824 pass (+2 new offline-gate tests).
Audit suite: 27 PASS / 1 SKIP / 0 FAIL across 28 fixtures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(linker): v1 hoisted — claim root slots by root_link_names, not pkg.name
Pre-fix `link_packages_hoisted`'s Phase 1 keyed slot claims strictly
on `pkg.name` (the canonical/registry identity). For an
`npm:<target>@<range>` alias declared at root level,
`resolved_to_install_packages` populates `LinkTarget.root_link_names`
with the LOCAL alias surface, which can differ from `pkg.name`
(e.g., `lodash-a: npm:lodash@^4` → `root_link_names = ["lodash-a"]`,
`pkg.name = "lodash"`). The pre-fix loop claimed slot "lodash" and
left the alias slots unclaimed → no `node_modules/lodash-a/` →
`require('lodash-a')` hard-failed at runtime.
Symptom surfaced by `bench/audit-fixtures/source-kind/npm-aliases`
under `LPM_STORE_VERSION=v1` + hoisted linker mode: 4 of 5 require()s
failed because alias dirs were missing. v2 hoisted worked because
`v2.rs::root_link_names` already iterated the alias surface at the
symlink-emit level — v1's hoisting algorithm needed the same
generalization at the slot-claim level.
The bug pre-dates this PR. It surfaced via CI matrix coverage that
finally combined the npm-aliases fixture (added on this branch in
`cdcce95`) with the v1 store matrix; main never had the fixture so
no prior CI run exercised both axes.
Fix:
- New `slots_for_pkg` closure: returns `root_link_names` if `Some`,
else `vec![pkg.name]`. Mirrors v2's contract semantically.
- Phase 1 loop iterates each pkg's slot list and claims each slot.
Conflict resolution (direct beats transitive, then first-come)
applies per-slot, identical tie-breaker to the canonical-only path.
- `find_hoisted_anchor` no longer assumes `hoisted.get(&pkg.name)`
finds a hoisted instance. Generalized to scan
`hoisted.values()` for the package idx and return whichever slot
name matched. O(hoisted_count) per anchor walk step; trivial vs
link-recursive wall.
Empirical verification (macOS, both store versions):
- `v1 + hoisted + npm-aliases`: pre-fix FAIL (4/5 require fails),
post-fix PASS (5/5)
- `v2 + hoisted + npm-aliases`: was PASS, stays PASS
- Full audit suite under `LPM_STORE_VERSION=v1`: 27 PASS / 1 SKIP
/ 0 FAIL across 28 fixtures
- Full audit suite under `LPM_STORE_VERSION=v2`: 27 PASS / 1 SKIP
/ 0 FAIL across 28 fixtures
- Workspace nextest: 5825 / 5825 pass (+1 new
`hoisted_mode_creates_top_level_dir_per_alias_root_link_name`
unit test pinning the slot-claim contract)
- Workspace clippy `-D warnings` clean; fmt clean
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Merged
7 tasks
tolgaergin
added a commit
that referenced
this pull request
May 12, 2026
Fixes a real semantic gap caught by the `hermetic_smoke` test pin post-initial-CI: Lever #4's L1 widening for matching-identity delegate-to-local-file shapes silently collapsed the script-tier review for users on the `--allow-new` path. With Lever #4 alone, a `node install.js` package whose `repository` URL matches its own base name auto-runs at L1-Green even when the user only opted out of cooldown (not script review). Two security axes that Phase 46 designed to be orthogonal became coupled — `--allow-new` silently ALSO bypassed `lpm approve-scripts` for the matching-identity subset. Option B restores the orthogonality at the classifier. `ManifestContext` gains two fields: - `publish_age_secs: Option<u64>` — package's age from now - `min_release_age_secs: u64` — configured `minimumReleaseAge` `matches_delegating_identity_green` refuses to widen when `min_release_age_secs > 0` AND publish age is below the threshold (or unknown — fail-closed). When `min_release_age_secs == 0` the user has globally opted out of cooldown and Lever #4 fires unconditionally on identity match. Implementation: - `lpm-security::publish_age_secs(Option<&str>) -> Option<u64>` — new public helper. Fail-closed posture matches `check_release_age` (unparseable → None). - Install pipeline (`commands/install.rs`): pulled `effective_min_age_secs` + `cooldown_policy` resolution OUT of the `if !allow_new` block. Built a `(name, version) → publish_age_secs` HashMap once per install (only when fresh resolution + `min_release_age > 0`). Used BOTH for the cooldown halt gate (still gated on `!allow_new`) AND threaded into `collect_amber_classification_requests` so the L1 classifier sees per-package publish age. - `collect_amber_classification_requests` signature extended with `&HashMap<(String, String), u64>` + `min_release_age_secs: u64`. - `build_state::compute_blocked_packages_with_metadata`: passes `publish_age_secs: None, min_release_age_secs: 0` — the UI annotation only fires for packages already in the blocked set (where install pipeline's cooldown defense was already applied upstream). - Audit-corpus hermetic + curated paths: hermetic synthesizes publish age from `entry.publish_age_hours`; curated defaults to 1 year matching `hermetic_l3_outcome` fixture default. Live + `--reclassify` paths pass `min_release_age_secs: 0` (audit-corpus is a measurement tool; production install applies cooldown defense via the actual pipeline). - `hermetic_smoke.rs` test pin updates from `L1: green=4 amber=8` (pre-46b) to `L1: green=6 amber=6` (post-46b-with-Option-B). The load-bearing invariant the comment locks: `hard-block=4` (3 reds + 1 cooldown) MUST be preserved — that's what Option B restores after Lever #4's initial pin showed `hard-block=3` (cooldown bypassed). Measurement (claude-cli, hermetic 5 ambers post-Option-B): | State | L1 green | L1 amber | L1 red | Portable auto-run | hard-block | |---|---:|---:|---:|---:|---:| | Baseline | 4 | 8 | 3 | 4 | 4 (3 reds + 1 cooldown) | | Lever #4 alone (bug) | 7 | 5 | 3 | 7 | 3⚠️ cooldown bypassed | | Lever #4 + Option B | 6 | 6 | 3 | 6 | 4 ✓ cooldown restored | Curated unchanged (all entries default to "old publish" of 1 year, well past 24h threshold): L1 green=332 amber=115 red=76, advisor- enhanced auto-run 384/447 (73.4%), static-gate corpus 74.3% (zero-FP-red intact). 7 new unit tests pin the cooldown defense: - recent_publish_stays_amber_under_default_cooldown - publish_exactly_at_threshold_widens - old_publish_widens_under_default_cooldown - unknown_publish_age_refuses_to_widen - min_release_age_zero_disables_cooldown_check - custom_min_release_age_compared_against_publish_age - cooldown_check_does_not_affect_compound_or_red_paths Local CI gate green: cargo nextest run --workspace --exclude lpm-integration-tests --no-fail-fast → 6169/6169 passed. cargo clippy --workspace --all-targets -- -D warnings clean. cargo fmt --check clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tolgaergin
added a commit
that referenced
this pull request
May 12, 2026
`lpm-security::static_gate`:
- New `classify_with_context(script, Option<&ManifestContext>)`. The
pre-existing `classify(script)` becomes a thin wrapper passing
`None` so unchanged callers keep their pre-Lever behaviour.
- New `ManifestContext { package_name, repository, bin_names }`.
- New Green arm `matches_delegating_identity_green` — fires when:
- script tokens are `node <reserved-basename>.{js,cjs,mjs}` AND
- package's base name (after scope-strip) appears as a path segment
of `repository`, OR equals a `bin` entry name.
- Identity-match excludes overly generic base names (`js`, `lib`,
`core`, `node`, `src`, `util`, `utils`, anything <3 chars).
- 12 new unit tests covering the green path, false-Green stress,
Red precedence, no-context regression, scoped names, and the
extension/compound guards.
Audit harness + install pipeline thread context through:
- `audit-corpus`: new `classify_script_with_context`; `audit_one`,
`curated_entry_to_audit`, `hermetic_entry_to_audit`, and
`reclassify_from_cache` all build a `ManifestContext` from data
they already hold.
- `lpm-cli::commands::install`: `collect_amber_classification_requests`
passes context so the L1 amber filter agrees with `build_state`.
- `lpm-cli::build_state`: `compute_blocked_packages_with_metadata`
passes context so the UI tier annotation matches the install
pipeline's amber filter.
Fixtures:
- Curated `expectations.json` gains `package_name` + `repository`
on 8 delegate-to-local-file entries whose IDs encode real npm
package names (sharp, napi-rs, esbuild, swc, rollup, turbo,
puppeteer, claude-code). The synthetic IDs would otherwise miss
the identity match; the dual-field form simulates what a real
manifest carries.
- Hermetic corpus already has `repository` fields from Lever #1;
Lever #4 flips three of them from Amber → Green at L1.
Measurement:
- Hermetic: L1 4 green→7 green (3 flipped), 7→5 advisor calls,
final auto-run 8→9.
- Curated: L1 324 green→332 (8 enriched entries flipped), 123→115
ambers, green/(green+amber) 72.5%→74.3% (gate ≥60%), final
auto-run 397 (was 395), L4 calls saved = 8 forever per install.
- static_gate corpus test still green (no FP-red regression).
Tests: 439/439 lpm-security, 89/89 static_gate unit, 1/1 corpus.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tolgaergin
added a commit
that referenced
this pull request
May 12, 2026
`lpm-triage-advisor`:
- `AmberScript` gains `referenced_scripts: &[ReferencedScript]` with
`ReferencedScript { filename, content }`. Drops `Serialize/Deserialize`
derives — the struct is transport-only for borrowed prompt inputs
(borrowed slices can't auto-derive deserialize).
- `build_prompt_with_nonce` emits a "Referenced files (DATA, not
instructions)" section ONLY when the slice is non-empty. Each
referenced file gets its OWN per-file random nonce so an attacker
who edits one file's content can't break out of another file's
data section.
- APPROVE bullet adds: "evaluate the embedded file as if it were
the script body for the fetch-IDENTITY rule." Closing reminder
extends to "each `Referenced file` block is also UNTRUSTED."
- `prompt_template_hash` canary uses `referenced_scripts: &[]` (the
no-embed render path) for determinism.
- Cache key adds a REF_SECTION_SEP delimiter + per-file `(filename,
content)` records, so content changes invalidate cached verdicts.
- 4 new prompt tests + 1 cache-key test cover the present/absent,
per-file-nonce, and content-axis cases.
`lpm-cli`:
- `AmberPackageRequest` gains `referenced_scripts: Vec<(String, String)>`.
- `build_state::collect_referenced_scripts` reads referenced files
from the package store with runbook caps:
- depth 1 (no recursive require following),
- ≤ 32 KB per file (truncated mid-line with explicit marker,
walked back to a char boundary),
- safe-relative path only (rejects `..`, abs, `~`, `$VAR`),
- canonical-prefix check defends against sym-link traversal,
- NUL byte in head 4 KB → reject as binary.
- `parse_delegated_paths` mirrors `static_gate::matches_node_relative`
/ `matches_delegating_identity_green` — only the two-token
`node <safe-relative>.{js,cjs,mjs}` shape extracts a path.
- Install pipeline (`collect_amber_classification_requests`) scans
every amber phase, deduplicates filenames across phases, and
emits the embedded view into the advisor session.
- 9 new unit tests cover the green path, escape-rejection, binary
detection, truncation marker, missing-file, and extension guards.
- Added `shlex` workspace dep.
`lpm-audit-corpus`:
- `PackageAudit` gains `referenced_scripts: Vec<ReferencedScriptEntry>`
persisted on each record. Live audit path leaves empty (no tarball
fetch); hermetic / curated fixtures supply content directly.
- `HermeticEntry` + `CuratedExpectation` gain `referenced_scripts`.
- `classify_one_with_advisor` threads the embedded view through
both the prompt builder AND the cache key — content changes
produce new cache slots so the verdict re-evaluates.
- Hermetic fixture: two delegate-to-local-file entries now carry
realistic install.js content (binary fetcher from same-repo
releases).
- Curated fixture: `amber-d18-013-sharp-install-js` carries an
excerpt of sharp's actual install.js.
Measurement (claude-cli, runs run-to-run variance ~3-5pp):
- Hermetic: advisor-enhanced auto-run 9→10 (one additional Approve
with embedded view). Stayed at 5 ambers post Lever #4.
- Curated: only 1 entry has referenced_scripts and it was already
L1-Green'd by Lever #4, so this corpus shows no isolated Lever #3
movement. Real install.js content shines through the install
pipeline's file-reader at install time.
Tests: 2257/2257 lpm-security, 17/17 lpm-cli triage, 9/9 new
build_state unit tests, 57/57 lpm-triage-advisor. Clippy + fmt clean.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tolgaergin
added a commit
that referenced
this pull request
May 12, 2026
Fixes a real semantic gap caught by the `hermetic_smoke` test pin post-initial-CI: Lever #4's L1 widening for matching-identity delegate-to-local-file shapes silently collapsed the script-tier review for users on the `--allow-new` path. With Lever #4 alone, a `node install.js` package whose `repository` URL matches its own base name auto-runs at L1-Green even when the user only opted out of cooldown (not script review). Two security axes that Phase 46 designed to be orthogonal became coupled — `--allow-new` silently ALSO bypassed `lpm approve-scripts` for the matching-identity subset. Option B restores the orthogonality at the classifier. `ManifestContext` gains two fields: - `publish_age_secs: Option<u64>` — package's age from now - `min_release_age_secs: u64` — configured `minimumReleaseAge` `matches_delegating_identity_green` refuses to widen when `min_release_age_secs > 0` AND publish age is below the threshold (or unknown — fail-closed). When `min_release_age_secs == 0` the user has globally opted out of cooldown and Lever #4 fires unconditionally on identity match. Implementation: - `lpm-security::publish_age_secs(Option<&str>) -> Option<u64>` — new public helper. Fail-closed posture matches `check_release_age` (unparseable → None). - Install pipeline (`commands/install.rs`): pulled `effective_min_age_secs` + `cooldown_policy` resolution OUT of the `if !allow_new` block. Built a `(name, version) → publish_age_secs` HashMap once per install (only when fresh resolution + `min_release_age > 0`). Used BOTH for the cooldown halt gate (still gated on `!allow_new`) AND threaded into `collect_amber_classification_requests` so the L1 classifier sees per-package publish age. - `collect_amber_classification_requests` signature extended with `&HashMap<(String, String), u64>` + `min_release_age_secs: u64`. - `build_state::compute_blocked_packages_with_metadata`: passes `publish_age_secs: None, min_release_age_secs: 0` — the UI annotation only fires for packages already in the blocked set (where install pipeline's cooldown defense was already applied upstream). - Audit-corpus hermetic + curated paths: hermetic synthesizes publish age from `entry.publish_age_hours`; curated defaults to 1 year matching `hermetic_l3_outcome` fixture default. Live + `--reclassify` paths pass `min_release_age_secs: 0` (audit-corpus is a measurement tool; production install applies cooldown defense via the actual pipeline). - `hermetic_smoke.rs` test pin updates from `L1: green=4 amber=8` (pre-46b) to `L1: green=6 amber=6` (post-46b-with-Option-B). The load-bearing invariant the comment locks: `hard-block=4` (3 reds + 1 cooldown) MUST be preserved — that's what Option B restores after Lever #4's initial pin showed `hard-block=3` (cooldown bypassed). Measurement (claude-cli, hermetic 5 ambers post-Option-B): | State | L1 green | L1 amber | L1 red | Portable auto-run | hard-block | |---|---:|---:|---:|---:|---:| | Baseline | 4 | 8 | 3 | 4 | 4 (3 reds + 1 cooldown) | | Lever #4 alone (bug) | 7 | 5 | 3 | 7 | 3⚠️ cooldown bypassed | | Lever #4 + Option B | 6 | 6 | 3 | 6 | 4 ✓ cooldown restored | Curated unchanged (all entries default to "old publish" of 1 year, well past 24h threshold): L1 green=332 amber=115 red=76, advisor- enhanced auto-run 384/447 (73.4%), static-gate corpus 74.3% (zero-FP-red intact). 7 new unit tests pin the cooldown defense: - recent_publish_stays_amber_under_default_cooldown - publish_exactly_at_threshold_widens - old_publish_widens_under_default_cooldown - unknown_publish_age_refuses_to_widen - min_release_age_zero_disables_cooldown_check - custom_min_release_age_compared_against_publish_age - cooldown_check_does_not_affect_compound_or_red_paths Local CI gate green: cargo nextest run --workspace --exclude lpm-integration-tests --no-fail-fast → 6169/6169 passed. cargo clippy --workspace --all-targets -- -D warnings clean. cargo fmt --check clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tolgaergin
added a commit
that referenced
this pull request
May 14, 2026
…low gate Ships the 10 cross-command flow tests enumerated in v2 baseline, flips EXPECT_FULL_V2_FLOWS_BACKFILL to true so future flow drops hard-fail the audit. Each flow exercises a real-user multi-command sequence and asserts the state-transfer claim that ties the commands together — what command A leaves on disk / in the keychain / in the lockfile is the input command B reads. Single-command tests assert each step in isolation; flow tests catch state-shape mismatches between steps. Flows shipped: - install → patch → patch-commit → install (patch persistence) - migrate → install → audit (lockfile round-trips) - install → rebuild → approve-scripts → rebuild (approval lifecycle) - doctor --fix → install (fix survives install) - add → install → graph (added dep visible) - install → upgrade --major → audit (envelope shape) - token-rotate → publish --dry-run --check (token hand-off) - publish --dry-run --check → publish (target agreement) - install -g → run shimmed binary → uninstall -g (shim lifecycle) - env push → env pull cross-machine (round-trip — scoped to local smoke until a cross-machine harness lands) Several flows had their assertions scoped narrower than the original "catches" claim: - Flow #6 (rebuild lifecycle): rebuild --policy=deny ignores the v2 object form of trustedDependencies that approve-scripts writes — a real contract gap, filed as private finding #75. The flow asserts the manifest mutation; rebuild #2 only checks envelope health. - Flow #4 (upgrade major audit): the workflow tier's MockRegistry helpers don't mount GET /api/registry/{name} per-package (only the batch endpoint), so upgrade's candidate selection finds no candidates. Flow asserts envelope shape; tighten when the mock grows the per-package GET. - Flow #7 (env push/pull cross-machine): proper round-trip needs a shared-vault-state test harness that doesn't exist yet. Flow smokes per-machine env state isolation; promote when the harness lands. - Flow #8 (install -g): gracefully degrades when install-g doesn't emit a shim on the test runner (cli-binary tier owns the strict contract). Run results: 10/10 flow tests pass, all 10 v2 audit tests pass, full lpm-workflows suite green (623/623), clippy clean, fmt clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tolgaergin
added a commit
that referenced
this pull request
May 14, 2026
…htening, cross-machine vault harness
Six focused follow-ups against the v2 coverage matrix.
JSON contract depth promotions (SemanticAsserts → InstaSnapshot):
- id 4 lpm whoami — insta snapshot added to
`whoami_recovers_session_from_refresh_token_only` in
`auth_lifecycle.rs`. Pins the envelope shape under a refresh-only
session recovery.
- id 97 lpm env ls/list — insta snapshot added to
`env_list_json_envelope_carries_keys` in `env_local.rs`. The
envelope is a flat key→masked-value map; locked with `sort_maps`
for stable ordering across `preserve_order`-enabled serde_json.
- id 101 lpm env push/pull — insta snapshot added to the GitLab
OIDC pull --json test in `env_vault.rs`. Pins the {env, count,
vars} shape after the LPM_OIDC_TOKEN canonical-input contract.
JSON contract depth promotions (None → SemanticAsserts):
- id 74 lpm approve-scripts `<pkg>` — verified the named `<pkg>`
form test reads `parsed["dry_run"]` and `parsed["approved_count"]`
via `serde_json::from_str`. Audited the other 34 None rows — most
are either commands that don't emit JSON envelopes (completions,
dev/tunnel streams, login/logout) or where the named sub-form
isn't directly covered by an envelope-reading test fn.
Cross-command flow #4 (install → upgrade --major → audit) tightened:
- Lifted the private `mount_upgrade_package` from `upgrade.rs` into
the shared `MockRegistry::with_full_package_metadata` helper. It
mounts the per-package GET (`/api/registry/{name}` + the
npm-direct `/{name}` path) AND the batch-metadata POST from one
metadata document, with optional `None` tarball-bytes for the
fail-tarball case. `lpm upgrade`'s candidate selector reads the
GET endpoint; the install fallback reads batch-metadata; the
shared helper makes both observable from a single call.
- Tightened the rebuild #2 assertion in flow #4 to require the
upgrade --major --dry-run envelope mentions both `2.0.0` and the
scoped package name. Was previously gated behind "shared mount
missing" — gate removed.
Finding #75 (rebuild --policy=deny ignores object-form
trustedDependencies) — RETRACTED:
- `TrustedDependencies` in lpm-workspace is `#[serde(untagged)]`
over both `Vec<String>` (Legacy) and `HashMap<String, Binding>`
(Rich). `evaluate_trust` in rebuild.rs routes through
`matches_strict`, which prefers the concrete `name@version` key
and falls back to the `name@*` preserve key. Object form is
already supported.
- The empty `packages[]` flow #6 originally observed was
`TrustMatch::BindingDrift`: the fixture's synthetic
`"sha256-flow-script-hash"` did not match the real
`compute_script_hash(store_dir)` value rebuild computes on disk.
Synthetic vs. recomputed hash divergence, not a missing reader.
- Fixed in flow #6 by computing the real script_hash via
`lpm_security::script_hash::compute_script_hash` and propagating
it through `.lpm/build-state.json` → approve-scripts → manifest.
Rebuild #2 now asserts `packages[]` contains `scripted-pkg@1.0.0`
with `trusted: true`.
Cross-command flow #7 (env push → env pull cross-machine) — full
byte-equality round-trip now lands:
- Added `MockRegistry::with_stateful_personal_sync(vault_id,
bearer)` to share `Arc<Mutex<Option<StoredSyncBlob>>>` between
POST and GET handlers on `/api/vaults/{vault_id}/sync`. POST
captures encryptedBlob + wrappedKey + bumps the version; GET
returns the stored payload signed with the bearer's HMAC. A
fresh GET before any POST returns 404 — the natural "machine B
pulls before machine A pushed" shape.
- Flow #7 now drives two TempProjects sharing this mock. Both
HOMEs are seeded with the same `<HOME>/.lpm/.vault-key` (32-byte
hex, the cryptographic outcome that real pairing produces) +
the same paired session bearer. Machine A: `env set` → `env push`.
Machine B: `env pull` → `env get --reveal`. The revealed
plaintext must byte-equal the value machine A pushed.
scenarios_by_file partitions populated for shared test files:
- id 83 lpm run `<script>` — run.rs: 14
- id 84 lpm run --filter / --all / --affected — run.rs: 7
- id 87 lpm lint — tools.rs: 5
- id 88 lpm fmt (write) — tools.rs: 3
- id 89 lpm fmt --check — tools.rs: 1
- id 91 lpm test — tools.rs: 7
- id 96 lpm env init — env_local.rs: 1
- id 98 lpm env set/get/delete — env_local.rs: 6
- id 99 lpm env import/export/print/copy — env_local.rs: 4
- id 100 lpm env diff/validate/check — env_local.rs: 4
Full CI gate green (workspace target, separate CARGO_TARGET_DIR):
- cargo clippy --workspace --all-targets -- -D warnings clean
- cargo fmt --check clean
- grep -r 'fancy-regex' crates/*/Cargo.toml (none)
- cargo build --workspace clean
- cargo nextest run --workspace --exclude lpm-integration-tests
6397/6397 pass
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tolgaergin
added a commit
that referenced
this pull request
May 14, 2026
…ix (#58) * test(workflows): pin concurrency + recovery contracts for lpm install Adds tests/workflows/tests/install_concurrency.rs with 13 falsifiable tests covering production failure modes that had zero coverage: Category A — process racing: * two concurrent installs on same project (pins finding-#77 floor) * install + concurrent store-clean serialize via shared/exclusive store_lock (probed via try_with_exclusive_lock on the actual lock file, not a directory-existence proxy) * two concurrent `lpm install -g` via global_tx_lock — proves final manifest + WAL coherence under serialized commits Category B — interruption recovery: * kill mid-tarball-fetch leaves no .lpm/install-hash * next `lpm install` converges to a coherent end state Category C — network faults: * tarball 503 → 200 succeeds after retry (counting Respond impl) * metadata 404 fails immediately without retry (<2s wall-clock) Category D — filesystem faults: * readonly project dir fails with actionable error (no panic); POSIX-only via #[cfg(unix)], RAII guard restores permissions * `<project>/.lpm` planted as a regular file fails clearly Category E — partial state recovery: * stale install-hash triggers re-resolve + refetch * partial node_modules re-links to full state * truncated lpm.lockb either recovers or fails cleanly (no panic) Category F — WAL recovery hook: * torn WAL tail (3 garbage bytes) gets truncated by the dispatcher's recovery hook before the command runs; idempotent on re-invocation Support helper refactor (same commit so the new helper has callers): * extracts env-isolation set into `LpmEnvSink` trait + `apply_lpm_env(cmd, project)` shared by `lpm()` (assert_cmd) and the new `lpm_spawnable()` / `lpm_spawnable_with_registry()` (std::process::Command, supports Child::kill()) * trait impl on both Command variants ensures the two helpers cannot drift on the ~30 env knobs that gate test isolation Surfaced findings during this work: * #77 — no project-level install lock: concurrent installs silently drop one side's work AND/OR fail with atomic-rename races (3 observed failure modes documented in findings.md). Fix shape: LpmRoot::project_install_lock + with_exclusive_lock_async wrap. * #78 — retry-backoff has no test-friendly knob; retry-exhaustion tests take 15s+. Fix shape: LPM_RETRY_BACKOFF_MS_OVERRIDE env in debug builds. CI gate locally green: clippy --workspace --all-targets -- -D warnings: clean cargo fmt --check: clean fancy-regex ban: empty cargo build --workspace: clean cargo nextest run --workspace --exclude lpm-integration-tests: 6439 passed, 7 skipped, 1 leaky (pre-existing) Deferred (filed under "next session" in the followup plan): B.3 (kill doesn't tear lockfile) — subsumed by B.1/B.2 B.4 (panic injection) — needs LPM_TEST_PANIC_AT env hook C.2 (retry exhaustion) — blocked by finding #78 C.3 (truncated body) — needs custom Respond with Content-Length mismatch D.3 (disk-full simulation) — no portable mechanism F.2, F.3 (orphan WAL, torn WAL with real records) — needs framed-WAL construction helpers Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(workflows): pin lpm.lock well-formedness + recovery skip-on-contention Closes B.3 and F.2 of the concurrency tranche — 13 → 15 tests, meeting the "≥15 of 21" acceptance criterion for Item 2. B.3 — `install_killed_mid_pipeline_leaves_well_formed_or_absent_lockfile`: Exercises two SIGKILL windows on the install pipeline — fresh project and project with a committed lpm.lock from a prior install. After each kill, asserts the on-disk lpm.lock is either absent OR parses as TOML. Never half-written. Adds `toml = { workspace = true }` as a workflow- tests dev-dep for the parse assertion. Helper `assert_lockfile_well_formed_or_absent` shared between both windows. F.2 — `lpm_command_skips_recovery_when_another_lpm_holds_global_tx_lock`: Validates the dispatcher's `try_with_exclusive_lock` idempotent-skip path at `main.rs:2531`. A background thread acquires `global_tx_lock` via `lpm_common::with_exclusive_lock` and blocks on a channel. With the lock held, runs `lpm global list` against a project with a torn- WAL prefix — asserts the WAL bytes are UNCHANGED (skip arm fired, recovery did not run). Then releases the lock and re-runs; asserts the WAL is now truncated (recovery defers correctly to the next lock-free invocation). Exercises both branches of the `try_with_ exclusive_lock` Ok(None) / Ok(Some) arm. CI gate locally green: cargo clippy --workspace --all-targets -- -D warnings: clean cargo fmt --check: clean cargo nextest run --workspace --exclude lpm-integration-tests: 6441/6441 passed, 7 skipped 5x parallel re-run of install_concurrency: 15/15 stable each run Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(workflows): pin truncated-tarball + orphan-WAL recovery contracts Two new tests in tests/workflows/tests/install_concurrency.rs: - C.3 tarball_connection_dropped_mid_body_fails_or_retries: a custom wiremock Respond impl serves half a tarball with a Content-Length header naming the full length. Pins the install pipeline's retry-then-fail behavior on transport-class failures (~14s wall-clock for the full 4-attempt retry schedule). Hyper 1.9 server-side panics on the Content-Length lie, dropping the connection — a valid surrogate for a broken upstream / CDN dropping mid-body. Surfaced 8 tarball GETs per install (deterministic, 3-of-3 reproducer), explained by two distinct download_tarball_* call sites in install.rs each running the 4-attempt retry budget. - F.3 lpm_command_with_orphan_pending_tx_emits_recovery_banner: plants both halves of an orphan transaction (WAL Intent record without matching Commit/Abort + matching [pending.<pkg>] row in manifest.toml pointing at a non-existent install root) and asserts the dispatcher's recovery hook fires the RolledBack banner from main.rs:2543. Sets RUST_LOG=lpm=info to lift the default lpm=warn filter so the tracing::info! line surfaces. Adds lpm-global as a workflow dev-dep for WalWriter / IntentPayload / write_for. Pins post-state: orphan pending row gone, no spurious active row. Together these close the C.3 and F.3 gaps in Item 2 of the test coverage follow-up plan: 17/21 scenarios pinned (was 15/21). The four remaining items all need source-side hooks (LPM_TEST_PANIC_AT, LPM_RETRY_BACKOFF_MS_OVERRIDE, container infra) and are out of scope for this tranche. Full CI gate green: clippy clean, fmt clean, fancy-regex empty, 6443/6443 nextest pass (was 6441 pre-tranche). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(workflows): pin tarball-extraction security contracts at install tier New file tests/workflows/tests/tarball_security.rs ships phase 1 of Item 3 (tarball-extraction security): 5 of 10 planned tests covering the most distinct security contracts at the install-pipeline tier. Each test constructs its malicious tarball in-line via tar::Builder (no checked-in fixtures), serves it through MockRegistry, and runs lpm install end-to-end so any pipeline-level regression that bypasses the extractor's hardening is caught. Tests landed: - #1 tarball_with_dot_dot_path_entry_is_rejected_by_install — pokes package/../escape.txt into the raw tar header bytes; install fails with "path traversal detected"; outside sentinel never created. - #3 tarball_with_absolute_path_entry_is_normalized_to_relative_under_package_dir — renamed from "rejected" to reflect actual contract. The extractor's strip_first_component consumes the RootDir; an entry like /etc/lpm-pwned.txt extracts as node_modules/<pkg>/etc/lpm-pwned.txt. Install SUCCEEDS; literal /etc/lpm-pwned.txt is never written. Defensible: malformed-but-safe input normalized rather than refused. - #2 tarball_with_symlink_to_outside_path_is_silently_skipped — renamed. The is_file() gate at lib.rs:398 silently drops symlinks; install succeeds with byte-identical outside sentinel. - #5 tarball_with_hard_link_to_outside_file_is_silently_skipped — renamed. Same is_file() gate; hardlinks silently skipped; outside victim file unmodified. - #8 tarball_with_setuid_executable_extracts_with_setuid_bit_stripped (POSIX-only) — tarball entry mode 0o4755 extracts as 0o755. SUID, SGID, and sticky bits all cleared via set_preserve_permissions(false) + the explicit `0o644 | exec_bits` mode set after write. Exec bits preserved. Three tests carry a "plan-vs-actual" docstring section explaining why the rename is defensible — the actual extractor contract differs from the plan's prescribed phrasing in safe ways, not in regression-grade ways. No findings filed. Phase 2 (5 remaining tests: Unicode normalization, device file, FIFO, zero-byte sanity, OS-max path) is deferred to a follow-up tranche with rationale + lift estimate documented in the plan. None blocks phase 1 acceptance. Pre-merge gate green: clippy clean, fmt clean, fancy-regex empty, 6448/6448 nextest pass (was 6443; +5 for the new tests). 0.18s wall- clock for the full file. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(install): per-project lock prevents concurrent-install data loss Closes finding #77. Two `lpm install <pkg>` invocations on the same project no longer race on the manifest snapshot+commit window. Pre-fix, both processes acquired only a SHARED store_lock and proceeded in parallel. Each opened its own per-process ManifestTransaction snapshot of the pre-edit package.json, staged its own dep on top, and ran the install pipeline. Whoever wrote package.json + lpm.lock last won; the other process's edits — including its node_modules link — silently vanished. Both processes still exited 0 with success-path output. CI scripts that ran two installs in parallel saw no signal of the data loss. The fix introduces: - crates/lpm-common/src/paths.rs::project_install_lock(project_dir): free helper returning <project_dir>/.lpm/.install.lock. Re-exported from crates/lpm-common/src/lib.rs. - run_add_packages and run_install_filtered_add in crates/lpm-cli/src/commands/install.rs now wrap the snapshot → stage → install → finalize → commit window in with_exclusive_lock_async against the project lock. The lock is per-project (no cross-project contention) and held across all ?-early-exits via the async block's return. For the workspace path, the lock sits at the discovered workspace root (not per-member) so two concurrent `lpm install --filter <member>` invocations on the same workspace serialize without per-member deadlock-ordering complexity. run_with_options (the inner install pipeline) does NOT acquire this lock — it's called from inside both run_add_packages's wrap and from many other commands; double-acquiring the same fd-lock would deadlock in-process. Deferred (phase 2, not exercised by A.1): lpm add (add.rs:723-904) has a similar 180-line transaction with recursive Swift handling. Wrapping it is invasive and the race surface is theoretical (users don't typically run `lpm add` and `lpm install` concurrently). Defer to a separate tranche if a concurrent `lpm add` × `lpm install` race is ever observed. Test contract tightening (bug-first per CLAUDE.md): two_concurrent_installs_on_same_project_leave_well_formed_manifest in tests/workflows/tests/install_concurrency.rs went from "at-least-one survives + manifest is well-formed JSON" (the floor) to "BOTH installs succeed, BOTH packages present in package.json deps, BOTH packages linked in node_modules/" (the contract). Pre-fix: 1/1 fail (pkg-b silently dropped). Post-fix: 5/5 pass with no flakes (~1.2s wall-clock each — install B observes pkg-a's commit and reports "Resolved 2 packages"). Pre-merge gate green: clippy --workspace --all-targets clean, fmt clean, fancy-regex empty, 6448/6448 nextest pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(registry): test-only retry-backoff override env knob Closes finding #78 + lands C.2 (`tarball_503_exhausts_retries_fails_with_http_status`). Pre-fix, retry-exhaustion tests were blocked: the registry client's backoff schedule (1+2+4+8s, capped at 10s) made every retry-exhaustion test take ~15s per fetch site (~28s with the install pipeline's 2 distinct download_tarball_* call sites). MAX_RETRIES, RETRY_BASE_DELAY, and RETRY_MAX_DELAY are private const with no env override. C.2 therefore had to be #[ignore]-gated behind LPM_RUN_SLOW_TESTS=1, and the retry-exhaustion contract went unproven on `cargo nextest run`. The fix introduces: - crates/lpm-registry/src/client.rs::backoff_override(): reads LPM_RETRY_BACKOFF_MS_OVERRIDE (a u64 ms value) gated by cfg!(debug_assertions) || LPM_TEST_MODE=1. Returns Some(Duration) when both conditions hold; None otherwise. Production retry policy is immune — release builds without LPM_TEST_MODE=1 silently ignore the env. - backoff_delay(attempt) consults the override before computing the exponential schedule. - The two 429 Retry-After sleep sites also consult the override so a future 429-flood retry-exhaustion test wouldn't hang on the server-supplied header. C.2 test landed alongside (bug-first per CLAUDE.md): - Mock returns 503 on every tarball request — no recovery path. - Test sets LPM_RETRY_BACKOFF_MS_OVERRIDE=10 on the lpm subprocess. - Asserts: install fails non-zero, no panic, ≥4 attempts (proves the retry loop fired), elapsed < 2s (load-bearing — without the knob this fails at ~14s), stderr contains an actionable HTTP-class noun (503 / status / http / network / etc). - Surfaces 8 tarball GETs per install (4 attempts × 2 distinct download_tarball_* call sites — matches C.3's observation). Pre-fix verification: same C.2 against the unfixed client.rs failed on the elapsed assertion at 14.04s (knob ignored). Post-fix: passes in 1.6s cold / 0.1s warm. 5/5 passes with no flakes. Pre-merge gate green: clippy --workspace --all-targets clean, fmt clean, fancy-regex empty, 6449/6449 nextest pass (was 6448 pre-fix; +1 for C.2). Item 2 of the test-coverage-followup-plan now at 18/21 (was 17/21). Both findings #77 and #78 fixed in production. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(workflows): tarball-security phase 2 — Unicode, device, FIFO, zero-byte, long-path Adds 5 more tests to tarball_security.rs, completing Item 3 of the test-coverage follow-up plan. Each test pins the actual extractor contract under malicious-or-edge-case tarball shapes that reach the install pipeline through MockRegistry. Tests landed: - #4 tarball_with_unicode_lookalike_parent_dir_extracts_safely_as_literal_bytes — renamed from "_normalization_traversal_rejected" to reflect the actual contract. Tarball entry path uses full-width dots U+FF0E `..` (bytewise NOT ASCII `..`). Component::ParentDir is byte-exact, so `..` becomes Component::Normal. Install SUCCEEDS; `..` materializes as a literal directory under node_modules/<pkg>/; outside sentinel byte-identical. Defensible because Path::components() doesn't NFKC-normalize on POSIX. - #6 tarball_with_character_device_entry_is_silently_skipped (POSIX-only). EntryType::Char with /dev/null-shaped major/minor. Same is_file() gate as symlinks/hardlinks — silently skipped. Install SUCCEEDS; no device file at the expected path. - #7 tarball_with_fifo_entry_is_silently_skipped (POSIX-only). EntryType::Fifo. Same posture as #6. - #9 tarball_with_zero_byte_regular_file_extracts_as_empty_file. Sanity check that empty files still extract correctly (legitimate npm shape: .gitkeep, license placeholders). - #10 tarball_with_single_path_component_exceeding_name_max_fails_cleanly. 300-byte single-component name, well over POSIX NAME_MAX=255. Tar wire format succeeds via GNU long-name extension; the FILESYSTEM rejects on extraction (ENAMETOOLONG). Extractor wraps as LpmError::Io → install fails non-zero with the OS error visible and an actionable noun in stderr. Three of the five tests are renamed to reflect actual extractor contract vs the plan's prescribed phrasing — same "plan-vs-actual" docstring pattern as phase 1. No findings filed; all 10 contracts across phase 1 + 2 are defensible-as-implemented. Pre-merge gate green: clippy --workspace --all-targets clean, fmt clean, fancy-regex empty, 6454/6454 nextest pass (was 6449 pre-tranche; +5 for the new tests). Full file 0.2s wall-clock for all 10 tests. Item 3 now COMPLETE (10/10). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(workflows): cross-command flows Item 4 — migrate→rebuild + workspace filter isolation Closes Item 4 of the test-coverage-followup-plan at 6/6 (target was ≥5). Two additions to tests/workflows/tests/cross_command_flows.rs: - Plan #1 — extended flow_migrate_install_audit_lockfile_round_trips with a `lpm rebuild --dry-run --policy=deny` step. Pins the full migrate → install → audit → rebuild lifecycle. Asserts the rebuild step exits 0 + does not mutate the post-audit state (lpm.lock + lpm.lockb still present). Catches regressions where rebuild's lockfile or build-state parser breaks against a freshly-migrated manifest. - Plan #5 — added flow_workspace_install_filter_member_a_does_not_mutate_member_b (new test, 159 LOC). Pins the workspace-member isolation contract using the workspace-monorepo fixture (3 members: app, core, utils): 1. Initial filtered install on @test/core (re-pinning its existing semver dep) populates core's per-member quadruple: lpm.lock=319 B, lockb=230 B, install_hash=118 B. 2. Snapshot core's full quadruple. 3. Run `lpm install chalk@5.3.0 --filter @test/app` to add a new dep to app ONLY. 4. Assert app's package.json gained chalk; core's quadruple (package.json + lpm.lock + lpm.lockb + install-hash) is BYTE-IDENTICAL post-install; chalk does NOT appear in core's node_modules/. Catches a regression where a per-member filtered install accidentally also mutates a sibling member's package.json / lockfile / install-hash — a real bug class because run_install_filtered_add shares the workspace-root project lock (added in #77 fix) and could over-snapshot if the target-set computation drifts. Helper `mount_pkg_full(mock, name, version)` factors out the three-step metadata + batch-metadata + tarball mount so the test body stays readable. Other 4 plan flows already covered pre-tranche: - Plan #2: flow_add_install_graph_added_dep_visible - Plan #3: flow_install_patch_patch_commit_install_persists_patch - Plan #4: flow_token_rotate_publish_dry_run_picks_new_token - Plan #6: flow_install_upgrade_major_audit_picks_new_version Pre-merge gate green: clippy --workspace --all-targets clean, fmt clean, fancy-regex empty, 6455/6455 nextest pass (was 6454; +1 for the new flow). Plan #5 stable across 5/5 reruns at ~0.11s each. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(install): LPM_TEST_PANIC_AT hook + B.4 panic-rollback contract Adds a deterministic panic-injection hook to the install pipeline + unblocks the long-deferred B.4 contract test for ManifestTransaction Drop-based rollback on panic. The hook (`maybe_test_panic(stage)` in crates/lpm-cli/src/commands/install.rs) reads LPM_TEST_PANIC_AT and panics when the env value matches the stage name. Gated to `cfg!(debug_assertions) || LPM_TEST_MODE=1` — same pattern as the #78 retry-backoff override. Production builds without LPM_TEST_MODE=1 silently treat the env as no-op. Wired 4 stages in `run_add_packages`: - "after-snapshot" — manifest unchanged; Drop is no-op - "after-stage" — placeholder `*` written to package.json (load-bearing) - "after-install" — pipeline complete; manifest still has `*` - "after-finalize" — concrete versions written; pre-commit only The hook unblocks B.4 (`install_panics_mid_pipeline_rollback_restores_manifest`), deferred since the original Item 2 tranche because there was no deterministic way to trigger a panic mid-install from a workflow test. Recoverable errors fire `?`-rollback (covered by E.1/E.2/E.3); SIGKILL bypasses Drop entirely (B.1/B.2/B.3 cover that). The panic path was the missing rollback proof. B.4 sets LPM_TEST_PANIC_AT=after-stage and asserts: - process exits non-zero (panic propagates to runtime) - stderr contains `"panicked at"` AND `"LPM_TEST_PANIC_AT=after-stage"` - package.json BYTE-IDENTICAL to pre-stage (Drop ran on unwind, snapshot bytes restored — load-bearing) - the new pkg is NOT in dependencies (placeholder rollback worked) - .lpm/install-hash absent (invalidate-on-rollback) - lpm.lock absent (matched optional snapshot's None pre-state) Catches a regression where: - panic = "abort" added to release profile (no Drop on panic) - ManifestTransaction Drop logic stops restoring snapshot bytes - The `lpm install` snapshot+commit window grows without re-wiring Drop Test runs in 0.07s warm. 5/5 stable across reruns. Pre-merge gate green: clippy --workspace --all-targets clean, fmt clean, fancy-regex empty, 6456/6456 nextest pass (was 6455; +1 for B.4). install_concurrency now at 19/19. Item 2 of test-coverage-followup-plan moves to 19/21 — only A.2 (no contract) and D.3 (needs container infra) remain deferred indefinitely. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(workflows): align MockRegistry tarball URL shape with production /-/ gate Workflow tests mounted tarballs at `/tarballs/{name}-{version}.tgz` — missing the `/-/` path segment that the registry-client's `evaluate_cached_url` gate at [crates/lpm-registry/src/client.rs#L4117] requires (`.tgz` suffix AND `/-/` substring). The gate is a defense-in-depth check that blocks the H1 auth-token leak: a tampered lockfile URL like `/api/admin/foo.tgz` (no `/-/`) would otherwise attach the bearer to a non-registry endpoint. The mismatch produced two test-environment side effects that don't manifest in production: 1. **WARN noise**: every install test that read a tarball URL from the lockfile fast path logged `cached tarball URL for X@Y failed shape check; falling back to on-demand lookup`. Polluted stderr across the suite. 2. **`shape_mismatch_count` defeated**: the registry-client documents this counter as a "BUG signal — the writer should never emit a gate-rejectable URL". Test runs incremented it on every install, making the counter useless for catching real bugs. This commit migrates the mock to the production-shape `/tarballs/{name}/-/{name}-{version}.tgz` everywhere — both the helper methods (`MockRegistry::tarball_path` / `tarball_url`) and the ~60 hard-coded `format!` sites across 14 test files + 1 snapshot. The new `tarball_path` helper is `pub` with a prominent docstring warning future test authors not to re-introduce the legacy shape. Internal mounts in `with_package_and_deps` / `with_package_published_at` / `with_full_package_metadata` all route through it. Post-fix verification: WARN gone, gate `Accepted` path runs, all 691 lpm-workflows tests pass (0 leaky in the latest full-workspace run, down from 1-3 leaky pre-fix — fewer fallback paths firing). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(workflows): test-coverage-followup tranche — Items 2/3/4/5 Closes the remaining open rows from `private/test-coverage-followup-plan.md` across four items. ~2,600 LOC of new test code + fixture + budget infra. **Item 3 — tarball-security additional candidate surfaces (7 tests in `tarball_security.rs`):** - `tarball_with_pax_path_traversal_rejected` — PAX extended `path` header smuggling `..` is rejected by the extractor's `Component::ParentDir` check after the tar crate resolves the override. - `tarball_with_gnu_longname_traversal_rejected` — symmetric GNU `L` entry; same rejection path. - `tarball_rejects_or_rolls_back_when_later_entry_is_malicious` — pins the `rollback_extraction` contract: valid first entry is cleaned up when a later `..`-traversal entry trips rejection mid-stream. - `tarball_with_duplicate_member_path_rejected_or_deterministic` — pins current last-write-wins contract (defensible; flagged scanner- disagreement risk in test comment). - `tarball_with_truncated_gzip_rolls_back_partial_extract` — half- truncated gzip stream → libdeflate fails cleanly → no partial extract. - `tarball_ignores_uid_gid_ownership_metadata` (POSIX) — bogus uid/gid in tar header is ignored; extracted files owned by process uid. - `tarball_with_sparse_huge_file_rejected_by_declared_size` — manually- constructed tarball with header declaring `MAX_FILE_SIZE + 1` and empty on-wire body; extractor rejects on the pre-check at lib.rs:306 before draining body. **Item 4 — cross-command flows additional candidate surfaces (2 tests in `cross_command_flows.rs`):** - `flow_install_uninstall_install_graph_round_trip` — pins manifest / link / graph hand-off through a full round-trip. - `flow_cache_clean_then_offline_install_uses_store_or_fails_helpfully` — pins the cache/store boundary: `cache clean` must not corrupt offline install; store-side bytes byte-identical after a clean. **Item 2 — concurrency/recovery additional candidate surfaces (3 tests in `install_concurrency.rs`):** - `cache_clean_during_slow_tarball_install_does_not_corrupt_install` (G.4) — install + cache clean run concurrently (different lock paths, no serialization); install succeeds despite metadata cache wipe mid-stream. Empirical timing observed: install elapsed 1.57s, cache clean fired at t=30-39ms cleanly inside the install window. - `install_panics_after_install_hash_write_rollback_invalidates_hash` (G.5) — reuses existing `LPM_TEST_PANIC_AT=after-install` stage (no new source-side hook needed — `write_post_install_v6_hash` runs inside `run_with_options` which returns BEFORE that stage fires). Pins that Drop-based rollback restores manifest AND deletes the freshly-written install-hash. - `malformed_registry_json_fails_without_manifest_or_lockfile_mutation` (G.6) — truncated JSON on all three metadata endpoints; install fails cleanly, no panic/backtrace, package.json byte-identical, no torn lockfile. **Verdaccio-npm parity for `which@4.0.0` (`install_real_registry.rs`):** - `verdaccio_npm_parity_for_bin_package_pins_metadata_and_shim_presence` — extends the existing lodash byte-diff with a bin-shipping target package. Asserts metadata equivalence + `.bin/<name>` shim present on both sides + bin target file materialized + exec bits non-zero (POSIX). **Item 5 — realworld fidelity (new fixture + new test file):** - `tests/fixtures/realworld-nextjs/` (package.json + README) — pinned Next.js 14.2.13 + React 18.3.1 + TypeScript 5.6.3 + 3 `@types/*` packages. Resolves to ~28 transitive deps empirically. README documents the calibration methodology including raw measurement data. - `tests/workflows/tests/install_realworld.rs` — `install_realworld_nextjs_fixture_succeeds_through_verdaccio` installs the fixture through Verdaccio→npmjs and asserts end-to-end success at production scale. Always logs cold + warm wall-clock + peak RSS to stderr for calibration data. - **`LPM_BUDGET_GATE=1`-gated budget assertions**: cold ≤ 25s, warm ≤ 25ms, cold peak RSS ≤ 1500 MiB. Calibrated from N=6 cold + N=3 warm + N=3 RSS runs on M-series macOS, 2026-05-14. Memory measurement via `/usr/bin/time -l` (macOS) / `-v` (Linux); Windows skips with a clear warning. This closes Item 5 entirely (all 4 acceptance criteria green) and brings Items 2/3/4 to the parked-by-design or infrastructure-blocked baseline. CI gate: clippy `--workspace --all-targets -- -D warnings` clean, fmt clean, fancy-regex empty, build clean, `cargo nextest run --workspace` 6471/6471 pass. Suite runtime ~2:40 (was ~2:24 pre-tranche; +15s for the realworld test). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(workflows): collapse Linux-only let-chain in parse_peak_rss CI lint on Linux failed on `clippy::collapsible_if` in the Linux-cfg'd branch of `parse_peak_rss`. The macOS branch had an intermediate `let bytes_str = rest.trim();` between the two `if let`s, which is why the local clippy run on macOS didn't catch this — only the macOS-cfg branch compiled there. Collapse the Linux branch to use `&&` (stable let-chains) so it satisfies the lint while preserving the same semantics. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TL;DR
Fixes a latent O(N) in the PubGrub
get_dependencieshot path.NpmRange::to_pubgrub_ranges(&available_versions)runs on every resolve-pass edge query AND re-runs on every backtrack query, converting the same raw range string against the same available-versions list repeatedly. Add a(ResolverPackage, raw_range) → Ranges<NpmVersion>memoization on the provider. Two unit tests pin the contract; 116 resolver tests + 3643 workspace tests stay green.Empirically a null result on decision-gate
Same-session 3×3 interleaved A/B vs Phase 42 main, cold cache each run, medians:
total_msresolve_mspubgrub_mspubgrub_core_msfollowup_rpc_msAll deltas sit inside run-to-run variance. Post-Phase-40-P4 the decision-gate walk barely backtracks, so every
get_dependenciescall is a compulsory cache miss — the cache has nothing to reuse. That's the condition where Gemini's Phase 41 prediction of ~700 ms of the 962 ms regression would have been recoverable via this exact cache under; without that metadata pressure the cache is dormant.Why ship anyway (the argument I buy)
(pkg, range)pair; runtime overhead is below the noise floor on decision-gate. Memory: ~16 KB on a 100-package resolve.Second-opinion framing: "algorithmic integrity ≠ feature YAGNI." Different bar for fixing a primitive's complexity than for shipping speculative architecture. This PR is the former.
Contract + correctness
(ResolverPackage, String /* raw range */). Identical raw-range strings produce identicalRanges<NpmVersion>given the same package identity.available_versions(pkg)is fixed onceensure_cached(pkg)runs (metadata cache is append-only per pass; platform filtering is pure over a fixed platform map).ajv[eslint]andajv(bare) key separate cells even with identical ranges — future per-split behaviour differences won't share stale cells.with_cache/with_prefetched_metadata. The metadata cache transfers; the range cache rebuilds. Correctness invariant stays local.available_versionsis empty) stays uncached: its cost is bounded by range bounds, not version count.Tests
Both are failing-test-first — they don't compile without the fix (unknown method + unknown field).
to_pubgrub_ranges_cached_hits_on_repeated_query— identical(pkg, range)query populates exactly one cache entry; second call returns structurally equalRanges; a different range keys a new entry.to_pubgrub_ranges_cached_distinguishes_split_packages— plain and split variants of the same canonical name stay in separate cells even with identical range + available set.Test plan
cargo clippy --workspace -- -D warnings— cleancargo fmt --check— cleangrep -r 'fancy-regex' crates/*/Cargo.toml— no hitscargo nextest run --workspace --exclude lpm-integration-tests --no-fail-fast— 3643 passed, 7 skipped, 0 failedcargo test -p lpm-auth×2 — 43 passed both (parallel-deterministic)Follow-ups from the Phase 42 roadmap
Tracked in
DOCS/new-features/37-rust-client-RUNNER-VISION-phase42.mdon a-package-manager. After this merges: Tier-1 P3 (NDJSON light mode at the worker) is the next candidate.🤖 Generated with Claude Code