Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ 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
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
Expand Down
12 changes: 6 additions & 6 deletions docs/reference/finding-lifecycle-vocabulary.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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`" |
Expand Down Expand Up @@ -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 (<the tripping knob(s)>) — <reason>` 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
Expand All @@ -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 <ref>` | **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".
Expand All @@ -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"` |
Expand Down
2 changes: 1 addition & 1 deletion src/wardline/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "1.0.6"
__version__ = "1.0.7"
29 changes: 19 additions & 10 deletions src/wardline/cli/scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 3 additions & 3 deletions tests/docs/test_glossary_vocabulary.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
50 changes: 48 additions & 2 deletions tests/unit/cli/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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:
Expand Down
Loading