From 87f13b0d6c402b1762f492a1e9aed47054b21109 Mon Sep 17 00:00:00 2001 From: John Morrissey Date: Mon, 29 Jun 2026 19:24:28 +1000 Subject: [PATCH 1/7] fix(packaging): loomweave extra self-includes scanner so single-extra installs don't drop it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `uv tool install wardline[loomweave]` REPLACES extras (it does not merge), so a bare `loomweave = ["blake3>=1.0"]` dropped the scanner deps and broke `wardline init`/`scan` — users whack-a-moled scanner<->loomweave. Mirror the `rust` extra and self-include `wardline[scanner]` (loomweave's taint-store writes fire only during `wardline scan`, so it genuinely needs the pipeline). Broaden the doctor `loomweave.dep` remediation to name `uv tool install` vs `pip install`, and add a regression guard pinning that scan-pipeline extras self-include scanner. Fixes wardline-c8d7e020e8. Co-Authored-By: Claude Opus 4.8 (1M context) --- pyproject.toml | 7 +++- src/wardline/install/doctor.py | 4 ++- tests/unit/install/test_extras_composition.py | 34 +++++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 tests/unit/install/test_extras_composition.py diff --git a/pyproject.toml b/pyproject.toml index d4dcc80f..89c7ba3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,12 @@ classifiers = [ [project.optional-dependencies] scanner = ["pyyaml>=6.0", "jsonschema>=4.0", "click>=8.0"] docs = ["mkdocs>=1.6", "mkdocs-material>=9.5"] -loomweave = ["blake3>=1.0"] +# Loomweave taint-store integration. Its taint-fact writes fire only DURING `wardline +# scan` (the scan pipeline persists per-entity facts), so it needs the scanner deps and +# self-includes `wardline[scanner]` — `uv tool install` REPLACES extras (does not merge), +# so a bare `wardline[loomweave]` would otherwise drop scanner and break the CLI. Mirrors +# the `rust` extra below. +loomweave = ["wardline[scanner]", "blake3>=1.0"] # Rust frontend (preview, Tier-A command-injection). tree-sitter-rust 0.24.2's # parser is ABI 15, loadable only by tree-sitter core >=0.25.0 — do NOT trust the # grammar's stale self-declared `tree-sitter~=0.22`. Both ship cp39-abi3 stable-ABI diff --git a/src/wardline/install/doctor.py b/src/wardline/install/doctor.py index e0998566..c230cbe2 100644 --- a/src/wardline/install/doctor.py +++ b/src/wardline/install/doctor.py @@ -935,7 +935,9 @@ def _check_loomweave_dep(root: Path, *, effective_url: str | None = None) -> Doc "loomweave.dep", "error", message="loomweave is configured but its [loomweave] extra is not installed; " - "taint-store writes silently no-op — install: pip install 'wardline[loomweave]'", + "taint-store writes silently no-op — reinstall with the extra: " + "`uv tool install 'wardline[loomweave]'` (uv tool) or " + "`pip install 'wardline[loomweave]'` (venv)", ) return DoctorCheck("loomweave.dep", "ok", message="loomweave extra installed") diff --git a/tests/unit/install/test_extras_composition.py b/tests/unit/install/test_extras_composition.py new file mode 100644 index 00000000..64b05e94 --- /dev/null +++ b/tests/unit/install/test_extras_composition.py @@ -0,0 +1,34 @@ +"""Extras-composition invariant: scan-pipeline extras must be self-sufficient. + +``uv tool install`` REPLACES the tool environment with exactly the named extras — it +does NOT merge. So ``uv tool install 'wardline[loomweave]'`` that resolved to *only* +``blake3`` would silently drop the scanner deps the CLI requires, and the user would +whack-a-mole scanner<->loomweave with each reinstall (the CLI then errors +"requires the scanner extra"). Any extra that powers a feature riding the scan pipeline +(``rust`` frontend, ``loomweave`` taint-store writes) must therefore self-include +``wardline[scanner]`` so a single-extra install carries the scanner deps with it — +exactly as the ``rust`` extra already does. +""" + +from __future__ import annotations + +import tomllib +from pathlib import Path + +_PYPROJECT = Path(__file__).resolve().parents[3] / "pyproject.toml" + + +def _optional_dependencies() -> dict[str, list[str]]: + data = tomllib.loads(_PYPROJECT.read_text(encoding="utf-8")) + return data["project"]["optional-dependencies"] + + +def test_scan_pipeline_extras_self_include_scanner() -> None: + extras = _optional_dependencies() + for name in ("rust", "loomweave"): + assert name in extras, f"expected a `{name}` extra in [project.optional-dependencies]" + assert any("wardline[scanner]" in dep for dep in extras[name]), ( + f"the `{name}` extra must self-include `wardline[scanner]` — uv tool install " + f"replaces extras, so a single-extra install must carry the scanner deps the " + f"CLI needs (else `wardline init`/`scan` break after installing `wardline[{name}]`)" + ) From 8c950e024c7f1d2cbf91cfdb3588ca5c0abef73b Mon Sep 17 00:00:00 2001 From: John Morrissey Date: Mon, 29 Jun 2026 19:36:46 +1000 Subject: [PATCH 2/7] fix(install): install hints name both installers (uv tool vs pip) via shared helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the install-context wording from the loomweave.dep doctor check (87f13b0d) to every extra-install hint — the scanner-extra CLI guard, the rust tree-sitter loader, the loomweave blake3 loader, and the optional_deps scanner message — through a single `extra_install_hint(extra)` helper in core.optional_deps. A uv-tool user must reinstall via `uv tool install` (pip targets the wrong env, and uv tool replaces rather than merges extras); the helper names both forms so the hint is right whichever installer is in use. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wardline/cli/entrypoint.py | 4 +++- src/wardline/core/optional_deps.py | 14 +++++++++++++- src/wardline/install/doctor.py | 6 +++--- src/wardline/loomweave/__init__.py | 4 +++- src/wardline/rust/_tree_sitter.py | 4 +++- tests/unit/cli/test_cli.py | 3 ++- 6 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/wardline/cli/entrypoint.py b/src/wardline/cli/entrypoint.py index 6db27333..3de35707 100644 --- a/src/wardline/cli/entrypoint.py +++ b/src/wardline/cli/entrypoint.py @@ -12,8 +12,10 @@ def main() -> None: from wardline.cli.main import cli except ModuleNotFoundError as exc: if exc.name in _SCANNER_EXTRA_IMPORTS: + from wardline.core.optional_deps import extra_install_hint + print( - "error: the wardline CLI requires the scanner extra; install `wardline[scanner]`.", + f"error: the wardline CLI requires the scanner extra — install {extra_install_hint('scanner')}.", file=sys.stderr, ) raise SystemExit(2) from exc diff --git a/src/wardline/core/optional_deps.py b/src/wardline/core/optional_deps.py index f4ab6d0d..2a3a72fb 100644 --- a/src/wardline/core/optional_deps.py +++ b/src/wardline/core/optional_deps.py @@ -7,8 +7,20 @@ from wardline.core.errors import ConfigError +def extra_install_hint(extra: str) -> str: + """Install command for a wardline ``extra``, naming both installers. + + ``uv tool install`` REPLACES the tool environment with exactly the named extras (it + does not merge), and ``pip install`` targets the active venv rather than the tool env + — so a uv-tool user reinstalls via ``uv tool`` (pip would patch the wrong env), a venv + user via ``pip``. The scan-pipeline extras self-include ``wardline[scanner]``, so a + single-extra reinstall is self-sufficient under either installer. + """ + return f"`uv tool install 'wardline[{extra}]'` (uv tool) or `pip install 'wardline[{extra}]'` (venv)" + + def _scanner_extra_message(feature: str, package: str) -> str: - return f"{feature} requires {package} from the scanner extra; install `wardline[scanner]`." + return f"{feature} requires {package} from the scanner extra — install {extra_install_hint('scanner')}." def require_yaml(feature: str) -> Any: diff --git a/src/wardline/install/doctor.py b/src/wardline/install/doctor.py index c230cbe2..0590b9cf 100644 --- a/src/wardline/install/doctor.py +++ b/src/wardline/install/doctor.py @@ -931,13 +931,13 @@ def _check_loomweave_dep(root: Path, *, effective_url: str | None = None) -> Doc require_blake3() except (LoomweaveError, ImportError): + from wardline.core.optional_deps import extra_install_hint + return DoctorCheck( "loomweave.dep", "error", message="loomweave is configured but its [loomweave] extra is not installed; " - "taint-store writes silently no-op — reinstall with the extra: " - "`uv tool install 'wardline[loomweave]'` (uv tool) or " - "`pip install 'wardline[loomweave]'` (venv)", + f"taint-store writes silently no-op — install with {extra_install_hint('loomweave')}", ) return DoctorCheck("loomweave.dep", "ok", message="loomweave extra installed") diff --git a/src/wardline/loomweave/__init__.py b/src/wardline/loomweave/__init__.py index d5164c2c..0190e887 100644 --- a/src/wardline/loomweave/__init__.py +++ b/src/wardline/loomweave/__init__.py @@ -22,7 +22,9 @@ def require_blake3() -> ModuleType: try: import blake3 except ModuleNotFoundError as exc: + from wardline.core.optional_deps import extra_install_hint + raise LoomweaveError( - "the Loomweave integration needs blake3 — install it with: pip install 'wardline[loomweave]'" + f"the Loomweave integration needs blake3 — install with {extra_install_hint('loomweave')}" ) from exc return blake3 diff --git a/src/wardline/rust/_tree_sitter.py b/src/wardline/rust/_tree_sitter.py index 5ecec9ef..10a97e63 100644 --- a/src/wardline/rust/_tree_sitter.py +++ b/src/wardline/rust/_tree_sitter.py @@ -31,8 +31,10 @@ def require_rust() -> tuple[Language, Parser]: from tree_sitter import Language, Parser from tree_sitter_rust import language as _rust_language except ModuleNotFoundError as exc: + from wardline.core.optional_deps import extra_install_hint + raise RustToolingError( - "the Rust frontend needs tree-sitter — install it with: pip install 'wardline[rust]'" + f"the Rust frontend needs tree-sitter — install with {extra_install_hint('rust')}" ) from exc grammar = Language(_rust_language()) return grammar, Parser(grammar) diff --git a/tests/unit/cli/test_cli.py b/tests/unit/cli/test_cli.py index 73502151..808889b3 100644 --- a/tests/unit/cli/test_cli.py +++ b/tests/unit/cli/test_cli.py @@ -1443,7 +1443,8 @@ def test_scan_missing_loomweave_extra_is_fail_soft_when_auto_discovered(tmp_path # 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]'" + "the Loomweave integration needs blake3 — install with " + "`uv tool install 'wardline[loomweave]'` (uv tool) or `pip install 'wardline[loomweave]'` (venv)" ) monkeypatch.setattr("wardline.loomweave.facts.require_blake3", _missing_extra) From 5e0a3bccfc0e9a43f6bd8c077eabedc42e8beb96 Mon Sep 17 00:00:00 2001 From: John Morrissey Date: Mon, 29 Jun 2026 20:08:16 +1000 Subject: [PATCH 3/7] =?UTF-8?q?product:=20install-friction=20fix=20recorde?= =?UTF-8?q?d=20=E2=80=94=20scan-pipeline=20extras=20self-include=20scanner?= =?UTF-8?q?;=20PDR-0010?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PDR-0010: loomweave extra self-includes wardline[scanner] (uv-tool whack-a-mole fix); shared install-hint helper; regression guard. Within grant; PyPI publish owner-gated. - metrics.md: dated G4 reading (per-release extras re-check; base stays 0-dep; no trigger). - current-state.md: PR #69 scope + provenance updated; Now bet (seam probe) unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/product/current-state.md | 39 +++++++---- .../0010-extras-self-include-scanner.md | 64 +++++++++++++++++++ docs/product/metrics.md | 8 +++ 3 files changed, 97 insertions(+), 14 deletions(-) create mode 100644 docs/product/decisions/0010-extras-self-include-scanner.md diff --git a/docs/product/current-state.md b/docs/product/current-state.md index 55cb4b0a..47000054 100644 --- a/docs/product/current-state.md +++ b/docs/product/current-state.md @@ -9,9 +9,9 @@ `roadmap.md` → Now) — **UNCHANGED**. The open frontier remains the **seam-health probe** — PRD-0002 criteria 1 + 2 (Layer-1 `doctor --seams` self-check with a mandatory machine-readable `reason`; Layer-2 consumer round-trip that never trusts a self-reported -status field). *This session did NOT advance the seam bet* — it was an **owner-directed -examination** of the parked Q4 strategic question (now resolved, below). The seam probe is -still the highest-blast-radius core unbuilt. +status field). *The last two sessions did NOT advance the seam bet* — the Q4 examination +(resolved, below) and this session's reactive **install-friction fix** (PDR-0010) were both +detours. The seam probe is still the highest-blast-radius core unbuilt. - *Metric it moves:* **G2-seam** (`metrics.md`): `BASELINE (2026-06-15): 3 of 6 surfaces lie or can't self-report → TARGET: 0 of 6 by 2026-07-31`. crit-3 closed the @@ -52,10 +52,13 @@ No vision change (anti-goal unchanged); the instrumentation was within grant. ## Open questions / blocked-on-owner 1. **PR #69 merge + release (owner gate) — SCOPE GREW AGAIN.** PR #69 - (`release/consolidation-2026-06-26` → main, HEAD now `53a1424d`) carries the prior detour - (Part A + pack-bridge + layering `cfe546ed`) **plus** the concurrent session's Part B + (`release/consolidation-2026-06-26` → main, HEAD now `8c950e02`) carries the prior detour + (Part A + pack-bridge + layering `cfe546ed`), the concurrent session's Part B (`652d3bf3`), Part C (`b5170e22`), de-elspeth refactor (`9886280c`), glossary re-sync - (`53a1424d`). Merging to main / any PyPI release are outward-facing — your call. + (`53a1424d`), the **v1.1.0 release prep** (`adb42a0e` version bump + CHANGELOG, `35401454`), + **plus this session's install-friction fix** (`87f13b0d`/`8c950e02`, PDR-0010). The + install fix is **unpublished** — it reaches users only via the owner-gated release. + Merging to main / any PyPI publish are outward-facing — your call. 2. **Install the pack in elspeth + relay corrected guidance (cross-repo / owner).** "blake3 will NOT fix the gate; install the pack." Pack-bridge + Part C both shipped generically wardline-side. 3. **3.12 fingerprint release note (owner).** Carry-over from PDR-0006 — one-line note on release. @@ -68,12 +71,19 @@ No vision change (anti-goal unchanged); the instrumentation was within grant. ## What this checkpoint did -- **PDR-0009** — Q4 owner decision A+C (hold vision, instrument); metric-bound non-self-sealing - reversal trigger (≥ 5; baseline = 1). -- **metrics.md** — dated G3 reading (inert-gate prevalence; no trigger crossed). -- **roadmap.md** — added option B to Later as PARKED+gated; Now unchanged. -- **Reconciled drift** — Part B/C were stale-as-"open" in the prior brief; now recorded DONE+ - committed; `bd9d1e65cb` recorded CLOSED. Grant re-confirmed 2026-06-29 (date-only). +- **PDR-0010** — install-friction fix: scan-pipeline extras self-include scanner (the + `loomweave` whack-a-mole under `uv tool install`) + a shared install-hint helper naming + both installers + a regression guard. Commits `87f13b0d`/`8c950e02` (pushed); also + installed the fixed build into the local uv tool (single `[loomweave]` install now keeps + scanner — verified). +- **metrics.md** — dated G4 reading (per-release extras re-check; base stays 0-dep; no + trigger crossed). +- **Tracker** — filed + closed `wardline-c8d7e020e8` (the dogfood install defect). +- **Cross-repo (not wardline's to own):** this session was primarily the plainweave↔warpline + requirements-enrichment federation gate + an exit-code investigation; those product records + live in the sibling workspaces (plainweave PDR-017, warpline PDR-0008), not here. +- The **Now bet was not advanced** (reactive install-fix detour). `roadmap.md` untouched; + grant unchanged (re-confirmed 2026-06-29 by the prior session). ## Where the next session starts @@ -86,5 +96,6 @@ No vision change (anti-goal unchanged); the instrumentation was within grant. Decisions: `0001` bootstrap, `0002` Now rotation, `0003` doctor seam, `0004` ACCEPT PRD-0001, `0005` ACCEPT crit-3 + source-drift CI, `0006` fingerprint determinism, `0007` inert-gate -visibility, `0008` elspeth pack-bridge, `0009` Q4 hold-vision+instrument. Tactical truth is the -tracker; intent lives here and in `roadmap.md`. +visibility, `0008` elspeth pack-bridge, `0009` Q4 hold-vision+instrument, `0010` extras +self-include scanner (install-friction fix). Tactical truth is the tracker; intent lives here +and in `roadmap.md`. diff --git a/docs/product/decisions/0010-extras-self-include-scanner.md b/docs/product/decisions/0010-extras-self-include-scanner.md new file mode 100644 index 00000000..64f52431 --- /dev/null +++ b/docs/product/decisions/0010-extras-self-include-scanner.md @@ -0,0 +1,64 @@ +# PDR-0010: Scan-pipeline extras self-include scanner (fix uv-tool install-friction) + +Date: 2026-06-29 +Status: accepted +Author: agent:claude (engineering session; recorded at /product-checkpoint) +Owner sign-off: autonomous within grant — a repo-local, reversible defect fix on the +install surface (the grant authorizes accept/dispatch of reversible repo-local work). +Committed + pushed on `release/consolidation-2026-06-26` (PR #69). Reaching users via a +**PyPI publish is owner-gated** and is NOT performed here. +Related: `vision.md` thesis invariant #1 (plug-and-play install); G4 (extras/weight +discipline) + G3 (zero-config activation) in `metrics.md`; tracker `wardline-c8d7e020e8` +(filed + closed this session); PDR-0007/0008 (the elspeth dogfood that surfaced +install-surface friction). + +## Context + +A dogfood report from `~/elspeth` (installed wardline 1.0.7, uv-tool install) hit an +install whack-a-mole: `uv tool install wardline[loomweave]` *uninstalled* the scanner deps +(pyyaml/jsonschema/click) and installed only blake3, so `wardline init`/`scan` then errored +"requires the scanner extra"; reinstalling scanner dropped blake3 again. Root cause: `uv +tool install` REPLACES the tool env with exactly the named extras (it does not merge), and +`loomweave = ["blake3>=1.0"]` was not self-sufficient — unlike the `rust` extra, which +already self-includes `wardline[scanner]` for exactly this reason. This breaks thesis +invariant #1 ("install it and it stands itself up"): installing a capability extra broke +the tool. + +## Options considered + +1. **Guidance only** — tell users to combine extras (`wardline[scanner,loomweave]`). + Rejected: pushes the uv-tool gotcha onto every user; the single-extra install (the + natural action, and what the doctor hint advised) stays broken. +2. **Make scan-pipeline extras self-sufficient** (CHOSEN) — `loomweave` self-includes + `wardline[scanner]`, mirroring `rust`; a single-extra install carries its prerequisites. +3. **Collapse loomweave into base/scanner** — rejected: violates G4 (capability stays + behind opt-in extras) and the zero-dep base. + +## The call + +**Make scan-pipeline extras self-sufficient.** `loomweave = ["wardline[scanner]", +"blake3>=1.0"]` (loomweave's taint-store writes fire only during `wardline scan`, so it +genuinely needs the pipeline; there is no loomweave-without-scanner path — the CLI refuses +to start without scanner). Plus a shared `extra_install_hint(extra)` naming both installers +(`uv tool install` vs `pip install`, since pip targets the wrong env for a uv-tool install) +across every extra hint, and a regression guard (`test_extras_composition.py`) pinning the +self-sufficiency invariant. Verified: built-wheel METADATA resolves `loomweave → +blake3+click+jsonschema+pyyaml`; a single `[loomweave]` install now keeps scanner (no +whack-a-mole, confirmed live in the uv tool); 256 install+cli tests + layering conformance +green. Commits `87f13b0d` + `8c950e02`. + +## Rationale + +Serves invariant #1 directly — the install surface is part of "plug-and-play." The fix is +the established in-repo pattern (`rust`), keeps the base zero-dep and capability opt-in (G4 +holds), and closes a previously **untested** invariant. Engineering-tracked in +`wardline-c8d7e020e8`; recorded as a product decision because the install surface is a +thesis-invariant-#1 concern, not incidental hygiene. + +## Reversal trigger + +Reopen the bundling decision if a legitimate consumer needs `wardline[loomweave]` WITHOUT +the scanner pipeline (none exists today), or if the extras-composition guard is ever +relaxed — watched under G4's per-release extras re-check (`metrics.md`). The +install-friction class itself reopens if a single-extra `uv tool install` whack-a-moles +again on any extra after this fix. diff --git a/docs/product/metrics.md b/docs/product/metrics.md index 36dd8589..2b363a6a 100644 --- a/docs/product/metrics.md +++ b/docs/product/metrics.md @@ -151,6 +151,14 @@ gates) enters the tool. - `BASELINE → TARGET`: `BASELINE: base = 0 runtime deps at 1.0.6 → TARGET: base stays 0 runtime deps; new deps only behind a named extra, re-checked each release` +- **Reading 2026-06-29 (PDR-0010):** per-release extras re-check — base package stays + **0 runtime deps** (G4 holds); capability stays behind named extras. Fixed an + install-friction defect: the `loomweave` extra was not self-sufficient under `uv tool + install` (which replaces, not merges, extras), so a bare `wardline[loomweave]` dropped + the scanner deps and broke the CLI (whack-a-mole). Now `loomweave` self-includes + `wardline[scanner]` (mirrors `rust`) so single-extra installs carry their prerequisites. + **No weight creep** (loomweave still opt-in; base unchanged) and **no G4 trigger + crossed**. Pinned by `test_extras_composition.py`; tracker `wardline-c8d7e020e8`. ## Notes From a6d1e0e63603cfadba38bef625ccda75df7c39c6 Mon Sep 17 00:00:00 2001 From: John Morrissey Date: Mon, 29 Jun 2026 20:59:15 +1000 Subject: [PATCH 4/7] fix(doctor): filigree.auth distinguishes ethereal-mode config from "not configured" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_resolve_probe_target` only ever finds a filigree *daemon* URL, so a project configured in ethereal mode (no daemon) — or with the daemon down — collapsed to the misleading "filigree not configured; nothing to verify", reading as "filigree absent" when it is fully set up. Read `.weft/filigree/config.json` mode: ethereal -> tell the user to put filigree in daemon mode (`filigree install --mode server`, then `filigree server start`); configured but no daemon reachable -> `filigree server start`; genuinely absent -> unchanged message. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wardline/install/doctor.py | 36 ++++++++++++++++++- .../unit/install/test_doctor_filigree_auth.py | 17 +++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/wardline/install/doctor.py b/src/wardline/install/doctor.py index 0590b9cf..7b69f101 100644 --- a/src/wardline/install/doctor.py +++ b/src/wardline/install/doctor.py @@ -683,6 +683,24 @@ def _repair_filigree_auth(root: Path, url: str, transport: Transport) -> DoctorC ) +def _filigree_local_mode(root: Path) -> str | None: + """The ``mode`` of a locally-configured filigree project (``.weft/filigree/config.json``; + the legacy ``.filigree/`` dot-dir is tolerated), or ``None`` when filigree is not configured + in this project at all. ``ethereal`` mode runs no daemon — and wardline emits findings to a + filigree *daemon* URL — so a configured-but-ethereal project has no endpoint to reach, which + the auth probe must distinguish from "filigree absent".""" + for base in (sibling_state_dir(root, "filigree"), legacy_sibling_dir(root, "filigree")): + text = safe_read_text_if_regular(root, base / "config.json", label="filigree config") + if text is None: + continue + try: + mode = json.loads(text).get("mode") + except (json.JSONDecodeError, ValueError, AttributeError): + return "unknown" + return mode if isinstance(mode, str) and mode else "unknown" + return None + + def _check_filigree_auth( root: Path, *, @@ -697,7 +715,23 @@ def _check_filigree_auth( probe_transport = transport if transport is not None else UrllibTransport(timeout=2.0) target = probe_target or _resolve_probe_target(root, filigree_url) if target is None: - return DoctorCheck("filigree.auth", "ok", message="filigree not configured; nothing to verify") + mode = _filigree_local_mode(root) + if mode is None: + return DoctorCheck("filigree.auth", "ok", message="filigree not configured; nothing to verify") + if mode == "ethereal": + return DoctorCheck( + "filigree.auth", + "ok", + message="filigree is configured in ethereal mode (no daemon) — wardline emits " + "findings to a filigree daemon, so there is no endpoint to reach yet; put filigree " + "in daemon mode: `filigree install --mode server`, then `filigree server start`", + ) + return DoctorCheck( + "filigree.auth", + "ok", + message="filigree is configured but no daemon is reachable; wardline emits to a " + "filigree daemon — start it with `filigree server start`", + ) url = target.url if not _is_loopback(url): return DoctorCheck("filigree.auth", "ok", message="non-loopback filigree; token not probed") diff --git a/tests/unit/install/test_doctor_filigree_auth.py b/tests/unit/install/test_doctor_filigree_auth.py index 9bab32d2..b8f62125 100644 --- a/tests/unit/install/test_doctor_filigree_auth.py +++ b/tests/unit/install/test_doctor_filigree_auth.py @@ -298,6 +298,23 @@ def test_check_ok_when_url_unresolved(tmp_path: Path, monkeypatch) -> None: assert "not configured" in (check.message or "") +def test_check_ethereal_mode_advises_daemon(tmp_path: Path, monkeypatch) -> None: + # filigree IS configured locally but in ethereal mode (no daemon). wardline emits to a + # filigree *daemon* URL, so there is no endpoint to reach — but the message must say THAT + # and point at server mode, not the misleading "not configured" (which reads as "absent"). + monkeypatch.delenv("WARDLINE_FILIGREE_URL", raising=False) + monkeypatch.setattr("wardline.install.doctor.Path.home", lambda: tmp_path / "nohome") + cfg = tmp_path / ".weft" / "filigree" / "config.json" + cfg.parent.mkdir(parents=True) + cfg.write_text(json.dumps({"prefix": "x", "name": "x", "version": 1, "mode": "ethereal"}), encoding="utf-8") + check = _check_filigree_auth(tmp_path, repair=False, transport=_ScriptedTransport({})) + assert check.status == "ok" + msg = check.message or "" + assert "ethereal" in msg + assert "filigree install --mode server" in msg + assert "not configured" not in msg + + # --- Stale --filigree-url pin shadowing a LIVE published daemon (rotated port) ------- From 14030acb7b1fe9adbdd53147b611bb1d73666d2c Mon Sep 17 00:00:00 2001 From: John Morrissey Date: Tue, 30 Jun 2026 00:18:33 +1000 Subject: [PATCH 5/7] fix(gate): preview-maturity rules now participate in --fail-on (wardline-4ada23bb09) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The --fail-on gate predicate silently skipped Maturity.PREVIEW findings, so six ERROR-severity rules — 118 (SQL injection), 119 (degenerate trust boundary), 120 (stored taint), 121 (XXE), 122 (SSTI), 124 (native-lib load) — fired as active ERROR defects but `wardline scan --fail-on ERROR` passed green (would_trip_at=None). A G2 false-green soundness hole, present in 1.1.0. maturity is now purely informational; preview rules gate AND are baselineable exactly like stable rules, matching the long-standing documented contract (docs/concepts/rules.md:60). Removed the preview-exclusion at all 5 sites: suppression.gate_trips, suppression.gate_breakdown, run.baseline_migration_hint, run._gate_reason, baseline._is_baselineable_finding. New universal regression pin (tests/unit/core/test_preview_gating.py) asserts every preview rule in the registry gates at its base severity, plus end-to-end scan->gate pins for 118/119. Inverted the two tests that encoded the old behavior (test_baseline, test_default_registry). CHANGELOG [Unreleased] + UPGRADING note the build-behavior change (a repo clean today with one of these flows now correctly fails --fail-on; recommend a minor bump). Also carried here (one shared working tree; run.py co-modified, so the hunks could not be split into separate commits): a concurrent session's `analyzed_paths` delta-coverage work (src/wardline/cli/scan.py, ScanResult.analyzed_paths in run.py, tests/unit/cli/test_scan_artifacts.py, uv.lock) and the glossary line-anchor reconciliation it required (tests/docs/test_glossary_vocabulary.py, docs/reference/finding-lifecycle-vocabulary.md run.py + scan.py citations). Full suite green (4557 passed); ruff + mypy clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 19 ++- UPGRADING.md | 20 +++ .../reference/finding-lifecycle-vocabulary.md | 56 +++---- src/wardline/cli/scan.py | 51 +++++- src/wardline/core/baseline.py | 7 +- src/wardline/core/run.py | 17 +- src/wardline/core/suppression.py | 10 +- tests/docs/test_glossary_vocabulary.py | 34 ++-- tests/unit/cli/test_scan_artifacts.py | 156 ++++++++++++++++++ tests/unit/core/test_baseline.py | 10 +- tests/unit/core/test_preview_gating.py | 102 ++++++++++++ .../scanner/rules/test_default_registry.py | 13 +- uv.lock | 6 + 13 files changed, 432 insertions(+), 69 deletions(-) create mode 100644 tests/unit/core/test_preview_gating.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c31c319e..910f28a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [1.1.0] - 2026-06-29 +### Fixed +- **Soundness — preview-maturity rules now gate (and baseline) exactly like stable + rules** (wardline-4ada23bb09). The `--fail-on` gate predicate silently excluded + `maturity: preview` findings, so six ERROR-severity rules — `PY-WL-118` (SQL + injection), `PY-WL-119` (no-op/degenerate trust boundary), `PY-WL-120` (stored + taint reaches trusted state), `PY-WL-121` (XXE), `PY-WL-122` (SSTI), and + `PY-WL-124` (native-library load) — fired as active ERROR defects but + `wardline scan --fail-on ERROR` passed **green** on them (`would_trip_at: null`). + `maturity` is now purely informational ("predicates may still sharpen"), matching + the long-standing documented contract that preview rules "participate in the gate, + baseline, waivers, and judge exactly like stable rules". Preview findings are also + now baselineable, so a preview false-positive has the usual escape hatch. + **Behavior change:** a repository that scans clean today but contains one of these + flows will now correctly **fail** `--fail-on ERROR`. Clear it the normal way — + fix the boundary/sink, baseline/waive the finding, or scope with `--new-since`. + This applies at every threshold, not just ERROR: the WARN-severity preview rules + (`PY-WL-116`/`117`/`123`/`126`) now gate under `--fail-on WARN`, and `PY-WL-125` + (INFO) under `--fail-on INFO` — a CI pinned below ERROR is affected too. ### Changed - **BREAKING (unreleased contract):** attest bundle schema bumped `wardline-attest-1` → `wardline-attest-2`; each boundary now carries `content_hash` (entity-body span blake3 binding key, null when unresolved). `wardline-attest-1` bundles no longer verify. diff --git a/UPGRADING.md b/UPGRADING.md index b3ed8149..3540dea2 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -2,6 +2,26 @@ Migration notes for changes that can alter a previously-green run. Newest first. +## To the next release (recommended v1.2) — preview rules now gate (soundness) + +`wardline-4ada23bb09`. The `--fail-on` gate previously **ignored** any rule whose +`maturity` is `preview`, so a scan could pass green while an active ERROR defect +was present. `maturity` is now purely informational; **preview rules gate (and +are baselineable) exactly like stable rules**, matching the documented contract. + +**Who is affected.** A repository that scans green today but contains one of the +previously-non-gating preview findings will now correctly **fail**. At +`--fail-on ERROR`: `PY-WL-118` (SQL injection), `PY-WL-119` (no-op/degenerate +trust boundary), `PY-WL-120` (stored taint → trusted), `PY-WL-121` (XXE), +`PY-WL-122` (SSTI), `PY-WL-124` (native-library load). At lower thresholds also +`PY-WL-116`/`117`/`123`/`126` (WARN) and `PY-WL-125` (INFO). + +**What to do.** This is a real finding, not noise — fix it at the boundary/sink. +If you must defer, use the normal escape hatches: `wardline baseline` (or the +`waiver_add` MCP tool) to suppress a specific finding, or `--new-since ` to +scope the gate to changed code. There is no config flag to restore the old +"preview never gates" behavior. + ## To v1.0 — Weft config/store consolidation (BREAKING) Wardline's operator config and machine state moved onto the Weft federation diff --git a/docs/reference/finding-lifecycle-vocabulary.md b/docs/reference/finding-lifecycle-vocabulary.md index d670ff07..524688a6 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:569`). +`suppressed` count (`src/wardline/cli/scan.py:616`). ## `active` is the one word for "non-suppressed defect" @@ -71,8 +71,8 @@ consistently, on every surface: | Surface | Where | Term | | --- | --- | --- | | 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:570` | `… {s.active} active` | +| Summary field | `src/wardline/core/run.py:70`, built at `src/wardline/core/run.py:558` | `ScanSummary.active` | +| CLI summary line | `src/wardline/cli/scan.py:617` | `… {s.active} active` | | MCP scan response | `src/wardline/mcp/server.py:928` | `summary.active` | | Agent-summary JSON | `src/wardline/core/agent_summary.py:130` | `summary.active_defects` | | `wardline:loop` prompt | `src/wardline/mcp/prompts.py:13` | "Read `summary.active`" | @@ -88,18 +88,18 @@ surfaces. ## The summary buckets partition the total -`ScanSummary` (`src/wardline/core/run.py:68-89`) counts split the whole scan into +`ScanSummary` (`src/wardline/core/run.py:68-88`) counts split the whole scan into buckets that **sum to `total`** exactly (weft-f506e5f845): - the defect buckets partition the `DEFECT`s by state — - `active` (`src/wardline/core/run.py:71`) + `baselined` (`src/wardline/core/run.py:73`) - + `waived` (`src/wardline/core/run.py:74`) + `judged` (`src/wardline/core/run.py:75`); -- `informational` (`src/wardline/core/run.py:81`) is **every non-defect finding** + `active` (`src/wardline/core/run.py:70`) + `baselined` (`src/wardline/core/run.py:72`) + + `waived` (`src/wardline/core/run.py:73`) + `judged` (`src/wardline/core/run.py:74`); +- `informational` (`src/wardline/core/run.py:80`) is **every non-defect finding** (facts, metrics, classifications) — the rest of `total`. So `active + baselined + waived + judged + informational == total` -(`src/wardline/core/run.py:70` for `total: int`). `unanalyzed` -(`src/wardline/core/run.py:89`) is an **overlay** — a subset of `informational` +(`src/wardline/core/run.py:69` for `total: int`). `unanalyzed` +(`src/wardline/core/run.py:88`) is an **overlay** — a subset of `informational` that surfaces a silent under-scan — and is deliberately **not** a partition member. The MCP `summary` block exposes `informational` (`src/wardline/mcp/server.py:936`) and `unanalyzed` (`src/wardline/mcp/server.py:940`); the agent-summary block mirrors @@ -111,19 +111,19 @@ There are **two distinct populations** of defects in one scan, and they can differ on purpose: 1. **Emitted-active** — `summary.active` counts `active` defects in the - **emitted** (post-annotation) findings (built at `src/wardline/core/run.py:554`). + **emitted** (post-annotation) findings (built at `src/wardline/core/run.py:558`). Baseline / waiver / judged annotate these findings in place; a suppressed defect is still emitted, just not counted as `active`. 2. **Gate population** — the `--fail-on` gate evaluates a **separate** `ScanResult.gate_findings` list: the *unsuppressed* population - (`src/wardline/core/run.py:489`). By default, repository-controlled + (`src/wardline/core/run.py:493`). By default, repository-controlled baseline / waiver / judged entries **annotate** the emitted findings but do **not** clear the gate — so a malicious PR cannot green the gate by committing a suppression keyed to its own new defect. `gate_decision` evaluates `gate_findings` when present, else falls back to `findings` (the trusted `--trust-suppressions` / directly-constructed path), selected at - `src/wardline/core/run.py:648` (`honors_suppressions`). + `src/wardline/core/run.py:656` (`honors_suppressions`). This is why **`summary.active: 0` can co-exist with `gate.tripped: true`**: every defect was suppressed by a committed baseline (so emitted-active is 0), but those @@ -132,8 +132,8 @@ bug. ### The gate verdict is explicit (never a vacuous green) -`GateDecision` (`src/wardline/core/run.py:152`) carries `tripped` / `fail_on` / -`exit_class` **plus** an explicit `verdict` (`src/wardline/core/run.py:162`) and a +`GateDecision` (`src/wardline/core/run.py:156`) carries `tripped` / `fail_on` / +`exit_class` **plus** an explicit `verdict` (`src/wardline/core/run.py:166`) and a `would_trip_at`, alongside a human `reason` and the `evaluated` population it judged. The `verdict` is one of: @@ -159,11 +159,11 @@ The MCP `scan` gate block exposes `gate.tripped` (`src/wardline/mcp/server.py:94 `src/wardline/core/agent_summary.py:148` (`verdict`). The CLI prints `gate: FAILED () — ` then `gate: evaluated <…>`, or a `gate: NOT_EVALUATED — …` line for a bare scan -(`src/wardline/cli/scan.py:622`). +(`src/wardline/cli/scan.py:669`). `--new-since` scopes **both** populations identically: any `active` defect outside the delta is re-marked `baselined` in both the emitted and gate lists -(`src/wardline/core/run.py:499`, `def apply_delta_scope`). +(`src/wardline/core/run.py:503`, `def apply_delta_scope`). ## The three meanings of "new" @@ -174,8 +174,8 @@ still legitimately means three different things depending on the surface: | "new" on this surface | Means | Owner / anchor | | --- | --- | --- | | 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:499`; 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:570` | +| `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:503`; 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:617` | The first-seen Filigree sense and the delta-scope `--new-since` sense are genuinely distinct concepts; neither is "active". @@ -186,19 +186,19 @@ 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:927`) | `total_findings` (`agent_summary.py:129`) | one finding per wire entry | -| live defect | `N active` (`scan.py:570`) | `active` (`run.py:71,551`) | `active` (`server.py:928`) | `active_defects` (`agent_summary.py:130`) | no `suppression_state` key (`finding.py:295`) | -| suppressed (sum) | `N suppressed` (`scan.py:569`) | `baselined+waived+judged` | the three keys | `suppressed_findings` (`agent_summary.py:131`) | `metadata.wardline.suppression_state` (`finding.py:295`) | -| baselined | `N baseline` | `baselined` (`run.py:73`) | `baselined` (`server.py:929`) | `baselined` (`agent_summary.py:133`) | `suppression_state: "baselined"` | -| waived | `N waiver` | `waived` (`run.py:74`) | `waived` (`server.py:930`) | `waived` (`agent_summary.py:134`) | `suppression_state: "waived"` | -| judged | `N judged` | `judged` (`run.py:75`) | `judged` (`server.py:931`) | `judged` (`agent_summary.py:135`) | `suppression_state: "judged"` | -| informational (summary) | (the remainder of `total`) | `informational` (`run.py:81`) | `informational` (`server.py:936`) | `informational` (`agent_summary.py:141`) | facts/metrics | +| every finding | `N finding(s)` | `total` (`run.py:69`) | `total` (`server.py:927`) | `total_findings` (`agent_summary.py:129`) | one finding per wire entry | +| live defect | `N active` (`scan.py:617`) | `active` (`run.py:70,558`) | `active` (`server.py:928`) | `active_defects` (`agent_summary.py:130`) | no `suppression_state` key (`finding.py:295`) | +| suppressed (sum) | `N suppressed` (`scan.py:616`) | `baselined+waived+judged` | the three keys | `suppressed_findings` (`agent_summary.py:131`) | `metadata.wardline.suppression_state` (`finding.py:295`) | +| baselined | `N baseline` | `baselined` (`run.py:72`) | `baselined` (`server.py:929`) | `baselined` (`agent_summary.py:133`) | `suppression_state: "baselined"` | +| waived | `N waiver` | `waived` (`run.py:73`) | `waived` (`server.py:930`) | `waived` (`agent_summary.py:134`) | `suppression_state: "waived"` | +| judged | `N judged` | `judged` (`run.py:74`) | `judged` (`server.py:931`) | `judged` (`agent_summary.py:135`) | `suppression_state: "judged"` | +| informational (summary) | (the remainder of `total`) | `informational` (`run.py:80`) | `informational` (`server.py:936`) | `informational` (`agent_summary.py:141`) | facts/metrics | | informational (display) | n/a | n/a | n/a | `informational` display array (`agent_summary.py:172`) — non-defect, non-engine-fact findings (metrics, classifications, suggestions, non-engine facts); excludes `engine_facts` which has its own display slot | facts/metrics | -| under-scan | `N file(s) could not be analyzed` | `unanalyzed` (`run.py:89`) | `unanalyzed` (`server.py:940`) | `unanalyzed` (`agent_summary.py:142`) | `WLN-ENGINE-*` facts | -| gate verdict | exit code + `--fail-on` | (`gate_findings`, `run.py:110`; `GateDecision`, `run.py:152`, `verdict` `run.py:162`) | `gate` (`server.py:942`), `gate.tripped` (`server.py:943`), `gate.verdict` (`server.py:947`) | `gate.tripped` (`agent_summary.py:145`), `gate.verdict` (`agent_summary.py:148`) | not emitted to Filigree | +| under-scan | `N file(s) could not be analyzed` | `unanalyzed` (`run.py:88`) | `unanalyzed` (`server.py:940`) | `unanalyzed` (`agent_summary.py:142`) | `WLN-ENGINE-*` facts | +| gate verdict | exit code + `--fail-on` | (`gate_findings`, `run.py:114`; `GateDecision`, `run.py:156`, `verdict` `run.py:166`) | `gate` (`server.py:942`), `gate.tripped` (`server.py:943`), `gate.verdict` (`server.py:947`) | `gate.tripped` (`agent_summary.py:145`), `gate.verdict` (`agent_summary.py:148`) | not emitted to Filigree | The unsuppressed gate population is built from `Baseline(frozenset())` -(`src/wardline/core/run.py:489`). +(`src/wardline/core/run.py:493`). ## For the suite diff --git a/src/wardline/cli/scan.py b/src/wardline/cli/scan.py index df0b9be0..022f90d6 100644 --- a/src/wardline/cli/scan.py +++ b/src/wardline/cli/scan.py @@ -29,7 +29,8 @@ from wardline.core.finding import Severity from wardline.core.paths import project_root_for, weft_config_path from wardline.core.resolution_posture import compute_resolution_posture -from wardline.core.run import baseline_migration_hint, gate_decision, run_scan +from wardline.core.ruleset import ruleset_hash +from wardline.core.run import ScanResult, baseline_migration_hint, gate_decision, run_scan from wardline.core.safe_paths import explicit_output_target, write_explicit_output_text from wardline.core.sarif import SarifSink, build_sarif @@ -37,6 +38,25 @@ from wardline.loomweave.identity import SeiResolver +def _scan_manifest_record(result: ScanResult, ruleset_id: str, *, full_coverage: bool) -> dict[str, object]: + """The ``scan_manifest`` header record for the default jsonl artifact. + + ``covered_paths`` is the repo-relative POSIX scan inventory in the SAME format as + ``Finding.location.path``, so a now-absent prior finding only reads as RESOLVED when its + path is genuinely in scope. It DEFAULTS to the ANALYZED set (``result.analyzed_paths``): + in ``--affected`` delta mode a discovered-but-not-re-analyzed file is NOT over-claimed as + covered (a prior finding there stays indeterminate, not falsely resolved). ``full_coverage`` + emits the full DISCOVERED inventory (``result.scanned_paths``); for a full scan the two are + identical. ``ruleset_id`` is the top-level ruleset hash (== legis ``rule_set_version``). + """ + covered = result.scanned_paths if full_coverage else result.analyzed_paths + return { + "kind": "scan_manifest", + "scope": {"covered_paths": list(covered)}, + "ruleset_id": ruleset_id, + } + + @click.command() @click.argument( "path", @@ -185,6 +205,18 @@ "lets the dev/tour loop exercise the Wardline->legis handshake without a commit." ), ) +@click.option( + "--manifest-full-coverage", + is_flag=True, + default=False, + help=( + "For --format jsonl: emit the FULL discovered scan inventory in the scan_manifest " + "covered_paths, not just the analyzed set. Default off — covered_paths reports only " + "the re-analyzed paths so a discovered-but-unanalyzed file in --affected delta mode is " + "not over-claimed as coverage (which could read a still-present finding as resolved). " + "No effect on a full scan, where analyzed == discovered." + ), +) def scan( path: Path, config_path: Path | None, @@ -208,6 +240,7 @@ def scan( allow_source_root_escape: bool, trust_suppressions: bool, allow_dirty: bool, + manifest_full_coverage: bool, ) -> None: """Scan PATH for findings. @@ -362,7 +395,21 @@ def confirm_cb(rel_path: str, orig: str, replacement: str, f: Finding) -> bool: SarifSink(output, root=output_root).write(findings, result.context, run_properties=scope_props) elif fmt == "jsonl": if output_is_default: - output = write_scan_artifact(path, fmt, cfg, "".join(f"{finding.to_jsonl()}\n" for finding in findings)) + # Header record: a `scan_manifest` JSONL line the federated consumer + # (plainweave's wardline_adapter) keys on to compute resolved/unseen. See + # _scan_manifest_record: covered_paths defaults to the ANALYZED set (honest + # coverage in --affected delta mode); --manifest-full-coverage opts into the + # full discovered inventory. Emitted UNCONDITIONALLY — a clean scan + # (findings == []) is exactly when RESOLVED detection matters most, so the + # artifact is never empty and the consumer's scan-identity-absent degrade only + # fires for pre-manifest snapshots. Additive: existing line-by-line finding + # readers ignore an unknown `kind`. + manifest = json.dumps( + _scan_manifest_record(result, ruleset_hash(cfg), full_coverage=manifest_full_coverage), + separators=(",", ":"), + ) + body = manifest + "\n" + "".join(f"{finding.to_jsonl()}\n" for finding in findings) + output = write_scan_artifact(path, fmt, cfg, body) else: assert output is not None output, output_root = explicit_output_target(path, output) diff --git a/src/wardline/core/baseline.py b/src/wardline/core/baseline.py index 4cb845ca..2db70059 100644 --- a/src/wardline/core/baseline.py +++ b/src/wardline/core/baseline.py @@ -21,7 +21,6 @@ FINGERPRINT_SCHEME, Finding, Kind, - Maturity, Severity, require_fingerprint_scheme, ) @@ -201,7 +200,11 @@ def inspect_baseline_store(root: Path) -> BaselineStoreStatus: def _is_baselineable_finding(finding: Finding) -> bool: - return finding.kind is Kind.DEFECT and finding.maturity is not Maturity.PREVIEW + # A DEFECT is baselineable regardless of rule maturity: preview rules gate + # exactly like stable ones (wardline-4ada23bb09), so a preview finding that + # gates must be suppressible too — otherwise a preview FP could break the + # build with no escape hatch. ``maturity`` is informational only. + return finding.kind is Kind.DEFECT def build_baseline_document(findings: Iterable[Finding]) -> dict[str, Any]: diff --git a/src/wardline/core/run.py b/src/wardline/core/run.py index 957cfe6d..e78345c8 100644 --- a/src/wardline/core/run.py +++ b/src/wardline/core/run.py @@ -31,7 +31,6 @@ Finding, Kind, Location, - Maturity, Severity, SuppressionState, ) @@ -98,6 +97,11 @@ class ScanResult: # this exact run instead of re-deriving. Never serialised over MCP. context: AnalysisContext | None scanned_paths: tuple[str, ...] = () + # The ANALYZED subset (== ``scanned_paths`` for a full scan; the narrower re-analyzed + # set in ``--affected`` delta mode). ``scan_manifest.covered_paths`` defaults to this so a + # discovered-but-not-re-analyzed delta file is not over-claimed as coverage — a prior + # finding there stays indeterminate, not falsely resolved. + analyzed_paths: tuple[str, ...] = () # The UNSUPPRESSED gate population (None SENTINEL — never a falsy-empty fallback). # Repository-controlled baseline/waiver/judged still ANNOTATE ``findings`` (visible # as ``suppressed=…``), but a malicious PR must not be able to clear the ``--fail-on`` @@ -588,6 +592,10 @@ def apply_delta_scope(candidates: list[Finding]) -> list[Finding]: path.relative_to(resolved_root).as_posix() if path.is_relative_to(resolved_root) else path.as_posix() for path in files ), + analyzed_paths=tuple( + path.relative_to(resolved_root).as_posix() if path.is_relative_to(resolved_root) else path.as_posix() + for path in analyze_files + ), gate_findings=gate_findings, gate_honors_suppressions=gate_honors_suppressions, scope=scope, @@ -736,10 +744,7 @@ def baseline_migration_hint( baselined = sum( 1 for f in result.findings - if f.kind is Kind.DEFECT - and f.suppressed is SuppressionState.BASELINED - and f.maturity is not Maturity.PREVIEW - and severity_gates(f.severity, fail_on) + if f.kind is Kind.DEFECT and f.suppressed is SuppressionState.BASELINED and severity_gates(f.severity, fail_on) ) if not baselined: return None # tripped by waived/judged only — different escape, not this hint @@ -778,7 +783,7 @@ def _gate_reason(result: ScanResult, fail_on: Severity, *, tripped: bool, honors active = 0 suppressed = 0 for f in gate_pop: - if f.kind is not Kind.DEFECT or f.maturity is Maturity.PREVIEW: + if f.kind is not Kind.DEFECT: continue if f.suppressed is not SuppressionState.ACTIVE or not severity_gates(f.severity, fail_on): continue diff --git a/src/wardline/core/suppression.py b/src/wardline/core/suppression.py index 44a2c228..46cc3281 100644 --- a/src/wardline/core/suppression.py +++ b/src/wardline/core/suppression.py @@ -13,7 +13,7 @@ from datetime import date from wardline.core.baseline import Baseline -from wardline.core.finding import ENGINE_PATH, Finding, Kind, Maturity, Severity, SuppressionState +from wardline.core.finding import ENGINE_PATH, Finding, Kind, Severity, SuppressionState from wardline.core.finding_identity import resolve_identity from wardline.core.judged import JudgedSet from wardline.core.waivers import WaiverSet @@ -91,8 +91,6 @@ def gate_trips(findings: Iterable[Finding], fail_on: Severity) -> bool: for f in findings: if f.kind is not Kind.DEFECT or f.suppressed is not SuppressionState.ACTIVE: continue - if f.maturity is Maturity.PREVIEW: - continue rank = _RANK.get(f.severity) if rank is not None and rank >= threshold: return True @@ -103,8 +101,8 @@ def gate_breakdown(findings: Iterable[Finding], fail_on: Severity) -> tuple[int, """Count gate-relevant DEFECTs at/above ``fail_on`` in the ANNOTATED population, split into ``(active, suppressed)``. - Same predicate as :func:`gate_trips` (DEFECT, non-PREVIEW, severity >= threshold) - but counts instead of short-circuiting and partitions by whether the finding is + Same predicate as :func:`gate_trips` (DEFECT, severity >= threshold) but counts + instead of short-circuiting and partitions by whether the finding is ACTIVE or repository-suppressed (baselined / waived / judged). Lets the gate verdict say *which* population tripped it without re-deriving the rule. Under the secure default the suppressed count is exactly the set that gates only because suppressions @@ -114,7 +112,7 @@ def gate_breakdown(findings: Iterable[Finding], fail_on: Severity) -> tuple[int, active = 0 suppressed = 0 for f in findings: - if f.kind is not Kind.DEFECT or f.maturity is Maturity.PREVIEW: + if f.kind is not Kind.DEFECT: continue rank = _RANK.get(f.severity) if rank is None or rank < threshold: diff --git a/tests/docs/test_glossary_vocabulary.py b/tests/docs/test_glossary_vocabulary.py index dee5f196..541ddbb4 100644 --- a/tests/docs/test_glossary_vocabulary.py +++ b/tests/docs/test_glossary_vocabulary.py @@ -24,24 +24,24 @@ # ``(repo-relative path, 1-based line, substring required on that line)``. _ANCHORS: tuple[tuple[str, int, str], ...] = ( # src/wardline/core/run.py — ScanSummary fields, gate population, delta-scope, gate_decision - ("src/wardline/core/run.py", 70, "total: int"), - ("src/wardline/core/run.py", 71, "active: int"), - ("src/wardline/core/run.py", 73, "baselined: int"), - ("src/wardline/core/run.py", 74, "waived: int"), - ("src/wardline/core/run.py", 75, "judged: int"), - ("src/wardline/core/run.py", 81, "informational: int"), - ("src/wardline/core/run.py", 89, "unanalyzed: int"), - ("src/wardline/core/run.py", 110, "gate_findings:"), - ("src/wardline/core/run.py", 152, "class GateDecision"), - ("src/wardline/core/run.py", 162, "verdict: str"), - ("src/wardline/core/run.py", 489, "Baseline(frozenset())"), - ("src/wardline/core/run.py", 499, "def apply_delta_scope"), - ("src/wardline/core/run.py", 554, "active=sum"), - ("src/wardline/core/run.py", 648, "honors_suppressions"), + ("src/wardline/core/run.py", 69, "total: int"), + ("src/wardline/core/run.py", 70, "active: int"), + ("src/wardline/core/run.py", 72, "baselined: int"), + ("src/wardline/core/run.py", 73, "waived: int"), + ("src/wardline/core/run.py", 74, "judged: int"), + ("src/wardline/core/run.py", 80, "informational: int"), + ("src/wardline/core/run.py", 88, "unanalyzed: int"), + ("src/wardline/core/run.py", 114, "gate_findings:"), + ("src/wardline/core/run.py", 156, "class GateDecision"), + ("src/wardline/core/run.py", 166, "verdict: str"), + ("src/wardline/core/run.py", 493, "Baseline(frozenset())"), + ("src/wardline/core/run.py", 503, "def apply_delta_scope"), + ("src/wardline/core/run.py", 558, "active=sum"), + ("src/wardline/core/run.py", 656, "honors_suppressions"), # src/wardline/cli/scan.py — CLI summary line + gate stderr - ("src/wardline/cli/scan.py", 569, "suppressed"), - ("src/wardline/cli/scan.py", 570, "{s.active} active"), - ("src/wardline/cli/scan.py", 622, "gate: FAILED"), + ("src/wardline/cli/scan.py", 616, "suppressed"), + ("src/wardline/cli/scan.py", 617, "{s.active} active"), + ("src/wardline/cli/scan.py", 669, "gate: FAILED"), # src/wardline/mcp/server.py — MCP scan summary + gate block ("src/wardline/mcp/server.py", 927, '"total": result.summary.total'), ("src/wardline/mcp/server.py", 928, '"active": result.summary.active'), diff --git a/tests/unit/cli/test_scan_artifacts.py b/tests/unit/cli/test_scan_artifacts.py index 126dd5b5..fb2e4cc2 100644 --- a/tests/unit/cli/test_scan_artifacts.py +++ b/tests/unit/cli/test_scan_artifacts.py @@ -10,16 +10,42 @@ from __future__ import annotations +import json import re from pathlib import Path from click.testing import CliRunner from wardline.cli.main import cli +from wardline.cli.scan import _scan_manifest_record +from wardline.core.config import load as load_config +from wardline.core.paths import weft_config_path +from wardline.core.ruleset import ruleset_hash +from wardline.core.run import ScanResult, ScanSummary from wardline.mcp.server import _scan as mcp_scan _STAMPED_JSONL_RE = re.compile(r"^\d{8}T\d{6}Z(-\d{3})?-findings\.jsonl$") +# A trusted boundary returning an external-tainted value: PY-WL-101 ERROR defect on +# ``leaky``. Mirrors ``_LEAKY`` in tests/unit/cli/test_scan_affected_cli.py — a real +# per-file POLICY finding (not an engine fact), so its ``location.path`` is a genuine +# file in ``scanned_paths`` and must therefore appear in the manifest's covered_paths. +_LEAKY = ( + "from wardline.decorators import external_boundary, trusted\n" + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted\ndef leaky(p):\n return read_raw(p)\n" +) + + +def _read_lines(artifact: Path) -> list[dict[str, object]]: + return [json.loads(line) for line in artifact.read_text(encoding="utf-8").splitlines() if line.strip()] + + +def _manifest_line(artifact: Path) -> dict[str, object]: + manifests = [rec for rec in _read_lines(artifact) if rec.get("kind") == "scan_manifest"] + assert len(manifests) == 1, f"expected exactly one scan_manifest line, got {len(manifests)}" + return manifests[0] + def _scan_artifacts_jsonl(project: Path) -> list[Path]: return sorted((project / ".wardline").glob("*-findings.jsonl")) @@ -171,3 +197,133 @@ def test_mcp_scan_writes_no_disk_artifact(tmp_path: Path) -> None: assert not (tmp_path / ".wardline").exists(), ( "MCP _scan() created a .wardline/ directory — disk writes must be CLI-only" ) + + +# --------------------------------------------------------------------------- +# Test 7: the default findings JSONL carries a scan_manifest header record with the +# exact shape plainweave's wardline_adapter keys on. +# --------------------------------------------------------------------------- + + +def test_default_jsonl_emits_scan_manifest_with_exact_shape(tmp_path: Path) -> None: + """The default ``.wardline/*-findings.jsonl`` artifact carries a single + ``scan_manifest`` line of shape + ``{"kind":"scan_manifest","scope":{"covered_paths":[...]},"ruleset_id":"sha256:..."}`` + — covered_paths NESTED under scope, ruleset_id TOP-LEVEL — and ruleset_id equals + ``ruleset_hash(loaded_config)``.""" + (tmp_path / "weft.toml").write_text('[wardline]\nsource_roots = ["."]\n', encoding="utf-8") + (tmp_path / "leaky.py").write_text(_LEAKY, encoding="utf-8") + + result = CliRunner().invoke(cli, ["scan", str(tmp_path)]) + assert result.exit_code in (0, 1), result.output # 1 = gate tripped on the PY-WL defect + + artifact = _only_scan_artifact(tmp_path) + manifest = _manifest_line(artifact) + + # Exact contract shape. + assert set(manifest) == {"kind", "scope", "ruleset_id"}, manifest + assert manifest["kind"] == "scan_manifest" + scope = manifest["scope"] + assert isinstance(scope, dict) and set(scope) == {"covered_paths"}, scope + covered = scope["covered_paths"] + assert isinstance(covered, list) and all(isinstance(p, str) for p in covered) + + # ruleset_id is TOP-LEVEL and equals ruleset_hash(config) (== legis rule_set_version). + rid = manifest["ruleset_id"] + assert isinstance(rid, str) and rid.startswith("sha256:") + assert rid == ruleset_hash(load_config(weft_config_path(tmp_path))) + + +def test_manifest_covered_paths_match_finding_location_format(tmp_path: Path) -> None: + """The one subtle correctness point: covered_paths use the SAME format/relativity as + a finding's ``location.path``. A real per-file POLICY finding's location.path MUST be + a member of covered_paths (membership, not equality — engine facts use other path + forms), and covered_paths are relative POSIX (no leading slash, no backslash).""" + (tmp_path / "weft.toml").write_text('[wardline]\nsource_roots = ["."]\n', encoding="utf-8") + (tmp_path / "leaky.py").write_text(_LEAKY, encoding="utf-8") + + result = CliRunner().invoke(cli, ["scan", str(tmp_path)]) + assert result.exit_code in (0, 1), result.output + + artifact = _only_scan_artifact(tmp_path) + records = _read_lines(artifact) + manifest = _manifest_line(artifact) + covered = set(manifest["scope"]["covered_paths"]) # type: ignore[index] + + # covered_paths are repo-relative POSIX paths. + assert all(not p.startswith("/") and "\\" not in p for p in covered), covered + assert "leaky.py" in covered, f"the scanned file is not in covered_paths: {covered}" + + # The real policy finding (PY-WL-* on a concrete file) must have its location.path + # in covered_paths — this is exactly what the consumer's RESOLVED logic depends on. + policy = [ + rec + for rec in records + if rec.get("kind") != "scan_manifest" + and str(rec.get("rule_id", "")).startswith("PY-WL-") + and isinstance(rec.get("location"), dict) + ] + assert policy, "fixture produced no PY-WL policy finding" + for rec in policy: + path = rec["location"]["path"] # type: ignore[index] + assert path in covered, f"finding location.path {path!r} absent from covered_paths {covered}" + + +def test_clean_scan_still_emits_manifest(tmp_path: Path) -> None: + """Regression guard for the empty-artifact bug: a CLEAN scan (zero findings) MUST + still emit the manifest line — that is precisely when RESOLVED detection matters + (prior findings now absent, their path still in covered_paths). The artifact must + never be empty, so the consumer's scan-identity-absent degrade only fires for + pre-manifest snapshots, not for fresh clean scans.""" + (tmp_path / "weft.toml").write_text('[wardline]\nsource_roots = ["."]\n', encoding="utf-8") + (tmp_path / "clean.py").write_text("def ok():\n return 1\n", encoding="utf-8") + + result = CliRunner().invoke(cli, ["scan", str(tmp_path)]) + assert result.exit_code == 0, result.output + + artifact = _only_scan_artifact(tmp_path) + records = _read_lines(artifact) + # No policy defects on this clean tree (engine facts may still appear, but the + # manifest must be present regardless). + manifest = _manifest_line(artifact) + assert manifest["scope"]["covered_paths"], "covered_paths empty on a non-empty scan" # type: ignore[index] + assert "clean.py" in set(manifest["scope"]["covered_paths"]) # type: ignore[index] + assert manifest == records[0], "manifest should be the header (first) record" + + +def test_scan_manifest_covered_paths_narrows_to_analyzed_set() -> None: + """covered_paths DEFAULTS to the analyzed set, so a discovered-but-not-re-analyzed file + in --affected delta mode is not over-claimed as coverage (a prior finding there stays + indeterminate, not falsely resolved); --manifest-full-coverage restores the full + discovered inventory. This fixture forces analyzed != discovered (a full scan has + analyzed == discovered, so the two coincide there and the default is unchanged).""" + result = ScanResult( + findings=[], + summary=ScanSummary(total=0, active=0, baselined=0, waived=0, judged=0), + files_scanned=2, + context=None, + scanned_paths=("a.py", "b.py"), # discovered + analyzed_paths=("a.py",), # only a.py re-analyzed (delta) + ) + default = _scan_manifest_record(result, "sha256:deadbeef", full_coverage=False) + assert default == { + "kind": "scan_manifest", + "scope": {"covered_paths": ["a.py"]}, # narrowed to the analyzed set + "ruleset_id": "sha256:deadbeef", + } + full = _scan_manifest_record(result, "sha256:deadbeef", full_coverage=True) + assert full["scope"] == {"covered_paths": ["a.py", "b.py"]} # full discovered inventory + + +def test_manifest_full_coverage_flag_is_wired(tmp_path: Path) -> None: + """The --manifest-full-coverage flag threads click -> scan() param -> _scan_manifest_record + and produces a valid manifest. On a full scan analyzed == discovered, so covered_paths is + unchanged — this guards the wiring, not the (delta-only) narrowing the unit test covers.""" + (tmp_path / "weft.toml").write_text('[wardline]\nsource_roots = ["."]\n', encoding="utf-8") + (tmp_path / "leaky.py").write_text("eval(input())\n", encoding="utf-8") + + result = CliRunner().invoke(cli, ["scan", str(tmp_path), "--manifest-full-coverage"]) + assert result.exit_code == 0, result.output + + manifest = _manifest_line(_only_scan_artifact(tmp_path)) + assert "leaky.py" in set(manifest["scope"]["covered_paths"]) # type: ignore[index] diff --git a/tests/unit/core/test_baseline.py b/tests/unit/core/test_baseline.py index 93579f46..f21cf7b3 100644 --- a/tests/unit/core/test_baseline.py +++ b/tests/unit/core/test_baseline.py @@ -111,14 +111,18 @@ def test_build_document_is_order_independent() -> None: assert build_baseline_document(fs) == build_baseline_document(list(reversed(fs))) -def test_build_document_excludes_preview_findings_that_never_gate() -> None: +def test_build_document_includes_preview_findings_that_gate() -> None: + # wardline-4ada23bb09: preview rules gate exactly like stable ones, so a + # preview DEFECT both trips the gate AND must be baselineable — otherwise a + # preview false-positive could break the build with no escape hatch. preview = _preview_finding(_FP_A) stable = _finding(_FP_B) doc = build_baseline_document([preview, stable]) - assert gate_trips([preview], Severity.ERROR) is False - assert [entry["fingerprint"] for entry in doc["entries"]] == [_FP_B] + assert gate_trips([preview], Severity.ERROR) is True + # CRITICAL sorts before ERROR; both are present (preview is no longer dropped). + assert sorted(entry["fingerprint"] for entry in doc["entries"]) == sorted([_FP_A, _FP_B]) def test_write_then_load_round_trips(tmp_path: Path) -> None: diff --git a/tests/unit/core/test_preview_gating.py b/tests/unit/core/test_preview_gating.py new file mode 100644 index 00000000..afa7104e --- /dev/null +++ b/tests/unit/core/test_preview_gating.py @@ -0,0 +1,102 @@ +"""Regression pins: PREVIEW-maturity rules gate exactly like STABLE ones. + +wardline-4ada23bb09. The gate predicate silently skipped ``Maturity.PREVIEW`` +findings, so six ERROR-severity preview rules — 118 (SQL injection), 119 +(no-op boundary), 120 (stored taint), 121 (XXE), 122 (SSTI), 124 (native-lib +load) — fired as active ERROR defects but ``--fail-on ERROR`` passed GREEN on +them. ``maturity`` is an informational "predicates may still sharpen" label +(docs/concepts/rules.md: preview rules "participate in the gate, baseline, +waivers, and judge exactly like stable rules"); it must NOT affect the gate. + +The bug survived because the rule suite asserted the FINDING fires but never +asserted the GATE trips. These pins close that gap: the unit invariant covers +EVERY preview rule (so the root cause cannot recur for a future preview rule), +and the end-to-end pins drive a real scan -> gate_decision for the two concrete +shapes the bug was reproduced on. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from wardline.core.finding import Finding, Kind, Location, Maturity, Severity +from wardline.core.run import gate_decision, run_scan +from wardline.core.suppression import gate_trips +from wardline.scanner.rules import BUILTIN_RULE_CLASSES + +_PREVIEW_RULES = [cls for cls in BUILTIN_RULE_CLASSES if cls.metadata.maturity is Maturity.PREVIEW] + + +def _active_defect(*, rule_id: str, severity: Severity, maturity: Maturity) -> Finding: + return Finding( + rule_id=rule_id, + message="synthetic", + severity=severity, + kind=Kind.DEFECT, + location=Location(path="src/m.py", line_start=1), + fingerprint="a" * 64, + maturity=maturity, + ) + + +def test_preview_error_defect_trips_gate_at_error() -> None: + # The exact inverse of the bug: an ACTIVE preview ERROR DEFECT must trip the + # severity gate at ERROR. Before the fix this returned False. + f = _active_defect(rule_id="PY-WL-119", severity=Severity.ERROR, maturity=Maturity.PREVIEW) + assert gate_trips([f], Severity.ERROR) is True + + +def test_preview_maturity_does_not_change_gate_outcome() -> None: + # maturity is informational only: a preview defect and a stable defect of the + # same severity gate identically. + preview = _active_defect(rule_id="PY-WL-119", severity=Severity.ERROR, maturity=Maturity.PREVIEW) + stable = _active_defect(rule_id="PY-WL-101", severity=Severity.ERROR, maturity=Maturity.STABLE) + assert gate_trips([preview], Severity.ERROR) == gate_trips([stable], Severity.ERROR) is True + + +@pytest.mark.parametrize("cls", _PREVIEW_RULES, ids=lambda c: c.metadata.rule_id) +def test_every_preview_rule_participates_in_gate(cls) -> None: + # Universal invariant over the whole registry: no preview rule is silently + # excluded from the gate at its own base severity. This is what makes the + # root cause unable to recur when a NEW preview rule is added. + md = cls.metadata + f = _active_defect(rule_id=md.rule_id, severity=md.base_severity, maturity=Maturity.PREVIEW) + assert gate_trips([f], md.base_severity) is True, f"{md.rule_id} (preview, {md.base_severity.value}) must gate" + + +def test_degenerate_boundary_119_gates_end_to_end(tmp_path: Path) -> None: + # The filed repro: a @trust_boundary that returns its input unvalidated fires + # PY-WL-119 at ERROR; --fail-on ERROR must TRIP, not pass green. + proj = tmp_path / "proj" + proj.mkdir() + (proj / "b.py").write_text( + "from wardline.decorators import trust_boundary\n" + "@trust_boundary(to_level='ASSURED')\ndef ingest(payload):\n return payload\n", + encoding="utf-8", + ) + result = run_scan(proj) + assert any(f.rule_id == "PY-WL-119" and f.severity is Severity.ERROR for f in result.findings) + decision = gate_decision(result, Severity.ERROR) + assert decision.tripped is True + assert decision.would_trip_at == "ERROR" + + +def test_sql_injection_118_gates_end_to_end(tmp_path: Path) -> None: + # Disproves the bug's "118 still gates" premise was wrong the OTHER way: 118 is + # preview, so it did NOT gate. After the fix an untrusted -> cursor.execute() + # flow in a trusted-tier function must trip --fail-on ERROR. + proj = tmp_path / "proj" + proj.mkdir() + (proj / "app.py").write_text( + "from wardline.decorators import external_boundary, trusted\n" + "@external_boundary\ndef read_raw(p):\n return p\n" + "@trusted(level='ASSURED')\ndef f(p, cursor):\n cursor.execute(read_raw(p))\n", + encoding="utf-8", + ) + result = run_scan(proj) + assert any(f.rule_id == "PY-WL-118" and f.severity is Severity.ERROR for f in result.findings) + decision = gate_decision(result, Severity.ERROR) + assert decision.tripped is True + assert decision.would_trip_at == "ERROR" diff --git a/tests/unit/scanner/rules/test_default_registry.py b/tests/unit/scanner/rules/test_default_registry.py index 7331eda3..50d8aff5 100644 --- a/tests/unit/scanner/rules/test_default_registry.py +++ b/tests/unit/scanner/rules/test_default_registry.py @@ -118,7 +118,12 @@ def test_analyzer_runs_default_rules_end_to_end(tmp_path) -> None: assert any(f.rule_id == "PY-WL-101" and f.qualname == "svc.leaky" for f in defects) -def test_preview_rules_are_non_gating() -> None: +def test_preview_rules_gate_like_stable() -> None: + # wardline-4ada23bb09: a preview rule's finding is stamped maturity=PREVIEW + # (informational — "predicates may still sharpen"), but it participates in the + # gate exactly like a stable rule. Previously gate_trips skipped PREVIEW, so an + # ERROR-severity preview rule (118 SQLi, 119, 121 XXE, 122 SSTI, ...) fired but + # --fail-on ERROR passed green. This pins the stamping AND the gate together. from wardline.core.finding import Finding, Kind, Location, Maturity, Severity from wardline.core.finding import compute_finding_fingerprint as _fp from wardline.core.suppression import gate_trips @@ -165,7 +170,7 @@ def check(self, context: AnalysisContext) -> list[Finding]: findings = registry.run(context) assert len(findings) == 1 + # maturity is still stamped from the rule metadata (informational label)… assert findings[0].maturity == Maturity.PREVIEW - - # Check that it doesn't trip the gate - assert not gate_trips(findings, Severity.ERROR) + # …but it no longer exempts the finding from the gate. + assert gate_trips(findings, Severity.ERROR) diff --git a/uv.lock b/uv.lock index eeb82d9f..2f0e3bc8 100644 --- a/uv.lock +++ b/uv.lock @@ -1250,6 +1250,9 @@ docs = [ ] loomweave = [ { name = "blake3" }, + { name = "click" }, + { name = "jsonschema" }, + { name = "pyyaml" }, ] rust = [ { name = "click" }, @@ -1280,12 +1283,15 @@ dev = [ [package.metadata] requires-dist = [ { name = "blake3", marker = "extra == 'loomweave'", specifier = ">=1.0" }, + { name = "click", marker = "extra == 'loomweave'", specifier = ">=8.0" }, { name = "click", marker = "extra == 'rust'", specifier = ">=8.0" }, { name = "click", marker = "extra == 'scanner'", specifier = ">=8.0" }, + { name = "jsonschema", marker = "extra == 'loomweave'", specifier = ">=4.0" }, { name = "jsonschema", marker = "extra == 'rust'", specifier = ">=4.0" }, { name = "jsonschema", marker = "extra == 'scanner'", specifier = ">=4.0" }, { name = "mkdocs", marker = "extra == 'docs'", specifier = ">=1.6" }, { name = "mkdocs-material", marker = "extra == 'docs'", specifier = ">=9.5" }, + { name = "pyyaml", marker = "extra == 'loomweave'", specifier = ">=6.0" }, { name = "pyyaml", marker = "extra == 'rust'", specifier = ">=6.0" }, { name = "pyyaml", marker = "extra == 'scanner'", specifier = ">=6.0" }, { name = "tree-sitter", marker = "extra == 'rust'", specifier = ">=0.25,<0.26" }, From 9c416e10fb8a36129ca4792550bee7a144d95501 Mon Sep 17 00:00:00 2001 From: John Morrissey Date: Tue, 30 Jun 2026 00:34:06 +1000 Subject: [PATCH 6/7] =?UTF-8?q?release:=20v1.2.0=20=E2=80=94=20preview=20r?= =?UTF-8?q?ules=20now=20gate=20(wardline-4ada23bb09)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Minor bump: preview-maturity rules now participate in the --fail-on gate (see [1.2.0] Fixed). This newly fails CI on repositories that scan green today but contain a previously-non-gating preview finding (118 SQLi / 119 / 120 / 121 XXE / 122 SSTI / 124 native-load at ERROR; 116/117/123/126 WARN; 125 INFO) — a deliberate, documented behavior change. 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 910f28a2..3c649280 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.2.0] - 2026-06-30 + ### Fixed - **Soundness — preview-maturity rules now gate (and baseline) exactly like stable rules** (wardline-4ada23bb09). The `--fail-on` gate predicate silently excluded diff --git a/src/wardline/_version.py b/src/wardline/_version.py index 6849410a..c68196d1 100644 --- a/src/wardline/_version.py +++ b/src/wardline/_version.py @@ -1 +1 @@ -__version__ = "1.1.0" +__version__ = "1.2.0" From 25097041369025ab3900c30b5a7c219fad1ca4f9 Mon Sep 17 00:00:00 2001 From: John Morrissey Date: Tue, 30 Jun 2026 00:51:54 +1000 Subject: [PATCH 7/7] fix(review): address review-panel findings on the v1.2.0 preview-gate change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three reviewers (gate/FP-soundness, correctness+feature, citation-accuracy) audited the unpushed delta. The gate fix and the bundled analyzed_paths feature were both confirmed sound; these are the follow-ups they raised. CHANGELOG (CRITICAL — reviewer-found): the v1.2.0 edit had swallowed the published `## [1.1.0] - 2026-06-29` header, orphaning 1.1.0's Changed/Added body (attest-v2, FastAPI taint, pack-bridge, doctor checks) under [1.2.0] — and v1.1.0 is a released tag on origin. Restored the [1.1.0] section; [1.2.0] now carries ONLY the preview-gate Fixed entry. Dropped the now-inaccurate "(unreleased contract)" qualifier from the released attest entry. Secure-default remediation guidance (F1, CHANGELOG + UPGRADING): the "baseline/waive the finding" advice was wrong for CI — under the secure default the gate evaluates the unsuppressed population, so a committed baseline/waiver clears it only under --trust-suppressions (trusted local checkout). CI must scope with --new-since; a baselined/waived finding alone will not green the build. Corrected both docs to match the CLI's own reason/hint wording. baseline_ops docstring (F2): collect_and_write_baseline's docstring still said "EXCLUDING preview findings that never gate" — contradicts the fix (preview defects are now baselineable). Corrected, and added the missing e2e pin that the orchestration actually baselines a preview defect. Test hardening (F3/F4): added a gate_breakdown coherence pin (preview ERROR defect counts as (1 active, 0 suppressed) — guards against a future regression that trips the gate while the reason says "0 defects"); added precondition asserts to the universal invariant so a future NONE-severity / non-DEFECT preview rule fails cleanly instead of raising KeyError in gate_trips. Full suite green (4559 passed); ruff + mypy clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 13 +++++++--- UPGRADING.md | 12 ++++++--- src/wardline/core/baseline_ops.py | 7 +++--- tests/unit/core/test_preview_gating.py | 35 +++++++++++++++++++++++++- 4 files changed, 55 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c649280..5bc3c965 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,14 +22,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 baseline, waivers, and judge exactly like stable rules". Preview findings are also now baselineable, so a preview false-positive has the usual escape hatch. **Behavior change:** a repository that scans clean today but contains one of these - flows will now correctly **fail** `--fail-on ERROR`. Clear it the normal way — - fix the boundary/sink, baseline/waive the finding, or scope with `--new-since`. - This applies at every threshold, not just ERROR: the WARN-severity preview rules + flows will now correctly **fail** `--fail-on`. Fix the boundary/sink, or reach for + an escape hatch — but mind the secure default: the gate evaluates the *unsuppressed* + population, so a committed baseline/waiver clears it only under `--trust-suppressions` + (a trusted local checkout). In **CI**, scope with `--new-since `; a + baselined or waived finding alone will not turn the build green. This applies at + every threshold, not just ERROR: the WARN-severity preview rules (`PY-WL-116`/`117`/`123`/`126`) now gate under `--fail-on WARN`, and `PY-WL-125` (INFO) under `--fail-on INFO` — a CI pinned below ERROR is affected too. +## [1.1.0] - 2026-06-29 + ### Changed -- **BREAKING (unreleased contract):** attest bundle schema bumped `wardline-attest-1` → `wardline-attest-2`; each boundary now carries `content_hash` (entity-body span blake3 binding key, null when unresolved). `wardline-attest-1` bundles no longer verify. +- **BREAKING:** attest bundle schema bumped `wardline-attest-1` → `wardline-attest-2`; each boundary now carries `content_hash` (entity-body span blake3 binding key, null when unresolved). `wardline-attest-1` bundles no longer verify. - Default scan artifacts now anchor to the weft-project root (the `weft.toml` directory) rather than the scan cwd, so a subdirectory scan writes to `/.wardline/`. Retention is therefore project-root-wide across heterogeneous subdir/root scans sharing diff --git a/UPGRADING.md b/UPGRADING.md index 3540dea2..696074b0 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -17,10 +17,14 @@ trust boundary), `PY-WL-120` (stored taint → trusted), `PY-WL-121` (XXE), `PY-WL-116`/`117`/`123`/`126` (WARN) and `PY-WL-125` (INFO). **What to do.** This is a real finding, not noise — fix it at the boundary/sink. -If you must defer, use the normal escape hatches: `wardline baseline` (or the -`waiver_add` MCP tool) to suppress a specific finding, or `--new-since ` to -scope the gate to changed code. There is no config flag to restore the old -"preview never gates" behavior. +If you must defer, mind the secure default: the gate evaluates the *unsuppressed* +population, so a committed baseline or waiver clears it **only** under +`--trust-suppressions` (a trusted local checkout), not in a default CI run. In +**CI**, scope the gate with `--new-since ` so it fires only on changed +code; a baselined/waived finding alone will not turn the build green. (`wardline +baseline` / the `waiver_add` MCP tool still record the suppression for the +trusted-checkout and `--new-since` paths.) There is no config flag to restore the +old "preview never gates" behavior. ## To v1.0 — Weft config/store consolidation (BREAKING) diff --git a/src/wardline/core/baseline_ops.py b/src/wardline/core/baseline_ops.py index 662d745e..37d9256e 100644 --- a/src/wardline/core/baseline_ops.py +++ b/src/wardline/core/baseline_ops.py @@ -37,9 +37,10 @@ def collect_and_write_baseline( """Derive the baselineable findings for ``root`` and write them to ``.weft/wardline/baseline.yaml``. Returns the findings that were baselined. - Captures current stable DEFECTs, EXCLUDING preview findings that never gate - and any with an active waiver (else the baseline swallows them and their - expiry never resurfaces — spec §8). + Captures current DEFECTs regardless of maturity — preview rules gate, so a + preview finding must be baselineable too (wardline-4ada23bb09) — EXCLUDING any + with an active waiver (else the baseline swallows them and their expiry never + resurfaces — spec §8). Honors ``config_path`` exactly as ``scan`` does, so the baseline is built from the same waiver set the scans will consume. diff --git a/tests/unit/core/test_preview_gating.py b/tests/unit/core/test_preview_gating.py index afa7104e..ffcdc2fd 100644 --- a/tests/unit/core/test_preview_gating.py +++ b/tests/unit/core/test_preview_gating.py @@ -23,7 +23,7 @@ from wardline.core.finding import Finding, Kind, Location, Maturity, Severity from wardline.core.run import gate_decision, run_scan -from wardline.core.suppression import gate_trips +from wardline.core.suppression import gate_breakdown, gate_trips from wardline.scanner.rules import BUILTIN_RULE_CLASSES _PREVIEW_RULES = [cls for cls in BUILTIN_RULE_CLASSES if cls.metadata.maturity is Maturity.PREVIEW] @@ -56,12 +56,27 @@ def test_preview_maturity_does_not_change_gate_outcome() -> None: assert gate_trips([preview], Severity.ERROR) == gate_trips([stable], Severity.ERROR) is True +def test_preview_error_defect_is_counted_active_in_gate_breakdown() -> None: + # Coherence guard between the gate PREDICATE (gate_trips) and the COUNT/REASON path + # (gate_breakdown -> _gate_reason). A future regression that re-added a + # `maturity is PREVIEW: continue` to gate_breakdown or _gate_reason ALONE would make + # the gate trip while the reason said "0 active ERROR+ defect(s)" — an incoherent + # verdict the full suite would otherwise still pass green. Pin the count too. + f = _active_defect(rule_id="PY-WL-119", severity=Severity.ERROR, maturity=Maturity.PREVIEW) + assert gate_breakdown([f], Severity.ERROR) == (1, 0) # (active, suppressed) + + @pytest.mark.parametrize("cls", _PREVIEW_RULES, ids=lambda c: c.metadata.rule_id) def test_every_preview_rule_participates_in_gate(cls) -> None: # Universal invariant over the whole registry: no preview rule is silently # excluded from the gate at its own base severity. This is what makes the # root cause unable to recur when a NEW preview rule is added. md = cls.metadata + # Preconditions so a future mis-declared preview rule fails cleanly HERE with a + # clear message rather than with a KeyError deep inside gate_trips (NONE severity) + # or by silently mis-passing (non-DEFECT kind never gates). + assert md.kind is Kind.DEFECT, f"{md.rule_id} is preview but not a DEFECT — it would never gate" + assert md.base_severity is not Severity.NONE, f"{md.rule_id} is preview with NONE severity — cannot gate" f = _active_defect(rule_id=md.rule_id, severity=md.base_severity, maturity=Maturity.PREVIEW) assert gate_trips([f], md.base_severity) is True, f"{md.rule_id} (preview, {md.base_severity.value}) must gate" @@ -100,3 +115,21 @@ def test_sql_injection_118_gates_end_to_end(tmp_path: Path) -> None: decision = gate_decision(result, Severity.ERROR) assert decision.tripped is True assert decision.would_trip_at == "ERROR" + + +def test_collect_and_write_baseline_captures_preview_defect(tmp_path: Path) -> None: + # The baseline escape hatch must capture preview defects: they now gate, so they + # must be suppressible too. Pins the ORCHESTRATION (collect_and_write_baseline), + # not just the pure build_baseline_document derive — the stale docstring that said + # "EXCLUDING preview findings that never gate" had no test holding it honest. + from wardline.core.baseline_ops import collect_and_write_baseline + + proj = tmp_path / "proj" + proj.mkdir() + (proj / "b.py").write_text( + "from wardline.decorators import trust_boundary\n" + "@trust_boundary(to_level='ASSURED')\ndef ingest(payload):\n return payload\n", + encoding="utf-8", + ) + baselined = collect_and_write_baseline(proj, overwrite=True) + assert any(f.rule_id == "PY-WL-119" for f in baselined)