Skip to content

Phase 61 — warm-path relayout & lpm run floor (Tier 1 + Tier 2)#23

Merged
tolgaergin merged 12 commits into
mainfrom
claude/phase-61-warm-path-relayout
Apr 30, 2026
Merged

Phase 61 — warm-path relayout & lpm run floor (Tier 1 + Tier 2)#23
tolgaergin merged 12 commits into
mainfrom
claude/phase-61-warm-path-relayout

Conversation

@tolgaergin
Copy link
Copy Markdown
Contributor

@tolgaergin tolgaergin commented Apr 30, 2026

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_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.

Also bundles Tier 1 lpm run dedups 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)

Tool Pre-Phase-61 After (this PR)
npm n/a 1564 ms
pnpm n/a 646 ms
bun 127 ms 285 ms
lpm 256 ms (2.02× bun) 22 ms (0.077× bun)

The bun number drifted upward in this run (machine state, not a regression here). The lpm number is the headline: pre-Phase-61 wiping node_modules invalidated 100 % of the incremental linker cache and forced a full relink-from-store; post-Phase-61 the wrapper tree survives at the project-root sibling location and the install collapses to root-symlink recreation.

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)

mode arm median mean stdev
clean npm 7093 ms 7149 ms 305
clean pnpm 1339 ms 1422 ms 159
clean bun 992 ms 998 ms 293
clean lpm 873 ms 1201 ms 560
full npm 8166 ms 8094 ms 771
full pnpm 2478 ms 2455 ms 130
full bun 909 ms 1015 ms 170
full lpm 2081 ms 2116 ms 445

clean/lpm is 0.88× bun (faster than bun on cold install). full/lpm is 2.29× bun, which is the expected non-target — Phase 61 explicitly does NOT target the full/* 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)

Round-2 audit fixes (post-implementation)

  • Critical: legacy root-symlink retarget. Pre-fix the 61.3 migration wiped node_modules/.lpm/ but Phase 3's if root_link.exists() guard skipped retargeting any pre-existing root symlink. Upgrade-in-place users would have been left with dangling node_modules/<pkg> symlinks pointing at the wiped location. Fix: cleanup_stale_entries's root-symlink sweep now walks each symlink target via Path::components() and removes it if it traverses .lpm/ without a following wrappers/ 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.
  • Medium: .version masks migration. cleanup_stale_entries writes <wrapper-root>/.version (D6 schema tag) BEFORE any wrapper materializes. Pre-fix dir_is_nonempty counted the .version file as evidence of a populated layout — half-completed installs (e.g., interrupted mid-cleanup) silently masked a needed migration AND made lpm doctor report a healthy isolated install with no wrappers. Fix: dir_is_nonempty skips dotfile entries. 2 new tests.

Test plan

  • cargo build --workspace — clean
  • cargo clippy --workspace -- -D warnings — clean
  • cargo fmt --check — clean
  • grep -r 'fancy-regex' crates/*/Cargo.toml — empty (banned dep absent)
  • cargo nextest run --workspace --exclude lpm-integration-tests4967 / 4967 pass, 7 skipped
  • cargo test -p lpm-auth — parallel-deterministic (no --test-threads=1 needed)
  • CI: Lint ✅ pass · Test ✅ pass
  • RUNS=11 ./bench/run.sh warm-installlpm 22 ms (was 256 ms; target was ≤ 180 ms)
  • RUNS=11 ./bench/run.sh script-overheadlpm 10 ms (flat / within noise as expected)
  • ./bench/scripts/run-readme.sh 5clean/lpm 873 ms (0.88× bun); full/lpm 2081 ms (expected non-target per non-goal Phase 35 — Lazy auth + SessionManager #1)
  • Single-source-of-truth grep (production-only, narrow): 4 hits, all hand-verified inside inline 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 next lpm install:

  1. LayoutPaths::needs_layout_migration() returns true (legacy populated, new empty).
  2. check_install_state and try_mtime_fast_path both consult the predicate and force up_to_date = false, bypassing the fast lane.
  3. migrate_legacy_wrapper_layout (called inside lpm install after the fast-exit guard) wipes node_modules/.lpm/.
  4. cleanup_stale_entries removes legacy-shape root symlinks (audit fix).
  5. The rest of the install rebuilds wrappers at <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)

  • Hoisted-mode symmetry — LayoutPaths ships hoisted helpers as B-ready stubs returning current node_modules/-scoped paths. Hoisted symmetry is the next focused phase if this lands cleanly.
  • Tier 3 levers (denser store, lock-hash sidecar, resident runner / direct-exec, wrapper-graph cache) — measurement-gated, none ships in this PR.

🤖 Generated with Claude Code

tolgaergin and others added 12 commits April 30, 2026 08:14
`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 tolgaergin merged commit 55cf420 into main Apr 30, 2026
3 checks passed
@tolgaergin tolgaergin deleted the claude/phase-61-warm-path-relayout branch April 30, 2026 10:17
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant