Skip to content

fix(extensions): refuse to silently overwrite on-disk extensions in auto-resolver#1187

Merged
stack72 merged 1 commit intomainfrom
worktree-velvety-sparking-blanket
Apr 17, 2026
Merged

fix(extensions): refuse to silently overwrite on-disk extensions in auto-resolver#1187
stack72 merged 1 commit intomainfrom
worktree-velvety-sparking-blanket

Conversation

@stack72
Copy link
Copy Markdown
Contributor

@stack72 stack72 commented Apr 17, 2026

Fixes swamp-club#121.

Summary

workflow validate and other commands that trigger auto-resolution could silently overwrite local edits to pulled extensions. The auto-resolver adapter hardcoded force: true when installing a type it failed to find locally, and installExtension's conflict check was gated on !force — so any user edits in the pulled directory were copyDir'd over with no prompt, no warning, and no diff.

The fix adds an isInstalled capability to ExtensionInstallerPort and has the domain service consult it before calling install. If the extension is already on disk (lockfile entry and per-extension directory both present), the resolver surfaces a new alreadyInstalledButFailed event naming the path and the explicit opt-in command (swamp extension pull <name> --force) and returns failure rather than clobbering. Belt-and-braces: the adapter now passes force: false and catches ConflictError, so a race past the per-type re-entrancy guard (concurrent auto-resolves for sibling types) still fails safely.

The datastore auto-update path in resolve_datastore.ts:132 has a related but distinct force: true bug with a different trigger and fix shape; tracked separately as swamp-club#126.

