diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 84a29744..6d1686ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -200,7 +200,9 @@ jobs: if: github.event_name == 'pull_request' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: rootless / no-net SelfCheck shell: pwsh run: ./scripts/rootless-nonet-check.ps1 -SelfCheck @@ -250,7 +252,9 @@ jobs: name: rootless-only matrix policy runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: assert rootless-only OCI runner matrix scaffold anchors shell: pwsh run: ./scripts/rootless-matrix-check.ps1 -SelfCheck diff --git a/.github/workflows/hermetic.yml b/.github/workflows/hermetic.yml index 1d2f19fa..93dd6d7f 100644 --- a/.github/workflows/hermetic.yml +++ b/.github/workflows/hermetic.yml @@ -69,9 +69,9 @@ jobs: run: ./scripts/reusable-provenance-check.ps1 -SelfCheck # Reusable workflow — job-level call, pinned to SessionLedger commit SHA. - # Pin: ec8916547e5678f72fe6894509249f9b23367b80 (see docs/ops/reusable-hermetic-pin.md) + # Pin: ec8916547e5678f72fe6894509249f9b23367b80 (see docs/ops/reusable-hermetic-pin.md) sl-daemon-offline-container: name: sl-daemon · repository builder image offline build - uses: KooshaPari/SessionLedger/.github/workflows/reusable-hermetic-build.yml@a8db485c046f9efab8ee51f25edb8f2458c95694 + uses: KooshaPari/SessionLedger/.github/workflows/reusable-hermetic-build.yml@ec8916547e5678f72fe6894509249f9b23367b80 with: - builder_image_digest: sha256:16381cf25d89fd5dc8a904ff4a7b8d4660a856ed9738b8a7e879d816439ce2a5 + builder_image_digest: sha256:16381cf25d89fd5dc8a904ff4a7b8d4660a856ed9738b8a7e879d816439ce2a5 diff --git a/CHANGELOG.md b/CHANGELOG.md index 611d3edc..d975c8b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/); versioning is [SemVer]( - CI drift cleanups (WBS-6.2 #428): `scripts/fuzz-cadence-check.ps1` re-points the "PR smoke stays short" anchor from `ci.yml` (10 s budget) to `fuzz-blocking.yml` (30 s budget) since the PR smoke was consolidated there. `scripts/rootless-nonet-check.ps1` + `.github/workflows/ci.yml` restore the documented `rootless-nonet-policy` cross-reference smoke job, with the script's regex tightened so `continue-on-error` detection can't bleed across jobs. `tests/alloc_profile.rs` + `tests/replay_breadth.rs` clear `clippy::panic_in_if_then` / `clippy::unnecessary_trailing_comma` under `--all-targets --all-features`. +- CI drift cleanups (WBS-6.2 #432 follow-up): `scripts/rootless-matrix-check.ps1` `^ rootless-matrix-policy:.*?continue-on-error:\s*true` regex was bleeding across jobs into the next `security:` job's `continue-on-error: true`; replaced with `[regex]::Match` + a proper terminator (`(?=^ [A-Za-z][\w-]*:\s|\z)`) so the check scopes to just the policy block. Same script now throws when the policy block is absent (was treating a failed match as success). `.github/workflows/ci.yml` pins `actions/checkout` to the immutable `3d3c42e5` SHA + `persist-credentials: false` for both policy jobs. `.github/workflows/hermetic.yml` aligns its reusable-workflow pin to the documented `ec891654` SHA (was `a8db485` — drift between pin doc + caller). `crates/sl-viewer/src/web_exports.rs` uses `WebExportProvider::default_subdir` to populate the `defaults` array in `web_export_roots_with_env` instead of repeating literal strings, fixing a `dead_code` warning that broke `cargo test cli_help` under `-D warnings`. + - Wave-44 plan landed: `WAVE44_SCOPE.md` + `docs/ops/WAVE44_PERT.md` enumerate 6 close-out lanes (3 machine, 3 human-gated) for the 6 unpaid residuals from Wave-43 (396/402 → 402/402 target). Theme: stack-stability closure + i18n migration + eval coverage + supply-chain signing. - Wave-44 reaudit (Wave-44-D): `audit/SCORECARD.md` refresh at commit `13c974f7` (machine-w44-reaudit); `docs/ops/TRACEABILITY.json` overall_audit wave=Wave-44 commit=13c974f7 (conservative hold at 396/402); `docs/ops/GAP_QA_MATRIX.md` C00 + C08 + PLAN-W8-B rows reflect Wave-44 closure (#368 W44-B6 corpus / #372 W44-B1 loom / #373 PERT correction). 2 of 3 machine lanes shipped 2026-07-24; remaining 6 raw pts across C04 L36 / C08 L76 / C11 L110. diff --git a/crates/sl-viewer/src/web_exports.rs b/crates/sl-viewer/src/web_exports.rs index 507f9840..b2b9d407 100644 --- a/crates/sl-viewer/src/web_exports.rs +++ b/crates/sl-viewer/src/web_exports.rs @@ -45,7 +45,7 @@ impl WebExportProvider { } /// The default subdirectory under `~/Downloads` for this provider's exports. - fn default_subdir(self) -> &'static str { + pub fn default_subdir(self) -> &'static str { match self { WebExportProvider::ChatGpt => "ChatGPT", WebExportProvider::Claude => "Claude", @@ -69,9 +69,18 @@ pub fn web_export_roots_with_env( }; let defaults = [ - (WebExportProvider::ChatGpt, home.join("Downloads").join("ChatGPT")), - (WebExportProvider::Claude, home.join("Downloads").join("Claude")), - (WebExportProvider::Gemini, home.join("Downloads").join("Gemini")), + ( + WebExportProvider::ChatGpt, + home.join("Downloads").join(WebExportProvider::ChatGpt.default_subdir()), + ), + ( + WebExportProvider::Claude, + home.join("Downloads").join(WebExportProvider::Claude.default_subdir()), + ), + ( + WebExportProvider::Gemini, + home.join("Downloads").join(WebExportProvider::Gemini.default_subdir()), + ), ]; if !explicit_list.is_empty() { diff --git a/crates/sl-viewer/tests/properties_viewer_unfinished_tab.rs b/crates/sl-viewer/tests/properties_viewer_unfinished_tab.rs index e457c260..de38fbe6 100644 --- a/crates/sl-viewer/tests/properties_viewer_unfinished_tab.rs +++ b/crates/sl-viewer/tests/properties_viewer_unfinished_tab.rs @@ -21,7 +21,7 @@ use proptest::prelude::*; use session_ledger::domain::session::{Corpus, Message, Role, Session}; -use session_ledger::domain::worklog::{UnfinishedReason, UnfinishedWorkItem}; +use session_ledger::domain::worklog::UnfinishedReason; use sl_viewer::unfinished_tab::{reason_label, unfinished_items}; // ── strategies ───────────────────────────────────────────────────────────── @@ -30,19 +30,18 @@ fn session_strategy() -> impl Strategy { ( // session_id — non-empty, identifier-shaped. "[a-zA-Z0-9_-]{1,16}", - // 0..6 messages; mix of roles + content. Bounded so the - // `detect_unfinished` projection runs cheaply. + // 0..6 messages; each message has its own independent + // `Option` ts_ms so the `detect_unfinished` projection's + // `find_map(|m| m.ts_ms).rev()` contract is exercised naturally + // (per-message timestamps, not a session-wide value). prop::collection::vec( - (0u8..5, "[ -~]{1,40}"), + (0u8..5, "[ -~]{1,40}", prop::option::of(0i64..1_000_000_000_000)), 0..6, ), - // last_activity_ms — Some(i64) or None. None is the "unknown - // last activity" sentinel the worklog projector uses. - prop::option::of(0i64..1_000_000_000_000), ) - .prop_map(|(session_id, messages, ts_ms)| { + .prop_map(|(session_id, messages)| { let mut session = Session::new(format!("sess-{session_id}"), Corpus::Forge); - for (role_idx, content) in messages { + for (role_idx, content, ts_ms) in messages { let role = match role_idx % 5 { 0 => Role::User, 1 => Role::Assistant, @@ -109,26 +108,46 @@ proptest! { } /// Property: `unfinished_items` orders known timestamps descending. - /// Two items with the same `last_activity_ms` may appear in any order - /// (we don't constrain the tiebreak here; see next property). + /// Two invariants: + /// (a) within the sliding window of items with a known timestamp, + /// timestamps are non-increasing; + /// (b) once an item with `last_activity_ms == None` appears, no + /// later item may carry a known timestamp (None is the + /// "unknown last activity" sentinel and always sorts last). #[test] fn unfinished_items_orders_known_timestamps_descending( sessions in prop::collection::vec(session_strategy(), 1..10), ) { let items = unfinished_items(&sessions); - // Filter to items with a known timestamp so the descending - // invariant applies cleanly. - let with_ts: Vec<&UnfinishedWorkItem> = - items.iter().filter(|i| i.last_activity_ms.is_some()).collect(); - - for window in with_ts.windows(2) { - let prev = window[0].last_activity_ms.expect("filtered Some"); - let next = window[1].last_activity_ms.expect("filtered Some"); - prop_assert!( - prev >= next, - "known timestamps must be descending: {prev} came before {next}", - ); + // (a) descending among items with a known timestamp. + for window in items.windows(2) { + let prev = &window[0]; + let next = &window[1]; + match (prev.last_activity_ms, next.last_activity_ms) { + (Some(a), Some(b)) => { + prop_assert!( + a >= b, + "known timestamps must be non-increasing: {a} came before {b}", + ); + } + _ => {} + } + } + + // (b) no Some(ts) appears after a None. + let mut seen_none = false; + for item in &items { + if seen_none { + prop_assert!( + item.last_activity_ms.is_none(), + "Some(ts) found after None: {:?} appeared after a None item", + item.last_activity_ms, + ); + } + if item.last_activity_ms.is_none() { + seen_none = true; + } } } @@ -177,4 +196,35 @@ proptest! { "appending sessions must not lose items: base={base_items}, combined={combined_items}", ); } + + /// Property: for each projected item, `last_activity_ms` equals the + /// maximum known `ts_ms` over the *session's* messages, or `None` + /// if none of the session's messages carried a timestamp. This pins + /// the per-message → projected-Item reduction explicitly (the unit + /// tests in `domain/worklog.rs` cover specific values; this property + /// pins the projection over many shapes). + #[test] + fn unfinished_items_last_activity_matches_session_max_ts( + sessions in prop::collection::vec(session_strategy(), 0..8), + ) { + let items = unfinished_items(&sessions); + + for item in &items { + // Reconstruct the source session by id. + let session = sessions + .iter() + .find(|s| s.id == item.session_id) + .expect("projected item must reference an input session"); + + let expected_last_activity_ms = + session.messages.iter().rev().find_map(|m| m.ts_ms); + + prop_assert_eq!( + item.last_activity_ms, + expected_last_activity_ms, + "session {}: projected last_activity_ms must equal session max known ts_ms", + session.id, + ); + } + } } diff --git a/scripts/rootless-matrix-check.ps1 b/scripts/rootless-matrix-check.ps1 index a427b912..02e8206c 100644 --- a/scripts/rootless-matrix-check.ps1 +++ b/scripts/rootless-matrix-check.ps1 @@ -149,7 +149,14 @@ if ($securityWf -match '(?ms)^ rootless-matrix:(.*?)(?=^ [a-z][a-z0-9-]*:)') { } [void](Write-Check -Label "security.yml rootless-matrix job is blocking when present" -Ok $true) -if ($ciWf -match '(?ms)^ rootless-matrix-policy:.*?continue-on-error:\s*true') { +if (-not ($ciWf -match '(?ms)^ rootless-matrix-policy:')) { + throw "ci.yml must define a rootless-matrix-policy job block (C04 L40 cross-reference anchor)." +} +$matrixPolicyBlockMatch = [regex]::Match( + $ciWf, + '(?ms)^ rootless-matrix-policy:.*?(?=^ [A-Za-z][\w-]*:\s|\z)' +) +if ($matrixPolicyBlockMatch.Success -and $matrixPolicyBlockMatch.Value -match 'continue-on-error:\s*true') { throw "ci.yml rootless-matrix-policy job must be blocking (no continue-on-error)." } [void](Write-Check -Label "ci.yml rootless-matrix-policy job is blocking when present" -Ok $true) diff --git a/scripts/rootless-nonet-check.ps1 b/scripts/rootless-nonet-check.ps1 index be461f0c..ea93ee40 100644 --- a/scripts/rootless-nonet-check.ps1 +++ b/scripts/rootless-nonet-check.ps1 @@ -139,7 +139,10 @@ $policyBlockMatch = [regex]::Match( $ciWf, '(?ms)^ rootless-nonet-policy:.*?(?=^ [A-Za-z][\w-]*:\s|\z)' ) -if ($policyBlockMatch.Success -and $policyBlockMatch.Value -match 'continue-on-error:\s*true') { +if (-not $policyBlockMatch.Success) { + throw "ci.yml must define a rootless-nonet-policy job block (C04 L40 cross-reference anchor)." +} +if ($policyBlockMatch.Value -match 'continue-on-error:\s*true') { throw "ci.yml rootless-nonet-policy job must be blocking (no continue-on-error)." } [void](Write-Check -Label "ci.yml rootless-nonet-policy job is blocking when present" -Ok $true) diff --git a/tests/alloc_profile.rs b/tests/alloc_profile.rs index 1787096c..64fd7caf 100644 --- a/tests/alloc_profile.rs +++ b/tests/alloc_profile.rs @@ -82,14 +82,15 @@ fn alloc_profile_script_self_check_parses_args_and_ceilings() { assert!(stdout.contains("Profiler: dhat"), "expected profiler echo, got:\n{stdout}"); } Err(error) => { - // Windows can't fall back to a portable load + print, so the - // spawn failure is unrecoverable there. Other targets run the - // portable fallback below. The clippy `panic_in_if_then` lint - // requires the if-then to have an else branch — fold the - // fallback into `else` so the panic sits on the windows-only path. + // On non-Windows targets, a `NotFound` error means pwsh isn't + // installed — fall back to the portable SelfCheck. Any other + // I/O error (permission, broken pipe, etc.) is treated as a + // hard failure since it likely means the test environment is + // misconfigured (e.g., spawn denial, missing CWD). Windows has + // no portable fallback path, so any spawn failure is fatal. if cfg!(target_os = "windows") { panic!("failed to spawn pwsh for self-check: {error}"); - } else { + } else if error.kind() == std::io::ErrorKind::NotFound { let (max_bytes, total_blocks) = load_profile(); println!( "pwsh unavailable; running portable alloc-profile SelfCheck fallback.\nSelf-check passed\nMax bytes ceiling: {max_bytes}\nTotal blocks ceiling: {total_blocks}\nProfiler: dhat" @@ -102,6 +103,8 @@ fn alloc_profile_script_self_check_parses_args_and_ceilings() { total_blocks >= 1_000, "total_blocks ceiling should stay generous (got {total_blocks})" ); + } else { + panic!("failed to spawn pwsh for self-check (non-NotFound): {error}"); } } }