updated CHANGELOG.md

This commit is contained in:
elipeter 2026-06-05 11:36:52 -05:00
parent 25863d222a
commit 291fe5d7be
13 changed files with 320 additions and 73 deletions

View file

@ -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.

View file

@ -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

View file

@ -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 {

View file

@ -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;

View file

@ -55,6 +55,7 @@ export function NewScanModal({ open, onClose }: NewScanModalProps) {
const [noVerify, setNoVerify] = useState(false);
const [verifyBackend, setVerifyBackend] = useState<VerifyBackend>('auto');
const [hardenProfile, setHardenProfile] = useState<HardenProfile>('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) {
</select>
<span className="form-hint">{BACKEND_HINTS[verifyBackend]}</span>
</div>
<div className="form-group">
<label>Process Hardening</label>
<select
value={hardenProfile}
disabled={noVerify || verifyBackend !== 'process'}
onChange={(e) =>
setHardenProfile(e.target.value as HardenProfile)
}
>
<option value="standard">Standard</option>
<option value="strict">Strict</option>
</select>
<span className="form-hint">{HARDEN_HINTS[hardenProfile]}</span>
</div>
{showProcessHardening && (
<div className="form-group">
<label>Process Hardening</label>
<select
value={hardenProfile}
onChange={(e) =>
setHardenProfile(e.target.value as HardenProfile)
}
>
<option value="standard">Standard</option>
<option value="strict">Strict</option>
</select>
<span className="form-hint">{HARDEN_HINTS[hardenProfile]}</span>
</div>
)}
<div className="scan-modal-actions">
<button className="btn btn-sm" onClick={onClose}>
Cancel

View file

@ -34,6 +34,7 @@ function SummaryTab({ scan }: { scan: ScanView }) {
const langs = (scan.languages || []).join(', ') || '-';
const timing = scan.timing;
const dynamicVerifyMs = timing?.dynamic_verify_ms ?? 0;
let total = 0;
if (timing) {
total =
@ -41,7 +42,8 @@ function SummaryTab({ scan }: { scan: ScanView }) {
timing.pass1_ms +
timing.call_graph_ms +
timing.pass2_ms +
timing.post_process_ms;
timing.post_process_ms +
dynamicVerifyMs;
}
const pct = (ms: number) => ((ms / total) * 100).toFixed(1);
@ -151,6 +153,13 @@ function SummaryTab({ scan }: { scan: ScanView }) {
style={{ width: `${pct(timing.post_process_ms)}%` }}
title={`Post-process: ${timing.post_process_ms}ms`}
></div>
{dynamicVerifyMs > 0 && (
<div
className="timing-bar-segment postprocess"
style={{ width: `${pct(dynamicVerifyMs)}%` }}
title={`Dynamic verification: ${dynamicVerifyMs}ms`}
></div>
)}
</div>
<div className="timing-legend">
<span className="timing-legend-item">
@ -188,6 +197,15 @@ function SummaryTab({ scan }: { scan: ScanView }) {
></span>{' '}
Post {timing.post_process_ms}ms
</span>
{dynamicVerifyMs > 0 && (
<span className="timing-legend-item">
<span
className="timing-legend-dot"
style={{ background: 'var(--text-tertiary)' }}
></span>{' '}
Dynamic {dynamicVerifyMs}ms
</span>
)}
</div>
</div>
)}

View file

@ -29,36 +29,86 @@ function ScanProgress({
}: {
data: NonNullable<ReturnType<typeof useSSE>['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<string, string> = {
];
const stageLabels: Record<ProgressStage, string> = {
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({
<div className="progress-bar-fill" style={{ width: `${pct}%` }}></div>
</div>
<div className="progress-stats">
<span>
{processed} / {data.files_discovered || 0} files
</span>
<span>{primaryProgressLabel}</span>
<span>{pct.toFixed(0)}%</span>
</div>
<div className="progress-stats">
<span>{data.files_parsed || 0} indexed</span>
<span>{data.files_skipped || 0} reused</span>
<span>{data.files_analyzed || 0} analyzed</span>
{dynamicTotal > 0 && <span>{dynamicCompleted} verified</span>}
</div>
{data.batches_total > 0 && (
{(data.batches_total > 0 || data.stage === 'dynamic_verification') && (
<div className="progress-stats">
<span>
Batch {Math.min(data.batches_completed, data.batches_total)} /{' '}
{data.batches_total}
</span>
<span>{stageLabels[data.stage] || data.stage}</span>
{data.batches_total > 0 ? (
<span>
Batch {Math.min(data.batches_completed, data.batches_total)} /{' '}
{data.batches_total}
</span>
) : (
<span>Dynamic verification</span>
)}
<span>{stageLabels[data.stage as ProgressStage] || data.stage}</span>
</div>
)}
<div className="progress-stats">
@ -113,6 +166,9 @@ function ScanProgress({
<span>Index {data.timing.pass1_ms}ms</span>
<span>Graph {data.timing.call_graph_ms}ms</span>
<span>Analyze {data.timing.pass2_ms}ms</span>
{(data.timing.dynamic_verify_ms ?? 0) > 0 && (
<span>Verify {data.timing.dynamic_verify_ms}ms</span>
)}
</div>
{data.current_file && (
<div className="progress-current-file">

View file

@ -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;

View file

@ -48,6 +48,7 @@ describe('NewScanModal', () => {
it('calls mutateAsync without verify key when checkbox is untouched', async () => {
render(<NewScanModal open={true} onClose={vi.fn()} />);
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(<NewScanModal open={true} onClose={vi.fn()} />);
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(<NewScanModal open={true} onClose={vi.fn()} />);
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();
});
});

View file

@ -354,11 +354,17 @@ pub(crate) fn verify_findings_for_scan(
config: &Config,
verbose: bool,
use_index_db: bool,
progress: Option<&Arc<ScanProgress>>,
) -> Option<crate::dynamic::verify::VerifyOptions> {
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"))]

View file

@ -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,

View file

@ -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.

View file

@ -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<String>,
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<HashMap<String, u64>>,
}
@ -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 {