What changed

  • src/domain/extensions/extension_auto_resolver.ts — port gains isInstalled and installedPath; service gains alreadyInstalledButFailed output event and the pre-install check in installAndLoad.
  • src/cli/auto_resolver_adapters.ts — installer adapter implements isInstalled (dual lockfile + filesystem check against the per-extension layout from feat(extensions): namespace pulled extensions by scoped name (per-extension layout) #1186), installedPath returns the per-extension root, install() flips to force: false and catches ConflictError with a defence-in-depth comment.
  • src/presentation/renderers/extension_auto_resolve.ts — new renderAutoResolveAlreadyInstalled for both log and json modes.
  • design/extension.md — new "Safety: never overwrite on-disk extensions" subsection under Automatic Resolution.

Test plan

  • deno fmt clean
  • deno check clean
  • deno lint clean
  • deno run test — 4400 pass / 0 fail (2 new service tests, 5 new adapter tests, 1 new integration regression test)
  • Manual reproduction against /tmp/swamp-repro-issue-121:
    • Pre-fix: md5 of system_usage.ts changed from user-edited to pristine registry version; WIP marker comment lost
    • Post-fix: md5 identical before/after trigger; marker preserved; user sees three ERR lines naming the path and the --force recovery command

Follow-up

  • swamp-club#126 — datastore auto-update force: true in resolve_datastore.ts:132 (related data-loss vector, different fix shape).

🤖 Generated with Claude Code

…uto-resolver (#121)

Fixes swamp-club#121.

## Summary

`workflow validate` and other commands that trigger auto-resolution could
silently overwrite local edits to pulled extensions. The auto-resolver
adapter hardcoded `force: true` when installing a type it failed to find
locally, and `installExtension`'s conflict check was gated on
`!force` — so any user edits in the pulled directory were copyDir'd
over with no prompt, no warning, and no diff.

The fix adds an `isInstalled` capability to `ExtensionInstallerPort` and
has the domain service consult it before calling `install`. If the
extension is already on disk (lockfile entry AND per-extension directory
both present), the resolver surfaces a new `alreadyInstalledButFailed`
event naming the path and the explicit opt-in command
(`swamp extension pull <name> --force`) and returns failure rather than
clobbering. Belt-and-braces: the adapter now passes `force: false` and
catches `ConflictError`, so a race past the per-type re-entrancy guard
(concurrent auto-resolves for sibling types) still fails safely.

The datastore auto-update path in `resolve_datastore.ts:132` has a
related but distinct `force: true` bug with a different trigger and
fix shape; tracked separately as swamp-club#126.

## Test plan

- [x] `deno fmt` clean
- [x] `deno check` clean
- [x] `deno lint` clean
- [x] `deno run test` — 4400 pass / 0 fail (2 new service tests, 5 new
      adapter tests, 1 new integration regression test)
- [x] Manual reproduction against /tmp/swamp-repro-issue-121
      - Pre-fix: md5 of `system_usage.ts` changed from user-edited to
        pristine registry version; WIP marker lost
      - Post-fix: md5 identical before/after trigger; marker preserved;
        user sees three ERR lines naming the path and the
        `--force` recovery command

## Follow-up

- swamp-club#126 — datastore auto-update `force: true` in
  `resolve_datastore.ts:132`

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

CLI UX Review

Blocking

None.

Suggestions

  1. Log-mode message preamble inconsistencyrenderAutoResolveNotFound and renderAutoResolveNetworkError both open with Auto-resolution failed for type ${type}: .... The new renderAutoResolveAlreadyInstalled opens differently: Extension ${extension} is already installed at ${path} but failed to load. The deviation is arguably clearer for this case, but a user scanning a log with multiple auto-resolve failures will notice the formatting change. Consider: Auto-resolution failed for ${extension}: already installed at ${path} but failed to load.

  2. JSON output missing type field — The not_found and network_error JSON objects both include the original type (e.g. @swamp/aws/ec2/instance). The new already_installed object omits it, including only extension and path. A script correlating a type lookup against the failure event would have to infer the type from the extension name. Low impact since the extension name is usually enough, but adding type would keep the JSON shapes uniform.

Verdict

PASS — no blocking issues. Recovery command (swamp extension pull <name> --force) is correct, all three error lines are actionable, JSON mode is present and well-structured, and the behavioral change is safe and backwards-compatible.

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Code Review

Clean, well-structured fix for the silent-overwrite data-loss bug. The DDD layering is correct: the domain service defines the isInstalled/installedPath ports on ExtensionInstallerPort, the CLI adapter implements them with proper infrastructure concerns (dual lockfile + filesystem check), and the presentation layer handles both log and json output modes for the new alreadyInstalledButFailed event.

Blocking Issues

None.

Suggestions

  1. Renderer test coverage: src/presentation/renderers/extension_auto_resolve.ts has no corresponding _test.ts file. The new renderAutoResolveAlreadyInstalled function is covered indirectly by the adapter and integration tests, and follows the same pattern as the existing untested functions in that file, so this is pre-existing rather than a regression — but worth noting for future cleanup.

  2. "pulled-extensions" string literal: The isInstalled and installedPath methods use the raw string "pulled-extensions" in swampPath(repoDir, "pulled-extensions", extensionName). There's no existing SWAMP_SUBDIRS constant for just the parent directory (only type-specific ones like pulledModels), so this is reasonable as-is. A shared constant could prevent drift if the path ever changes, but that's marginal.

What looks good

  • DDD port design: isInstalled (async, needs I/O) and installedPath (sync, deterministic computation) are well-separated on the port interface. The domain service never reaches into infrastructure.
  • Defence-in-depth: The ConflictError catch in the adapter's install() method covers the race window between isInstalled and install that the per-type re-entrancy guard can't fully close (concurrent resolves for sibling types in the same extension).
  • Dual check logic: Requiring both lockfile entry AND filesystem directory prevents confusing error messages when the user has manually deleted the extension directory — a clean reinstall proceeds instead.
  • Test coverage: Three levels — domain unit tests (mock ports), adapter unit tests (real filesystem), and an integration test that wires the real adapter to the real domain service and asserts file contents + mtime are unchanged.
  • Import boundary: All libswamp imports in the adapter go through src/libswamp/mod.ts (ConflictError, installExtension, enumeratePulledExtensionDirs). The readUpstreamExtensions import from infrastructure is consistent with the established pattern in other CLI files.
  • Output modes: Both log (three actionable error lines with the recovery command) and json (structured event with reason: "already_installed") are implemented.
  • Design doc updated with the safety invariant and the two-check rationale.

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Adversarial Review

Critical / High

None found.

Medium

  1. TOCTOU race between isInstalled and install for concurrent sibling typessrc/domain/extensions/extension_auto_resolver.ts:328 / src/cli/auto_resolver_adapters.ts:113-138

    The re-entrancy guard (this.resolving) is keyed on normalized type strings, not extension names. Two types from the same extension (e.g. @swamp/aws/ec2/instance and @swamp/aws/s3/bucket) can resolve concurrently. Both pass isInstalled → false, both enter install. The first succeeds; the second hits ConflictError → returns null → installAndLoad returns false. The caller for type B (resolveModelType) receives false and returns undefined — a false-negative even though the extension was loaded by type A's hot-load.

    The PR already acknowledges this race in code comments and provides ConflictError catch as defense-in-depth. This is a pre-existing architectural limitation that the PR makes safer (ConflictError vs. silent overwrite). Noting for awareness — not blocking.

Low

  1. Bare catch in isInstalled's Deno.statsrc/cli/auto_resolver_adapters.ts:96

    Catches all errors, not just Deno.errors.NotFound. A permission error would silently return false, causing a fresh install attempt that would then likely also fail with a different (potentially confusing) error. In practice, if the directory is unreadable, installExtension would also fail at the same path, so the end result is the same. Purely theoretical.

  2. readUpstreamExtensions re-reads lockfile on every isInstalled callsrc/cli/auto_resolver_adapters.ts:89

    Parses the full lockfile JSON each time. Negligible for auto-resolution (one call per type per resolve), but worth noting if the call site ever changes to a batch pattern.

Verdict

PASS — The fix correctly addresses the data-loss bug (issue #121). The domain-level isInstalled check prevents silent overwrites; the adapter's force: false + ConflictError catch provides belt-and-braces safety for the TOCTOU race. Port interface additions are backwards-compatible (only two implementations, both updated). Test coverage is strong: 2 domain unit tests, 5 adapter unit tests, and 1 integration regression test that verifies file-survival invariant via mtime + content comparison. Design documentation is accurate and actionable.

@stack72 stack72 merged commit 7181443 into main Apr 17, 2026
10 checks passed
@stack72 stack72 deleted the worktree-velvety-sparking-blanket branch April 17, 2026 22:04
ianarsenault pushed a commit to ianarsenault/swamp that referenced this pull request Apr 21, 2026
…lub#126) (systeminit#1190)

## Summary

Fixes swamp-club#126 — datastore auto-update uses `force: true` in the
`pullExtension` callback and silently overwrites any local edits a user
has made under `.swamp/pulled-extensions/<name>/`. Same data-loss family
as swamp-club#121/systeminit#1187 (auto-resolver force-pull), different trigger
(version-behind rather than type-not-found), different fix shape
(per-file anchor rather than `isInstalled` skip — because auto-update,
by definition, wants to replace the prior install).

**Approach:**

- Record a rolled-up SHA-256 digest of the per-extension on-disk subtree
in `upstream_extensions.json` at install time (`filesChecksum`),
distinct from the existing archive `checksum`.
- Before auto-update fires `installExtension` with `force: true`, the
service calls a new `detectLocalEdits` dep that compares the stored
anchor with a fresh disk digest. Tri-state outcome: `match` proceeds;
`mismatch` returns a structured `{ skipped: "local_edits",
previousVersion, newVersion }` result and does NOT call `pullExtension`;
`no-anchor` grandfathers pre-existing installs and proceeds.
- The CLI caller (`resolve_datastore.ts`) inspects the skipped result
and emits `logger.warn` directing the user to `swamp extension pull
<name> --force` as the explicit opt-in. The refusal is a STRUCTURED
RESULT rather than a thrown error because the outer try/catch
intentionally swallows exceptions to honor "never block command
execution for auto-update failures"; a structured result separates
\"error — swallow\" from \"refusal — surface\" cleanly.

**DDD split:**

- Pure combiner in `src/domain/extensions/installed_extension_digest.ts`
takes pre-hashed entries and produces the rollup digest. No FS access.
- FS-walking reader in
`src/infrastructure/persistence/installed_extension_digest_reader.ts`
walks the per-extension tree, hashes each file, and calls the pure
combiner.
- Mirrors the pattern already in use for `src/domain/models/checksum.ts`
(pure compute) vs callers that read bytes from disk.

**Grandfather / migration:**

Pre-anchor lockfile entries (installs from before this change) lack
`filesChecksum`. `detectLocalEdits` returns `no-anchor` for them and
auto-update proceeds as it does today. On the next install, the anchor
is written and all subsequent auto-updates are protected. A one-time
silent-overwrite window remains for users with pending edits AND a stale
lockfile entry AND an auto-update firing before they next pull —
acceptable given the scope of the fix; mentioned here for release notes.

## Follow-ups filed separately

- **swamp-club#129** — `src/cli/commands/open.ts:185` has the same
`force: true` pattern in the web UI's non-interactive install callback.
Same data-loss family, different trigger; tracked separately.
- **swamp-club#130** — `logger.warn` is silent in `--json` mode because
the logger's catch-all is `lowestLevel: \"fatal\"` in JSON output mode.
The auto-update refusal is silent in JSON mode (data is still protected
— refusal short-circuits before `installExtension` — but the user gets
no signal why auto-update isn't happening). Pre-existing issue affecting
~50 `logger.warn` call sites; changing it is too much blast radius for a
bug-fix PR.

**Explicitly out of scope / confirmed safe:**

- `src/cli/commands/extension_install.ts:114` uses `force: true` but the
caller in `src/libswamp/extensions/install.ts:88-97` short-circuits via
`hasAnyMissingFiles` before calling `installExtension` if any tracked
file exists. Local edits and force:true cannot co-exist on that path.
- `swamp extension pull` / `extension update` — explicit user opt-in,
not a silent path; unaffected.

## Test plan

- [x] `deno check` clean
- [x] `deno lint` clean
- [x] `deno fmt` applied
- [x] `deno run test` — 4447 passed, 0 failed (includes 7 new tests
covering: digest determinism, FS-reader invariants, auto-update
tri-state decision tree, caller-layer warning message)
- [x] `deno run compile` succeeded
- [ ] Manual JSON-mode verification deliberately skipped — this PR does
not change JSON-mode behavior (the WARN is silent in JSON mode by
pre-existing design; tracked as swamp-club#130)
- [ ] UAT follow-up: a new
`tests/cli/extension/auto_update_preserves_local_edits_test.ts` parallel
to `pull_wip_preservation_test.ts` (which locks in the systeminit#121 fix) should
be filed as a swamp-uat issue after merge.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
ianarsenault pushed a commit to ianarsenault/swamp that referenced this pull request Apr 21, 2026
systeminit#1191)

## Summary

Third in the data-loss family (sibling of systeminit#121/systeminit#1187 and systeminit#126/systeminit#1190).
`swamp open`'s web UI install callback calls `pullExtension` with
`force: true` and, pre-fix, had no local-edits check — so hitting `POST
/api/extensions/install` for an already-installed extension would
silently overwrite anything the user had edited under
`.swamp/pulled-extensions/<name>/`.

Port systeminit#126's `filesChecksum` anchor + detectLocalEdits pattern into a
shared libswamp helper and consult it from the web UI before the
force-pull. On mismatch, throw a typed `LocalEditsError`; the HTTP
handler maps it to **409 Conflict** with a body that names the extension
and the opt-in remediation (`swamp extension pull <name> --force`).

The ui.ts Install button only renders for **uninstalled** extensions
today, so there's no UI click path that reaches this bug — the fix is
defense-in-depth for the HTTP endpoint itself, which is reachable by
curl, scripts, or any future UI change (e.g. an "Update / Reinstall"
button). Scope and fix-shape intentionally mirror systeminit#126.

### What changes
- New `src/libswamp/extensions/local_edits.ts` — shared helper
(`detectLocalEditsForExtension`, `LocalEditsStatus`, `LocalEditsError`),
re-exported through `libswamp/mod.ts`
- `src/cli/resolve_datastore.ts` — systeminit#126's inline `detectLocalEdits`
closure collapses to a one-line call into the new helper (pure DRY)
- `src/cli/commands/open.ts` — `installExtension` callback runs the
local-edits check before force-pull; throws `LocalEditsError` on
mismatch
- `src/serve/open/http.ts` — `handleExtensionInstall` maps
`LocalEditsError` → 409, unchanged 500 fallthrough for other errors
- Unit tests at the new helper and at the HTTP handler layer

### What's out of scope
- `extension_update.ts:118` and `extension_install.ts:114` still use
`force:true`; the former is an explicit user command, the latter is
lockfile-restore. Refusing them would break their contracts.
- The dependency-with-corrupt-lockfile edge case (parent reinstall
recurses into a dep missing from `upstream_extensions.json` but present
on disk with edits) — matches systeminit#126's scope. Fixing it would require
pushing the check into `installExtension` itself, which would wrongly
refuse the opt-in paths above.
- End-to-end UAT for the web UI refuse flow: filed as
[systeminit/swamp-uat#150](systeminit/swamp-uat#150).

## Test Plan

- [x] `deno check`, `deno lint`, `deno fmt`, full `deno run test` (4455
passed), `deno run compile`
- [x] New unit coverage: 6 tests in
`src/libswamp/extensions/local_edits_test.ts` (match, mismatch,
no-anchor × 3 conditions, LocalEditsError message shape)
- [x] New handler coverage: 2 tests in `src/serve/open/http_test.ts`
(409 on `LocalEditsError`, 500 on plain `Error` as a regression fence)
- [x] Manual E2E with locally compiled binary: scratch repo → `swamp
extension pull @stack72/system-extensions` → edit a file → `curl POST
/api/extensions/install` → got 409 with the expected remediation body.
`swamp extension pull --force` → curl again → 200.
- [x] systeminit#126's `datastore_auto_update_test.ts` and
`resolve_datastore_test.ts` both still green after the helper refactor

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
ianarsenault pushed a commit to ianarsenault/swamp that referenced this pull request Apr 21, 2026
…lve (swamp-club#133) (systeminit#1197)

Fixes [swamp-club#133](https://swamp.club/lab/issues/133).

## Summary

Auto-resolve treated any `pulled-extensions/<name>/` directory that
existed on disk as "installed" — regardless of whether its contents were
complete. A tree with `manifest.yaml` missing or a declared kind
subdirectory missing still passed the `isInstalled` check, so the
resolver emitted `reason: already_installed` and the downstream loader
produced a confusing `Unknown datastore type "@swamp/s3-datastore"` /
`Unknown model type: @swamp/issue-lifecycle` error with no pointer at
what was actually wrong.

This is a regression from [PR
systeminit#1187](systeminit#1187) (swamp-club#121).
Before that change, the adapter called `installExtension` with `force:
true` on any load failure — a truncated tree self-healed on the next
command. After systeminit#1187 the strict "never overwrite" guard caught the
truncated case alongside the user-edit case it was meant for, and the
truncation recovery path was lost.

## Approach

The fix extends the "never overwrite" guarantee to truncation while
giving users an actionable error. It is **read-only** — no filesystem
mutation in the auto-resolve path. `--force` remains the only way
auto-install state can overwrite a pulled extension.

- Introduces a tri-state `InstallationInspection` result (`missing` |
`intact` | `truncated`) on `ExtensionInstallerPort`, replacing
`isInstalled` + `installedPath`. Both the path and the missing-files
list ride back on the result.
- The adapter drives the check from the lockfile's file list
(authoritative file-level granularity, so partial truncation inside a
kind subdir is caught too — not just whole-subdir removal).
- The domain service branches on the three states: `missing` → install,
`intact` → `alreadyInstalledButFailed` (unchanged systeminit#121 path for user
WIP), `truncated` → new `alreadyInstalledTruncated` event naming every
missing file plus the `swamp extension pull <name> --force` recovery
command.

Auto-repair was considered and rejected. The user-journey analysis
surfaced that mutating the filesystem in response to a read-shaped
command has too many quiet side effects for shared-repo / multi-worktree
setups: silent version bumps (install is always `version: null` →
latest), concurrent-worktree-rm reversal, masking of deliberate user
state changes, and latency surprise. A loud, actionable error with an
explicit recovery command is safer and keeps the systeminit#121 invariant intact.

## What changed

- `src/domain/extensions/extension_auto_resolver.ts` — new
`InstallationInspection` type, port method `inspectInstallation`
(replaces `isInstalled` + `installedPath`), new
`alreadyInstalledTruncated` event on `AutoResolveOutputPort`, three-way
branch in `installAndLoad`.
- `src/cli/auto_resolver_adapters.ts` — implements `inspectInstallation`
against the lockfile + per-extension directory + per-file stats; wires
the new renderer through `createAutoResolveOutputAdapter`.
- `src/presentation/renderers/extension_auto_resolve.ts` — new
`renderAutoResolveTruncated` for both `log` (three-line actionable
error) and `json`
(`{"event":"auto_resolve","status":"failed","reason":"truncated","missing":[...]}`).
- `src/cli/auto_resolver_adapters_test.ts` — 7 new `inspectInstallation`
tests replacing the 5 prior `isInstalled`/`installedPath` cases; covers
missing / intact / truncated / partial-truncation / all-files-missing /
empty-files edge cases.
- `src/domain/extensions/extension_auto_resolver_test.ts` — updated mock
port; new regression guards for the three-way branch (`intact →
alreadyInstalledButFailed` preserved from systeminit#121, `truncated →
alreadyInstalledTruncated` is new).
- `integration/auto_resolver_truncated_test.ts` — new hermetic
integration test: hand-written scratch fixture, deletes one declared
file, asserts the truncated event fires with the missing file AND the
on-disk tree is byte-identical after the resolve attempt.
- `integration/auto_resolver_no_clobber_test.ts` — ported to the new
port method (no behavior change; same systeminit#121 invariant).
- `design/extension.md` — "Safety: never overwrite on-disk extensions"
section extended with the tri-state taxonomy, truncation predicate, and
JSON event shape for scripting consumers.

## Coverage

Works across all kinds that go through auto-resolve (models, vaults,
datastores) — any file missing anywhere in the extension tree is
detected when auto-resolve fires for a type from that extension, because
`inspectInstallation` walks the whole lockfile file list. Driver-only or
report-only extensions (no auto-resolve trigger) are not covered by this
PR; extending to those would require new `resolveDriverType` /
`resolveReportType` plumbing and is scope creep for systeminit#133. Worth a
follow-up if belt-and-braces coverage is desired.

## Test plan

- [x] `deno fmt` clean
- [x] `deno check` clean
- [x] `deno lint` clean
- [x] `deno run test` — 4445 pass / 0 fail (6 new adapter tests, 2 new
domain-service tests, 1 new integration test; rewrote 5 systeminit#121 regression
cases against the new port shape)
- [x] `deno run compile` — binary built
- [x] Manual reproduction against `/tmp/swamp-repro-issue-133` with the
compiled binary:
- **Before truncation:** `swamp extension pull @swamp/digitalocean` then
`swamp model create @swamp/digitalocean/droplet test-droplet` succeeds.
- **Truncate:** `rm -rf
.swamp/pulled-extensions/@swamp/digitalocean/models`.
- **Re-run `model create`:** json mode emits
`{"event":"auto_resolve","status":"failed","reason":"truncated","missing":[68
files]}`; log mode emits three ERR lines naming the missing files and
the `swamp extension pull @swamp/digitalocean --force` recovery.
- **Follow the recovery command:** `swamp extension pull
@swamp/digitalocean --force` restores the tree.
  - **Re-run `model create`:** succeeds.
- [x] Tree on disk is byte-identical before and after the resolve
attempt — no auto-repair, no silent overwrite.

## Follow-up

- Driver-only / report-only extensions remain uncovered by auto-resolve
(see "Coverage" above). Worth a separate issue if exhaustive cross-kind
coverage is desired.
- Root cause of truncation itself is still unknown (partial extract,
interrupted install, concurrent worktree op). This PR makes the symptom
actionable so the cause is easier to investigate when it next occurs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
ianarsenault pushed a commit to ianarsenault/swamp that referenced this pull request Apr 21, 2026
…tion check (systeminit#1199)

## Summary

The tri-state `inspectInstallation` introduced in systeminit#1197 stats every path
in the lockfile's file list to detect a truncated install. That list
includes compiled bundles under `.swamp/bundles/**`,
`.swamp/vault-bundles/**`, `.swamp/driver-bundles/**`,
`.swamp/datastore-bundles/**`, and `.swamp/report-bundles/**` —
regenerable build artifacts, not source.

When a user clears any of those caches (normal hygiene, stale rebuild,
fresh worktree), auto-resolve flipped from `intact` → `truncated` and
emitted:

```
incomplete — missing 1 file(s): ".swamp/bundles/91139983/system_usage.js"
```

…instead of the `alreadyInstalledButFailed` ("already installed at … run
`--force` to recover") path that protects user WIP per systeminit#121. The
motivating swamp-club#133 symptoms are all source-tree artifacts
(manifest, kind subdirs, declared source), so the fix is to scope the
truncation predicate to source files only.

This filters bundle prefixes out of the stat loop in
`inspectInstallation`. Prefixes are derived from
`SWAMP_SUBDIRS.{bundles, vaultBundles, driverBundles, datastoreBundles,
reportBundles}` so a future bundle-dir addition stays in sync. No
filesystem mutation, no API/event/wire-format changes.

## Test Plan

- [x] `deno fmt --check`, `deno lint`, `deno check` clean
- [x] `src/cli/auto_resolver_adapters_test.ts` — 18/18 pass, including 2
new regression cases:
- `inspectInstallation ignores absent bundle artifacts and stays intact`
(covers all 5 bundle subdirs)
- `inspectInstallation reports truncated only for missing source, not
missing bundles`
- [x] `integration/auto_resolver_truncated_test.ts` — passes (systeminit#1197/systeminit#133
source-truncation behaviour preserved)
- [x] `integration/auto_resolver_no_clobber_test.ts` — passes (systeminit#121
no-clobber invariant preserved)
- [x] `deno run compile` succeeded
- [x] **End-to-end repro against the rebuilt binary:** `swamp extension
pull @stack72/[email protected]` → edit pulled `.ts` with
WIP marker + syntax error → `rm -rf .swamp/bundles` → `swamp model
create @stack72/system-usage instance1` now produces:
  ```
Extension "@stack72/system-extensions" is already installed at … but
failed to load.
Local edits may be preventing it from registering — inspect the source
and fix errors.
To reset to the registry version and discard local changes, run: swamp
extension pull "@stack72/system-extensions" --force
  ```
…instead of the buggy `incomplete — missing 1 file(s):
".swamp/bundles/91139983/system_usage.js"`.
- [ ] UAT `tests/cli/extension/pull_wip_preservation_test.ts:156` in
swamp-uat (CI gate — local run blocked by membership auto-trust
collision unrelated to this fix)

## Cross-references

- Introducing PR: systeminit#1197
- Fixes regression to swamp-club#121 / systeminit#1187 user-WIP preservation
- swamp-uat coverage: swamp-uat#149

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
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