diff --git a/CHANGELOG.md b/CHANGELOG.md index ac5bc9f0..1e3d6f07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,7 +68,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/); versioning is [SemVer]( - 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. - +- session-ledger heuristic context extractor property surface (WBS-6.2 #464): `crates/sl-viewer/tests/properties_session_ledger_context.rs` adds 20 proptest properties pinning `session_ledger::distill::context_extractor::HeuristicContextExtractor::extract_context` (the P2 adapter for [`ContextExtractor`] — drives the working-context slice of any `ContinuationBundle`). `cwd` and `title` are always copied verbatim from the session. Empty sessions and whitespace-only messages yield an empty `Context`. Every detected file path is non-empty, ≥3 chars, and either contains a `/` or ends with a documented extension. Every documented file extension (28 of them: `.rs`, `.ts`, `.tsx`, `.js`, `.jsx`, `.py`, `.go`, `.java`, `.kt`, `.rb`, `.c`, `.h`, `.cpp`, `.hpp`, `.cs`, `.swift`, `.toml`, `.json`, `.yaml`, `.yml`, `.md`, `.sql`, `.css`, `.scss`, `.html`, `.sh`, `.tf`, `.lock`) is detected. Files / symbols / decisions / environment notes are deduplicated across messages. Decision summaries all read `Session contains '' language` and rationale equals the source message. Every documented decision pattern (15 of them) and every documented environment pattern (19 of them) is detected, both case-insensitively. Symbols are extracted only from tokens containing `::` or `()`. `extract_context` is deterministic across calls, and the `ContextExtractor` trait path returns the same `Context` as the associated function. +- session-ledger heuristic contract extractor property surface (WBS-6.2 #465): `crates/sl-viewer/tests/properties_session_ledger_contract.rs` adds 18 proptest properties pinning `session_ledger::distill::contract_extractor::HeuristicContractExtractor::extract_contract` (the P2 adapter for [`ContractExtractor`] — drives the acceptance-contract slice of any `ContinuationBundle`). Empty sessions and whitespace-only messages yield an empty `Contract`. Every section string has a documented prefix and suffix shape (`Goal/requirement: '...'`, `Verification: '...'`, `Constraint: '...'`, `Do-not-touch: '...'`). Every documented criteria pattern (12 of them), test pattern (21 of them), constraint pattern (15 of them), and do-not-touch pattern (14 of them) is detected, and all patterns match case-insensitively. Success criteria / tests / constraints / do-not-touch lists are deduplicated across messages. `extract_contract` is deterministic across calls, and the `ContractExtractor` trait path returns the same `Contract` as the associated function. `is_empty()` is true iff every collection is empty. - 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_context.rs b/crates/sl-viewer/tests/properties_session_ledger_context.rs new file mode 100644 index 00000000..c6f7af70 --- /dev/null +++ b/crates/sl-viewer/tests/properties_session_ledger_context.rs @@ -0,0 +1,464 @@ +//! Property evidence for `session_ledger::distill::context_extractor::HeuristicContextExtractor`. +//! +//! The heuristic context extractor is the P2 adapter for [`ContextExtractor`] +//! in the session-ledger distill pipeline. It powers the working-context +//! slice of any `ContinuationBundle` (files touched, decisions reached, +//! symbols referenced, environment notes). If `extract_context` drifts — +//! `cwd`/`title` no longer mirror the session, file extraction misses +//! documented extensions, decisions / symbols / environment notes lose +//! their deduplication guarantees — every downstream consumer (search +//! index, wiki docs, resume prompt) sees stale context. + +use proptest::prelude::*; +use session_ledger::distill::context_extractor::HeuristicContextExtractor; +use session_ledger::domain::session::{Corpus, Message, Role, Session}; +use session_ledger::ports::ContextExtractor; + +const FILE_EXTENSIONS: &[&str] = &[ + ".rs", ".ts", ".tsx", ".js", ".jsx", ".py", ".go", ".java", ".kt", ".rb", ".c", ".h", ".cpp", + ".hpp", ".cs", ".swift", ".toml", ".json", ".yaml", ".yml", ".md", ".sql", ".css", ".scss", + ".html", ".sh", ".tf", ".lock", +]; + +const DECISION_PATTERNS: &[&str] = &[ + "decided", "decision", "let's use", "lets use", "we should", "we chose", "chose", + "opted for", "went with", "picked", "settled on", "i'll go with", "going with", + "best to use", "prefer", +]; + +const ENVIRONMENT_PATTERNS: &[&str] = &[ + "install", "installed", "setup", "set up", "configure", "version", "npm", "cargo", + "pip", "brew", "apt", "docker", "compose", "env", "export", "add ", "added ", + "upgrade", "updated", +]; + +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 +} + +// ── cwd / title mirroring ─────────────────────────────────────────────────── + +proptest! { + /// `cwd` and `title` are always copied verbatim from the session. + #[test] + fn cwd_and_title_mirror_session( + cwd in proptest::option::of("[a-zA-Z0-9/_.-]{0,40}"), + title in proptest::option::of("[a-zA-Z0-9 _.-]{0,40}"), + ) { + let mut session = Session::new("ctx-mirror", Corpus::Forge); + session.cwd = cwd.clone(); + session.title = title.clone(); + let ctx = HeuristicContextExtractor::extract_context(&session); + prop_assert_eq!(ctx.cwd, cwd); + prop_assert_eq!(ctx.title, title); + } +} + +// ── empty / whitespace-only sessions ─────────────────────────────────────── + +proptest! { + /// Empty sessions always produce an empty `Context`. + #[test] + fn empty_session_produces_empty_context(_dummy in 0_u8..1) { + let session = Session::new("empty", Corpus::Forge); + let ctx = HeuristicContextExtractor::extract_context(&session); + prop_assert!(ctx.is_empty()); + prop_assert_eq!(ctx.files_mentioned.len(), 0); + prop_assert_eq!(ctx.key_decisions.len(), 0); + prop_assert_eq!(ctx.key_symbols.len(), 0); + prop_assert_eq!(ctx.environment_notes.len(), 0); + } + + /// Whitespace-only / empty messages never contribute findings. + #[test] + fn whitespace_only_messages_produce_empty_context( + n_blank in 1_usize..4, + ) { + let mut messages: Vec<(Role, &str)> = Vec::new(); + for _ in 0..n_blank { + messages.push((Role::User, " \t\n ")); + } + let session = make_session("blank", &messages); + let ctx = HeuristicContextExtractor::extract_context(&session); + prop_assert!(ctx.files_mentioned.is_empty()); + prop_assert!(ctx.key_decisions.is_empty()); + prop_assert!(ctx.key_symbols.is_empty()); + prop_assert!(ctx.environment_notes.is_empty()); + } +} + +// ── File paths ────────────────────────────────────────────────────────────── + +proptest! { + /// Every detected file path is non-empty and either + /// contains a `/` OR ends with a documented extension. + #[test] + fn files_mentioned_match_path_shape( + bodies in proptest::collection::vec( + "([a-zA-Z0-9._/-]{1,32}\\.[a-zA-Z]{1,4}|see [a-zA-Z0-9._/-]{1,32})", + 0..4, + ), + ) { + let owned: Vec = bodies.clone(); + let refs: Vec<&str> = owned.iter().map(String::as_str).collect(); + let messages: Vec<(Role, &str)> = refs + .iter() + .enumerate() + .map(|(i, m)| (if i % 2 == 0 { Role::User } else { Role::Assistant }, *m)) + .collect(); + let session = make_session("files-shape", &messages); + let ctx = HeuristicContextExtractor::extract_context(&session); + for f in &ctx.files_mentioned { + prop_assert!(!f.is_empty(), "file path must be non-empty"); + prop_assert!(f.len() >= 3, "file path must be at least 3 chars"); + let has_slash = f.contains('/'); + let ends_with_ext = FILE_EXTENSIONS.iter().any(|e| f.to_lowercase().ends_with(e)); + prop_assert!( + has_slash || ends_with_ext, + "file path must contain / or end with documented extension: {f}" + ); + } + } + + /// Repeated file mentions across messages are deduplicated. + #[test] + fn duplicate_file_mentions_are_deduplicated( + body in "[a-zA-Z0-9/_.-]{3,40}\\.(rs|ts|py|toml)", + ) { + let owned = body.clone(); + let s = owned.as_str(); + let messages = vec![ + (Role::User, s), + (Role::Assistant, s), + (Role::User, s), + ]; + let session = make_session("dup-file", &messages); + let ctx = HeuristicContextExtractor::extract_context(&session); + // The same exact token may appear under multiple wrappings; the + // output must have at most one entry per unique path. + let occurrences = ctx.files_mentioned.iter() + .filter(|f| f.contains(&body) || body.contains(f.as_str())) + .count(); + prop_assert!(occurrences <= 1, "duplicate file paths must be deduplicated, got {occurrences}"); + } + + /// Every documented file extension is detected on a single test message. + #[test] + fn every_documented_file_extension_is_detected(ext in proptest::sample::select(FILE_EXTENSIONS)) { + let owned = ext.to_string(); + let ext_str = owned.as_str(); + let body = format!("main{ext_str}"); + let messages = vec![(Role::User, body.as_str())]; + let session = make_session("ext-coverage", &messages); + let ctx = HeuristicContextExtractor::extract_context(&session); + prop_assert!( + !ctx.files_mentioned.is_empty(), + "documented extension {ext} must be detected" + ); + } +} + +// ── Decisions ─────────────────────────────────────────────────────────────── + +proptest! { + /// Every detected decision summary contains the pattern text. + #[test] + fn decision_summaries_carry_pattern_text( + body in "[a-zA-Z0-9 .]{1,80}", + ) { + let owned = body.clone(); + let s = owned.as_str(); + let messages = vec![(Role::User, s)]; + let session = make_session("dec-text", &messages); + let ctx = HeuristicContextExtractor::extract_context(&session); + for d in &ctx.key_decisions { + // summary is `format!("Session contains '{pat}' language")` for the matching pattern. + prop_assert!(d.summary.starts_with("Session contains '")); + prop_assert!(d.summary.ends_with("' language")); + } + } + + /// Decision rationale equals the full message content. + #[test] + fn decision_rationale_equals_message_content( + body in "[a-zA-Z0-9 .]{1,80}", + ) { + // Force a decision pattern to be present. + let owned = format!("decided {}", body); + let s = owned.as_str(); + let messages = vec![(Role::User, s)]; + let session = make_session("dec-rationale", &messages); + let ctx = HeuristicContextExtractor::extract_context(&session); + prop_assert!(!ctx.key_decisions.is_empty()); + let has_match = ctx.key_decisions.iter() + .any(|d| d.rationale.as_deref() == Some(owned.as_str())); + prop_assert!(has_match, "decision rationale must include the source message"); + } + + /// Every documented decision pattern is detected on a minimal trigger. + #[test] + fn every_documented_decision_pattern_detected(pat in proptest::sample::select(DECISION_PATTERNS)) { + let body = format!("we {pat} rust"); + let messages = vec![(Role::User, body.as_str())]; + let session = make_session("dec-coverage", &messages); + let ctx = HeuristicContextExtractor::extract_context(&session); + prop_assert!(!ctx.key_decisions.is_empty(), "decision pattern {pat} must be detected"); + } + + /// Decision patterns are detected case-insensitively. + #[test] + fn decision_patterns_case_insensitive( + pat in proptest::sample::select(DECISION_PATTERNS), + ) { + let upper = pat.to_uppercase(); + let body = format!("we {upper} rust"); + let messages = vec![(Role::User, body.as_str())]; + let session = make_session("dec-case", &messages); + let ctx = HeuristicContextExtractor::extract_context(&session); + prop_assert!(!ctx.key_decisions.is_empty(), "decision pattern {pat} must match case-insensitively"); + } + + /// Decisions are deduplicated by summary. + #[test] + fn decisions_deduplicated_by_summary( + body in "[a-zA-Z0-9 .]{1,40}", + ) { + let owned = format!("we decided {}", body); + let s = owned.as_str(); + let messages = vec![ + (Role::User, s), + (Role::Assistant, s), + (Role::User, s), + ]; + let session = make_session("dec-dedup", &messages); + let ctx = HeuristicContextExtractor::extract_context(&session); + let decision_count = ctx.key_decisions.iter() + .filter(|d| d.summary.contains("decided")) + .count(); + prop_assert_eq!(decision_count, 1, "decisions must be deduplicated by summary"); + } +} + +// ── Symbols ──────────────────────────────────────────────────────────────── + +proptest! { + /// Every detected symbol either contains `::` (the trimmed symbol keeps + /// `::`) or was extracted from a token whose original form contained `()`. + /// The extractor trims parens/brackets/braces from the symbol, so a + /// function-call source token yields a non-`::` cleaned symbol. + #[test] + fn symbols_match_double_colon_or_function_call( + bodies in proptest::collection::vec( + "[a-zA-Z_][a-zA-Z0-9_]*(::[a-zA-Z_][a-zA-Z0-9_]*|\\(\\))", + 0..4, + ), + ) { + // Track which token form each detected symbol could have come from. + let mut symbol_sources: Vec = Vec::new(); + for body in &bodies { + for token in body.split_whitespace() { + if token.contains("::") || token.contains("()") { + if !symbol_sources.iter().any(|s| s == token) { + symbol_sources.push(token.to_string()); + } + } + } + } + let owned: Vec = bodies.clone(); + let refs: Vec<&str> = owned.iter().map(String::as_str).collect(); + let messages: Vec<(Role, &str)> = refs + .iter() + .enumerate() + .map(|(i, m)| (if i % 2 == 0 { Role::User } else { Role::Assistant }, *m)) + .collect(); + let session = make_session("sym-shape", &messages); + let ctx = HeuristicContextExtractor::extract_context(&session); + for sym in &ctx.key_symbols { + // Either the symbol itself contains `::` (kept through trimming), + // or it was derived from a token whose raw form contained `()`. + let from_double_colon = sym.contains("::"); + let from_func_call = symbol_sources.iter().any(|s| { + s.contains("()") && s.matches(|c: char| c.is_alphanumeric() || c == '_' || c == ':') + .collect::() + .contains(sym.as_str()) + }); + prop_assert!( + from_double_colon || from_func_call, + "symbol must have come from a `::` or `()` token, got {sym}" + ); + } + } + + /// Repeated symbols are deduplicated. + #[test] + fn symbols_deduplicated( + symbol in "(HashMap|String|Vec)::[a-zA-Z_][a-zA-Z0-9_]{1,20}", + ) { + let owned = format!("use {symbol} now"); + let s = owned.as_str(); + let messages = vec![ + (Role::User, s), + (Role::Assistant, s), + (Role::User, s), + ]; + let session = make_session("sym-dedup", &messages); + let ctx = HeuristicContextExtractor::extract_context(&session); + let occurrences = ctx.key_symbols.iter() + .filter(|sym| sym.contains(&symbol) || symbol.contains(sym.as_str())) + .count(); + prop_assert!(occurrences <= 1, "duplicate symbols must be deduplicated, got {occurrences}"); + } + + /// Plain identifiers without `::` or `()` are NOT extracted as symbols. + #[test] + fn plain_identifiers_not_extracted_as_symbols( + body in "[a-zA-Z_][a-zA-Z0-9_]{0,20}", + ) { + let owned = body.clone(); + let s = owned.as_str(); + let messages = vec![(Role::User, s)]; + let session = make_session("sym-plain", &messages); + let ctx = HeuristicContextExtractor::extract_context(&session); + let strs: Vec<&str> = ctx.key_symbols.iter().map(String::as_str).collect(); + prop_assert!( + !strs.iter().any(|s| *s == body), + "plain identifier {body} must not be extracted as a symbol" + ); + } +} + +// ── Environment notes ────────────────────────────────────────────────────── + +proptest! { + /// Every detected environment note is unique (post-dedup). + #[test] + fn environment_notes_deduplicated( + body in "[a-zA-Z0-9 .]{1,40}", + ) { + let owned = format!("install {}", body); + let s = owned.as_str(); + let messages = vec![ + (Role::User, s), + (Role::Assistant, s), + (Role::User, s), + ]; + let session = make_session("env-dedup", &messages); + let ctx = HeuristicContextExtractor::extract_context(&session); + let mut sorted = ctx.environment_notes.clone(); + sorted.sort(); + let original_len = ctx.environment_notes.len(); + sorted.dedup(); + prop_assert_eq!(sorted.len(), original_len, "environment notes must be deduplicated"); + } + + /// Every documented environment pattern is detected. + #[test] + fn every_documented_environment_pattern_detected(pat in proptest::sample::select(ENVIRONMENT_PATTERNS)) { + let body = format!("please {pat} the tool"); + let messages = vec![(Role::User, body.as_str())]; + let session = make_session("env-coverage", &messages); + let ctx = HeuristicContextExtractor::extract_context(&session); + prop_assert!(!ctx.environment_notes.is_empty(), "environment pattern {pat} must be detected"); + } + + /// Environment notes are detected case-insensitively. + #[test] + fn environment_patterns_case_insensitive( + pat in proptest::sample::select(ENVIRONMENT_PATTERNS), + ) { + let upper = pat.to_uppercase(); + let body = format!("please {upper} the tool"); + let messages = vec![(Role::User, body.as_str())]; + let session = make_session("env-case", &messages); + let ctx = HeuristicContextExtractor::extract_context(&session); + prop_assert!(!ctx.environment_notes.is_empty(), "environment pattern {pat} must match case-insensitively"); + } +} + +// ── Determinism ──────────────────────────────────────────────────────────── + +proptest! { + /// `extract_context` is deterministic across repeated calls. + #[test] + fn extract_context_is_deterministic( + bodies in proptest::collection::vec("[a-zA-Z0-9 ._/:-]{1,40}", 0..6), + ) { + let owned: Vec = bodies.clone(); + let refs: Vec<&str> = owned.iter().map(String::as_str).collect(); + let messages: Vec<(Role, &str)> = refs + .iter() + .enumerate() + .map(|(i, m)| (if i % 2 == 0 { Role::User } else { Role::Assistant }, *m)) + .collect(); + let session = make_session("det", &messages); + let a = HeuristicContextExtractor::extract_context(&session); + let b = HeuristicContextExtractor::extract_context(&session); + prop_assert_eq!(a.cwd, b.cwd); + prop_assert_eq!(a.title, b.title); + prop_assert_eq!(a.files_mentioned, b.files_mentioned); + prop_assert_eq!(a.key_symbols, b.key_symbols); + prop_assert_eq!(a.environment_notes, b.environment_notes); + prop_assert_eq!(a.key_decisions.len(), b.key_decisions.len()); + } + + /// The `ContextExtractor` trait path yields the same `Context` as the + /// associated function. + #[test] + fn trait_path_matches_associated_function( + bodies in proptest::collection::vec("[a-zA-Z0-9 ._/:-]{1,40}", 0..4), + ) { + let owned: Vec = bodies.clone(); + let refs: Vec<&str> = owned.iter().map(String::as_str).collect(); + let messages: Vec<(Role, &str)> = refs + .iter() + .enumerate() + .map(|(i, m)| (if i % 2 == 0 { Role::User } else { Role::Assistant }, *m)) + .collect(); + let session = make_session("trait", &messages); + let via_fn = HeuristicContextExtractor::extract_context(&session); + let extractor = HeuristicContextExtractor::new(); + let via_trait = extractor.extract(&session).expect("extract must succeed"); + prop_assert_eq!(via_fn.cwd, via_trait.cwd); + prop_assert_eq!(via_fn.title, via_trait.title); + prop_assert_eq!(via_fn.files_mentioned, via_trait.files_mentioned); + prop_assert_eq!(via_fn.key_symbols, via_trait.key_symbols); + prop_assert_eq!(via_fn.environment_notes, via_trait.environment_notes); + prop_assert_eq!(via_fn.key_decisions.len(), via_trait.key_decisions.len()); + } +} + +// ── `is_empty` ↔ findings ────────────────────────────────────────────────── + +proptest! { + /// `is_empty()` is true iff every collection is empty AND cwd/title are None. + #[test] + fn is_empty_iff_no_findings( + bodies in proptest::collection::vec("[a-zA-Z0-9 ._/:-]{1,40}", 0..4), + ) { + let owned: Vec = bodies.clone(); + let refs: Vec<&str> = owned.iter().map(String::as_str).collect(); + let messages: Vec<(Role, &str)> = refs + .iter() + .enumerate() + .map(|(i, m)| (if i % 2 == 0 { Role::User } else { Role::Assistant }, *m)) + .collect(); + let session = make_session("empty-iff", &messages); + let ctx = HeuristicContextExtractor::extract_context(&session); + let all_empty = ctx.cwd.is_none() + && ctx.title.is_none() + && ctx.files_mentioned.is_empty() + && ctx.key_decisions.is_empty() + && ctx.key_symbols.is_empty() + && ctx.environment_notes.is_empty(); + if all_empty { + prop_assert!(ctx.is_empty()); + } + if !ctx.is_empty() { + prop_assert!(!all_empty); + } + } +} diff --git a/crates/sl-viewer/tests/properties_session_ledger_contract.rs b/crates/sl-viewer/tests/properties_session_ledger_contract.rs new file mode 100644 index 00000000..e3d152d8 --- /dev/null +++ b/crates/sl-viewer/tests/properties_session_ledger_contract.rs @@ -0,0 +1,371 @@ +//! Property evidence for `session_ledger::distill::contract_extractor::HeuristicContractExtractor`. +//! +//! The heuristic contract extractor is the P2 adapter for [`ContractExtractor`] +//! in the session-ledger distill pipeline. It powers the acceptance-contract +//! slice of any `ContinuationBundle` (success criteria, test commands, +//! constraints, do-not-touch rules). If `extract_contract` drifts — pattern +//! coverage breaks, deduplication stops working, or the trait path diverges +//! from the associated function — every acceptance gate downstream consumes +//! stale contract data. + +use proptest::prelude::*; +use session_ledger::distill::contract_extractor::HeuristicContractExtractor; +use session_ledger::domain::session::{Corpus, Message, Role, Session}; +use session_ledger::ports::ContractExtractor; + +const CRITERIA_PATTERNS: &[&str] = &[ + "goal", "objective", "need to", "needs to", "should work", "want to", "make sure", + "ensure", "purpose", "aim", "requirement", "required", +]; + +const TEST_PATTERNS: &[&str] = &[ + "cargo test", "npm test", "npm run test", "yarn test", "go test", "pytest", + "python -m pytest", "cargo check", "npm run check", "cargo build", "make test", + "bazel test", "run tests", "verify", "verify that", "check that", "validate", + "assert", "assert that", "test that", "should pass", "must pass", +]; + +const CONSTRAINT_PATTERNS: &[&str] = &[ + "must", "must not", "mustn't", "important", "requirement", "required", "mandatory", + "critical", "essential", "necessary", "must be", "has to", "have to", "needs to", + "need to", +]; + +const DO_NOT_TOUCH_PATTERNS: &[&str] = &[ + "don't touch", "do not touch", "don't modify", "do not modify", "don't change", + "do not change", "leave alone", "leave as is", "keep as is", "preserve", "maintain", + "never change", "never modify", "stay 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 +} + +// ── Empty / whitespace sessions ──────────────────────────────────────────── + +proptest! { + /// Empty sessions always produce an empty `Contract`. + #[test] + fn empty_session_produces_empty_contract(_dummy in 0_u8..1) { + let session = Session::new("empty", Corpus::Forge); + let c = HeuristicContractExtractor::extract_contract(&session); + prop_assert!(c.is_empty()); + prop_assert_eq!(c.success_criteria.len(), 0); + prop_assert_eq!(c.tests_or_verifications.len(), 0); + prop_assert_eq!(c.constraints.len(), 0); + prop_assert_eq!(c.do_not_touch.len(), 0); + } + + /// Whitespace-only messages never contribute findings. + #[test] + fn whitespace_only_messages_produce_empty_contract( + n_blank in 1_usize..4, + ) { + let mut messages: Vec<(Role, &str)> = Vec::new(); + for _ in 0..n_blank { + messages.push((Role::User, " \t\n ")); + } + let session = make_session("blank", &messages); + let c = HeuristicContractExtractor::extract_contract(&session); + prop_assert!(c.success_criteria.is_empty()); + prop_assert!(c.tests_or_verifications.is_empty()); + prop_assert!(c.constraints.is_empty()); + prop_assert!(c.do_not_touch.is_empty()); + } +} + +// ── Section shape ────────────────────────────────────────────────────────── + +proptest! { + /// Every success criterion starts with `Goal/requirement: '` and ends with `'`. + #[test] + fn success_criteria_section_shape( + body in "[a-zA-Z0-9 .]{1,80}", + ) { + let owned = format!("the goal is {}", body); + let s = owned.as_str(); + let messages = vec![(Role::User, s)]; + let session = make_session("criteria-shape", &messages); + let c = HeuristicContractExtractor::extract_contract(&session); + for crit in &c.success_criteria { + prop_assert!(crit.starts_with("Goal/requirement: '")); + prop_assert!(crit.ends_with("'")); + } + } + + /// Every test/verification string starts with `Verification: '` and ends with `'`. + #[test] + fn verification_section_shape( + body in "[a-zA-Z0-9 .]{1,80}", + ) { + let owned = format!("verify {}", body); + let s = owned.as_str(); + let messages = vec![(Role::User, s)]; + let session = make_session("verify-shape", &messages); + let c = HeuristicContractExtractor::extract_contract(&session); + for v in &c.tests_or_verifications { + prop_assert!(v.starts_with("Verification: '")); + prop_assert!(v.ends_with("'")); + } + } + + /// Every constraint string starts with `Constraint: '` and ends with `'`. + #[test] + fn constraint_section_shape( + body in "[a-zA-Z0-9 .]{1,80}", + ) { + let owned = format!("this must {}", body); + let s = owned.as_str(); + let messages = vec![(Role::User, s)]; + let session = make_session("constraint-shape", &messages); + let c = HeuristicContractExtractor::extract_contract(&session); + for ct in &c.constraints { + prop_assert!(ct.starts_with("Constraint: '")); + prop_assert!(ct.ends_with("'")); + } + } + + /// Every do-not-touch string starts with `Do-not-touch: '` and ends with `'`. + #[test] + fn do_not_touch_section_shape( + body in "[a-zA-Z0-9 .]{1,80}", + ) { + let owned = format!("don't touch {}", body); + let s = owned.as_str(); + let messages = vec![(Role::User, s)]; + let session = make_session("dnt-shape", &messages); + let c = HeuristicContractExtractor::extract_contract(&session); + for dnt in &c.do_not_touch { + prop_assert!(dnt.starts_with("Do-not-touch: '")); + prop_assert!(dnt.ends_with("'")); + } + } +} + +// ── Pattern coverage ─────────────────────────────────────────────────────── + +proptest! { + /// Every documented criteria pattern is detected on a minimal trigger. + #[test] + fn every_documented_criteria_pattern_detected(pat in proptest::sample::select(CRITERIA_PATTERNS)) { + let body = format!("the {pat} is rust"); + let messages = vec![(Role::User, body.as_str())]; + let session = make_session("criteria-coverage", &messages); + let c = HeuristicContractExtractor::extract_contract(&session); + prop_assert!(!c.success_criteria.is_empty(), "criteria pattern {pat} must be detected"); + } + + /// Every documented test pattern is detected. + #[test] + fn every_documented_test_pattern_detected(pat in proptest::sample::select(TEST_PATTERNS)) { + let body = format!("please {pat} now"); + let messages = vec![(Role::User, body.as_str())]; + let session = make_session("test-coverage", &messages); + let c = HeuristicContractExtractor::extract_contract(&session); + prop_assert!(!c.tests_or_verifications.is_empty(), "test pattern {pat} must be detected"); + } + + /// Every documented constraint pattern is detected. + #[test] + fn every_documented_constraint_pattern_detected(pat in proptest::sample::select(CONSTRAINT_PATTERNS)) { + let body = format!("this {pat} works"); + let messages = vec![(Role::User, body.as_str())]; + let session = make_session("constraint-coverage", &messages); + let c = HeuristicContractExtractor::extract_contract(&session); + prop_assert!(!c.constraints.is_empty(), "constraint pattern {pat} must be detected"); + } + + /// Every documented do-not-touch pattern is detected. + #[test] + fn every_documented_do_not_touch_pattern_detected(pat in proptest::sample::select(DO_NOT_TOUCH_PATTERNS)) { + let body = format!("please {pat} the file"); + let messages = vec![(Role::User, body.as_str())]; + let session = make_session("dnt-coverage", &messages); + let c = HeuristicContractExtractor::extract_contract(&session); + prop_assert!(!c.do_not_touch.is_empty(), "do-not-touch pattern {pat} must be detected"); + } +} + +// ── Case sensitivity ─────────────────────────────────────────────────────── + +proptest! { + /// All patterns are detected case-insensitively. + #[test] + fn all_patterns_case_insensitive( + pat in proptest::sample::select(CRITERIA_PATTERNS), + ) { + let upper = pat.to_uppercase(); + let body = format!("we have a {upper} now"); + let messages = vec![(Role::User, body.as_str())]; + let session = make_session("criteria-case", &messages); + let c = HeuristicContractExtractor::extract_contract(&session); + prop_assert!(!c.success_criteria.is_empty(), "criteria pattern {pat} must match case-insensitively"); + } +} + +// ── Deduplication ────────────────────────────────────────────────────────── + +proptest! { + /// Repeated success criteria trigger phrases are deduplicated (the + /// `Contract.try_join` strings are unique per pattern). + #[test] + fn success_criteria_deduplicated( + body in "[a-zA-Z0-9 .]{1,40}", + ) { + let owned = format!("the goal is {}", body); + let s = owned.as_str(); + let messages = vec![ + (Role::User, s), + (Role::Assistant, s), + (Role::User, s), + ]; + let session = make_session("criteria-dedup", &messages); + let c = HeuristicContractExtractor::extract_contract(&session); + let goal_count = c.success_criteria.iter() + .filter(|x| x.contains("'goal'")) + .count(); + prop_assert_eq!(goal_count, 1, "duplicate criteria must be deduplicated"); + } + + /// Repeated do-not-touch trigger phrases are deduplicated. + #[test] + fn do_not_touch_deduplicated( + body in "[a-zA-Z0-9 .]{1,40}", + ) { + let owned = format!("don't touch {}", body); + let s = owned.as_str(); + let messages = vec![ + (Role::User, s), + (Role::Assistant, s), + (Role::User, s), + ]; + let session = make_session("dnt-dedup", &messages); + let c = HeuristicContractExtractor::extract_contract(&session); + let dnt_count = c.do_not_touch.iter() + .filter(|x| x.contains("don't touch")) + .count(); + prop_assert_eq!(dnt_count, 1, "duplicate do-not-touch must be deduplicated"); + } + + /// Repeated constraint triggers are deduplicated. + #[test] + fn constraints_deduplicated( + body in "[a-zA-Z0-9 .]{1,40}", + ) { + let owned = format!("must {}", body); + let s = owned.as_str(); + let messages = vec![ + (Role::User, s), + (Role::Assistant, s), + (Role::User, s), + ]; + let session = make_session("constraint-dedup", &messages); + let c = HeuristicContractExtractor::extract_contract(&session); + let must_count = c.constraints.iter() + .filter(|x| x.contains("'must'")) + .count(); + prop_assert_eq!(must_count, 1, "duplicate constraints must be deduplicated"); + } + + /// Repeated test patterns are deduplicated. + #[test] + fn tests_deduplicated( + body in "[a-zA-Z0-9 .]{1,40}", + ) { + let owned = format!("cargo test {}", body); + let s = owned.as_str(); + let messages = vec![ + (Role::User, s), + (Role::Assistant, s), + (Role::User, s), + ]; + let session = make_session("test-dedup", &messages); + let c = HeuristicContractExtractor::extract_contract(&session); + let cargo_count = c.tests_or_verifications.iter() + .filter(|x| x.contains("cargo test")) + .count(); + prop_assert_eq!(cargo_count, 1, "duplicate test patterns must be deduplicated"); + } +} + +// ── Determinism ──────────────────────────────────────────────────────────── + +proptest! { + /// `extract_contract` is deterministic across repeated calls. + #[test] + fn extract_contract_is_deterministic( + bodies in proptest::collection::vec("[a-zA-Z0-9 .]{1,40}", 0..6), + ) { + let owned: Vec = bodies.clone(); + let refs: Vec<&str> = owned.iter().map(String::as_str).collect(); + let messages: Vec<(Role, &str)> = refs + .iter() + .enumerate() + .map(|(i, m)| (if i % 2 == 0 { Role::User } else { Role::Assistant }, *m)) + .collect(); + let session = make_session("det", &messages); + let a = HeuristicContractExtractor::extract_contract(&session); + let b = HeuristicContractExtractor::extract_contract(&session); + prop_assert_eq!(a.success_criteria, b.success_criteria); + prop_assert_eq!(a.tests_or_verifications, b.tests_or_verifications); + prop_assert_eq!(a.constraints, b.constraints); + prop_assert_eq!(a.do_not_touch, b.do_not_touch); + } + + /// The `ContractExtractor` trait path yields the same `Contract` as the + /// associated function. + #[test] + fn trait_path_matches_associated_function( + bodies in proptest::collection::vec("[a-zA-Z0-9 .]{1,40}", 0..4), + ) { + let owned: Vec = bodies.clone(); + let refs: Vec<&str> = owned.iter().map(String::as_str).collect(); + let messages: Vec<(Role, &str)> = refs + .iter() + .enumerate() + .map(|(i, m)| (if i % 2 == 0 { Role::User } else { Role::Assistant }, *m)) + .collect(); + let session = make_session("trait", &messages); + let via_fn = HeuristicContractExtractor::extract_contract(&session); + let extractor = HeuristicContractExtractor::new(); + let via_trait = extractor.extract(&session).expect("extract must succeed"); + prop_assert_eq!(via_fn.success_criteria, via_trait.success_criteria); + prop_assert_eq!(via_fn.tests_or_verifications, via_trait.tests_or_verifications); + prop_assert_eq!(via_fn.constraints, via_trait.constraints); + prop_assert_eq!(via_fn.do_not_touch, via_trait.do_not_touch); + } +} + +// ── is_empty ↔ findings ─────────────────────────────────────────────────── + +proptest! { + /// `is_empty()` is true iff every collection is empty. + #[test] + fn is_empty_iff_no_findings( + bodies in proptest::collection::vec("[a-zA-Z0-9 .]{1,40}", 0..4), + ) { + let owned: Vec = bodies.clone(); + let refs: Vec<&str> = owned.iter().map(String::as_str).collect(); + let messages: Vec<(Role, &str)> = refs + .iter() + .enumerate() + .map(|(i, m)| (if i % 2 == 0 { Role::User } else { Role::Assistant }, *m)) + .collect(); + let session = make_session("empty-iff", &messages); + let c = HeuristicContractExtractor::extract_contract(&session); + let all_empty = c.success_criteria.is_empty() + && c.tests_or_verifications.is_empty() + && c.constraints.is_empty() + && c.do_not_touch.is_empty(); + if all_empty { + prop_assert!(c.is_empty()); + } + if !c.is_empty() { + prop_assert!(!all_empty); + } + } +} diff --git a/docs/ops/TRACEABILITY.json b/docs/ops/TRACEABILITY.json index 2a739d7d..a4632c32 100644 --- a/docs/ops/TRACEABILITY.json +++ b/docs/ops/TRACEABILITY.json @@ -331,6 +331,8 @@ "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", + "crates/sl-viewer/tests/properties_session_ledger_context.rs", + "crates/sl-viewer/tests/properties_session_ledger_contract.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 8412ee04..43688e18 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`; `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 | +| 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`; `crates/sl-viewer/tests/properties_session_ledger_context.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; session-ledger heuristic context extractor properties #464; session-ledger heuristic contract extractor properties #465; full loom/shuttle unpaid | ## audit-v38 waves