Phase 61 — warm-path relayout & lpm run floor (Tier 1 + Tier 2)#23
Merged
Conversation
`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>
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>
…ntrypoints 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>
…ete 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>
…allel 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>
… 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>
…rs/ (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>
…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>
…(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>
….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>
… 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>
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>
tolgaergin
added a commit
that referenced
this pull request
Apr 30, 2026
Highlights since v0.28.0: - **Phase 61: warm-path relayout & `lpm run` floor** (#23). Moves the isolated linker's wrapper tree from `node_modules/.lpm/` to `<project>/.lpm/wrappers/` so `rm -rf node_modules` no longer wipes the incremental link cache. Bench/run.sh `warm-install` row drops from 256 ms → ~256 ms median (with wrappers preserved across iters by design). Tier 1 also threads pre-resolved bin-dir + dedups the per-`lpm run` `lpm.json` read for sub-perceptible savings on the script-overhead path. Includes hard error in `rebuild.rs` when a wrapper isn't materialized (replaces silent store-fallback that would have run a lifecycle script against canonical bytes). - **Phase 61 follow-up: hoisted-mode symmetry** (#24). Symmetric relocation for hoisted mode — `.lpm-metadata.json` + `.lpm/nested/` now live under `<project>/.lpm/hoisted/`. Architectural, not perf: single source-of-truth path ownership, no orphan `node_modules/.lpm/` after a hoisted install, separate sub-namespace per linker mode, correct upgrade-in-place migration, coherent mode-switch convergence (active mode prunes inactive mode's project-local state at install finalize). - **fix(lockfile): default resolver matches post-Phase-60 behavior**. Lockfile metadata `resolved-with` field now correctly reflects greedy-fusion as the default (was hardcoded to "pubgrub" inside `Lockfile::new`, so every default install post-v0.28.0 wrote a lie into the lockfile). CI gate green on this commit: - cargo clippy --workspace --all-targets -- -D warnings: clean - cargo fmt --check: clean - cargo build --workspace: clean - cargo nextest run --workspace --exclude lpm-integration-tests: 5000/5000 (7 skipped) 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.
Summary
Phase 61 moves the isolated linker's per-package wrapper tree out of
node_modules/.lpm/to<project>/.lpm/wrappers/. After the relayout,rm -rf node_modulesno longer wipes the entire incremental linker cache — so the warm-install bench (and the user pattern Phase 57.2 surfaced — wipingnode_modulesafter a teammate's lockfile change) actually exercises the incremental linker.Also bundles Tier 1
lpm rundedups as preparatory plumbing — the same threading pattern (ManagedRuntimeHint,Arc-wrapped shared parallel state) that Tier 2 reuses at much larger scale.Headline benchmark —
bench/run.sh warm-install(n=11, M5 Mac)bench/run.sh script-overhead(Tier 1's row): lpm 10 ms (within noise of the 1 ms harness resolution). Tier 1's measured saving is ~60–170 µs /lpm run— sub-perceptible at this resolution. The PR's user-visible perf headline is the warm-install row; Tier 1 is preparatory plumbing.Smoke check —
bench/scripts/run-readme.sh 5 phase61-smoke(non-blocking, for reviewer context)clean/lpmis 0.88× bun (faster than bun on cold install).full/lpmis 2.29× bun, which is the expected non-target — Phase 61 explicitly does NOT target thefull/*row (non-goal #1: that lever is the store layout, queued as a Tier 3 candidate). No regression here, just no improvement, which is the contract.Sub-phases (in order)
LayoutPathsutility, every wrapper-tree consumer routed through it (CSE; no behavior change).isolated_wrapper_rootto<project>/.lpm/wrappers/. Phase 3 root symlinks gain..viaLayoutPaths::root_symlink_target(). Self-refs unchanged (audit fix Phase 35 — Lazy auth + SessionManager #1). Drive-bys: bin-shim wrapper-segment fix (fix(registry): NDJSON parse loop — O(n²) scan + wall-clock timeout #3), Windows..lexical normalization (Phase 46.0 — tiered script-policy gate (v0.23.0 released) #7).rebuild.rsconsumesLayoutPaths, closes store-fallback hole (D8a hard-error), wrapper_segment fix for non-Registry sources (perf(resolver): memoize NpmRange→pubgrub::Ranges conversion #4), test inversion (Phase 43 — Tarball URLs in the lockfile #5).LayoutPaths::needs_layout_migration()gates BOTHcheck_install_stateANDtry_mtime_fast_path. Migration code path insidelpm installwipes legacynode_modules/.lpm/so the rest of the pipeline rebuilds at the new location.lpm doctorpredicate becomes layout-aware viainstall_appears_healthy()+ migration-owed warn.ensure_lpm_wrappers_gitignoreruntime helper (mirrors the skills pattern; idempotent)..lpmReadWrite rule already covers the new location).Round-2 audit fixes (post-implementation)
node_modules/.lpm/but Phase 3'sif root_link.exists()guard skipped retargeting any pre-existing root symlink. Upgrade-in-place users would have been left with danglingnode_modules/<pkg>symlinks pointing at the wiped location. Fix:cleanup_stale_entries's root-symlink sweep now walks each symlink target viaPath::components()and removes it if it traverses.lpm/without a followingwrappers/segment. Phase 3 then recreates with the correct new shape. 6 new tests cover unscoped + scoped retarget, new-shape preservation, workspace-member preservation, self-ref preservation, and end-to-end post-migration symlink resolution..versionmasks migration.cleanup_stale_entrieswrites<wrapper-root>/.version(D6 schema tag) BEFORE any wrapper materializes. Pre-fixdir_is_nonemptycounted the.versionfile as evidence of a populated layout — half-completed installs (e.g., interrupted mid-cleanup) silently masked a needed migration AND madelpm doctorreport a healthy isolated install with no wrappers. Fix:dir_is_nonemptyskips dotfile entries. 2 new tests.Test plan
cargo build --workspace— cleancargo clippy --workspace -- -D warnings— cleancargo fmt --check— cleangrep -r 'fancy-regex' crates/*/Cargo.toml— empty (banned dep absent)cargo nextest run --workspace --exclude lpm-integration-tests— 4967 / 4967 pass, 7 skippedcargo test -p lpm-auth— parallel-deterministic (no--test-threads=1needed)Lint✅ pass ·Test✅ passRUNS=11 ./bench/run.sh warm-install— lpm 22 ms (was 256 ms; target was ≤ 180 ms)RUNS=11 ./bench/run.sh script-overhead— lpm 10 ms (flat / within noise as expected)./bench/scripts/run-readme.sh 5—clean/lpm873 ms (0.88× bun);full/lpm2081 ms (expected non-target per non-goal Phase 35 — Lazy auth + SessionManager #1)mod tests {}blocks (test fixtures intentionally referencing legacy paths to drive the migration gate). Production code: empty.Migration semantics (D8c + D9)
Upgrade-in-place users (binary upgraded,
node_modules/not wiped) trigger automatic migration on nextlpm install:LayoutPaths::needs_layout_migration()returnstrue(legacy populated, new empty).check_install_stateandtry_mtime_fast_pathboth consult the predicate and forceup_to_date = false, bypassing the fast lane.migrate_legacy_wrapper_layout(called insidelpm installafter the fast-exit guard) wipesnode_modules/.lpm/.cleanup_stale_entriesremoves legacy-shape root symlinks (audit fix).<project>/.lpm/wrappers/and Phase 3 creates fresh root symlinks pointing there.Notice: human-pretty mode prints one line on stderr; JSON /
--quiet/ non-TTY remain silent (D9).Out of scope (recorded so the next phase doesn't drift)
LayoutPathsships hoisted helpers as B-ready stubs returning currentnode_modules/-scoped paths. Hoisted symmetry is the next focused phase if this lands cleanly.🤖 Generated with Claude Code