diff --git a/CHANGELOG.md b/CHANGELOG.md index 392efd5e..b1644c78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/); versioning is [SemVer]( - sl-viewer bundle-diff property surface (WBS-6.2 #434): `crates/sl-viewer/tests/properties_viewer_bundle_diff.rs` adds 10 proptest properties — `diff_fields` returns the documented field set in stable order, is reflexive on `a == a`, idempotent on `a == a.clone()`, value-flipped symmetric (`diff_fields(b, a)` swaps `value_a`/`value_b` while `differs` matches), `differs` matches `value_a != value_b`, and `Option` fields render the em-dash fallback when both sides are `None`. `OkfBundle::from_bundle` properties pin the reduction: `message_count` matches slice count, `has_acceptance`/`has_contract` reflect kind presence, `token_count` falls back to 0 when no Intent slice carries numeric `user_turn_count`, `source_id` carries through unchanged. +- sl-viewer corpus_paths round-trip property surface (WBS-6.2 #446): `crates/sl-viewer/tests/properties_viewer_corpus_paths.rs` adds 10 proptest properties — `CorpusPathConfig::empty()` produces a config with zero custom paths; `Default::default()` equals `empty()`; `is_empty()` is true iff `custom_paths` is empty. JSON round-trip preserves `custom_paths` exactly (order-sensitive), is idempotent, and preserves length. `save_config_to(c, p); load_config_from(p)` round-trips equal configs; missing files yield `Ok(empty())`; junk JSON surfaces `Err`; `save_config_to` creates missing parent directories. + - 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_corpus_paths.rs b/crates/sl-viewer/tests/properties_viewer_corpus_paths.rs new file mode 100644 index 00000000..23edd2c5 --- /dev/null +++ b/crates/sl-viewer/tests/properties_viewer_corpus_paths.rs @@ -0,0 +1,208 @@ +//! Property evidence for sl-viewer's `corpus_paths` module. +//! +//! Integration tests. The unit tests in `corpus_paths.rs` pin specific +//! values; these properties pin invariants over the full shape of +//! inputs the helpers can receive. +//! +//! `CorpusPathConfig` invariants: +//! * `empty()` produces a config with zero custom paths. +//! * `is_empty()` is true iff `custom_paths.is_empty()`. +//! * `Default::default()` equals `empty()`. +//! * JSON round-trip preserves `custom_paths` exactly (order-sensitive). +//! +//! `save_config_to` / `load_config_from` invariants: +//! * Round-trip: `save_config_to(c, p); load_config_from(p) == c`. +//! * Missing file yields `Ok(empty())` (no error surfaced). +//! * Junk JSON surfaces an `Err` (never silently drops the file). +//! * `save_config_to` creates missing parent directories. +//! +//! proptest is added to `sl-viewer/[dev-dependencies]` (mirroring the +//! workspace root); see PR #425 for the initial wiring. + +use std::fs; +use std::path::PathBuf; + +use proptest::prelude::*; +use sl_viewer::corpus_paths::{ + load_config_from, save_config_to, CorpusPathConfig, +}; + +// ── strategies ────────────────────────────────────────────────────────────── + +/// Strategy for a list of relative / absolute path-like strings. +fn path_strategy() -> impl Strategy { + prop::string::string_regex("[/a-zA-Z0-9._-]{1,40}") + .expect("valid regex") + .prop_map(PathBuf::from) +} + +/// Strategy for a `CorpusPathConfig` with 0..6 paths. +fn config_strategy() -> impl Strategy { + prop::collection::vec(path_strategy(), 0..6).prop_map(|paths| CorpusPathConfig { + custom_paths: paths, + }) +} + +/// Strategy for junk JSON content that is *not* valid `CorpusPathConfig`. +fn junk_json_strategy() -> impl Strategy { + prop::sample::select(vec![ + // Plain garbage. + "not json at all".to_owned(), + // Empty string. + String::new(), + // Truncated object. + r#"{"custom_paths":["#.to_owned(), + // Wrong shape — `custom_paths` as a number. + r#"{"custom_paths": 42}"#.to_owned(), + // Wrong shape — `custom_paths` as an object. + r#"{"custom_paths": {"k": "v"}}"#.to_owned(), + // Trailing junk. + r#"{"custom_paths": []} trailing junk"#.to_owned(), + ]) +} + +// ── CorpusPathConfig pure reductions ──────────────────────────────────────── + +proptest! { + /// Property: `empty()` returns a config with zero `custom_paths`. + #[test] + fn empty_has_no_custom_paths(_i in 0u8..4) { + let config = CorpusPathConfig::empty(); + prop_assert!(config.custom_paths.is_empty()); + prop_assert!(config.is_empty()); + } + + /// Property: `Default::default()` equals `empty()`. + #[test] + fn default_equals_empty(_i in 0u8..4) { + let a: CorpusPathConfig = CorpusPathConfig::default(); + let b: CorpusPathConfig = CorpusPathConfig::empty(); + prop_assert_eq!(a, b); + } + + /// Property: `is_empty()` is true iff `custom_paths` is empty. + #[test] + fn is_empty_iff_no_paths(config in config_strategy()) { + let expected = config.custom_paths.is_empty(); + prop_assert_eq!(config.is_empty(), expected); + } + + /// Property: JSON round-trip preserves `custom_paths` exactly + /// (order-sensitive — the on-disk contract is `Vec`). + #[test] + fn json_round_trip_preserves_paths(config in config_strategy()) { + let json = serde_json::to_string(&config).expect("serialize"); + let restored: CorpusPathConfig = serde_json::from_str(&json).expect("parse"); + prop_assert_eq!(restored, config); + } + + /// Property: JSON round-trip is idempotent — round-tripping a + /// restored config yields the same JSON bytes. + #[test] + fn json_round_trip_idempotent(config in config_strategy()) { + let json1 = serde_json::to_string(&config).expect("serialize 1"); + let restored: CorpusPathConfig = serde_json::from_str(&json1).expect("parse 1"); + let json2 = serde_json::to_string(&restored).expect("serialize 2"); + prop_assert_eq!(json1, json2); + } + + /// Property: `len(custom_paths)` is preserved through JSON + /// round-trip (catches drift where the round-trip drops / dedups + /// path entries). + #[test] + fn json_round_trip_preserves_len(config in config_strategy()) { + let json = serde_json::to_string(&config).expect("serialize"); + let restored: CorpusPathConfig = serde_json::from_str(&json).expect("parse"); + prop_assert_eq!(restored.custom_paths.len(), config.custom_paths.len()); + } +} + +// ── save_config_to / load_config_from ─────────────────────────────────────── + +proptest! { + /// Property: `save_config_to` followed by `load_config_from` yields + /// an equal config (round-trip). This is the contract the viewer's + /// "user picks a folder" → "viewer reads it back" flow depends on. + #[test] + fn save_load_round_trip(config in config_strategy(), i in 0u8..3) { + let dir = std::env::temp_dir().join(format!( + "sessionledger-corpus-paths-roundtrip-{}-{}", + std::process::id(), + i, + )); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("mkdir"); + let path = dir.join("corpus_paths.json"); + + save_config_to(&config, &path).expect("save"); + let restored = load_config_from(&path).expect("load"); + + prop_assert_eq!(restored, config); + + let _ = fs::remove_dir_all(&dir); + } + + /// Property: `load_config_from()` returns `Ok(empty())` + /// — the viewer's first launch on a new machine must not fail + /// just because the user hasn't picked anything yet. + #[test] + fn missing_file_yields_empty_config(i in 0u8..4) { + let dir = std::env::temp_dir().join(format!( + "sessionledger-corpus-paths-missing-{}-{}", + std::process::id(), + i, + )); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("mkdir"); + let path = dir.join("does-not-exist.json"); + + let result = load_config_from(&path); + prop_assert!(result.is_ok(), "missing file must yield Ok, got {:?}", result.err()); + let config = result.unwrap(); + prop_assert!(config.is_empty()); + + let _ = fs::remove_dir_all(&dir); + } + + /// Property: `load_config_from()` surfaces an `Err` — the + /// viewer must never silently drop the user's picks on a + /// malformed file. + #[test] + fn junk_json_surfaces_error(junk in junk_json_strategy(), i in 0u8..3) { + let dir = std::env::temp_dir().join(format!( + "sessionledger-corpus-paths-junk-{}-{}", + std::process::id(), + i, + )); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("mkdir"); + let path = dir.join("corpus_paths.json"); + fs::write(&path, junk.as_bytes()).expect("write junk"); + + let result = load_config_from(&path); + prop_assert!(result.is_err(), "junk JSON must surface as Err, got {result:?}"); + + let _ = fs::remove_dir_all(&dir); + } + + /// Property: `save_config_to` creates missing parent directories + /// (the viewer may save into a fresh `~/.../SessionLedger/` that + /// doesn't exist yet). + #[test] + fn save_creates_parent_directories(config in config_strategy(), i in 0u8..3) { + let dir = std::env::temp_dir().join(format!( + "sessionledger-corpus-paths-nested-{}-{}", + std::process::id(), + i, + )); + let _ = fs::remove_dir_all(&dir); + let nested = dir.join("a").join("b").join("c").join("corpus_paths.json"); + prop_assert!(!nested.parent().expect("parent").exists()); + + save_config_to(&config, &nested).expect("save nested"); + + prop_assert!(nested.exists()); + + let _ = fs::remove_dir_all(&dir); + } +} diff --git a/docs/ops/TRACEABILITY.json b/docs/ops/TRACEABILITY.json index 0ddfd26c..49a91cdf 100644 --- a/docs/ops/TRACEABILITY.json +++ b/docs/ops/TRACEABILITY.json @@ -321,6 +321,7 @@ "crates/sl-viewer/tests/properties_viewer_corpus_cta.rs", "crates/sl-viewer/tests/properties_viewer_theme.rs", "crates/sl-viewer/tests/properties_viewer_settings.rs", + "crates/sl-viewer/tests/properties_viewer_corpus_paths.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 124d69da..afcb2cc2 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`; `crates/sl-viewer/tests/properties_viewer_unfinished_tab.rs`; `crates/sl-viewer/tests/properties_viewer_timeline.rs`; `crates/sl-viewer/tests/properties_viewer_search_memory.rs`; `crates/sl-viewer/tests/properties_viewer_history.rs`; `crates/sl-viewer/tests/properties_viewer_web_exports.rs`; `crates/sl-viewer/tests/properties_viewer_bundle_detail.rs`; `crates/sl-viewer/tests/properties_viewer_bundle_diff.rs`; `crates/sl-viewer/tests/properties_viewer_mock_data.rs`; `crates/sl-viewer/tests/properties_viewer_cli_help.rs`; `crates/sl-viewer/tests/properties_viewer_corpus_cta.rs`; `crates/sl-viewer/tests/properties_viewer_theme.rs`; `crates/sl-viewer/tests/properties_viewer_settings.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; viewer bundle_diff + timeline properties + web_exports/hmetic-pin cleanups #432; viewer bundle_diff properties #434; viewer search/memory properties #435; viewer history_tab properties #444; viewer web_exports properties #437; viewer bundle_list + detail_pane properties #436; viewer mock_data fixture properties #451; viewer cli_help / command_palette properties #452; viewer corpus_cta constants properties #453; viewer theme + settings properties #454; 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`; `crates/sl-viewer/tests/properties_viewer_timeline.rs`; `crates/sl-viewer/tests/properties_viewer_search_memory.rs`; `crates/sl-viewer/tests/properties_viewer_history.rs`; `crates/sl-viewer/tests/properties_viewer_web_exports.rs`; `crates/sl-viewer/tests/properties_viewer_bundle_detail.rs`; `crates/sl-viewer/tests/properties_viewer_bundle_diff.rs`; `crates/sl-viewer/tests/properties_viewer_mock_data.rs`; `crates/sl-viewer/tests/properties_viewer_cli_help.rs`; `crates/sl-viewer/tests/properties_viewer_corpus_cta.rs`; `crates/sl-viewer/tests/properties_viewer_theme.rs`; `crates/sl-viewer/tests/properties_viewer_settings.rs`; `crates/sl-viewer/tests/properties_viewer_corpus_paths.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; viewer bundle_diff + timeline properties + web_exports/hmetic-pin cleanups #432; viewer bundle_diff properties #434; viewer search/memory properties #435; viewer history_tab properties #444; viewer web_exports properties #437; viewer bundle_list + detail_pane properties #436; viewer mock_data fixture properties #451; viewer cli_help / command_palette properties #452; viewer corpus_cta constants properties #453; viewer theme + settings properties #454; viewer corpus_paths round-trip properties #446; full loom/shuttle unpaid | ## audit-v38 waves