From e3a568fc30d69cbb172eac01cf4b0e15fbf1fe6b Mon Sep 17 00:00:00 2001 From: John Morrissey Date: Wed, 24 Jun 2026 17:37:47 +1000 Subject: [PATCH 1/2] fix(scan): fail-soft on Loomweave taint-store write errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bare `wardline scan` auto-discovers a running Loomweave from its published ephemeral port (ADR-044) with no --loomweave-url flag. On an install without the [loomweave] extra, the opt-in taint-fact write reached require_blake3() and raised LoomweaveError, which the CLI surfaced as a hard exit 2 ("could not run — missing blake3 dep"), killing the gate — its real job — over an optional side-channel's optional dependency. Catch LoomweaveError around the taint-store write and degrade to a not-reachable WriteResult (reported with an actionable warning), at full parity with the MCP scan tool. The write is best-effort enrichment; it never changes the scan's exit code. This covers the missing-extra, 4xx, and bad-scheme cases — closing the whole class, not just blake3. Realign the finding-lifecycle glossary line anchors shifted by the fix. Reported by a federated sibling (elspeth). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 12 +++++ .../reference/finding-lifecycle-vocabulary.md | 12 ++--- src/wardline/cli/scan.py | 29 +++++++---- tests/docs/test_glossary_vocabulary.py | 6 +-- tests/unit/cli/test_cli.py | 50 ++++++++++++++++++- 5 files changed, 88 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3e575c7..b7903693 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- **Auto-discovered Loomweave emission no longer kills a base-install scan.** + `wardline scan` auto-discovers a running Loomweave from its published + ephemeral port (ADR-044) with no `--loomweave-url` flag. On an install + without the `[loomweave]` extra, the opt-in taint-fact write reached + `require_blake3()` and raised `LoomweaveError`, which the CLI surfaced as a + hard `exit 2` ("could not run — missing blake3 dep"). The taint-store write + is now FULLY fail-soft at parity with the MCP `scan` tool: a `LoomweaveError` + (missing extra, 4xx, or bad scheme) degrades to a not-reachable write with an + actionable warning, and the scan's gate — its real job — is unaffected. + Reported by a federated sibling (elspeth). + ## [1.0.6] - 2026-06-20 ### Changed diff --git a/docs/reference/finding-lifecycle-vocabulary.md b/docs/reference/finding-lifecycle-vocabulary.md index 3cd59297..a91112a0 100644 --- a/docs/reference/finding-lifecycle-vocabulary.md +++ b/docs/reference/finding-lifecycle-vocabulary.md @@ -61,7 +61,7 @@ The Filigree metadata only carries the key when the state is not `active` **"suppressed"** survives only as the umbrella *word* for "any state other than `active`": `baselined` + `waived` + `judged`. The CLI prints this sum as the -`suppressed` count (`src/wardline/cli/scan.py:556`). +`suppressed` count (`src/wardline/cli/scan.py:565`). ## `active` is the one word for "non-suppressed defect" @@ -72,7 +72,7 @@ consistently, on every surface: | --- | --- | --- | | Enum | `src/wardline/core/finding.py:72` | `SuppressionState.ACTIVE = "active"` | | Summary field | `src/wardline/core/run.py:71`, built at `src/wardline/core/run.py:551` | `ScanSummary.active` | -| CLI summary line | `src/wardline/cli/scan.py:557` | `… {s.active} active` | +| CLI summary line | `src/wardline/cli/scan.py:566` | `… {s.active} active` | | MCP scan response | `src/wardline/mcp/server.py:1010` | `summary.active` | | Agent-summary JSON | `src/wardline/core/agent_summary.py:140` | `summary.active_defects` | | `wardline:loop` prompt | `src/wardline/mcp/prompts.py:13` | "Read `summary.active`" | @@ -159,7 +159,7 @@ The MCP `scan` gate block exposes `gate.tripped` (`src/wardline/mcp/server.py:10 `src/wardline/core/agent_summary.py:158` (`verdict`). The CLI prints `gate: FAILED () — ` then `gate: evaluated <…>`, or a `gate: NOT_EVALUATED — …` line for a bare scan -(`src/wardline/cli/scan.py:609`). +(`src/wardline/cli/scan.py:618`). `--new-since` scopes **both** populations identically: any `active` defect outside the delta is re-marked `baselined` in both the emitted and gate lists @@ -175,7 +175,7 @@ still legitimately means three different things depending on the surface: | --- | --- | --- | | Filigree store | An **unseen fingerprint** — first time this finding identity is seen for a `(file, scan_source)`. | **Filigree-owned** lifecycle (`src/wardline/core/filigree_emit.py:68-76`) | | `wardline scan --new-since ` | **Delta-scope**: the gate fires only on defects in files/entities changed since a git ref; everything else is re-marked `baselined`. | `src/wardline/core/run.py:496`; help text `src/wardline/cli/scan.py` (`--new-since`) | -| (historical) CLI summary | Formerly relabelled the `active` count as "N new". **Corrected to "N active"**. | `src/wardline/cli/scan.py:556` | +| (historical) CLI summary | Formerly relabelled the `active` count as "N new". **Corrected to "N active"**. | `src/wardline/cli/scan.py:565` | The first-seen Filigree sense and the delta-scope `--new-since` sense are genuinely distinct concepts; neither is "active". @@ -187,8 +187,8 @@ How each concept appears on each surface: | Concept | CLI summary text | `ScanSummary` field | MCP `summary` key | Agent-summary key | Filigree store | | --- | --- | --- | --- | --- | --- | | every finding | `N finding(s)` | `total` (`run.py:70`) | `total` (`server.py:1009`) | `total_findings` (`agent_summary.py:139`) | one finding per wire entry | -| live defect | `N active` (`scan.py:557`) | `active` (`run.py:71,551`) | `active` (`server.py:1010`) | `active_defects` (`agent_summary.py:140`) | no `suppression_state` key (`finding.py:295`) | -| suppressed (sum) | `N suppressed` (`scan.py:556`) | `baselined+waived+judged` | the three keys | `suppressed_findings` (`agent_summary.py:141`) | `metadata.wardline.suppression_state` (`finding.py:295`) | +| live defect | `N active` (`scan.py:566`) | `active` (`run.py:71,551`) | `active` (`server.py:1010`) | `active_defects` (`agent_summary.py:140`) | no `suppression_state` key (`finding.py:295`) | +| suppressed (sum) | `N suppressed` (`scan.py:565`) | `baselined+waived+judged` | the three keys | `suppressed_findings` (`agent_summary.py:141`) | `metadata.wardline.suppression_state` (`finding.py:295`) | | baselined | `N baseline` | `baselined` (`run.py:73`) | `baselined` (`server.py:1011`) | `baselined` (`agent_summary.py:143`) | `suppression_state: "baselined"` | | waived | `N waiver` | `waived` (`run.py:74`) | `waived` (`server.py:1012`) | `waived` (`agent_summary.py:144`) | `suppression_state: "waived"` | | judged | `N judged` | `judged` (`run.py:75`) | `judged` (`server.py:1013`) | `judged` (`agent_summary.py:145`) | `suppression_state: "judged"` | diff --git a/src/wardline/cli/scan.py b/src/wardline/cli/scan.py index 4c884f2f..413c57d3 100644 --- a/src/wardline/cli/scan.py +++ b/src/wardline/cli/scan.py @@ -419,20 +419,29 @@ def confirm_cb(rel_path: str, orig: str, replacement: str, f: Finding) -> bool: scanned_paths=result.scanned_paths, mark_unseen=False if delta_mode else None, ) - # Loomweave taint-store write is fail-soft: an outage/403 returns a not-reachable - # WriteResult (reported below); a LoomweaveError (missing extra, 4xx, bad scheme) - # is a WardlineError → caught here → exit 2, exactly as Filigree errors do. + # Loomweave taint-store write is FULLY fail-soft, at parity with the MCP scan tool + # (mcp/server.py): an outage/403 returns a not-reachable WriteResult, and a + # LoomweaveError (missing [loomweave] extra, 4xx, bad scheme) is caught right here + # and degraded to the same not-reachable WriteResult (reported below). The write is + # a best-effort enrichment side-channel, never load-bearing for the gate — it must + # not change the scan's exit code. This matters because the loomweave URL can be + # AUTO-DISCOVERED from a sibling's published ephemeral port (ADR-044) with no flag, + # so a base install without blake3 must not have its gate killed by an optional dep. if loomweave_url is not None: - from wardline.loomweave.client import LoomweaveClient + from wardline.core.errors import LoomweaveError + from wardline.loomweave.client import LoomweaveClient, WriteResult from wardline.loomweave.config import load_loomweave_token, resolve_project_name from wardline.loomweave.write import write_facts_to_loomweave - client = LoomweaveClient( - loomweave_url, - secret=load_loomweave_token(path), - project=resolve_project_name(path), - ) - loomweave_result = write_facts_to_loomweave(result, path, client) + try: + client = LoomweaveClient( + loomweave_url, + secret=load_loomweave_token(path), + project=resolve_project_name(path), + ) + loomweave_result = write_facts_to_loomweave(result, path, client) + except LoomweaveError as exc: + loomweave_result = WriteResult(reachable=False, disabled_reason=str(exc)) if fmt == "agent-summary": from wardline.core.agent_summary import build_agent_summary diff --git a/tests/docs/test_glossary_vocabulary.py b/tests/docs/test_glossary_vocabulary.py index fdc12528..6db5b0ad 100644 --- a/tests/docs/test_glossary_vocabulary.py +++ b/tests/docs/test_glossary_vocabulary.py @@ -39,9 +39,9 @@ ("src/wardline/core/run.py", 551, "active=sum"), ("src/wardline/core/run.py", 643, "honors_suppressions"), # src/wardline/cli/scan.py — CLI summary line + gate stderr - ("src/wardline/cli/scan.py", 556, "suppressed"), - ("src/wardline/cli/scan.py", 557, "{s.active} active"), - ("src/wardline/cli/scan.py", 609, "gate: FAILED"), + ("src/wardline/cli/scan.py", 565, "suppressed"), + ("src/wardline/cli/scan.py", 566, "{s.active} active"), + ("src/wardline/cli/scan.py", 618, "gate: FAILED"), # src/wardline/mcp/server.py — MCP scan summary + gate block ("src/wardline/mcp/server.py", 1009, '"total": result.summary.total'), ("src/wardline/mcp/server.py", 1010, '"active": result.summary.active'), diff --git a/tests/unit/cli/test_cli.py b/tests/unit/cli/test_cli.py index d9c82332..9070c0e4 100644 --- a/tests/unit/cli/test_cli.py +++ b/tests/unit/cli/test_cli.py @@ -1329,7 +1329,13 @@ def emit(self, findings, *, scanned_paths=(), language=None, mark_unseen=None): assert "connection refused" in result.output -def test_scan_loomweave_loud_error_exits_2(tmp_path, monkeypatch) -> None: +def test_scan_loomweave_error_is_fail_soft(tmp_path, monkeypatch) -> None: + # A LoomweaveError from the taint-store write (4xx / bad scheme / missing extra) is + # FULLY fail-soft at parity with the MCP scan tool: the write is a best-effort + # enrichment side-channel, never load-bearing for the gate, so it must not change the + # scan's exit code. (Previously a LoomweaveError exited 2 "exactly as Filigree errors + # do" — but Filigree uses stdlib urllib and has no missing-extra mode, so that + # precedent never covered the case that bit a federated sibling.) from wardline.core.errors import LoomweaveError proj = tmp_path / "proj" @@ -1342,8 +1348,48 @@ def _raise(*a, **k): monkeypatch.setattr("wardline.loomweave.write.write_facts_to_loomweave", _raise) out = tmp_path / "f.jsonl" result = CliRunner().invoke(scan, [str(proj), "--output", str(out), "--loomweave-url", "http://x/api/taint"]) - assert result.exit_code == 2, result.output + assert result.exit_code == 0, result.output + assert "Loomweave taint store not written" in result.output assert "bad request" in result.output + assert "scan unaffected" in result.output + + +def test_scan_missing_loomweave_extra_is_fail_soft_when_auto_discovered(tmp_path, monkeypatch) -> None: + # Regression (elspeth dogfood): a bare `wardline scan` in the Loom federation + # auto-discovers a running Loomweave from its published ephemeral port (ADR-044) with + # NO --loomweave-url flag. On a base install WITHOUT the [loomweave] extra, the + # taint-fact write reaches require_blake3() -> LoomweaveError. That used to exit 2 + # ("could not run — missing blake3 dep"); it must instead degrade fail-soft, leaving + # the gate (the scan's real job) intact. Exercises the real resolve_loomweave_url -> + # published-port rung, with blake3 simulated absent at the genuine call site. + from wardline.core.errors import LoomweaveError + + monkeypatch.delenv("WARDLINE_LOOMWEAVE_URL", raising=False) + proj = tmp_path / "proj" + proj.mkdir() + _write(proj, "svc.py", _LEAKY) + # Loomweave published its live read-API port -> resolve_loomweave_url returns it (no flag). + port_dir = proj / ".weft" / "loomweave" + port_dir.mkdir(parents=True) + (port_dir / "ephemeral.port").write_text("59999", encoding="utf-8") + + # Simulate a base install: blake3 is not importable, so require_blake3() raises the + # actionable LoomweaveError at the exact site the write hits it. + def _missing_extra() -> ModuleType: + raise LoomweaveError( + "the Loomweave integration needs blake3 — install it with: pip install 'wardline[loomweave]'" + ) + + monkeypatch.setattr("wardline.loomweave.facts.require_blake3", _missing_extra) + out = tmp_path / "f.jsonl" + # No --loomweave-url: the URL is auto-discovered. No --fail-on either, so a clean run + # exits 0 and any non-soft failure would surface as a non-zero exit. + result = CliRunner().invoke(scan, [str(proj), "--output", str(out)]) + assert result.exit_code == 0, result.output + assert "Loomweave taint store not written" in result.output + assert "blake3" in result.output + assert "wardline[loomweave]" in result.output + assert "scan unaffected" in result.output def test_baseline_create_honors_project_waivers(tmp_path) -> None: From 84d4fe62272a6de07a06cc5e465c571421cf421d Mon Sep 17 00:00:00 2001 From: John Morrissey Date: Wed, 24 Jun 2026 17:56:31 +1000 Subject: [PATCH 2/2] chore: prepare wardline 1.0.7 Cut the Loomweave taint-store write fail-soft fix as 1.0.7 so a base install (no [loomweave] extra) that auto-discovers a running Loomweave can run `wardline scan` again without installing blake3. Unblocks the federated sibling (elspeth) via `pip install -U wardline`. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 ++ src/wardline/_version.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7903693..07915642 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.7] - 2026-06-24 + ### Fixed - **Auto-discovered Loomweave emission no longer kills a base-install scan.** `wardline scan` auto-discovers a running Loomweave from its published diff --git a/src/wardline/_version.py b/src/wardline/_version.py index 382021f3..9e604c04 100644 --- a/src/wardline/_version.py +++ b/src/wardline/_version.py @@ -1 +1 @@ -__version__ = "1.0.6" +__version__ = "1.0.7"