Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ Versioning: [SemVer 2.0](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [3.2.3] — 2026-07-15

Fix: **the v3.2.2 program-points cap was per-function only**, so scry-on-scry's
~800 functions still produced a ~10 MB `self-analysis.html` (800 × 20 rows).

### Fixed — scry-viz

- Program points now render a **cheap one-row summary for every function**, and a
detailed per-point table only for the first `FUNCS_WITH_DETAIL_CAP` (12)
functions — so the page stays well under 1 MB regardless of function count.
- The page-size test now synthesizes points across **many functions** (500 × 30),
not just many points in one function, so this regression is caught.
- Corrected an inaccurate cap note: the capped per-point rows are scry's library
`AnalysisResult` output, not the `guidance.json` feed (which carries advisories
+ trap verdicts, not per-point invariants).

## [3.2.2] — 2026-07-15

Headline: **make the dashboard readable.** A four-persona review of the deployed
Expand Down
30 changes: 15 additions & 15 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ default-members = [
# on crates.io matches the release artifacts. The crates.io publish workflow
# asserts the pushed `v*` tag equals this version, so a release bump must move
# both in lockstep (and the internal path-dep `version = "..."` fields below).
version = "3.2.2"
version = "3.2.3"
edition = "2024"
license = "MIT OR Apache-2.0"
repository = "https://github.com/pulseengine/scry"
Expand Down
18 changes: 9 additions & 9 deletions crates/scry-analyze-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ path = "src/lib.rs"
# Path deps carry `version` so `cargo publish` rewrites them to the crates.io
# coordinate (crates.io rejects path-only deps). The version equals the
# workspace version and must be bumped in lockstep with it.
scry-sai-interval = { path = "../scry-interval", version = "3.2.2" }
scry-sai-interval = { path = "../scry-interval", version = "3.2.3" }

# Step 2 (DD-012): the analyze body + helpers moved here. wasmparser parses
# the input Wasm Core Model module; sha2 digests the module bytes for
Expand All @@ -43,44 +43,44 @@ sha2 = { workspace = true }

# Security-label (taint) lattice for the noninterference analysis (FEAT-009)
# and the pure meld<->scry provenance boundary crate (FEAT-002 / DD-002).
scry-sai-taint = { path = "../scry-taint", version = "3.2.2" }
scry-sai-provenance = { path = "../scry-provenance", version = "3.2.2" }
scry-sai-taint = { path = "../scry-taint", version = "3.2.3" }
scry-sai-provenance = { path = "../scry-provenance", version = "3.2.3" }

# Octagon relational domain (FEAT-016 slice-2b-ii): carried alongside the
# intervals through the structured-CFG fixpoint so a loop counter bounded by a
# VARIABLE relation (`i < n`) stays bounded where the interval domain alone
# widens it to ⊤. Same pure `#![no_std]` dual-compile crate as scry-interval.
scry-sai-octagon = { path = "../scry-octagon", version = "3.2.2" }
scry-sai-octagon = { path = "../scry-octagon", version = "3.2.3" }

# Known-bits × interval-guarded congruence reduced product (FEAT-037 / DD-017):
# an additive bit/alignment/stride companion computed in a straight-line-sound
# pass, surfaced library-only on `AnalysisResult.bit_facts`. Same pure
# `#![no_std]` dual-compile crate as the other domains.
scry-sai-bits = { path = "../scry-bits", version = "3.2.2" }
scry-sai-bits = { path = "../scry-bits", version = "3.2.3" }

# Pentagons weakly-relational domain (FEAT-044 / AC-014): intervals + strict
# `x < y` facts, the cheap relational layer behind sound out-of-bounds-trap
# detection (FEAT-046). An additive guard-recording pass surfaces proven
# strict relations library-only on `AnalysisResult.pentagon_facts`. Same pure
# `#![no_std]` dual-compile crate as the other domains.
scry-sai-pentagon = { path = "../scry-pentagon", version = "3.2.2" }
scry-sai-pentagon = { path = "../scry-pentagon", version = "3.2.3" }

# IEEE-754 float-interval domain (FEAT-047 / AC-022): sound f32/f64 abstraction
# with NaN/±inf tracking + round-to-nearest-aware widening. An additive
# straight-line pass surfaces sound float intervals library-only on
# `AnalysisResult.float_facts`. Same pure `#![no_std]` dual-compile crate.
scry-sai-float = { path = "../scry-float", version = "3.2.2" }
scry-sai-float = { path = "../scry-float", version = "3.2.3" }

# Affine Component-Model handle-state lattice (FEAT-049 / MF-007): tracks
# own/borrow resource-handle state to flag use-after-drop / double-drop. A
# straight-line pass over the canonical-ABI `[resource-drop]` call sites
# surfaces findings library-only on `AnalysisResult.handle_findings`.
scry-sai-handle = { path = "../scry-handle", version = "3.2.2" }
scry-sai-handle = { path = "../scry-handle", version = "3.2.3" }

# FEAT-058: the linear-memory segmentation domain (content-sensitive memory).
# The interpreter tracks per-offset interval content for i32 loads/stores
# instead of degrading every load to ⊤.
scry-sai-segment = { path = "../scry-segment", version = "3.2.2" }
scry-sai-segment = { path = "../scry-segment", version = "3.2.3" }

[dev-dependencies]
# Test-only (the crate is otherwise dep-light + no_std): assemble the .wat
Expand Down
2 changes: 1 addition & 1 deletion crates/scry-analyze-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -856,7 +856,7 @@ mod domain {
scry_taint::join(a, b)
}
}
const SCRY_VERSION: &str = "3.2.2";
const SCRY_VERSION: &str = "3.2.3";
const INVARIANT_SCHEMA_URL: &str = "https://pulseengine.eu/scry-invariants/v1";

/// Default Wasm linear-memory page size (64 KiB).
Expand Down
2 changes: 1 addition & 1 deletion crates/scry-segment/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@ path = "src/lib.rs"
# The per-segment content domain. Path dep carries `version` so `cargo publish`
# rewrites it to the crates.io coordinate; the version equals the workspace
# version and is bumped in lockstep.
scry-sai-interval = { path = "../scry-interval", version = "3.2.2" }
scry-sai-interval = { path = "../scry-interval", version = "3.2.3" }
2 changes: 1 addition & 1 deletion crates/scry-viz/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ path = "src/main.rs"
# The only dependency: the published analyzer library. scry-viz is a plain
# `std` host tool, so it can read the `AnalysisResult` plain-Rust types and
# render them — no WIT, no component, no wasmtime.
scry-sai-core = { path = "../scry-analyze-core", version = "3.2.2" }
scry-sai-core = { path = "../scry-analyze-core", version = "3.2.3" }
# Assemble `.wat` inputs to module bytes (so the CLI accepts both .wat and
# .wasm); host-only, same dep the test harness uses.
wat = { workspace = true }
Expand Down
97 changes: 64 additions & 33 deletions crates/scry-viz/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,19 @@ use scry_analyze_core::{
/// dashboard"). Used in both the `<title>` and the `<h1>`.
const HERO_TITLE: &str = "scry — a sound static analyzer for WebAssembly";

/// Program-points cap: the number of per-function points rendered inline. Above
/// this, a function shows its first `POINTS_PER_FN_CAP` points and a "… showing
/// N of M; full data in the JSON feed" note. This keeps the whole HTML small
/// (the un-capped points section is ~90% of the bytes on scry-on-scry).
/// Program-points cap: the number of per-function points rendered inline in a
/// detailed table. Above this, a function shows its first `POINTS_PER_FN_CAP`
/// points and a "… showing N of M" note.
const POINTS_PER_FN_CAP: usize = 20;

/// Cap on the number of FUNCTIONS rendered with a detailed per-point table.
/// scry-on-scry has ~800 functions, so a per-function cap alone still yields a
/// multi-MB dump (800 × 20 rows). All four persona reviews found the raw
/// per-point dump is noise for readers, so every function appears in a cheap
/// one-row summary and only the first `FUNCS_WITH_DETAIL_CAP` get a detailed
/// table; the full per-point invariants are scry's library `AnalysisResult`.
const FUNCS_WITH_DETAIL_CAP: usize = 12;

/// Guidance cap: for every advisory class EXCEPT `DefiniteFault` (always shown
/// in full — proven bugs), render at most this many rows, then a "… and N more"
/// line. Faults are never elided.
Expand Down Expand Up @@ -1110,15 +1117,37 @@ fn render_points(s: &mut String, r: &AnalysisResult) {
let mut func_indices: Vec<u32> = points.iter().map(|p| p.func_index).collect();
func_indices.sort_unstable();
func_indices.dedup();
for idx in func_indices {
let n_funcs = func_indices.len();

// Compact summary over EVERY function (one cheap row each — small even for
// scry-on-scry's ~800 functions). The detailed per-point tables below are
// capped by function count AND points/function, or the section is a dump.
let _ = write!(
s,
"<p class=\"muted\">{n_funcs} function(s) with program points — summary below; \
detailed per-point invariants for the first {FUNCS_WITH_DETAIL_CAP}. The full \
per-point data is scry's library <code>AnalysisResult</code>; the actionable \
subset is in <code>guidance.json</code>.</p>\
<table><thead><tr><th>function</th><th>points</th><th>max locals</th>\
</tr></thead><tbody>",
);
for &idx in &func_indices {
let fp = points.iter().filter(|p| p.func_index == idx);
let count = fp.clone().count();
let nloc = fp.map(|p| p.locals.len()).max().unwrap_or(0);
let _ = write!(
s,
"<tr><td>{}</td><td>{count}</td><td>{nloc}</td></tr>",
fn_link(r, idx),
);
}
s.push_str("</tbody></table>");

for &idx in func_indices.iter().take(FUNCS_WITH_DETAIL_CAP) {
let heading = match fn_meta(r, idx).and_then(|m| m.name.as_deref()) {
Some(n) => format!("func {idx} · {}", name_span(&demangle(n))),
None => format!("func {idx}"),
};
// Per-function summary + cap. The un-capped points section is ~90% of
// the bytes on scry-on-scry (16k+ points), so we render a SUMMARY
// (name, #points, #locals) and only the first `POINTS_PER_FN_CAP`
// points per function; the full per-point data lives in the JSON feed.
let fn_points: Vec<_> = points.iter().filter(|p| p.func_index == idx).collect();
let n_points = fn_points.len();
let n_locals = fn_points.iter().map(|p| p.locals.len()).max().unwrap_or(0);
Expand Down Expand Up @@ -1189,8 +1218,8 @@ fn render_points(s: &mut String, r: &AnalysisResult) {
if n_points > POINTS_PER_FN_CAP {
let _ = write!(
s,
"<p class=\"muted\">… showing {} of {} points; full data in the JSON feed \
(<code>guidance.json</code>).</p>",
"<p class=\"muted\">… showing {} of {} points for this function \
(full per-point invariants are scry's library output).</p>",
POINTS_PER_FN_CAP, n_points,
);
}
Expand Down Expand Up @@ -1914,41 +1943,43 @@ mod tests {

#[test]
fn points_section_is_capped_and_page_stays_small() {
// Page-size sanity: a function with far more than the cap of program
// points must render only the first `POINTS_PER_FN_CAP`, plus a summary
// and a "showing N of M" note — and the whole page must stay well under
// 1 MB. We synthesize a large point set on a real result (the analyzer's
// own point count depends on its fixpoint, so we don't rely on it).
// Page-size sanity: scry-on-scry has ~800 FUNCTIONS each with points, so
// a per-function cap alone is not enough (800 × 20 rows is still ~10 MB).
// Synthesize points across many functions and assert (a) only the first
// `FUNCS_WITH_DETAIL_CAP` functions get a detailed table, and (b) the
// whole page stays well under 1 MB.
let mut r = analyze_wat(
"(module (func (export \"run\") (result i32) i32.const 42 i32.const 7 i32.add))",
);
let template = r.invariants.points[0].clone();
r.invariants.points.clear();
for pc in 0..3000u32 {
let mut p = template.clone();
p.func_index = 0;
p.pc = pc;
r.invariants.points.push(p);
const FUNCS: u32 = 500;
const PTS_PER_FN: u32 = 30;
for f in 0..FUNCS {
for pc in 0..PTS_PER_FN {
let mut p = template.clone();
p.func_index = f;
p.pc = pc;
r.invariants.points.push(p);
}
}
let total = r.invariants.points.len();
assert!(
total > POINTS_PER_FN_CAP * 5,
"fixture must produce many points (got {total})"
);
assert_eq!(r.invariants.points.len() as u32, FUNCS * PTS_PER_FN);
let html = render_html(&r, "big");
// The cap note is present
// Every function appears in the cheap summary line
assert!(
html.contains("full data in the JSON feed"),
"capped points note present"
html.contains("function(s) with program points"),
"per-function summary present"
);
// … but only the first FUNCS_WITH_DETAIL_CAP get a detailed table.
let detailed = html.matches("class=\"fn-points\"").count();
assert!(
html.contains("program point(s)"),
"per-function summary line"
detailed <= FUNCS_WITH_DETAIL_CAP,
"detailed tables capped at {FUNCS_WITH_DETAIL_CAP}, got {detailed}"
);
// … and the page is small despite thousands of points.
// … and the whole page stays under 1 MB despite 15k points / 500 funcs.
assert!(
html.len() < 1_000_000,
"page must stay under 1 MB even for many points; was {} bytes",
"page must stay under 1 MB across many functions; was {} bytes",
html.len()
);
}
Expand Down
Loading