diff --git a/CHANGELOG.md b/CHANGELOG.md index 220c40ee..ac5bc9f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,18 @@ Follows [Keep a Changelog](https://keepachangelog.com/); versioning is [SemVer]( - sl-viewer menu id taxonomy property surface (WBS-6.2 #457): `crates/sl-viewer/tests/properties_viewer_menu.rs` adds 5 proptest properties — every documented menu id (9 of them: `ID_APP_ABOUT`, `ID_APP_SETTINGS`, `ID_FILE_RELOAD_DISCOVERY`, `ID_FILE_SETTINGS`, `ID_EDIT_FIND`, `ID_VIEW_RELOAD`, `ID_VIEW_TOGGLE_THEME`, `ID_VIEW_COMMAND_PALETTE`, `ID_HELP_TOGGLE`) is non-empty, kebab-case ASCII, carries the `sl-viewer.` prefix, and is unique across the set so a muda event resolves to one DOM action. The menu taxonomy has exactly 9 documented ids so the operator documentation can be re-aligned if it drifts. +- sl-viewer async_states SkeletonLayout property surface (WBS-6.2 #458): `crates/sl-viewer/tests/properties_viewer_async_states.rs` adds 7 proptest properties — `SkeletonLayout::default()` is `Bundles`, exposes exactly three variants (`Bundles`, `ListDetail`, `StreamFeed`), and every variant's `Debug` label is non-empty, single-line, and matches one of the documented names. `list_rows.clamp(3, 6)` lands in `[3, 6]` for every input, is monotonic non-decreasing, and has the documented fixed points (`0` / `2` → `3`, `6` / `usize::MAX` → `6`). + +- session-ledger OKF document validator property surface (WBS-6.2 #459): `crates/sl-viewer/tests/properties_session_ledger_okf.rs` adds 12 proptest properties pinning `session_ledger::OkfDocument::new` and `validate_okf_document` (the OKF v1 graph validator that backs the export / wiki / search pipeline). `new(b, c)` always produces `okf = "1.0"`, propagates `bundle.source_id` into `source_id` and `provenance.source_id`, propagates `c` into `provenance.corpus`, and starts with empty entities/relations/tags. `validate_okf_document` reports exactly one `unsupported_version` error per non-`"1.0"` `okf` (with the offending version in the message), exactly one `source_id_mismatch` error per provenance/source mismatch, exactly one `duplicate_entity_id` error per duplicate entity occurrence, `dangling_relation_source`/`dangling_relation_target` errors with field paths, and every `OkfValidationError` carries non-empty `field`/`code`/`message`. + +- session-ledger worklog projector property surface (WBS-6.2 #460): `crates/sl-viewer/tests/properties_session_ledger_worklog.rs` adds 11 proptest properties pinning the crash-recovery / lost-work projector. Empty sessions project `None`; final user turns project as `AwaitingAssistantResponse`; final tool/subagent turns project as `InterruptedExecution`; final assistant turns with one of the 9 documented completion markers (`complete`, `completed`, `done`, `[completed]`, ``, `status: complete`, `status: completed`, `task complete`, `task completed`) project as `None`; final assistant turns without any marker project as `MissingCompletionMarker`; the summary never exceeds 241 characters, is single-line, and carries the originating session id, corpus, and message count. `project_unfinished_work` returns one item per unfinished session in input order and is deterministic. `WorklogProjection::from_session` carries `message_count` and matches `detect_unfinished` exactly. + +- session-ledger OKF export adapter property surface (WBS-6.2 #461): `crates/sl-viewer/tests/properties_session_ledger_export.rs` adds 8 proptest properties pinning `session_ledger::export_to_okf` (the main entry point of the OKF v1 export pipeline). `export_to_okf` always produces `okf = "1.0"` and propagates `bundle.source_id` into `source_id` and `provenance.source_id`, propagates the corpus arg into `provenance.corpus`, and produces zero entities/relations/tags on an empty bundle. Every exported document passes `validate_okf_document` (validator contract for downstream consumers). `export_to_okf` is deterministic across calls. Intent bundles always emit exactly one goal entity (label = goal) plus one `acceptance` entity per acceptance signal and one `constraint` entity per constraint. Context bundles emit exactly one `resource` entity when `cwd` is present. Acceptance bundles emit exactly one `gate` entity with `label = "resume-gate"` and `properties.ready = true` / `properties.scope_sized = true`. The exporter never produces duplicate entity ids across multiple intents/contexts/acceptances/contracts. + +- session-ledger distill compiler property surface (WBS-6.2 #462): `crates/sl-viewer/tests/properties_session_ledger_distill.rs` adds 9 proptest properties pinning `session_ledger::distill::compile` and `compile_and_store` (the deterministic compilation pipeline that turns a `Session` into a `ContinuationBundle` + episodic memories). `compile` always produces a bundle whose `source_id` equals `session.id`, is injectable (carries an `Acceptance` slice), emits one slice for every documented kind (`Acceptance` / `Intent` / `Context` / `Contract` / `Provenance` / `Worklog`) — even for empty sessions — and whose `total_token_estimate()` equals the sum of per-slice `token_estimate` values. `compile` is deterministic across calls. The `Worklog` slice's body deserializes to a `WorklogProjection` whose `message_count` equals `session.messages.len()`. `compile_and_store` returns an injectable bundle with the input `source_id`, writes exactly 3 episodic memories (intent / contract / context) to the memory store, and is deterministic across fresh stores. + +- session-ledger heuristic intent extractor property surface (WBS-6.2 #463): `crates/sl-viewer/tests/properties_session_ledger_intent.rs` adds 10 proptest properties pinning `session_ledger::distill::extractor::HeuristicIntentExtractor::extract_intent` (the P1 SSOT for what the user wants — drives resume prompt, search index, and wiki/docs view). `user_turn_count` always equals the count of `Role::User` messages and ignores `Assistant` / `Subagent` / `Tool` / `System` messages. Empty sessions produce an empty `Intent`. Repeated acceptance / constraint patterns are deduplicated. Every documented acceptance pattern (16 of them: `looks good`, `works`, `that's correct`, `correct`, `done`, `fixed`, `passes`, `approved`, `looks right`, `looks great`, `all good`, `that works`, `nice`, `perfect`, `exactly`, `confirmed`) and every documented constraint pattern (19 of them: `don't change`, `do not change`, `must not`, `should not`, `keep`, `maintain`, `preserve`, `never`, `don't touch`, `do not touch`, `don't modify`, `do not modify`, `only`, `but don't`, `but do not`, `without changing`, `without modifying`, `leave alone`, `leave as is`) is recognized in any user message. Labeled `Goal:` / `Objective:` / `Task:` lines win over surrounding preamble. Labeled `Constraint:` / `Requirement:` / `Boundary:` lines contribute their full text. The extractor is deterministic across calls. + - Commit signing header scan (C04 L34): `commit-signing-check.ps1` reads bounded commit headers via line-scanner (no unbounded `git cat-file` buffers or `(?ms)` regex); `-SelfCheck` + `tests/commit_signing_check.rs`. - Loom permutation CI timeout (P0 stability): split blocking `loom-permutation.yml` into core + per-daemon `loom_model` jobs with `LOOM_MAX_PREEMPTIONS` on broadcast/pipeline/shutdown; mirror in soft `loom-smoke.yml` so Wave-40 tokio-shaped daemon graph tests no longer exceed single-job ceilings. diff --git a/crates/sl-viewer/tests/properties_session_ledger_distill.rs b/crates/sl-viewer/tests/properties_session_ledger_distill.rs new file mode 100644 index 00000000..a8081438 --- /dev/null +++ b/crates/sl-viewer/tests/properties_session_ledger_distill.rs @@ -0,0 +1,198 @@ +//! Property evidence for `session_ledger::distill::compile` and +//! `compile_and_store` (the deterministic compilation pipeline +//! that turns a `Session` into a `ContinuationBundle`). +//! +//! The compile pipeline is the SSOT for "what does an injectable +//! bundle look like?". If it stops emitting the documented slice +//! kinds, or `is_injectable()` ever returns false, downstream +//! resume / inject / search all break. + +use proptest::prelude::*; +use session_ledger::distill::{compile, compile_and_store, DistillOutput}; +use session_ledger::domain::bundle::BundleKind; +use session_ledger::domain::session::{Corpus, Message, Role, Session}; +use session_ledger::ports::adapters::InMemoryMemoryStore; +use session_ledger::ports::MemoryStore; + +const ALL_KINDS: &[BundleKind] = &[ + BundleKind::Acceptance, + BundleKind::Intent, + BundleKind::Context, + BundleKind::Contract, + BundleKind::Provenance, + BundleKind::Worklog, +]; + +proptest! { + /// `compile(session)` always returns a bundle whose `source_id` + /// equals `session.id`. + #[test] + fn compile_carries_session_id( + session_id in "[a-zA-Z0-9_-]{1,16}", + ) { + let session = Session::new(&session_id, Corpus::Forge); + let bundle = compile(&session); + prop_assert_eq!(bundle.source_id, session.id); + } + + /// `compile(session)` always returns an injectable bundle + /// (i.e., it carries an `Acceptance` slice). This is the + /// load-bearing contract for resume. + #[test] + fn compile_is_always_injectable( + session_id in "[a-zA-Z0-9_-]{1,16}", + corpus_idx in 0..4_usize, + messages in proptest::collection::vec( + (0..5_usize, role_choice()), + 0..5, + ), + ) { + let corpus = [Corpus::Forge, Corpus::Codex, Corpus::Cursor, Corpus::ClaudeCode][corpus_idx]; + let mut session = Session::new(&session_id, corpus); + for (i, role) in messages { + session.messages.push(Message::new(role, format!("message-{i}"))); + } + let bundle = compile(&session); + prop_assert!(bundle.is_injectable(), + "compile({session_id}) did not produce an injectable bundle"); + prop_assert!(bundle.has(BundleKind::Acceptance)); + } + + /// `compile(session)` always emits one slice for every documented + /// kind — even when the session is empty. + #[test] + fn compile_emits_all_documented_slice_kinds( + session_id in "[a-zA-Z0-9_-]{1,16}", + corpus_idx in 0..4_usize, + ) { + let corpus = [Corpus::Forge, Corpus::Codex, Corpus::Cursor, Corpus::ClaudeCode][corpus_idx]; + let session = Session::new(&session_id, corpus); + let bundle = compile(&session); + for kind in ALL_KINDS { + prop_assert!(bundle.has(*kind), + "compile({session_id}) missing {kind:?} slice"); + } + } + + /// `compile(session)` always returns a bundle whose + /// `total_token_estimate()` equals the sum of per-slice + /// `token_estimate` values. + #[test] + fn compile_token_total_equals_sum( + n_messages in 0_usize..5, + ) { + let mut session = Session::new("token-sum", Corpus::Forge); + for i in 0..n_messages { + session.messages.push(Message::new(Role::User, format!("user message {i}"))); + } + let bundle = compile(&session); + let sum: u32 = bundle.bundles.iter().map(|slice| slice.token_estimate).sum(); + prop_assert_eq!(bundle.total_token_estimate(), sum); + } + + /// `compile(session)` is deterministic across calls. + #[test] + fn compile_is_deterministic( + n_messages in 0_usize..4, + corpus_idx in 0..4_usize, + ) { + let corpus = [Corpus::Forge, Corpus::Codex, Corpus::Cursor, Corpus::ClaudeCode][corpus_idx]; + let mut session = Session::new("det", corpus); + for i in 0..n_messages { + session.messages.push(Message::new(Role::User, format!("msg-{i}"))); + } + let a = compile(&session); + let b = compile(&session); + prop_assert_eq!(a, b); + } + + /// The `Worklog` slice's body deserializes to a `WorklogProjection` + /// whose `message_count` equals `session.messages.len()`. + #[test] + fn compile_worklog_carries_message_count( + n_messages in 0_usize..5, + ) { + let mut session = Session::new("wl", Corpus::Forge); + for i in 0..n_messages { + session.messages.push(Message::new(Role::User, format!("m-{i}"))); + } + let bundle = compile(&session); + let worklog = bundle + .bundles + .iter() + .find(|slice| slice.kind == BundleKind::Worklog) + .expect("compile must emit Worklog slice"); + let projection: session_ledger::domain::worklog::WorklogProjection = + serde_json::from_value(worklog.body.clone()).expect("worklog body should deserialize"); + prop_assert_eq!(projection.message_count, n_messages); + } + + /// `compile_and_store` returns an injectable bundle with the same + /// `source_id` as the input session. + #[test] + fn compile_and_store_returns_injectable_bundle( + session_id in "[a-zA-Z0-9_-]{1,16}", + n_messages in 0_usize..3, + ) { + let mut session = Session::new(&session_id, Corpus::Forge); + for i in 0..n_messages { + session.messages.push(Message::new(Role::User, format!("do it {i}"))); + } + let store = InMemoryMemoryStore::default(); + let output: DistillOutput = compile_and_store(&session, &store) + .expect("compile_and_store must succeed for well-formed session"); + let source_id = output.bundle.source_id.clone(); + prop_assert_eq!(source_id, session.id); + prop_assert!(output.bundle.is_injectable()); + } + + /// `compile_and_store` writes exactly 3 episodic memories + /// (intent, contract, context) to the memory store. + #[test] + fn compile_and_store_writes_three_memories( + n_messages in 0_usize..3, + ) { + let mut session = Session::new("store-3", Corpus::Forge); + for i in 0..n_messages { + session.messages.push(Message::new(Role::User, format!("Goal: m-{i}\nConstraint: c-{i}"))); + } + let store = InMemoryMemoryStore::default(); + let output = compile_and_store(&session, &store).expect("compile_and_store"); + prop_assert_eq!(output.memories.len(), 3, + "expected 3 memories (intent / contract / context), got {}", output.memories.len()); + let recalled = store + .recall("session/store-3/episodic", 10) + .expect("recall stored facts"); + prop_assert_eq!(recalled.len(), 3); + } + + /// `compile_and_store` is deterministic — same session + store + /// produce the same DistillOutput. + #[test] + fn compile_and_store_is_deterministic( + n_messages in 0_usize..3, + ) { + let mut session = Session::new("det-store", Corpus::Forge); + for i in 0..n_messages { + session.messages.push(Message::new(Role::User, format!("m {i}"))); + } + let store_a = InMemoryMemoryStore::default(); + let store_b = InMemoryMemoryStore::default(); + let out_a = compile_and_store(&session, &store_a).expect("compile_and_store a"); + let out_b = compile_and_store(&session, &store_b).expect("compile_and_store b"); + prop_assert_eq!(out_a.bundle, out_b.bundle); + prop_assert_eq!(out_a.memories.len(), out_b.memories.len()); + } +} + +// ── Strategy helpers ─────────────────────────────────────────────────────── + +fn role_choice() -> impl Strategy { + prop::sample::select(vec![ + Role::User, + Role::Assistant, + Role::Subagent, + Role::Tool, + Role::System, + ]) +} diff --git a/crates/sl-viewer/tests/properties_session_ledger_export.rs b/crates/sl-viewer/tests/properties_session_ledger_export.rs new file mode 100644 index 00000000..62c2b541 --- /dev/null +++ b/crates/sl-viewer/tests/properties_session_ledger_export.rs @@ -0,0 +1,255 @@ +//! Property evidence for `session_ledger::export_to_okf` +//! (the OKF export adapter that compiles a `ContinuationBundle` +//! into an `OkfDocument`). +//! +//! `export_to_okf` is the main entry point for the OKF v1 export +//! pipeline (CLI `--okf`, wiki writer, search index publisher). The +//! output must always pass `validate_okf_document` so downstream +//! consumers can rely on it. + +use proptest::prelude::*; +use session_ledger::domain::bundle::{Bundle, BundleKind, ContinuationBundle}; +use session_ledger::export::okf::export_to_okf; +use session_ledger::validate_okf_document; + +const CORPORA: &[&str] = &["forge", "codex", "claude-code", "cursor", "factory-droid"]; + +proptest! { + /// `export_to_okf` always produces an OKF v1 document (okf = "1.0") + /// carrying the originating bundle `source_id`. + #[test] + fn export_carries_source_id_and_version( + source_id in "[a-zA-Z0-9_-]{1,16}", + corpus_idx in 0..CORPORA.len(), + ) { + let corpus = CORPORA[corpus_idx]; + let bundle = ContinuationBundle::new(&source_id); + let doc = export_to_okf(&bundle, corpus); + prop_assert_eq!(doc.okf, "1.0"); + prop_assert_eq!(doc.source_id, bundle.source_id); + prop_assert_eq!(doc.provenance.corpus, corpus); + prop_assert_eq!(doc.provenance.source_id, source_id); + } + + /// `export_to_okf` on an empty bundle produces zero entities, + /// zero relations, and zero tags. + #[test] + fn export_empty_bundle_is_empty_graph( + source_id in "[a-zA-Z0-9_-]{1,16}", + corpus_idx in 0..CORPORA.len(), + ) { + let corpus = CORPORA[corpus_idx]; + let bundle = ContinuationBundle::new(&source_id); + let doc = export_to_okf(&bundle, corpus); + prop_assert!(doc.entities.is_empty()); + prop_assert!(doc.relations.is_empty()); + prop_assert!(doc.tags.is_empty()); + } + + /// Every exported document passes `validate_okf_document` + /// (the validator contract for downstream consumers). + #[test] + fn export_output_always_validates( + source_id in "[a-zA-Z0-9_-]{1,16}", + n_intent in 0_usize..3, + n_context in 0_usize..3, + n_acceptance in 0_usize..3, + n_contract in 0_usize..3, + ) { + let mut bundle = ContinuationBundle::new(&source_id); + for i in 0..n_intent { + bundle.push(Bundle::new( + BundleKind::Intent, + serde_json::json!({ + "goal": format!("goal-{i}"), + "acceptance_signals": ["looks good"], + "constraints": ["do not break the schema"], + "user_turn_count": 2_u64, + }), + )); + } + for i in 0..n_context { + bundle.push(Bundle::new( + BundleKind::Context, + serde_json::json!({ + "cwd": format!("/work/{i}"), + "title": format!("ctx-{i}"), + }), + )); + } + for i in 0..n_acceptance { + bundle.push(Bundle::new( + BundleKind::Acceptance, + serde_json::json!({ + "ready": true, + "scope_sized": true, + "label": format!("accept-{i}"), + }), + )); + } + for i in 0..n_contract { + bundle.push(Bundle::new( + BundleKind::Contract, + serde_json::json!({ + "criteria": [format!("criterion-{i}")], + }), + )); + } + let doc = export_to_okf(&bundle, "forge"); + let errors = validate_okf_document(&doc); + prop_assert!(errors.is_empty(), + "exported OKF document has validator errors: {errors:?}"); + } + + /// `export_to_okf` is deterministic: two calls on the same bundle + /// produce byte-equal documents. + #[test] + fn export_is_deterministic( + source_id in "[a-zA-Z0-9_-]{1,16}", + n_bundles in 0_usize..4, + ) { + let mut bundle = ContinuationBundle::new(&source_id); + for i in 0..n_bundles { + bundle.push(Bundle::new( + BundleKind::Intent, + serde_json::json!({ + "goal": format!("goal-{i}"), + "acceptance_signals": ["looks good"], + "constraints": [], + "user_turn_count": i as u64, + }), + )); + } + let a = export_to_okf(&bundle, "forge"); + let b = export_to_okf(&bundle, "forge"); + prop_assert_eq!(a, b); + } + + /// Every Intent bundle contributes at least one entity + /// (the goal entity) and the goal's label is the bundle's goal. + #[test] + fn intent_bundle_emits_goal_entity( + goal in "[a-zA-Z0-9 .,!?-]{1,40}", + n_signals in 0_usize..4, + n_constraints in 0_usize..4, + ) { + let signals: Vec = (0..n_signals).map(|i| format!("signal-{i}")).collect(); + let constraints: Vec = (0..n_constraints).map(|i| format!("constraint-{i}")).collect(); + let mut bundle = ContinuationBundle::new("intent-test"); + bundle.push(Bundle::new( + BundleKind::Intent, + serde_json::json!({ + "goal": goal, + "acceptance_signals": signals, + "constraints": constraints, + "user_turn_count": 1_u64, + }), + )); + let doc = export_to_okf(&bundle, "forge"); + let intent_entities: Vec<&session_ledger::OkfEntity> = doc + .entities + .iter() + .filter(|e| e.r#type == "intent") + .collect(); + prop_assert_eq!(intent_entities.len(), 1); + prop_assert_eq!(&intent_entities[0].label, &goal); + // acceptance + constraint entity counts match. + let acceptance_count = doc.entities.iter().filter(|e| e.r#type == "acceptance").count(); + let constraint_count = doc.entities.iter().filter(|e| e.r#type == "constraint").count(); + prop_assert_eq!(acceptance_count, n_signals); + prop_assert_eq!(constraint_count, n_constraints); + } + + /// Every Context bundle contributes at least one resource entity + /// when `cwd` is present. + #[test] + fn context_bundle_emits_resource_entity( + cwd in "/[a-zA-Z0-9_./-]{1,40}", + ) { + let mut bundle = ContinuationBundle::new("ctx-test"); + bundle.push(Bundle::new( + BundleKind::Context, + serde_json::json!({ "cwd": cwd, "title": "ctx" }), + )); + let doc = export_to_okf(&bundle, "forge"); + let resource_entities: Vec<&session_ledger::OkfEntity> = doc + .entities + .iter() + .filter(|e| e.r#type == "resource") + .collect(); + prop_assert_eq!(resource_entities.len(), 1); + } + + /// Every Acceptance bundle contributes exactly one gate entity + /// with `ready` and `scope_sized` properties. + #[test] + fn acceptance_bundle_emits_gate_entity( + _seed in any::(), + ) { + let mut bundle = ContinuationBundle::new("acc-test"); + bundle.push(Bundle::new( + BundleKind::Acceptance, + serde_json::json!({ + "ready": true, + "scope_sized": true, + "label": "resume", + }), + )); + let doc = export_to_okf(&bundle, "forge"); + let gates: Vec<&session_ledger::OkfEntity> = doc + .entities + .iter() + .filter(|e| e.r#type == "gate") + .collect(); + prop_assert_eq!(gates.len(), 1); + // The gate entity's label is fixed ("resume-gate") regardless + // of the input `label` field — it's a gate, not a per-input + // accept signal. + prop_assert_eq!(gates[0].label.as_str(), "resume-gate"); + prop_assert_eq!(&gates[0].properties["ready"], &serde_json::json!(true)); + prop_assert_eq!(&gates[0].properties["scope_sized"], &serde_json::json!(true)); + } + + /// `export_to_okf` never produces duplicate entity ids across + /// multiple intents / contexts / acceptances / contracts. + #[test] + fn export_entity_ids_unique( + n_intent in 1_usize..3, + n_context in 1_usize..3, + n_acceptance in 1_usize..3, + n_contract in 1_usize..3, + ) { + let mut bundle = ContinuationBundle::new("dup-id-test"); + for i in 0..n_intent { + bundle.push(Bundle::new( + BundleKind::Intent, + serde_json::json!({ "goal": format!("g-{i}") }), + )); + } + for i in 0..n_context { + bundle.push(Bundle::new( + BundleKind::Context, + serde_json::json!({ "cwd": format!("/d/{i}"), "title": format!("t-{i}") }), + )); + } + for _ in 0..n_acceptance { + bundle.push(Bundle::new( + BundleKind::Acceptance, + serde_json::json!({ "ready": true, "scope_sized": true }), + )); + } + for _ in 0..n_contract { + bundle.push(Bundle::new( + BundleKind::Contract, + serde_json::json!({ "criteria": ["criterion"] }), + )); + } + let doc = export_to_okf(&bundle, "forge"); + let mut ids: Vec<&str> = doc.entities.iter().map(|e| e.id.as_str()).collect(); + ids.sort(); + let original_len = ids.len(); + ids.dedup(); + let dup_count = original_len - ids.len(); + prop_assert_eq!(dup_count, 0, "export_to_okf produced duplicate entity ids"); + } +} diff --git a/crates/sl-viewer/tests/properties_session_ledger_intent.rs b/crates/sl-viewer/tests/properties_session_ledger_intent.rs new file mode 100644 index 00000000..e815deb7 --- /dev/null +++ b/crates/sl-viewer/tests/properties_session_ledger_intent.rs @@ -0,0 +1,231 @@ +//! Property evidence for `session_ledger::distill::extractor::HeuristicIntentExtractor::extract_intent` +//! and `session_ledger::domain::intent::Intent`. +//! +//! The heuristic intent extractor is the P1 SSOT for "what does the user +//! want?" — it powers the resume prompt, the search index, and the +//! wiki / docs view. If `user_turn_count` drifts, `goal` starts picking +//! up assistant text, or the acceptance/constraint whitelists stop +//! matching, every downstream consumer sees the wrong intent. + +use proptest::prelude::*; +use session_ledger::distill::extractor::HeuristicIntentExtractor; +use session_ledger::domain::intent::Intent; +use session_ledger::domain::session::{Corpus, Message, Role, Session}; + +// Heuristic patterns mirrored from `extractor.rs`. +const ACCEPTANCE_PATTERNS: &[&str] = &[ + "looks good", "works", "that's correct", "correct", "done", "fixed", + "passes", "approved", "looks right", "looks great", "all good", + "that works", "nice", "perfect", "exactly", "confirmed", +]; + +const CONSTRAINT_PATTERNS: &[&str] = &[ + "don't change", "do not change", "must not", "should not", + "keep", "maintain", "preserve", "never", "don't touch", + "do not touch", "don't modify", "do not modify", "only", + "but don't", "but do not", "without changing", "without modifying", + "leave alone", "leave as is", +]; + +fn make_session(id: &str, messages: &[(Role, &str)]) -> Session { + let mut session = Session::new(id, Corpus::Forge); + for (role, content) in messages { + session.messages.push(Message::new(*role, *content)); + } + session +} + +// ── user_turn_count ──────────────────────────────────────────────────────── + +proptest! { + /// `user_turn_count` always equals the count of `Role::User` + /// messages in the session. + #[test] + fn user_turn_count_matches_role_user_messages( + n_user in 0_usize..5, + n_assistant in 0_usize..5, + ) { + let mut messages: Vec<(Role, &str)> = Vec::new(); + for i in 0..n_user { + messages.push((Role::User, Box::leak(format!("user {i}").into_boxed_str()))); + } + for i in 0..n_assistant { + messages.push((Role::Assistant, Box::leak(format!("assistant {i}").into_boxed_str()))); + } + let session = make_session("utc", &messages); + let intent = HeuristicIntentExtractor::extract_intent(&session); + prop_assert_eq!(intent.user_turn_count, n_user); + } + + /// Assistant / tool / subagent / system messages never contribute + /// to `user_turn_count`. + #[test] + fn only_user_role_counts_for_user_turn_count(_seed in any::()) { + let session = make_session("non-user", &[ + (Role::Assistant, "anything"), + (Role::Subagent, "anything"), + (Role::Tool, "anything"), + (Role::System, "anything"), + ]); + let intent = HeuristicIntentExtractor::extract_intent(&session); + prop_assert_eq!(intent.user_turn_count, 0); + } +} + +// ── empty session ────────────────────────────────────────────────────────── + +proptest! { + /// Empty session → `Intent::empty()` semantics: no goal, no + /// signals, no constraints, zero user_turn_count. + #[test] + fn empty_session_produces_empty_intent(_seed in any::()) { + let session = Session::new("empty", Corpus::Forge); + let intent = HeuristicIntentExtractor::extract_intent(&session); + prop_assert!(intent.is_empty()); + prop_assert!(intent.goal.is_none()); + prop_assert!(intent.acceptance_signals.is_empty()); + prop_assert!(intent.constraints.is_empty()); + prop_assert_eq!(intent.user_turn_count, 0); + } +} + +// ── Acceptance / constraint dedup ────────────────────────────────────────── + +proptest! { + /// Repeated acceptance signals are deduplicated (one entry per + /// pattern, even if the pattern appears in many user messages). + #[test] + fn acceptance_signals_deduplicated( + repeats in 2_usize..6, + ) { + let mut messages: Vec<(Role, &str)> = Vec::new(); + for i in 0..repeats { + messages.push(( + Role::User, + Box::leak(format!("user {i} looks good ship it").into_boxed_str()), + )); + } + let session = make_session("dedup-acc", &messages); + let intent = HeuristicIntentExtractor::extract_intent(&session); + let has = intent.acceptance_signals.iter() + .filter(|s| s.as_str() == "looks good") + .count(); + prop_assert_eq!(has, 1, "acceptance signal should appear once"); + } + + /// Repeated constraint patterns are deduplicated. + #[test] + fn constraints_deduplicated( + repeats in 2_usize..6, + ) { + let mut messages: Vec<(Role, &str)> = Vec::new(); + for i in 0..repeats { + messages.push(( + Role::User, + Box::leak(format!("user {i} don't change the schema").into_boxed_str()), + )); + } + let session = make_session("dedup-con", &messages); + let intent = HeuristicIntentExtractor::extract_intent(&session); + let has = intent.constraints.iter() + .filter(|s| s.as_str() == "don't change") + .count(); + prop_assert_eq!(has, 1, "constraint should appear once"); + } +} + +// ── Pattern recognition ──────────────────────────────────────────────────── + +proptest! { + /// Every documented acceptance pattern, when present in a user + /// message, ends up in `acceptance_signals` exactly once. + #[test] + fn every_acceptance_pattern_is_recognized( + idx in 0..ACCEPTANCE_PATTERNS.len(), + ) { + let pat = ACCEPTANCE_PATTERNS[idx]; + let session = make_session("acc-rec", &[ + (Role::User, pat), + ]); + let intent = HeuristicIntentExtractor::extract_intent(&session); + prop_assert!(intent.acceptance_signals.contains(&pat.to_string()), + "acceptance pattern {pat:?} not recognized in {intent:?}"); + } + + /// Every documented constraint pattern, when present in a user + /// message, ends up in `constraints` exactly once. + #[test] + fn every_constraint_pattern_is_recognized( + idx in 0..CONSTRAINT_PATTERNS.len(), + ) { + let pat = CONSTRAINT_PATTERNS[idx]; + let session = make_session("con-rec", &[ + (Role::User, pat), + ]); + let intent = HeuristicIntentExtractor::extract_intent(&session); + prop_assert!(intent.constraints.contains(&pat.to_string()), + "constraint pattern {pat:?} not recognized in {intent:?}"); + } +} + +// ── Labeled goal / constraint extraction ──────────────────────────────────── + +const GOAL_LABELS: &[&str] = &["goal", "objective", "task"]; +const CONSTRAINT_LABELS: &[&str] = &["constraint", "requirement", "boundary"]; + +proptest! { + /// Explicit `Goal:` / `Objective:` / `Task:` lines win over + /// surrounding preamble. + #[test] + fn labeled_goal_beats_preamble( + label_idx in 0..GOAL_LABELS.len(), + body in "[a-zA-Z0-9.-]{1,40}", + ) { + let label = GOAL_LABELS[label_idx]; + let session = make_session("lbl-goal", &[ + (Role::User, "Please use the following brief."), + (Role::User, Box::leak(format!("{label}: {body}").into_boxed_str())), + ]); + let intent = HeuristicIntentExtractor::extract_intent(&session); + prop_assert_eq!(intent.goal.as_deref(), Some(body.as_str())); + } + + /// `Constraint:` / `Requirement:` / `Boundary:` lines contribute + /// their full text to `constraints` (not just the matching + /// pattern substring). + #[test] + fn labeled_constraint_carries_full_text( + label_idx in 0..CONSTRAINT_LABELS.len(), + body in "[a-zA-Z0-9.-]{1,40}", + ) { + let label = CONSTRAINT_LABELS[label_idx]; + let session = make_session("lbl-con", &[ + (Role::User, Box::leak(format!("{label}: {body}").into_boxed_str())), + ]); + let intent = HeuristicIntentExtractor::extract_intent(&session); + prop_assert!(intent.constraints.contains(&body.to_string()), + "labeled constraint body {body:?} missing from {intent:?}"); + } +} + +// ── Determinism ──────────────────────────────────────────────────────────── + +proptest! { + /// `extract_intent` is deterministic across calls. + #[test] + fn extract_intent_is_deterministic( + n_user in 0_usize..3, + ) { + let mut messages: Vec<(Role, &str)> = Vec::new(); + for i in 0..n_user { + messages.push(( + Role::User, + Box::leak(format!("user msg {i} with looks good and don't change the schema").into_boxed_str()), + )); + } + let session = make_session("det", &messages); + let a: Intent = HeuristicIntentExtractor::extract_intent(&session); + let b: Intent = HeuristicIntentExtractor::extract_intent(&session); + prop_assert_eq!(a, b); + } +} diff --git a/crates/sl-viewer/tests/properties_session_ledger_okf.rs b/crates/sl-viewer/tests/properties_session_ledger_okf.rs new file mode 100644 index 00000000..46a0ece3 --- /dev/null +++ b/crates/sl-viewer/tests/properties_session_ledger_okf.rs @@ -0,0 +1,268 @@ +//! Property evidence for `session_ledger::validate_okf_document` and +//! `session_ledger::OkfDocument::new` (OKF v1 graph validation). +//! +//! The OKF validator is the single source of truth for whether an exported +//! document is well-formed before it is published to downstream consumers +//! (search index, wiki, replay). Any drift between the validator and the +//! exporter surfaces as a `Vec` whose shape is pinned +//! here. +//! +//! `OkfDocument::new` invariants: +//! * `okf` is always `"1.0"`. +//! * `source_id` matches the input `bundle.source_id`. +//! * `provenance.corpus` matches the corpus arg. +//! * `provenance.source_id` matches the bundle `source_id`. +//! * `entities`, `relations`, `tags` start empty. +//! +//! `validate_okf_document` invariants: +//! * A freshly-constructed `OkfDocument::new(b, c)` is valid (zero errors). +//! * Bumping `okf` to anything other than `"1.0"` produces exactly one +//! `unsupported_version` error with field `"okf"`. +//! * `provenance.source_id != source_id` produces exactly one +//! `source_id_mismatch` error with field `"provenance.source_id"`. +//! * Duplicated entity ids each surface a `duplicate_entity_id` error. +//! * Dangling relation source/target each surface their respective error. +//! * Each error has non-empty `field` / `code` / `message`. + +use proptest::prelude::*; +use session_ledger::{ + validate_okf_document, OkfDocument, OkfEntity, OkfProvenance, OkfRelation, + OkfValidationError, ContinuationBundle, +}; + +// ── Strategies ───────────────────────────────────────────────────────────── + +const CORPORA: &[&str] = &["forge", "codex", "claude-code", "cursor"]; + +// ── OkfDocument::new ─────────────────────────────────────────────────────── + +proptest! { + /// `OkfDocument::new(b, c)` always carries `okf = "1.0"`. + #[test] + fn new_document_okf_version_is_one_zero( + source_id in "[a-zA-Z0-9_-]{1,16}", + corpus_idx in 0..CORPORA.len(), + ) { + let corpus = CORPORA[corpus_idx]; + let bundle = ContinuationBundle::new(&source_id); + let document = OkfDocument::new(&bundle, corpus); + prop_assert_eq!(document.okf, "1.0"); + } + + /// `OkfDocument::new(b, c)` propagates `bundle.source_id`. + #[test] + fn new_document_source_id_matches_bundle( + source_id in "[a-zA-Z0-9_-]{1,16}", + corpus_idx in 0..CORPORA.len(), + ) { + let corpus = CORPORA[corpus_idx]; + let bundle = ContinuationBundle::new(&source_id); + let document = OkfDocument::new(&bundle, corpus); + prop_assert_eq!(document.source_id, bundle.source_id); + } + + /// `OkfDocument::new(b, c)` propagates `c` into `provenance.corpus`. + #[test] + fn new_document_provenance_corpus_matches_arg( + source_id in "[a-zA-Z0-9_-]{1,16}", + corpus_idx in 0..CORPORA.len(), + ) { + let corpus = CORPORA[corpus_idx]; + let bundle = ContinuationBundle::new(&source_id); + let document = OkfDocument::new(&bundle, corpus); + prop_assert_eq!(document.provenance.corpus, corpus); + } + + /// `OkfDocument::new(b, c)` propagates `bundle.source_id` into + /// `provenance.source_id`. + #[test] + fn new_document_provenance_source_id_matches_bundle( + source_id in "[a-zA-Z0-9_-]{1,16}", + corpus_idx in 0..CORPORA.len(), + ) { + let corpus = CORPORA[corpus_idx]; + let bundle = ContinuationBundle::new(&source_id); + let document = OkfDocument::new(&bundle, corpus); + prop_assert_eq!(document.provenance.source_id, bundle.source_id); + } + + /// `OkfDocument::new(b, c)` starts with empty entities, relations, tags. + #[test] + fn new_document_collections_start_empty( + source_id in "[a-zA-Z0-9_-]{1,16}", + corpus_idx in 0..CORPORA.len(), + ) { + let corpus = CORPORA[corpus_idx]; + let bundle = ContinuationBundle::new(&source_id); + let document = OkfDocument::new(&bundle, corpus); + prop_assert!(document.entities.is_empty()); + prop_assert!(document.relations.is_empty()); + prop_assert!(document.tags.is_empty()); + } +} + +// ── validate_okf_document ────────────────────────────────────────────────── + +proptest! { + /// A freshly-constructed OKF document validates with zero errors. + #[test] + fn fresh_document_validates_clean( + source_id in "[a-zA-Z0-9_-]{1,16}", + corpus_idx in 0..CORPORA.len(), + ) { + let corpus = CORPORA[corpus_idx]; + let bundle = ContinuationBundle::new(&source_id); + let document = OkfDocument::new(&bundle, corpus); + let errors = validate_okf_document(&document); + prop_assert!(errors.is_empty(), "expected no errors, got {errors:?}"); + } + + /// Bumping `okf` to anything other than `"1.0"` produces exactly one + /// `unsupported_version` error with field `"okf"`. + #[test] + fn wrong_okf_version_produces_one_unsupported_version_error( + source_id in "[a-zA-Z0-9_-]{1,16}", + bad_version in "[0-9A-Za-z.]{1,8}", + ) { + let bundle = ContinuationBundle::new(&source_id); + let mut document = OkfDocument::new(&bundle, "forge"); + // Filter out the trivial case where the random string happens to + // equal "1.0" — we want a strictly-non-"1.0" version. + prop_assume!(bad_version != "1.0"); + document.okf = bad_version.clone(); + let errors = validate_okf_document(&document); + let matching: Vec<&OkfValidationError> = errors + .iter() + .filter(|e| e.code == "unsupported_version" && e.field == "okf") + .collect(); + prop_assert_eq!(matching.len(), 1); + // The message must mention the offending version. + prop_assert!(matching[0].message.contains(&bad_version)); + } + + /// `provenance.source_id != source_id` produces exactly one + /// `source_id_mismatch` error. + #[test] + fn provenance_source_mismatch_produces_one_error( + source_id in "[a-zA-Z0-9_-]{1,16}", + other_id in "[a-zA-Z0-9_-]{1,16}", + ) { + prop_assume!(source_id != other_id); + let bundle = ContinuationBundle::new(&source_id); + let mut document = OkfDocument::new(&bundle, "forge"); + document.provenance = OkfProvenance { + corpus: "forge".into(), + source_id: other_id.clone(), + }; + let errors = validate_okf_document(&document); + let matching: Vec<&OkfValidationError> = errors + .iter() + .filter(|e| e.code == "source_id_mismatch" && e.field == "provenance.source_id") + .collect(); + prop_assert_eq!(matching.len(), 1); + } + + /// Duplicate entity ids surface a `duplicate_entity_id` error per + /// offending id (one per duplicate occurrence). + #[test] + fn duplicate_entity_ids_surface_errors( + n in 2_usize..6, + ) { + let bundle = ContinuationBundle::new("dup-session"); + let mut document = OkfDocument::new(&bundle, "forge"); + let entity = OkfEntity { + id: "dup-entity".into(), + r#type: "intent".into(), + label: "shared".into(), + properties: serde_json::Value::Null, + }; + document.entities = (0..n).map(|_| entity.clone()).collect(); + let errors = validate_okf_document(&document); + let dups: Vec<&OkfValidationError> = errors + .iter() + .filter(|e| e.code == "duplicate_entity_id") + .collect(); + // The validator reports the *second* (and subsequent) occurrences. + prop_assert_eq!(dups.len(), n - 1); + } + + /// Dangling relation source surfaces a `dangling_relation_source` error. + #[test] + fn dangling_relation_source_surfaces_error( + source_id in "[a-zA-Z0-9_-]{1,16}", + ) { + let bundle = ContinuationBundle::new(&source_id); + let mut document = OkfDocument::new(&bundle, "forge"); + document.entities = vec![OkfEntity { + id: "present".into(), + r#type: "intent".into(), + label: "p".into(), + properties: serde_json::Value::Null, + }]; + document.relations = vec![OkfRelation { + source: "missing-source".into(), + target: "present".into(), + r#type: "grounds".into(), + provenance: document.provenance.clone(), + }]; + let errors = validate_okf_document(&document); + prop_assert!(errors.iter().any(|e| e.code == "dangling_relation_source" + && e.field == "relations[0].source")); + } + + /// Dangling relation target surfaces a `dangling_relation_target` error. + #[test] + fn dangling_relation_target_surfaces_error( + source_id in "[a-zA-Z0-9_-]{1,16}", + ) { + let bundle = ContinuationBundle::new(&source_id); + let mut document = OkfDocument::new(&bundle, "forge"); + document.entities = vec![OkfEntity { + id: "present".into(), + r#type: "intent".into(), + label: "p".into(), + properties: serde_json::Value::Null, + }]; + document.relations = vec![OkfRelation { + source: "present".into(), + target: "missing-target".into(), + r#type: "grounds".into(), + provenance: document.provenance.clone(), + }]; + let errors = validate_okf_document(&document); + prop_assert!(errors.iter().any(|e| e.code == "dangling_relation_target" + && e.field == "relations[0].target")); + } + + /// Every `OkfValidationError` carries non-empty `field`, `code`, + /// `message` so callers can render an actionable diagnostic. + #[test] + fn every_error_has_nonempty_components(_seed in any::()) { + let bundle = ContinuationBundle::new("err-shape"); + let mut document = OkfDocument::new(&bundle, "forge"); + // Force every error class at once. + document.okf = "2.0".into(); + document.provenance.source_id = "other".into(); + document.entities = vec![ + OkfEntity { + id: "x".into(), + r#type: "intent".into(), + label: "x".into(), + properties: serde_json::Value::Null, + }, + OkfEntity { + id: "x".into(), + r#type: "intent".into(), + label: "x".into(), + properties: serde_json::Value::Null, + }, + ]; + let errors = validate_okf_document(&document); + prop_assert!(!errors.is_empty()); + for err in &errors { + prop_assert!(!err.field.is_empty(), "error has empty field"); + prop_assert!(!err.code.is_empty(), "error has empty code"); + prop_assert!(!err.message.is_empty(), "error has empty message"); + } + } +} diff --git a/crates/sl-viewer/tests/properties_session_ledger_worklog.rs b/crates/sl-viewer/tests/properties_session_ledger_worklog.rs new file mode 100644 index 00000000..7b884d6b --- /dev/null +++ b/crates/sl-viewer/tests/properties_session_ledger_worklog.rs @@ -0,0 +1,233 @@ +//! Property evidence for `session_ledger::detect_unfinished`, +//! `project_unfinished_work`, and `WorklogProjection::from_session` +//! (the crash-recovery / lost-work projection pipeline). +//! +//! The worklog projector is the SSOT that decides whether a session +//! is "safe to discard" or "needs a resume". If `detect_unfinished` +//! drifts (false positives inflate the Unfinished tab; false +//! negatives strand real recovery work) the operator loses trust in +//! the viewer. +//! +//! Every `Role` ↔ `UnfinishedReason` mapping, the completion-marker +//! whitelist, and the `summarize` budget are pinned here. + +use proptest::prelude::*; +use session_ledger::domain::session::{Corpus, Message, Role, Session}; +use session_ledger::domain::worklog::{ + detect_unfinished, project_unfinished_work, UnfinishedReason, WorklogProjection, +}; + +// ── Strategies ───────────────────────────────────────────────────────────── + +const ROLES: &[Role] = &[Role::User, Role::Assistant, Role::Subagent, Role::Tool, Role::System]; + +const COMPLETION_MARKERS: &[&str] = &[ + "complete", + "completed", + "done", + "[completed]", + "", + "status: complete", + "status: completed", + "task complete", + "task completed", +]; + +fn role_choice() -> impl Strategy { + prop::sample::select(ROLES.to_vec()) +} + +// ── detect_unfinished ────────────────────────────────────────────────────── + +proptest! { + /// Empty session → `None` (nothing to detect). + #[test] + fn empty_session_detects_no_unfinished_work(_seed in any::()) { + let session = Session::new("empty", Corpus::Forge); + prop_assert!(detect_unfinished(&session).is_none()); + } + + /// A session whose final message is `Role::User` projects as + /// `AwaitingAssistantResponse`. + #[test] + fn final_user_turn_is_awaiting_assistant_response( + content in "[a-zA-Z0-9 .,!?-]{1,40}", + ) { + let mut session = Session::new("u", Corpus::Forge); + session.messages = vec![Message::new(Role::User, content)]; + let item = detect_unfinished(&session).expect("user work is unfinished"); + prop_assert_eq!(item.reason, UnfinishedReason::AwaitingAssistantResponse); + prop_assert_eq!(item.session_id, "u"); + prop_assert_eq!(item.message_count, 1); + } + + /// A session whose final message is `Role::Tool` or `Role::Subagent` + /// projects as `InterruptedExecution`. + #[test] + fn final_tool_or_subagent_turn_is_interrupted_execution( + final_role in prop::sample::select(vec![Role::Tool, Role::Subagent]), + ) { + let mut session = Session::new("i", Corpus::Forge); + session.messages = vec![ + Message::new(Role::User, "Run migration"), + Message::new(Role::Assistant, "starting"), + Message::new(final_role, "log line"), + ]; + let item = detect_unfinished(&session).expect("tool/subagent tail is unfinished"); + prop_assert_eq!(item.reason, UnfinishedReason::InterruptedExecution); + } + + /// A session whose final assistant message has a completion marker + /// (any of the documented whitelist strings) projects as `None`. + #[test] + fn assistant_with_completion_marker_is_finished( + marker_idx in 0..COMPLETION_MARKERS.len(), + ) { + let marker = COMPLETION_MARKERS[marker_idx]; + let mut session = Session::new("d", Corpus::Forge); + session.messages = vec![ + Message::new(Role::User, "do it"), + Message::new(Role::Assistant, format!("intro\n{marker}\nmore text")), + ]; + prop_assert!(detect_unfinished(&session).is_none(), + "marker {marker:?} should mark session finished"); + } + + /// A session whose final assistant message lacks any completion + /// marker projects as `MissingCompletionMarker`. + #[test] + fn assistant_without_completion_marker_is_unfinished( + body in "[a-zA-Z0-9 .,!?]{1,40}", + ) { + let mut session = Session::new("a", Corpus::Forge); + session.messages = vec![ + Message::new(Role::User, "do it"), + Message::new(Role::Assistant, body), + ]; + let item = detect_unfinished(&session).expect("missing marker is unfinished"); + prop_assert_eq!(item.reason, UnfinishedReason::MissingCompletionMarker); + } + + /// Every `UnfinishedWorkItem` carries the originating session id + /// and the original `message_count`. + #[test] + fn unfinished_item_carries_session_metadata( + n in 1_usize..5, + ) { + let mut session = Session::new("session-meta", Corpus::Forge); + session.messages = (0..n) + .map(|i| Message::new(Role::User, format!("prompt {i}"))) + .collect(); + let item = detect_unfinished(&session).expect("non-empty is unfinished"); + prop_assert_eq!(item.session_id, "session-meta"); + prop_assert_eq!(item.message_count, n); + prop_assert_eq!(item.corpus, Corpus::Forge); + } + + /// The summary field never exceeds 241 characters (240 + an ellipsis + /// suffix when the source is longer). + #[test] + fn summary_bounded_at_241_chars( + body in ".{0,500}", + ) { + let mut session = Session::new("s", Corpus::Forge); + session.messages = vec![Message::new(Role::User, body)]; + let item = detect_unfinished(&session).expect("user work is unfinished"); + prop_assert!(item.summary.chars().count() <= 241, + "summary len {} > 241: {:?}", item.summary.chars().count(), item.summary); + } + + /// The summary never carries embedded newlines or tab characters + /// (the content was whitespace-normalized). + #[test] + fn summary_is_single_line( + body in ".{1,200}", + ) { + let mut session = Session::new("s", Corpus::Forge); + session.messages = vec![Message::new(Role::User, body)]; + let item = detect_unfinished(&session).expect("user work is unfinished"); + prop_assert!(!item.summary.contains('\n')); + prop_assert!(!item.summary.contains('\t')); + } +} + +// ── project_unfinished_work ───────────────────────────────────────────────── + +proptest! { + /// `project_unfinished_work` returns one item per unfinished + /// session in the input slice, in input order. + #[test] + fn project_unfinished_work_one_per_unfinished_session( + n_finished in 0_usize..4, + n_unfinished in 0_usize..4, + ) { + let mut sessions = Vec::new(); + for i in 0..n_finished { + let mut s = Session::new(format!("finished-{i}"), Corpus::Forge); + s.messages = vec![ + Message::new(Role::User, "do it"), + Message::new(Role::Assistant, "complete"), + ]; + sessions.push(s); + } + for i in 0..n_unfinished { + let mut s = Session::new(format!("unfinished-{i}"), Corpus::Forge); + s.messages = vec![Message::new(Role::User, "do it")]; + sessions.push(s); + } + let projected = project_unfinished_work(&sessions); + prop_assert_eq!(projected.len(), n_unfinished); + for (idx, item) in projected.iter().enumerate() { + prop_assert_eq!(&item.session_id, &format!("unfinished-{idx}")); + } + } + + /// `project_unfinished_work` is deterministic across calls. + #[test] + fn project_unfinished_work_deterministic( + n in 0_usize..5, + ) { + let mut sessions = Vec::new(); + for i in 0..n { + let mut s = Session::new(format!("s-{i}"), Corpus::Forge); + s.messages = vec![Message::new(Role::User, format!("msg {i}"))]; + sessions.push(s); + } + let a = project_unfinished_work(&sessions); + let b = project_unfinished_work(&sessions); + prop_assert_eq!(a, b); + } +} + +// ── WorklogProjection::from_session ───────────────────────────────────────── + +proptest! { + /// `WorklogProjection::from_session` carries `message_count` and + /// zero or one unfinished item, matching `detect_unfinished`. + #[test] + fn worklog_projection_matches_detect( + n in 0_usize..5, + final_role in role_choice(), + ) { + let mut session = Session::new("wp", Corpus::Forge); + session.messages = (0..n) + .map(|i| { + // Alternate user / assistant so the message list is + // meaningful; final role is whatever the strategy picks. + let role = if i == n - 1 { final_role } else if i % 2 == 0 { Role::User } else { Role::Assistant }; + let content = match role { + Role::Assistant => "complete".to_string(), + _ => format!("msg {i}"), + }; + Message::new(role, content) + }) + .collect(); + let projection = WorklogProjection::from_session(&session); + prop_assert_eq!(projection.message_count, n); + let detected = detect_unfinished(&session); + match detected { + Some(_) => prop_assert_eq!(projection.unfinished.len(), 1), + None => prop_assert!(projection.unfinished.is_empty()), + } + } +} diff --git a/crates/sl-viewer/tests/properties_viewer_async_states.rs b/crates/sl-viewer/tests/properties_viewer_async_states.rs new file mode 100644 index 00000000..812c14a0 --- /dev/null +++ b/crates/sl-viewer/tests/properties_viewer_async_states.rs @@ -0,0 +1,103 @@ +//! Property evidence for sl-viewer's `async_states::SkeletonLayout` +//! enum and the `clamp_rows` helper used by `ContentSkeleton`. +//! +//! `async_states::SkeletonLayout` invariants: +//! * `SkeletonLayout::default()` is `Bundles` so the most common +//! desktop surface (bundle list) is the first paint. +//! * Every variant has a stable, kebab-case-ish single-line label. +//! * The enum exposes exactly three variants so the +//! `match layout { Bundles | ListDetail | StreamFeed }` arms in +//! `ContentSkeleton` stay exhaustive. +//! +//! `list_rows` clamp invariants: +//! * `list_rows.clamp(3, 6)` is deterministic and lands in `[3, 6]` +//! for every input. +//! * The clamp is monotonic non-decreasing on the input range: +//! larger input never produces smaller output. + +use proptest::prelude::*; +use sl_viewer::async_states::SkeletonLayout; + +proptest! { + /// `SkeletonLayout::default()` is `Bundles`. + #[test] + fn skeleton_layout_default_is_bundles(_seed in any::()) { + prop_assert_eq!(SkeletonLayout::default(), SkeletonLayout::Bundles); + } + + /// The enum exposes exactly three variants — the number of + /// documented match arms in `ContentSkeleton`. + #[test] + fn skeleton_layout_has_three_variants(_seed in any::()) { + let variants = [ + SkeletonLayout::Bundles, + SkeletonLayout::ListDetail, + SkeletonLayout::StreamFeed, + ]; + // Round-trip through Debug to confirm each variant's name + // survives stable serialisation. + let mut seen = std::collections::HashSet::new(); + for v in variants { + let name = format!("{v:?}"); + prop_assert!(name.is_ascii(), "variant {name:?} is not ASCII"); + seen.insert(name); + } + prop_assert_eq!(seen.len(), 3, "variant count drifted"); + } + + /// Every variant's Debug label is non-empty, single-line, and + /// matches one of the documented variant names. + #[test] + fn skeleton_layout_labels_documented(variant in prop::sample::select(vec![ + SkeletonLayout::Bundles, + SkeletonLayout::ListDetail, + SkeletonLayout::StreamFeed, + ])) { + let label = format!("{variant:?}"); + prop_assert!(!label.is_empty()); + prop_assert!(!label.contains('\n')); + let valid = label == "Bundles" || label == "ListDetail" || label == "StreamFeed"; + prop_assert!(valid, "label {label:?} is not a documented variant name"); + } + + /// `SkeletonLayout::default()` matches the first arm in the + /// `match` block in `ContentSkeleton` so adding a new variant + /// forces a deliberate `default()` change. + #[test] + fn skeleton_layout_default_is_first_arm(_seed in any::()) { + let first = match () { + () => SkeletonLayout::Bundles, // mirrors the first match arm in ContentSkeleton + }; + prop_assert_eq!(SkeletonLayout::default(), first); + } + + /// `list_rows.clamp(3, 6)` lands in `[3, 6]` for every input. + #[test] + fn list_rows_clamp_in_range(input in any::()) { + let clamped = input.clamp(3, 6); + prop_assert!((3..=6).contains(&clamped), "clamp produced {clamped} for input {input}"); + } + + /// The clamp is monotonic non-decreasing. + #[test] + fn list_rows_clamp_monotonic( + a in any::(), + b in any::(), + ) { + let (lo, hi) = if a <= b { (a, b) } else { (b, a) }; + let c_lo = lo.clamp(3, 6); + let c_hi = hi.clamp(3, 6); + prop_assert!(c_lo <= c_hi, "clamp not monotonic: {lo}→{c_lo}, {hi}→{c_hi}"); + } + + /// The clamp has the documented fixed points: `0` and `2` clamp + /// to `3`; `6` and `u64::MAX` clamp to `6`. + #[test] + fn list_rows_clamp_fixed_points(_seed in any::()) { + prop_assert_eq!(0_usize.clamp(3, 6), 3); + prop_assert_eq!(2_usize.clamp(3, 6), 3); + prop_assert_eq!(3_usize.clamp(3, 6), 3); + prop_assert_eq!(6_usize.clamp(3, 6), 6); + prop_assert_eq!(usize::MAX.clamp(3, 6), 6); + } +} diff --git a/docs/ops/TRACEABILITY.json b/docs/ops/TRACEABILITY.json index 9ec60536..2a739d7d 100644 --- a/docs/ops/TRACEABILITY.json +++ b/docs/ops/TRACEABILITY.json @@ -325,6 +325,12 @@ "crates/sl-viewer/tests/properties_viewer_help_overlay.rs", "crates/sl-viewer/tests/properties_viewer_settings_tab.rs", "crates/sl-viewer/tests/properties_viewer_menu.rs", + "crates/sl-viewer/tests/properties_viewer_async_states.rs", + "crates/sl-viewer/tests/properties_session_ledger_okf.rs", + "crates/sl-viewer/tests/properties_session_ledger_worklog.rs", + "crates/sl-viewer/tests/properties_session_ledger_export.rs", + "crates/sl-viewer/tests/properties_session_ledger_distill.rs", + "crates/sl-viewer/tests/properties_session_ledger_intent.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 b032d0e6..8412ee04 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`; `crates/sl-viewer/tests/properties_viewer_corpus_paths.rs`; `crates/sl-viewer/tests/properties_viewer_help_overlay.rs`; `crates/sl-viewer/tests/properties_viewer_settings_tab.rs`; `crates/sl-viewer/tests/properties_viewer_menu.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; viewer help_overlay shortcuts properties #455; viewer settings_tab HealthStatus properties #456; viewer menu id taxonomy properties #457; 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`; `crates/sl-viewer/tests/properties_viewer_help_overlay.rs`; `crates/sl-viewer/tests/properties_viewer_settings_tab.rs`; `crates/sl-viewer/tests/properties_viewer_menu.rs`; `crates/sl-viewer/tests/properties_viewer_async_states.rs`; `crates/sl-viewer/tests/properties_session_ledger_okf.rs`; `crates/sl-viewer/tests/properties_session_ledger_worklog.rs`; `crates/sl-viewer/tests/properties_session_ledger_export.rs`; `crates/sl-viewer/tests/properties_session_ledger_distill.rs`; `crates/sl-viewer/tests/properties_session_ledger_intent.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; viewer help_overlay shortcuts properties #455; viewer settings_tab HealthStatus properties #456; viewer menu id taxonomy properties #457; viewer async_states SkeletonLayout properties #458; session-ledger OKF document validator properties #459; session-ledger worklog projector properties #460; session-ledger OKF export adapter properties #461; session-ledger distill compiler properties #462; session-ledger heuristic intent extractor properties #463; full loom/shuttle unpaid | ## audit-v38 waves