From 291fe5d7bed0dbf2942946f1064549f74d035980 Mon Sep 17 00:00:00 2001 From: elipeter Date: Fri, 5 Jun 2026 11:36:52 -0500 Subject: [PATCH] updated CHANGELOG.md --- CHANGELOG.md | 1 + LICENSE-GRANTS.md | 16 +-- frontend/src/api/types.ts | 1 + frontend/src/contexts/SSEContext.tsx | 3 + frontend/src/modals/NewScanModal.tsx | 34 +++--- frontend/src/pages/ScanDetailPage.tsx | 20 +++- frontend/src/pages/ScansPage.tsx | 90 +++++++++++++--- frontend/src/styles/global.css | 70 +++++++----- .../src/test/modals/NewScanModal.test.tsx | 13 ++- src/commands/scan.rs | 22 +++- src/server/app.rs | 3 + src/server/jobs.rs | 18 +++- src/server/progress.rs | 102 +++++++++++++++++- 13 files changed, 320 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b637bcc8..8603c892 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,7 @@ The attack-surface map and chain composer turn the flat finding list into a rout ### Frontend +- **Project target selector in `nyx serve`.** The sidebar now remembers scan roots, lets you switch the active target, and accepts a new project path without restarting the server. `/api/targets` backs the selector, scans can opt into a different `scan_root`, and `nyx scan` / `nyx index build` register the projects they touch so `nyx serve` can pick them up later. - **Surface page** with ELK auto-layout and the shared node-style palette. - **Verdict badge** on finding detail, plus a dynamic-verdict section that surfaces the verdict, the payload that triggered it, and a link to the repro bundle. - **Scan compare** gains a dynamic-verdict diff column so two scans can be compared on what was confirmed versus what was downgraded. diff --git a/LICENSE-GRANTS.md b/LICENSE-GRANTS.md index 6ab1d201..dca6bea5 100644 --- a/LICENSE-GRANTS.md +++ b/LICENSE-GRANTS.md @@ -26,7 +26,7 @@ GPL-3.0-or-later, without affecting the public GPL release. ## How forks are affected -A third-party fork of Nyctos that obtains the Nyctos source under PolyForm +A third-party fork of nyx-agent that obtains the nyx-agent source under PolyForm Small Business 1.0.0 (or any successor source-available license) does not acquire any rights to Nyx beyond the public GPL-3.0-or-later terms. The internal grant below is project-to-project and non-transferable. Anyone @@ -39,18 +39,18 @@ dual-licensing grants. ## Grant Register -### Grant 1: Nyctos +### Grant 1: nyx-agent | Field | Value | |---|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | Grantor | Eli Peter, sole copyright holder of Nyx as of the effective date | -| Grantee | The Nyctos project (`Nyctos` daemon, web UI, and accompanying tooling). Repository: `nyctos` | +| Grantee | The nyx-agent project (`nyx-agent` daemon, web UI, and accompanying tooling). Repository: `nyx-agent` | | Effective date | 2026-05-17 | | Scope | All Nyx source code, documentation, fixtures, build artefacts, and binaries (the "Licensed Material") in any version released as of the effective date or thereafter, plus any future modifications the Grantor authors or accepts under the CLA | -| Permitted uses | (a) static or dynamic linking of the Licensed Material into the Nyctos daemon; (b) modification of the Licensed Material as required for Nyctos integration; (c) redistribution of the Licensed Material as part of the Nyctos distribution; (d) sublicensing the Licensed Material to end users of Nyctos solely under whatever license terms Nyctos itself is distributed under (currently PolyForm Small Business 1.0.0, or a separately negotiated commercial license) | -| Restrictions | (a) this grant does not modify, supersede, or revoke the public GPL-3.0-or-later release of Nyx; (b) this grant is non-transferable; only the Nyctos project, owned by the Grantor, may exercise it; (c) any third-party fork of Nyctos must obtain Nyx under the public GPL terms unless it negotiates a separate grant from the Grantor; (d) attribution of Nyx authorship must be preserved in any redistribution per the CLA's moral-rights waiver | -| Duration | Perpetual and irrevocable, subject only to the Grantee maintaining ownership-or-control by the Grantor. If the Nyctos project is sold, assigned, or otherwise transferred to a third party, this grant terminates and the new owner must negotiate a separate license | -| Sublicensing of the grant itself | Not permitted. The Grantee may distribute Nyx as part of Nyctos to end users under Nyctos's outward terms, but the Grantee may not grant any other project the right to use Nyx outside the public GPL terms | +| Permitted uses | (a) static or dynamic linking of the Licensed Material into the nyx-agent daemon; (b) modification of the Licensed Material as required for nyx-agent integration; (c) redistribution of the Licensed Material as part of the nyx-agent distribution; (d) sublicensing the Licensed Material to end users of nyx-agent solely under whatever license terms nyx-agent itself is distributed under (currently PolyForm Small Business 1.0.0, or a separately negotiated commercial license) | +| Restrictions | (a) this grant does not modify, supersede, or revoke the public GPL-3.0-or-later release of Nyx; (b) this grant is non-transferable; only the nyx-agent project, owned by the Grantor, may exercise it; (c) any third-party fork of nyx-agent must obtain Nyx under the public GPL terms unless it negotiates a separate grant from the Grantor; (d) attribution of Nyx authorship must be preserved in any redistribution per the CLA's moral-rights waiver | +| Duration | Perpetual and irrevocable, subject only to the Grantee maintaining ownership-or-control by the Grantor. If the nyx-agent project is sold, assigned, or otherwise transferred to a third party, this grant terminates and the new owner must negotiate a separate license | +| Sublicensing of the grant itself | Not permitted. The Grantee may distribute Nyx as part of nyx-agent to end users under nyx-agent's outward terms, but the Grantee may not grant any other project the right to use Nyx outside the public GPL terms | | Governing law | Same as Nyx CLA | --- @@ -64,7 +64,7 @@ entries with their own date, not as edits to the original. Grants the Grantor anticipates issuing in the future include: -- Commercial-license SKU grants to individual customers of Nyctos that +- Commercial-license SKU grants to individual customers of nyx-agent that exceed the PolyForm Small Business threshold. These will be issued per-customer under a separate Nyx Commercial License contract. - Stewardship-transition grants if the project is ever handed off (for diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 71659e50..e846dfc8 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -172,6 +172,7 @@ export interface TimingBreakdown { call_graph_ms: number; pass2_ms: number; post_process_ms: number; + dynamic_verify_ms?: number; } export interface ScanMetricsSnapshot { diff --git a/frontend/src/contexts/SSEContext.tsx b/frontend/src/contexts/SSEContext.tsx index 397fb53d..6ec0f994 100644 --- a/frontend/src/contexts/SSEContext.tsx +++ b/frontend/src/contexts/SSEContext.tsx @@ -19,6 +19,9 @@ export interface ScanProgress { files_skipped: number; batches_total: number; batches_completed: number; + dynamic_enabled?: boolean; + dynamic_total: number; + dynamic_completed: number; current_file: string; elapsed_ms: number; timing: TimingBreakdown; diff --git a/frontend/src/modals/NewScanModal.tsx b/frontend/src/modals/NewScanModal.tsx index 806a504d..53138693 100644 --- a/frontend/src/modals/NewScanModal.tsx +++ b/frontend/src/modals/NewScanModal.tsx @@ -55,6 +55,7 @@ export function NewScanModal({ open, onClose }: NewScanModalProps) { const [noVerify, setNoVerify] = useState(false); const [verifyBackend, setVerifyBackend] = useState('auto'); const [hardenProfile, setHardenProfile] = useState('standard'); + const showProcessHardening = !noVerify && verifyBackend === 'process'; const handleStart = async () => { const root = scanRoot.trim(); @@ -66,7 +67,9 @@ export function NewScanModal({ open, onClose }: NewScanModalProps) { body.verify = false; } else { body.verify_backend = verifyBackend; - body.harden_profile = hardenProfile; + if (verifyBackend === 'process') { + body.harden_profile = hardenProfile; + } } const payload = Object.keys(body).length ? body : undefined; try { @@ -162,20 +165,21 @@ export function NewScanModal({ open, onClose }: NewScanModalProps) { {BACKEND_HINTS[verifyBackend]} -
- - - {HARDEN_HINTS[hardenProfile]} -
+ {showProcessHardening && ( +
+ + + {HARDEN_HINTS[hardenProfile]} +
+ )}
+ {dynamicVerifyMs > 0 && ( +
+ )}
@@ -188,6 +197,15 @@ function SummaryTab({ scan }: { scan: ScanView }) { >{' '} Post {timing.post_process_ms}ms + {dynamicVerifyMs > 0 && ( + + {' '} + Dynamic {dynamicVerifyMs}ms + + )}
)} diff --git a/frontend/src/pages/ScansPage.tsx b/frontend/src/pages/ScansPage.tsx index 636b67bf..190c6f2d 100644 --- a/frontend/src/pages/ScansPage.tsx +++ b/frontend/src/pages/ScansPage.tsx @@ -29,36 +29,86 @@ function ScanProgress({ }: { data: NonNullable['scanProgress']>; }) { - const stages = [ + type ProgressStage = + | 'discovering' + | 'indexing' + | 'loading_summaries' + | 'building_call_graph' + | 'analyzing' + | 'post_processing' + | 'dynamic_verification' + | 'complete'; + + const hasDynamicStage = + data.dynamic_enabled || + data.dynamic_total > 0 || + data.stage === 'dynamic_verification'; + const stages: ProgressStage[] = [ 'discovering', 'indexing', 'loading_summaries', 'building_call_graph', 'analyzing', 'post_processing', + ...(hasDynamicStage ? ['dynamic_verification' as ProgressStage] : []), 'complete', - ] as const; - const stageLabels: Record = { + ]; + const stageLabels: Record = { discovering: 'Discovering', indexing: 'Indexing', loading_summaries: 'Loading Summaries', building_call_graph: 'Call Graph', analyzing: 'Analyzing', post_processing: 'Post-Process', + dynamic_verification: 'Dynamic Verify', complete: 'Complete', }; - const currentIdx = stages.indexOf(data.stage as (typeof stages)[number]); + const currentIdx = stages.indexOf(data.stage as ProgressStage); - const total = data.files_discovered || 1; - const processed = + const totalFiles = data.files_discovered || 0; + const safeTotalFiles = totalFiles || 1; + const processedFiles = data.stage === 'indexing' ? data.files_parsed : data.stage === 'analyzing' || data.stage === 'post_processing' ? data.files_analyzed : data.stage === 'complete' - ? total + ? totalFiles : 0; - const pct = Math.min(100, (processed / total) * 100); + const dynamicTotal = data.dynamic_total ?? 0; + const dynamicCompleted = Math.min( + data.dynamic_completed ?? 0, + dynamicTotal || data.dynamic_completed || 0, + ); + const clamp01 = (value: number) => Math.max(0, Math.min(1, value)); + const stageProgress = + data.stage === 'indexing' + ? clamp01(data.files_parsed / safeTotalFiles) + : data.stage === 'loading_summaries' || + data.stage === 'building_call_graph' || + data.stage === 'post_processing' + ? 0.5 + : data.stage === 'analyzing' + ? clamp01(data.files_analyzed / safeTotalFiles) + : data.stage === 'dynamic_verification' + ? dynamicTotal > 0 + ? clamp01(dynamicCompleted / dynamicTotal) + : 0 + : data.stage === 'complete' + ? 1 + : 0; + const stageTransitions = stages.length - 1; + const rawPct = + currentIdx >= 0 + ? ((currentIdx + stageProgress) / stageTransitions) * 100 + : 0; + const pct = data.stage === 'complete' ? 100 : Math.min(99, rawPct); + const primaryProgressLabel = + data.stage === 'dynamic_verification' + ? dynamicTotal > 0 + ? `${dynamicCompleted} / ${dynamicTotal} findings verified` + : 'Verifying findings' + : `${processedFiles} / ${totalFiles} files`; const elapsed = data.elapsed_ms ? (data.elapsed_ms / 1000).toFixed(1) + 's' : '-'; @@ -89,23 +139,26 @@ function ScanProgress({
- - {processed} / {data.files_discovered || 0} files - + {primaryProgressLabel} {pct.toFixed(0)}%
{data.files_parsed || 0} indexed {data.files_skipped || 0} reused {data.files_analyzed || 0} analyzed + {dynamicTotal > 0 && {dynamicCompleted} verified}
- {data.batches_total > 0 && ( + {(data.batches_total > 0 || data.stage === 'dynamic_verification') && (
- - Batch {Math.min(data.batches_completed, data.batches_total)} /{' '} - {data.batches_total} - - {stageLabels[data.stage] || data.stage} + {data.batches_total > 0 ? ( + + Batch {Math.min(data.batches_completed, data.batches_total)} /{' '} + {data.batches_total} + + ) : ( + Dynamic verification + )} + {stageLabels[data.stage as ProgressStage] || data.stage}
)}
@@ -113,6 +166,9 @@ function ScanProgress({ Index {data.timing.pass1_ms}ms Graph {data.timing.call_graph_ms}ms Analyze {data.timing.pass2_ms}ms + {(data.timing.dynamic_verify_ms ?? 0) > 0 && ( + Verify {data.timing.dynamic_verify_ms}ms + )}
{data.current_file && (
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index ace0dbef..741b151d 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -179,7 +179,7 @@ a:hover { } .target-switcher { position: relative; - padding: 0 var(--space-3) var(--space-2); + padding: 0 10px var(--space-3); } .target-trigger, .target-option, @@ -191,27 +191,43 @@ a:hover { } .target-trigger { width: 100%; - min-height: 48px; + min-height: 56px; display: grid; - grid-template-columns: 32px minmax(0, 1fr) 12px; + grid-template-columns: 42px minmax(0, 1fr) 14px; align-items: center; - gap: var(--space-2); - padding: 7px 8px; - border: 1px solid var(--border); + gap: var(--space-3); + padding: 6px 8px; + border: 1px solid transparent; border-radius: var(--radius-sm); - background: var(--surface); + background: transparent; color: var(--text); text-align: left; } .target-trigger:hover, .target-trigger[aria-expanded='true'] { - border-color: var(--line-strong); background: var(--bg-secondary); } -.target-avatar, +.target-trigger[aria-expanded='true'] { + box-shadow: inset 0 0 0 1px var(--border); +} +.target-avatar { + width: 42px; + height: 42px; + border-radius: var(--radius-sm); + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--surface); + border: 1px solid var(--border); + color: var(--accent); + font-weight: var(--weight-semibold); + font-size: 1.05rem; + box-shadow: var(--shadow-sm); + flex-shrink: 0; +} .target-option-avatar { - width: 32px; - height: 32px; + width: 30px; + height: 30px; border-radius: var(--radius-sm); display: inline-flex; align-items: center; @@ -219,6 +235,7 @@ a:hover { background: var(--accent-light); color: var(--accent); font-weight: var(--weight-semibold); + font-size: 0.85rem; flex-shrink: 0; } .target-trigger-copy, @@ -231,12 +248,18 @@ a:hover { .target-name, .target-option-name { color: var(--text); - font-size: var(--text-sm); + font-size: 1.05rem; font-weight: var(--weight-semibold); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.target-trigger .target-path { + display: none; +} +.target-option-name { + font-size: var(--text-sm); +} .target-path, .target-option-path { color: var(--text-tertiary); @@ -246,10 +269,10 @@ a:hover { white-space: nowrap; } .target-caret { - width: 8px; - height: 8px; - border-right: 1.5px solid var(--text-tertiary); - border-bottom: 1.5px solid var(--text-tertiary); + width: 10px; + height: 10px; + border-right: 2px solid var(--text-secondary); + border-bottom: 2px solid var(--text-secondary); transform: rotate(45deg) translateY(-2px); transition: transform var(--transition-base); } @@ -258,8 +281,8 @@ a:hover { } .target-menu { position: absolute; - left: var(--space-3); - right: var(--space-3); + left: 10px; + right: 10px; top: calc(100% - var(--space-1)); z-index: 30; padding: var(--space-2); @@ -277,12 +300,12 @@ a:hover { } .target-option { display: grid; - grid-template-columns: 28px minmax(0, 1fr); + grid-template-columns: 30px minmax(0, 1fr); align-items: center; gap: var(--space-2); width: 100%; - min-height: 42px; - padding: 5px 6px; + min-height: 44px; + padding: 6px; border-radius: var(--radius-sm); background: transparent; color: var(--text); @@ -298,11 +321,6 @@ a:hover { cursor: default; opacity: 0.7; } -.target-option-avatar { - width: 28px; - height: 28px; - font-size: 0.8rem; -} .target-add-form { display: grid; grid-template-columns: minmax(0, 1fr) 30px; diff --git a/frontend/src/test/modals/NewScanModal.test.tsx b/frontend/src/test/modals/NewScanModal.test.tsx index 8dffe8b2..fcee52b9 100644 --- a/frontend/src/test/modals/NewScanModal.test.tsx +++ b/frontend/src/test/modals/NewScanModal.test.tsx @@ -48,6 +48,7 @@ describe('NewScanModal', () => { it('calls mutateAsync without verify key when checkbox is untouched', async () => { render(); + expect(screen.queryByText('Process Hardening')).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Start scan' })); await waitFor(() => expect(mockMutateAsync).toHaveBeenCalledOnce()); const payload = mockMutateAsync.mock.calls[0][0]; @@ -55,7 +56,6 @@ describe('NewScanModal', () => { expect(payload).toEqual({ engine_profile: 'balanced', verify_backend: 'auto', - harden_profile: 'standard', }); }); @@ -72,6 +72,7 @@ describe('NewScanModal', () => { render(); const selects = screen.getAllByRole('combobox'); fireEvent.change(selects[2], { target: { value: 'process' } }); + expect(screen.getByText('Process Hardening')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Start scan' })); await waitFor(() => expect(mockMutateAsync).toHaveBeenCalledOnce()); const payload = mockMutateAsync.mock.calls[0][0]; @@ -80,4 +81,14 @@ describe('NewScanModal', () => { harden_profile: 'standard', }); }); + + it('hides process hardening when leaving the process backend', () => { + render(); + const selects = screen.getAllByRole('combobox'); + fireEvent.change(selects[2], { target: { value: 'process' } }); + expect(screen.getByText('Process Hardening')).toBeInTheDocument(); + + fireEvent.change(selects[2], { target: { value: 'docker' } }); + expect(screen.queryByText('Process Hardening')).not.toBeInTheDocument(); + }); }); diff --git a/src/commands/scan.rs b/src/commands/scan.rs index ec7aa6cd..c46d2709 100644 --- a/src/commands/scan.rs +++ b/src/commands/scan.rs @@ -354,11 +354,17 @@ pub(crate) fn verify_findings_for_scan( config: &Config, verbose: bool, use_index_db: bool, + progress: Option<&Arc>, ) -> Option { if !config.scanner.verify { return None; } + if let Some(p) = progress { + p.start_dynamic_verification(diags.len() as u64); + } + let verify_start = std::time::Instant::now(); + let mut opts = crate::dynamic::verify::VerifyOptions::from_config(config); // Phase 30 (Track C observability): surface the per-finding // [`crate::dynamic::trace::VerifyTrace`] on stderr when the operator @@ -405,14 +411,27 @@ pub(crate) fn verify_findings_for_scan( if let Some(trace) = &lane_trace { trace.print_to_stderr(); } + if let Some(p) = progress { + p.inc_dynamic_completed(out.len() as u64); + } out } else { diags .iter() - .map(|d| crate::dynamic::verify::verify_finding(d, &opts)) + .map(|d| { + let result = crate::dynamic::verify::verify_finding(d, &opts); + if let Some(p) = progress { + p.inc_dynamic_completed(1); + } + result + }) .collect() }; + if let Some(p) = progress { + p.record_dynamic_verify_ms(verify_start.elapsed().as_millis() as u64); + } + for (diag, mut result) in diags.iter_mut().zip(results) { if result.status == crate::dynamic::report::VerifyStatus::Confirmed && let Some(ref log_path) = telemetry_log @@ -808,6 +827,7 @@ pub fn handle( config, verbose, index_mode != IndexMode::Off, + None, ); #[cfg(not(feature = "dynamic"))] diff --git a/src/server/app.rs b/src/server/app.rs index 12f454f6..650f69a0 100644 --- a/src/server/app.rs +++ b/src/server/app.rs @@ -38,6 +38,9 @@ pub enum ServerEvent { files_skipped: u64, batches_total: u64, batches_completed: u64, + dynamic_enabled: bool, + dynamic_total: u64, + dynamic_completed: u64, current_file: String, elapsed_ms: u64, timing: TimingBreakdown, diff --git a/src/server/jobs.rs b/src/server/jobs.rs index 42e9b404..8c3cc0ae 100644 --- a/src/server/jobs.rs +++ b/src/server/jobs.rs @@ -9,6 +9,7 @@ use r2d2::Pool; use r2d2_sqlite::SqliteConnectionManager; use std::collections::HashMap; use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Instant; use tokio::sync::broadcast; @@ -107,6 +108,10 @@ impl JobManager { let progress = Arc::new(ScanProgress::new()); let metrics = Arc::new(ScanMetrics::new()); let log_collector = Arc::new(ScanLogCollector::default()); + #[cfg(feature = "dynamic")] + if config.scanner.verify { + progress.expect_dynamic_verification(); + } let engine_version = env!("CARGO_PKG_VERSION").to_string(); @@ -184,11 +189,12 @@ impl JobManager { let progress_for_sse = Arc::clone(&progress); let event_tx_sse = event_tx.clone(); let jid_sse = job_id.clone(); + let progress_done = Arc::new(AtomicBool::new(false)); + let progress_done_sse = Arc::clone(&progress_done); std::thread::spawn(move || { loop { std::thread::sleep(std::time::Duration::from_millis(500)); let snap = progress_for_sse.snapshot(); - let is_complete = snap.stage == "complete"; let _ = event_tx_sse.send(ServerEvent::ScanProgress { job_id: jid_sse.clone(), stage: snap.stage, @@ -198,11 +204,14 @@ impl JobManager { files_skipped: snap.files_skipped, batches_total: snap.batches_total, batches_completed: snap.batches_completed, + dynamic_enabled: snap.dynamic_enabled, + dynamic_total: snap.dynamic_total, + dynamic_completed: snap.dynamic_completed, current_file: snap.current_file, elapsed_ms: snap.elapsed_ms, timing: snap.timing, }); - if is_complete { + if progress_done_sse.load(Ordering::Relaxed) { break; } } @@ -264,6 +273,7 @@ impl JobManager { &config, false, true, + Some(&progress), ); } Ok(diags) @@ -271,6 +281,10 @@ impl JobManager { #[cfg(feature = "dynamic")] crate::dynamic::sandbox::cleanup_docker_containers(); let elapsed = start.elapsed().as_secs_f64(); + if result.is_ok() { + progress.finish_dynamic_verification(); + } + progress_done.store(true, Ordering::Relaxed); // Collect snapshots and do expensive work (post-processing, // JSON serialization) BEFORE acquiring the jobs mutex. diff --git a/src/server/progress.rs b/src/server/progress.rs index 8f7ccad6..f7eef48d 100644 --- a/src/server/progress.rs +++ b/src/server/progress.rs @@ -1,7 +1,7 @@ use serde::Serialize; use std::collections::HashMap; use std::sync::Mutex; -use std::sync::atomic::{AtomicU8, AtomicU64, Ordering::Relaxed}; +use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering::Relaxed}; use std::time::Instant; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -14,7 +14,8 @@ pub enum ScanStage { BuildingCallGraph = 4, Analyzing = 5, PostProcessing = 6, - Complete = 7, + DynamicVerification = 7, + Complete = 8, } impl ScanStage { @@ -27,6 +28,7 @@ impl ScanStage { Self::BuildingCallGraph => "building_call_graph", Self::Analyzing => "analyzing", Self::PostProcessing => "post_processing", + Self::DynamicVerification => "dynamic_verification", Self::Complete => "complete", } } @@ -43,6 +45,10 @@ pub struct ScanProgress { files_skipped: AtomicU64, batches_total: AtomicU64, batches_completed: AtomicU64, + dynamic_expected: AtomicBool, + dynamic_finished: AtomicBool, + dynamic_total: AtomicU64, + dynamic_completed: AtomicU64, current_file: Mutex, started_at: Instant, walk_ms: AtomicU64, @@ -50,6 +56,7 @@ pub struct ScanProgress { call_graph_ms: AtomicU64, pass2_ms: AtomicU64, post_process_ms: AtomicU64, + dynamic_verify_ms: AtomicU64, languages: Mutex>, } @@ -69,6 +76,10 @@ impl ScanProgress { files_skipped: AtomicU64::new(0), batches_total: AtomicU64::new(0), batches_completed: AtomicU64::new(0), + dynamic_expected: AtomicBool::new(false), + dynamic_finished: AtomicBool::new(false), + dynamic_total: AtomicU64::new(0), + dynamic_completed: AtomicU64::new(0), current_file: Mutex::new(String::new()), started_at: Instant::now(), walk_ms: AtomicU64::new(0), @@ -76,14 +87,52 @@ impl ScanProgress { call_graph_ms: AtomicU64::new(0), pass2_ms: AtomicU64::new(0), post_process_ms: AtomicU64::new(0), + dynamic_verify_ms: AtomicU64::new(0), languages: Mutex::new(HashMap::new()), } } pub fn set_stage(&self, stage: ScanStage) { + let stage = if stage == ScanStage::Complete + && self.dynamic_expected.load(Relaxed) + && !self.dynamic_finished.load(Relaxed) + { + ScanStage::PostProcessing + } else { + stage + }; self.stage.store(stage as u8, Relaxed); } + pub fn expect_dynamic_verification(&self) { + self.dynamic_expected.store(true, Relaxed); + self.dynamic_finished.store(false, Relaxed); + self.dynamic_total.store(0, Relaxed); + self.dynamic_completed.store(0, Relaxed); + } + + pub fn start_dynamic_verification(&self, total: u64) { + self.dynamic_expected.store(true, Relaxed); + self.dynamic_finished.store(false, Relaxed); + self.dynamic_total.store(total, Relaxed); + self.dynamic_completed.store(0, Relaxed); + self.stage + .store(ScanStage::DynamicVerification as u8, Relaxed); + } + + pub fn inc_dynamic_completed(&self, n: u64) { + self.dynamic_completed.fetch_add(n, Relaxed); + } + + pub fn finish_dynamic_verification(&self) { + self.dynamic_finished.store(true, Relaxed); + let total = self.dynamic_total.load(Relaxed); + if total > 0 { + self.dynamic_completed.store(total, Relaxed); + } + self.stage.store(ScanStage::Complete as u8, Relaxed); + } + pub fn set_files_discovered(&self, count: u64) { self.files_discovered.store(count, Relaxed); } @@ -143,6 +192,10 @@ impl ScanProgress { self.post_process_ms.fetch_add(ms, Relaxed); } + pub fn record_dynamic_verify_ms(&self, ms: u64) { + self.dynamic_verify_ms.fetch_add(ms, Relaxed); + } + pub fn record_language(&self, lang: &str) { if let Ok(mut langs) = self.languages.try_lock() { *langs.entry(lang.to_string()).or_insert(0) += 1; @@ -158,6 +211,9 @@ impl ScanProgress { x if x == ScanStage::BuildingCallGraph as u8 => ScanStage::BuildingCallGraph.as_str(), x if x == ScanStage::Analyzing as u8 => ScanStage::Analyzing.as_str(), x if x == ScanStage::PostProcessing as u8 => ScanStage::PostProcessing.as_str(), + x if x == ScanStage::DynamicVerification as u8 => { + ScanStage::DynamicVerification.as_str() + } x if x == ScanStage::Complete as u8 => ScanStage::Complete.as_str(), _ => "unknown", } @@ -183,6 +239,9 @@ impl ScanProgress { files_skipped: self.files_skipped.load(Relaxed), batches_total: self.batches_total.load(Relaxed), batches_completed: self.batches_completed.load(Relaxed), + dynamic_enabled: self.dynamic_expected.load(Relaxed), + dynamic_total: self.dynamic_total.load(Relaxed), + dynamic_completed: self.dynamic_completed.load(Relaxed), current_file, elapsed_ms: self.elapsed_ms(), timing: TimingBreakdown { @@ -191,6 +250,7 @@ impl ScanProgress { call_graph_ms: self.call_graph_ms.load(Relaxed), pass2_ms: self.pass2_ms.load(Relaxed), post_process_ms: self.post_process_ms.load(Relaxed), + dynamic_verify_ms: self.dynamic_verify_ms.load(Relaxed), }, languages, } @@ -207,6 +267,9 @@ pub struct ScanProgressSnapshot { pub files_skipped: u64, pub batches_total: u64, pub batches_completed: u64, + pub dynamic_enabled: bool, + pub dynamic_total: u64, + pub dynamic_completed: u64, pub current_file: String, pub elapsed_ms: u64, pub timing: TimingBreakdown, @@ -221,6 +284,8 @@ pub struct TimingBreakdown { pub call_graph_ms: u64, pub pass2_ms: u64, pub post_process_ms: u64, + #[serde(default)] + pub dynamic_verify_ms: u64, } /// Engine-level metrics collected during a scan. @@ -261,6 +326,39 @@ impl ScanMetrics { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dynamic_verification_defers_static_complete_stage() { + let progress = ScanProgress::new(); + + progress.expect_dynamic_verification(); + progress.set_stage(ScanStage::Complete); + + let static_done = progress.snapshot(); + assert_eq!(static_done.stage, "post_processing"); + assert!(static_done.dynamic_enabled); + assert_eq!(static_done.dynamic_total, 0); + assert_eq!(static_done.dynamic_completed, 0); + + progress.start_dynamic_verification(3); + progress.inc_dynamic_completed(2); + + let verifying = progress.snapshot(); + assert_eq!(verifying.stage, "dynamic_verification"); + assert_eq!(verifying.dynamic_total, 3); + assert_eq!(verifying.dynamic_completed, 2); + + progress.finish_dynamic_verification(); + + let complete = progress.snapshot(); + assert_eq!(complete.stage, "complete"); + assert_eq!(complete.dynamic_completed, 3); + } +} + /// Serializable snapshot of engine metrics. #[derive(Debug, Clone, Serialize, Default)] pub struct ScanMetricsSnapshot {