diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6ad5075..90366cc4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -188,6 +188,23 @@ jobs: shell: pwsh run: ./scripts/eval-repro-check.ps1 -SelfCheck + # Hard C04 L40 rootless / no-net CI evidence cross-reference. The blocking + # PR gate lives in `.github/workflows/rootless-nonet.yml`; this PR-only + # smoke re-runs the script's `-SelfCheck` mode so `ci.yml` retains its + # cross-reference anchor (per `scripts/rootless-nonet-check.ps1` and + # `docs/ops/sandbox-boundary.md` C04 L40 row). Does NOT enforce live + # rootless-only runners or blocking no-net on cargo-fetch jobs — those + # remain unpaid. + rootless-nonet-policy: + name: ci / rootless-nonet policy smoke + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: rootless / no-net SelfCheck + shell: pwsh + run: ./scripts/rootless-nonet-check.ps1 -SelfCheck + security: name: Security Scan needs: detect diff --git a/CHANGELOG.md b/CHANGELOG.md index 5aabf586..611d3edc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ Follows [Keep a Changelog](https://keepachangelog.com/); versioning is [SemVer]( - IntentState serde property surface (WBS-6.2 #426): `tests/properties.rs` adds `intent_state_json_round_trip_preserves_variant` (every variant serialises to its kebab-case `Debug` name and round-trips back) and `intent_state_terminal_invariant_holds_across_serde` (`is_terminal` agrees with the serde representation). Guards drift in the `#[serde(rename_all = "kebab-case")]` attribute. +- sl-viewer unfinished-tab property surface (WBS-6.2 #428): `crates/sl-viewer/tests/properties_viewer_unfinished_tab.rs` adds 6 proptest properties — `reason_label` is non-empty + injective; `unfinished_items` is deterministic, orders known `last_activity_ms` descending, ties break by `session_id` ascending, is length-monotonic w.r.t. input. + +- CI drift cleanups (WBS-6.2 #428): `scripts/fuzz-cadence-check.ps1` re-points the "PR smoke stays short" anchor from `ci.yml` (10 s budget) to `fuzz-blocking.yml` (30 s budget) since the PR smoke was consolidated there. `scripts/rootless-nonet-check.ps1` + `.github/workflows/ci.yml` restore the documented `rootless-nonet-policy` cross-reference smoke job, with the script's regex tightened so `continue-on-error` detection can't bleed across jobs. `tests/alloc_profile.rs` + `tests/replay_breadth.rs` clear `clippy::panic_in_if_then` / `clippy::unnecessary_trailing_comma` under `--all-targets --all-features`. + - Wave-44 plan landed: `WAVE44_SCOPE.md` + `docs/ops/WAVE44_PERT.md` enumerate 6 close-out lanes (3 machine, 3 human-gated) for the 6 unpaid residuals from Wave-43 (396/402 → 402/402 target). Theme: stack-stability closure + i18n migration + eval coverage + supply-chain signing. - Wave-44 reaudit (Wave-44-D): `audit/SCORECARD.md` refresh at commit `13c974f7` (machine-w44-reaudit); `docs/ops/TRACEABILITY.json` overall_audit wave=Wave-44 commit=13c974f7 (conservative hold at 396/402); `docs/ops/GAP_QA_MATRIX.md` C00 + C08 + PLAN-W8-B rows reflect Wave-44 closure (#368 W44-B6 corpus / #372 W44-B1 loom / #373 PERT correction). 2 of 3 machine lanes shipped 2026-07-24; remaining 6 raw pts across C04 L36 / C08 L76 / C11 L110. diff --git a/crates/sl-viewer/tests/properties_viewer_unfinished_tab.rs b/crates/sl-viewer/tests/properties_viewer_unfinished_tab.rs new file mode 100644 index 00000000..e457c260 --- /dev/null +++ b/crates/sl-viewer/tests/properties_viewer_unfinished_tab.rs @@ -0,0 +1,180 @@ +//! Property evidence for sl-viewer's `unfinished_tab` module. +//! +//! This file complements `crates/sl-viewer/src/unfinished_tab.rs`'s +//! per-function `#[cfg(test)] mod tests` block by pinning invariants +//! over the *full* shape of the inputs the module can receive (the unit +//! tests pin specific values; the property tests below pin invariants +//! over many values). +//! +//! `unfinished_tab` invariants: +//! * `reason_label(reason)` is total and deterministic — every +//! `UnfinishedReason` variant yields a non-empty, label-shaped string. +//! * `reason_label` is distinct — two different reasons produce two +//! different labels (no accidental aliasing in the UI badge). +//! * `unfinished_items` is monotonic w.r.t. `last_activity_ms`: +//! items with a known timestamp appear before items without one +//! (None → "unknown last activity" → sorts last), and among +//! timestamped items the order is descending by timestamp. +//! * `unfinished_items` is stable under session-id tiebreak: when two +//! items share the same `last_activity_ms`, the one with the smaller +//! session_id appears first (lexicographic, ascending). + +use proptest::prelude::*; +use session_ledger::domain::session::{Corpus, Message, Role, Session}; +use session_ledger::domain::worklog::{UnfinishedReason, UnfinishedWorkItem}; +use sl_viewer::unfinished_tab::{reason_label, unfinished_items}; + +// ── strategies ───────────────────────────────────────────────────────────── + +fn session_strategy() -> impl Strategy { + ( + // session_id — non-empty, identifier-shaped. + "[a-zA-Z0-9_-]{1,16}", + // 0..6 messages; mix of roles + content. Bounded so the + // `detect_unfinished` projection runs cheaply. + prop::collection::vec( + (0u8..5, "[ -~]{1,40}"), + 0..6, + ), + // last_activity_ms — Some(i64) or None. None is the "unknown + // last activity" sentinel the worklog projector uses. + prop::option::of(0i64..1_000_000_000_000), + ) + .prop_map(|(session_id, messages, ts_ms)| { + let mut session = Session::new(format!("sess-{session_id}"), Corpus::Forge); + for (role_idx, content) in messages { + let role = match role_idx % 5 { + 0 => Role::User, + 1 => Role::Assistant, + 2 => Role::Subagent, + 3 => Role::Tool, + _ => Role::System, + }; + let mut msg = Message::new(role, content); + msg.ts_ms = ts_ms; + session.messages.push(msg); + } + session + }) +} + +fn unfinished_reason_strategy() -> impl Strategy { + prop::sample::select(vec![ + UnfinishedReason::AwaitingAssistantResponse, + UnfinishedReason::InterruptedExecution, + UnfinishedReason::MissingCompletionMarker, + ]) +} + +// ── reason_label properties ───────────────────────────────────────────────── + +proptest! { + /// Property: `reason_label` is total — every variant produces a + /// non-empty, non-whitespace string. Catches a future addition of a + /// `UnfinishedReason` variant whose match arm maps to `""`. + #[test] + fn reason_label_is_non_empty_for_every_variant(reason in unfinished_reason_strategy()) { + let label = reason_label(reason); + prop_assert!(!label.is_empty(), "reason_label must not be empty for {reason:?}"); + prop_assert!(!label.trim().is_empty(), "reason_label must not be all-whitespace for {reason:?}"); + } + + /// Property: `reason_label` is injective — distinct reasons produce + /// distinct labels. Catches accidental aliasing where, e.g., two + /// reasons share the same badge text in the UI. + #[test] + fn reason_label_is_injective( + left in unfinished_reason_strategy(), + right in unfinished_reason_strategy(), + ) { + if left == right { + return Ok(()); + } + prop_assert_ne!(reason_label(left), reason_label(right)); + } +} + +// ── unfinished_items ordering properties ──────────────────────────────────── + +proptest! { + /// Property: `unfinished_items` is deterministic. Two calls on the + /// same input yield the same output (the function is pure). + #[test] + fn unfinished_items_is_deterministic( + sessions in prop::collection::vec(session_strategy(), 0..8), + ) { + let first = unfinished_items(&sessions); + let second = unfinished_items(&sessions); + prop_assert_eq!(first, second); + } + + /// Property: `unfinished_items` orders known timestamps descending. + /// Two items with the same `last_activity_ms` may appear in any order + /// (we don't constrain the tiebreak here; see next property). + #[test] + fn unfinished_items_orders_known_timestamps_descending( + sessions in prop::collection::vec(session_strategy(), 1..10), + ) { + let items = unfinished_items(&sessions); + + // Filter to items with a known timestamp so the descending + // invariant applies cleanly. + let with_ts: Vec<&UnfinishedWorkItem> = + items.iter().filter(|i| i.last_activity_ms.is_some()).collect(); + + for window in with_ts.windows(2) { + let prev = window[0].last_activity_ms.expect("filtered Some"); + let next = window[1].last_activity_ms.expect("filtered Some"); + prop_assert!( + prev >= next, + "known timestamps must be descending: {prev} came before {next}", + ); + } + } + + /// Property: `unfinished_items` ties on `last_activity_ms` break by + /// session_id ascending (lexicographic). When the `last_activity_ms` + /// field is equal, the item with the smaller session_id must appear + /// first. + #[test] + fn unfinished_items_ties_break_by_session_id_ascending( + sessions in prop::collection::vec(session_strategy(), 1..10), + ) { + let items = unfinished_items(&sessions); + + for window in items.windows(2) { + let prev = &window[0]; + let next = &window[1]; + match (prev.last_activity_ms, next.last_activity_ms) { + (Some(a), Some(b)) if a == b => { + prop_assert!( + prev.session_id <= next.session_id, + "tie on ts_ms must break by session_id asc: {} came before {}", + prev.session_id, + next.session_id, + ); + } + _ => { + // No invariant to check across mixed-Some/None or + // unequal timestamps (covered by other properties). + } + } + } + } + + /// Property: `unfinished_items` is length-monotonic w.r.t. input — + /// doubling the input sessions cannot produce fewer items than the + /// original (the projector is non-destructive). + #[test] + fn unfinished_items_is_length_monotonic( + base in prop::collection::vec(session_strategy(), 0..6), + more in prop::collection::vec(session_strategy(), 0..6), + ) { + let base_items = unfinished_items(&base).len(); + let combined_items = unfinished_items(&[base.clone(), more].concat()).len(); + prop_assert!( + combined_items >= base_items, + "appending sessions must not lose items: base={base_items}, combined={combined_items}", + ); + } +} diff --git a/docs/ops/TRACEABILITY.json b/docs/ops/TRACEABILITY.json index f80148a6..8bddca2e 100644 --- a/docs/ops/TRACEABILITY.json +++ b/docs/ops/TRACEABILITY.json @@ -309,6 +309,7 @@ "tests/properties.rs", "crates/sl-viewer/tests/properties_viewer.rs", "crates/sl-viewer/tests/properties_viewer_theme_url.rs", + "crates/sl-viewer/tests/properties_viewer_unfinished_tab.rs", "fuzz/fuzz_targets/okf_roundtrip.rs", "fuzz/fuzz_targets/jsonl_ingest.rs", ".github/workflows/ci.yml", diff --git a/docs/ops/WBS.md b/docs/ops/WBS.md index d24a6ccb..0f5117ae 100644 --- a/docs/ops/WBS.md +++ b/docs/ops/WBS.md @@ -29,7 +29,7 @@ without a new audit. | WBS-4.2 | P4 FTS recall via context-mode and explicit TUI decision | partial | human | `docs/DESIGN.md` §3, §7; `crates/sl-viewer/` | DESIGN P4 residual; C00, C11 | | WBS-5.1 | P5 deterministic dedup merge and crash/lost-work recovery E2E | done | machine | `src/domain/merge.rs`; `src/domain/worklog.rs`; `tests/merge_recovery.rs` | FR-011; T-024, T-035; C03 | | WBS-6.1 | P6 85% coverage gate and deterministic golden corpus | done | machine | `.github/workflows/ci.yml`; `tests/okf_golden.rs`; `tests/fixtures/okf/` | T-037, T-038; C01, C08 | -| WBS-6.2 | P6 property tests, fuzzing, race checks, and enforced performance budgets | partial | machine | `tests/properties.rs`; `crates/sl-viewer/tests/properties_viewer.rs`; `crates/sl-viewer/tests/properties_viewer_theme_url.rs`; `fuzz/fuzz_targets/okf_roundtrip.rs`; `fuzz/fuzz_targets/jsonl_ingest.rs`; `.github/workflows/ci.yml`; `.github/workflows/bench-gate.yml`; `docs/ops/perf-baseline.json`; `scripts/bench-gate.ps1`; `benches/pipeline.rs`; `tests/loom_model.rs` | DESIGN P6 residual; C00 L6-L8; C07 L66-L68; C08 L74; perf-budget enforced Wave-26 #223; p95 latency enforced Wave-30 #256; FSM properties Wave-31 #261; soft loom Wave-31 #264; viewer corpus_paths/parquet/settings properties #425; viewer theme + daemon_url properties #426; full loom/shuttle unpaid | +| WBS-6.2 | P6 property tests, fuzzing, race checks, and enforced performance budgets | partial | machine | `tests/properties.rs`; `crates/sl-viewer/tests/properties_viewer.rs`; `crates/sl-viewer/tests/properties_viewer_theme_url.rs`; `crates/sl-viewer/tests/properties_viewer_unfinished_tab.rs`; `fuzz/fuzz_targets/okf_roundtrip.rs`; `fuzz/fuzz_targets/jsonl_ingest.rs`; `.github/workflows/ci.yml`; `.github/workflows/bench-gate.yml`; `docs/ops/perf-baseline.json`; `scripts/bench-gate.ps1`; `benches/pipeline.rs`; `tests/loom_model.rs` | DESIGN P6 residual; C00 L6-L8; C07 L66-L68; C08 L74; perf-budget enforced Wave-26 #223; p95 latency enforced Wave-30 #256; FSM properties Wave-31 #261; soft loom Wave-31 #264; viewer corpus_paths/parquet/settings properties #425; viewer theme + daemon_url properties #427; viewer unfinished_tab properties + fuzz/rootless CI drift fixes #428; full loom/shuttle unpaid | ## audit-v38 waves diff --git a/scripts/fuzz-cadence-check.ps1 b/scripts/fuzz-cadence-check.ps1 index 963be01b..20c265a7 100644 --- a/scripts/fuzz-cadence-check.ps1 +++ b/scripts/fuzz-cadence-check.ps1 @@ -169,10 +169,14 @@ if ($workflow -notmatch 'okf_roundtrip' -or $workflow -notmatch 'jsonl_ingest') [void](Write-Check -Label "workflow exercises both fuzz targets" -Ok $true) Write-Host "PR smoke stays short:" -if ($ci -notmatch 'max_total_time=10') { - throw "ci.yml fuzz-smoke must keep -max_total_time=10 (do not slow PR CI here)." +# The PR smoke contract was consolidated into fuzz-blocking.yml (C07 L67 +# follow-up). The 10 s `ci.yml` `fuzz-smoke` job no longer exists; the +# blocking sustained PR budget is now `fuzz-blocking.yml` at 30 s / target. +# Enforce that here so PR CI doesn't silently lose its bounded PR fuzz. +if ($blockingWorkflow -notmatch 'max_total_time=30') { + throw "fuzz-blocking.yml must keep -max_total_time=30 for the PR fuzz budget (do not slow PR CI here)." } -[void](Write-Check -Label "ci.yml fuzz-smoke max_total_time=10" -Ok $true) +[void](Write-Check -Label "fuzz-blocking.yml PR fuzz max_total_time=30" -Ok $true) Write-Host "Fuzz cadence SelfCheck passed" exit 0 diff --git a/scripts/rootless-nonet-check.ps1 b/scripts/rootless-nonet-check.ps1 index dd804235..be461f0c 100644 --- a/scripts/rootless-nonet-check.ps1 +++ b/scripts/rootless-nonet-check.ps1 @@ -131,7 +131,15 @@ Test-DocContains -Doc $ciWf -Needle "rootless-nonet.yml" ` Test-DocContains -Doc $ciWf -Needle "rootless-nonet-check.ps1" ` -Label "ci.yml references rootless-nonet SelfCheck script" -Context ".github/workflows/ci.yml" -if ($ciWf -match '(?ms)^ rootless-nonet-policy:.*?continue-on-error:\s*true') { +# Extract just the rootless-nonet-policy: block (until the next top-level +# job or end of file) so the continue-on-error check can't bleed across +# into unrelated jobs like `security:`. (?ms) = multi-line + dotall so +# `.*?` can span newlines. +$policyBlockMatch = [regex]::Match( + $ciWf, + '(?ms)^ rootless-nonet-policy:.*?(?=^ [A-Za-z][\w-]*:\s|\z)' +) +if ($policyBlockMatch.Success -and $policyBlockMatch.Value -match 'continue-on-error:\s*true') { throw "ci.yml rootless-nonet-policy job must be blocking (no continue-on-error)." } [void](Write-Check -Label "ci.yml rootless-nonet-policy job is blocking when present" -Ok $true) diff --git a/tests/alloc_profile.rs b/tests/alloc_profile.rs index e6c7cb3a..1787096c 100644 --- a/tests/alloc_profile.rs +++ b/tests/alloc_profile.rs @@ -82,22 +82,27 @@ fn alloc_profile_script_self_check_parses_args_and_ceilings() { assert!(stdout.contains("Profiler: dhat"), "expected profiler echo, got:\n{stdout}"); } Err(error) => { + // Windows can't fall back to a portable load + print, so the + // spawn failure is unrecoverable there. Other targets run the + // portable fallback below. The clippy `panic_in_if_then` lint + // requires the if-then to have an else branch — fold the + // fallback into `else` so the panic sits on the windows-only path. if cfg!(target_os = "windows") { panic!("failed to spawn pwsh for self-check: {error}"); + } else { + let (max_bytes, total_blocks) = load_profile(); + println!( + "pwsh unavailable; running portable alloc-profile SelfCheck fallback.\nSelf-check passed\nMax bytes ceiling: {max_bytes}\nTotal blocks ceiling: {total_blocks}\nProfiler: dhat" + ); + assert!( + max_bytes >= 1024 * 1024, + "max_bytes ceiling should stay >= 1 MiB for debug smoke (got {max_bytes})" + ); + assert!( + total_blocks >= 1_000, + "total_blocks ceiling should stay generous (got {total_blocks})" + ); } - - let (max_bytes, total_blocks) = load_profile(); - println!( - "pwsh unavailable; running portable alloc-profile SelfCheck fallback.\nSelf-check passed\nMax bytes ceiling: {max_bytes}\nTotal blocks ceiling: {total_blocks}\nProfiler: dhat" - ); - assert!( - max_bytes >= 1024 * 1024, - "max_bytes ceiling should stay >= 1 MiB for debug smoke (got {max_bytes})" - ); - assert!( - total_blocks >= 1_000, - "total_blocks ceiling should stay generous (got {total_blocks})" - ); } } } diff --git a/tests/replay_breadth.rs b/tests/replay_breadth.rs index 4842147a..913a8207 100644 --- a/tests/replay_breadth.rs +++ b/tests/replay_breadth.rs @@ -119,7 +119,7 @@ fn w44_b6_each_generated_fixture_is_well_formed_okf_v1() { Err(e) => bad.push((slug.to_string(), format!("json parse: {e}"))), } } - assert!(bad.is_empty(), "W44-B6 fixtures failed shape check: {bad:#?}",); + assert!(bad.is_empty(), "W44-B6 fixtures failed shape check: {bad:#?}"); assert_eq!(parsed, W44_B6_SLUGS.len(), "parsed count mismatch"); } @@ -128,18 +128,18 @@ fn w44_b6_generator_script_present_and_importable() { // The generator is a Python script, not part of the Rust crate, but its // presence on disk is part of the W44-B6 deliverable. let script = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("scripts/corpus-generate.py"); - assert!(script.is_file(), "expected corpus generator at {}", script.display(),); + assert!(script.is_file(), "expected corpus generator at {}", script.display()); let raw = std::fs::read_to_string(&script).expect("read corpus-generate.py"); assert!(raw.contains("OKF_VERSION"), "generator must define OKF_VERSION"); - assert!(raw.contains("FIXTURE_SPECS"), "generator must declare FIXTURE_SPECS",); - assert!(raw.contains("FAILURE_FIXTURES"), "generator must isolate failure-mode fixtures",); + assert!(raw.contains("FIXTURE_SPECS"), "generator must declare FIXTURE_SPECS"); + assert!(raw.contains("FAILURE_FIXTURES"), "generator must isolate failure-mode fixtures"); } #[test] fn w44_b6_corpus_breadth_doc_present() { let doc = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("docs/ops/corpus-breadth.md"); - assert!(doc.is_file(), "expected docs/ops/corpus-breadth.md at {}", doc.display(),); + assert!(doc.is_file(), "expected docs/ops/corpus-breadth.md at {}", doc.display()); let raw = std::fs::read_to_string(&doc).expect("read corpus-breadth.md"); assert!(raw.contains("C08 L73"), "doc must reference C08 L73 pillar"); - assert!(raw.contains("Wave-44"), "doc must reference Wave-44 (W44-B6) close-out",); + assert!(raw.contains("Wave-44"), "doc must reference Wave-44 (W44-B6) close-out"); }