nyx/tests/js_ts_pass2_convergence_tests.rs
Eli Peter a438886217
Python fp and docs updtes (#58)
* refactor: Update comments for clarity and add expectations.json files for performance metrics

* feat: Implement FP guard for JS/TS local-collection receivers to suppress missing ownership checks

* feat: Enhance Rust parameter handling to classify local collections and prevent false ownership checks

* refactor: Simplify code formatting for better readability in multiple files

* refactor: Improve UTF-8 sequence length handling and enhance clarity in loop iteration

* feat: Update Java and Python patterns to include new security rules

* refactor: Improve comment clarity and consistency across multiple Rust files

* refactor: Simplify code formatting for improved readability in integration tests and module files

* refactor: Improve comment formatting and enhance clarity in assertions across multiple files
2026-04-29 19:53:34 -04:00

180 lines
7.5 KiB
Rust

//! Regression tests for JS/TS in-file pass-2 convergence.
//!
//! Pass 2 is the Jacobi-style iteration that combines each non-toplevel
//! body's exit state (filtered to top-level keys) back into the shared
//! seed and re-runs non-toplevel bodies with the enlarged seed. The
//! hardcoded cap of `3` that used to live in `analyse_file` silently
//! truncated any file whose convergence required 4+ rounds, this
//! phase lifts the cap to [`JS_TS_PASS2_SAFETY_CAP`] (64), adds an
//! observability counter, and tags cap-hit findings with
//! [`EngineNote::InFileFixpointCapped`].
//!
//! Mirrors `tests/scc_convergence_tests.rs` in structure and intent.
//!
//! If you raise or lower the cap in `taint::JS_TS_PASS2_SAFETY_CAP`,
//! update the iteration-count assertions accordingly.
mod common;
use common::{scan_fixture_dir, validate_expectations};
use nyx_scanner::taint::{last_js_ts_pass2_iterations, set_js_ts_pass2_cap_override};
use nyx_scanner::utils::config::AnalysisMode;
use std::path::Path;
use std::sync::Mutex;
fn fixture_path(name: &str) -> std::path::PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures")
.join(name)
}
/// Serialize any test that mutates the pass-2 cap override or reads
/// `last_js_ts_pass2_iterations()`. The override is a process-wide
/// `AtomicUsize` and `cargo test` runs tests in parallel by default ,
/// without this guard, one test's override leaks into another's scan.
static PASS2_TEST_GUARD: Mutex<()> = Mutex::new(());
/// Five top-level `const` bindings threaded through four helper
/// functions. With the default cap of 64 this converges and the
/// `child_process.exec(stage4)` sink sees the transitive taint flow.
///
/// The test asserts both that the finding is reported and that the
/// observability counter surfaces a sensible value so future
/// regressions in the pass-2 plumbing (e.g. the counter being reset
/// or the cap being bypassed) are caught.
#[test]
fn js_ts_pass2_deep_chain_emits_transitive_finding() {
let _guard = PASS2_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
set_js_ts_pass2_cap_override(0); // ensure no stale override from a prior test
let dir = fixture_path("js_ts_pass2_deep_chain");
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
// Hard assertion: the transitive taint must be detected.
validate_expectations(&diags, &dir);
// Observability + load-bearing assertion. The fixture's
// cross-body global publishing means pass-2 has to do real work:
// at least two rounds are needed for `seed_handler`'s
// `globalG1` to reach `finalize_handler`'s sink, then one more
// round to confirm convergence. A drop to `1` means the pass-2
// loop is short-circuiting and this fixture is no longer
// load-bearing. An upper bound of `8` catches summary-monotonicity
// regressions that would churn near the safety cap of 64.
let iters = last_js_ts_pass2_iterations();
assert!(
(2..=8).contains(&iters),
"expected 2..=8 pass-2 iterations for the deep-chain fixture; \
got {iters}. Lower bound guards against pass-2 becoming a \
no-op on this fixture; upper bound guards against \
summary-monotonicity regressions.",
);
}
/// Override plumbing: verify that `set_js_ts_pass2_cap_override` binds
/// the effective cap and that restoring the default clears cleanly.
///
/// We use a cap of 1 (meaning `rounds == 0`, the pass-2 loop does not
/// enter). This is the sharpest possible override and exercises the
/// "cap bound to minimum" code path. The counter must then fall back
/// to the pass-1-only value of `1`.
#[test]
fn js_ts_pass2_cap_override_binds_effective_cap() {
let _guard = PASS2_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
let dir = fixture_path("js_ts_pass2_deep_chain");
// First scan with the cap forced to 1, the pass-2 loop does not
// enter at all (`max_iterations.saturating_sub(1) == 0`). The
// counter must report exactly `1` (the sentinel for "pass-1
// containment ran, no pass-2 iterations").
set_js_ts_pass2_cap_override(1);
let _ = scan_fixture_dir(&dir, AnalysisMode::Full);
let iters_capped = last_js_ts_pass2_iterations();
assert_eq!(
iters_capped, 1,
"cap=1 must short-circuit pass-2 to zero rounds; got {iters_capped} \
iterations. Check js_ts_pass2_cap() is wired into max_iterations.",
);
// Restore default and scan again. On this fixture pass-2 needs
// several rounds to converge, so the counter must now report a
// value strictly greater than the cap=1 short-circuit reading.
// This guards against the override "sticking" (e.g. if the
// override were stored into the cap const instead of a distinct
// atomic).
set_js_ts_pass2_cap_override(0);
let _ = scan_fixture_dir(&dir, AnalysisMode::Full);
let iters_default = last_js_ts_pass2_iterations();
assert!(
iters_default > iters_capped && iters_default <= 64,
"after clearing the override the counter must report a value \
strictly greater than the cap=1 reading ({iters_capped}); \
got {iters_default}",
);
}
/// Cap-hit engine-note emission.
///
/// When pass-2 exhausts its budget without detecting convergence,
/// every finding from the file must carry
/// [`EngineNote::InFileFixpointCapped`] so downstream reviewers can
/// identify potentially-imprecise results. The deep-chain fixture's
/// pass-2 seed actually grows between rounds (`seed_handler` publishes
/// `globalG1` to other bodies), so forcing the cap to `2` binds the
/// loop at a single round, the seed grew, no convergence was
/// detected, and the note path fires.
#[test]
fn js_ts_pass2_cap_hit_emits_engine_note() {
use nyx_scanner::engine_notes::EngineNote;
let _guard = PASS2_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
let dir = fixture_path("js_ts_pass2_deep_chain");
// cap=2 → max_iterations=2, rounds=1. Round 0 combines
// `seed_handler`'s exit (which includes `globalG1`) into the
// seed, the seed grows from empty to 1 entry, so the
// convergence-equality branch does not fire. Loop exits with
// `converged_early = false`, note emission triggers.
set_js_ts_pass2_cap_override(2);
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
set_js_ts_pass2_cap_override(0);
let iters = last_js_ts_pass2_iterations();
assert_eq!(
iters, 1,
"expected cap-override (2) to bind the pass-2 loop at 1 round; \
got {iters} iterations",
);
// Every finding produced from the capped file must carry the note.
// Strict assertion: if *any* finding lacks the note, pass-2's
// per-finding note merging regressed.
assert!(
!diags.is_empty(),
"cap-hit scan must still emit diagnostics (truncation is not silent drop); \
got none",
);
for d in &diags {
let has_note = d
.evidence
.as_ref()
.map(|e| {
e.engine_notes
.iter()
.any(|n| matches!(n, EngineNote::InFileFixpointCapped { .. }))
})
.unwrap_or(false);
assert!(
has_note,
"every diag from a cap-hit pass-2 scan must carry \
EngineNote::InFileFixpointCapped; missing on {}:{}:{} id={} \
notes={:?}",
d.path,
d.line,
d.col,
d.id,
d.evidence
.as_ref()
.map(|e| e.engine_notes.clone())
.unwrap_or_default(),
);
}
}