From 46a455db7c68f156a3839f03ef908ac196f0b91a Mon Sep 17 00:00:00 2001
From: Ralf Anton Beier
Date: Wed, 15 Jul 2026 06:20:58 +0200
Subject: [PATCH] =?UTF-8?q?release:=20v3.2.4=20=E2=80=94=20cap=20ALL=20fla?=
=?UTF-8?q?t=20viz=20sections=20(final=20page-size=20pass)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
After v3.2.3 the deployed self-analysis.html was still ~4.9 MB: capping program
points alone wasn't enough — Diagnostics (~1.7 MB), Call graph (~1.7 MB), Trap
checks, Functions, gaps/handle/float/pentagon each render one row per entry, and
scry-on-scry produces thousands of each.
scry-viz: a shared SECTION_ROW_CAP (100) + cap_note applied to every flat
section; the call-graph Mermaid diagram is skipped for graphs > cap (a
thousands-edge Mermaid source was itself ~1.7 MB). Trap checks show POTENTIAL-TRAP
first so the cap never hides one. New comprehensive test blows up EVERY section
to 4000 entries and asserts the page stays <1 MB (would have caught the earlier
partial fixes). Full un-capped data remains in guidance.json.
Version 3.2.3 → 3.2.4 + Cargo.lock. 25 viz tests, clippy + fmt clean.
Co-Authored-By: Claude Opus 4.8
---
CHANGELOG.md | 21 +++++
Cargo.lock | 30 +++----
Cargo.toml | 2 +-
crates/scry-analyze-core/Cargo.toml | 18 ++--
crates/scry-analyze-core/src/lib.rs | 2 +-
crates/scry-segment/Cargo.toml | 2 +-
crates/scry-viz/Cargo.toml | 2 +-
crates/scry-viz/src/lib.rs | 127 ++++++++++++++++++++++++----
8 files changed, 158 insertions(+), 46 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3f3a7ae..dab358e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,27 @@ Versioning: [SemVer 2.0](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
+## [3.2.4] — 2026-07-15
+
+Fix (final page-size pass): after v3.2.3 the deployed `self-analysis.html` was
+still ~4.9 MB — capping *program points* alone wasn't enough. The other flat
+sections (Diagnostics ~1.7 MB, Call graph ~1.7 MB, Trap checks, Functions, gaps,
+handle/float/pentagon facts) render one row per entry and scry-on-scry produces
+thousands of each.
+
+### Fixed — scry-viz
+
+- **Every flat list/table section is now capped** (`SECTION_ROW_CAP` = 100 rows +
+ a "showing N of M" note): Diagnostics, Call graph (edge table), Trap checks
+ (POTENTIAL-TRAPs shown first so the cap never hides one), Functions, gaps,
+ handle faults, float facts, relational guards.
+- **The call-graph diagram is skipped for large graphs** — a Mermaid source of
+ thousands of edges was itself ~1.7 MB.
+- **New comprehensive test** `all_flat_sections_capped_page_stays_small` blows up
+ *every* section to 4,000 entries and asserts the page stays <1 MB — the check
+ that would have caught the v3.2.2/v3.2.3 partial fixes. The actionable subset of
+ every section remains available un-capped in the `guidance.json` feed.
+
## [3.2.3] — 2026-07-15
Fix: **the v3.2.2 program-points cap was per-function only**, so scry-on-scry's
diff --git a/Cargo.lock b/Cargo.lock
index 19f329a..e53014c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1834,7 +1834,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "scry-host-tests"
-version = "3.2.3"
+version = "3.2.4"
dependencies = [
"anyhow",
"jsonschema",
@@ -1851,18 +1851,18 @@ dependencies = [
[[package]]
name = "scry-sai-analyzer"
-version = "3.2.3"
+version = "3.2.4"
dependencies = [
"scry-sai-core",
]
[[package]]
name = "scry-sai-bits"
-version = "3.2.3"
+version = "3.2.4"
[[package]]
name = "scry-sai-core"
-version = "3.2.3"
+version = "3.2.4"
dependencies = [
"scry-sai-bits",
"scry-sai-float",
@@ -1880,19 +1880,19 @@ dependencies = [
[[package]]
name = "scry-sai-float"
-version = "3.2.3"
+version = "3.2.4"
[[package]]
name = "scry-sai-handle"
-version = "3.2.3"
+version = "3.2.4"
[[package]]
name = "scry-sai-interval"
-version = "3.2.3"
+version = "3.2.4"
[[package]]
name = "scry-sai-lattice"
-version = "3.2.3"
+version = "3.2.4"
dependencies = [
"bitflags",
"scry-sai-octagon",
@@ -1901,34 +1901,34 @@ dependencies = [
[[package]]
name = "scry-sai-octagon"
-version = "3.2.3"
+version = "3.2.4"
[[package]]
name = "scry-sai-pentagon"
-version = "3.2.3"
+version = "3.2.4"
[[package]]
name = "scry-sai-poly"
-version = "3.2.3"
+version = "3.2.4"
[[package]]
name = "scry-sai-provenance"
-version = "3.2.3"
+version = "3.2.4"
[[package]]
name = "scry-sai-segment"
-version = "3.2.3"
+version = "3.2.4"
dependencies = [
"scry-sai-interval",
]
[[package]]
name = "scry-sai-taint"
-version = "3.2.3"
+version = "3.2.4"
[[package]]
name = "scry-sai-viz"
-version = "3.2.3"
+version = "3.2.4"
dependencies = [
"cpp_demangle 0.5.1",
"rustc-demangle",
diff --git a/Cargo.toml b/Cargo.toml
index 7d386ec..8c9d7ff 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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.3"
+version = "3.2.4"
edition = "2024"
license = "MIT OR Apache-2.0"
repository = "https://github.com/pulseengine/scry"
diff --git a/crates/scry-analyze-core/Cargo.toml b/crates/scry-analyze-core/Cargo.toml
index e31bfac..f87bd3a 100644
--- a/crates/scry-analyze-core/Cargo.toml
+++ b/crates/scry-analyze-core/Cargo.toml
@@ -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.3" }
+scry-sai-interval = { path = "../scry-interval", version = "3.2.4" }
# Step 2 (DD-012): the analyze body + helpers moved here. wasmparser parses
# the input Wasm Core Model module; sha2 digests the module bytes for
@@ -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.3" }
-scry-sai-provenance = { path = "../scry-provenance", version = "3.2.3" }
+scry-sai-taint = { path = "../scry-taint", version = "3.2.4" }
+scry-sai-provenance = { path = "../scry-provenance", version = "3.2.4" }
# 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.3" }
+scry-sai-octagon = { path = "../scry-octagon", version = "3.2.4" }
# 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.3" }
+scry-sai-bits = { path = "../scry-bits", version = "3.2.4" }
# 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.3" }
+scry-sai-pentagon = { path = "../scry-pentagon", version = "3.2.4" }
# 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.3" }
+scry-sai-float = { path = "../scry-float", version = "3.2.4" }
# 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.3" }
+scry-sai-handle = { path = "../scry-handle", version = "3.2.4" }
# 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.3" }
+scry-sai-segment = { path = "../scry-segment", version = "3.2.4" }
[dev-dependencies]
# Test-only (the crate is otherwise dep-light + no_std): assemble the .wat
diff --git a/crates/scry-analyze-core/src/lib.rs b/crates/scry-analyze-core/src/lib.rs
index 17e49df..ec8dc18 100644
--- a/crates/scry-analyze-core/src/lib.rs
+++ b/crates/scry-analyze-core/src/lib.rs
@@ -856,7 +856,7 @@ mod domain {
scry_taint::join(a, b)
}
}
-const SCRY_VERSION: &str = "3.2.3";
+const SCRY_VERSION: &str = "3.2.4";
const INVARIANT_SCHEMA_URL: &str = "https://pulseengine.eu/scry-invariants/v1";
/// Default Wasm linear-memory page size (64 KiB).
diff --git a/crates/scry-segment/Cargo.toml b/crates/scry-segment/Cargo.toml
index ad41efd..80c8bc0 100644
--- a/crates/scry-segment/Cargo.toml
+++ b/crates/scry-segment/Cargo.toml
@@ -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.3" }
+scry-sai-interval = { path = "../scry-interval", version = "3.2.4" }
diff --git a/crates/scry-viz/Cargo.toml b/crates/scry-viz/Cargo.toml
index e63b32b..59920d9 100644
--- a/crates/scry-viz/Cargo.toml
+++ b/crates/scry-viz/Cargo.toml
@@ -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.3" }
+scry-sai-core = { path = "../scry-analyze-core", version = "3.2.4" }
# 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 }
diff --git a/crates/scry-viz/src/lib.rs b/crates/scry-viz/src/lib.rs
index a90bc7c..422a3ac 100644
--- a/crates/scry-viz/src/lib.rs
+++ b/crates/scry-viz/src/lib.rs
@@ -44,6 +44,14 @@ const HERO_TITLE: &str = "scry — a sound static analyzer for WebAssembly";
/// points and a "… showing N of M" note.
const POINTS_PER_FN_CAP: usize = 20;
+/// Row cap for the flat list/table sections (Diagnostics, Call graph, Trap
+/// checks, Functions, gaps, …). scry-on-scry produces thousands of rows in each
+/// (Info diagnostics per bounds-check, 1349 call edges, thousands of trap
+/// checks); un-capped they are megabytes of noise. Each section shows the first
+/// `SECTION_ROW_CAP` rows and a "showing N of M" note; the actionable subset is
+/// in the guidance.json feed.
+const SECTION_ROW_CAP: usize = 100;
+
/// 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
@@ -383,7 +391,8 @@ fn render_functions(s: &mut String, r: &AnalysisResult) {
.collect();
indices.sort_unstable();
indices.dedup();
- for idx in indices {
+ let n_funcs = indices.len();
+ for idx in indices.into_iter().take(SECTION_ROW_CAP) {
let meta = fn_meta(r, idx);
let summary = r.function_summaries.iter().find(|f| f.func_index == idx);
let stack: Option<&FunctionStack> =
@@ -428,7 +437,9 @@ fn render_functions(s: &mut String, r: &AnalysisResult) {
yesno(recursive),
);
}
- s.push_str("");
+ s.push_str("");
+ cap_note(s, n_funcs, "functions");
+ s.push_str("");
}
fn render_call_graph(s: &mut String, r: &AnalysisResult) {
@@ -441,7 +452,7 @@ fn render_call_graph(s: &mut String, r: &AnalysisResult) {
"| caller | pc | kind | \
resolved targets | soundness |
",
);
- for e in &r.call_graph {
+ for e in r.call_graph.iter().take(SECTION_ROW_CAP) {
// FEAT-027: resolve caller + target indices to named links so an edge
// reads `1 $compute → 2 $helper`, and each end jumps to its row.
let targets = if e.resolved_targets.is_empty() {
@@ -466,9 +477,14 @@ fn render_call_graph(s: &mut String, r: &AnalysisResult) {
);
}
s.push_str("
");
- // FEAT-028: a call-graph DIAGRAM. Inline SVG (self-contained, zero-JS) for
- // graphs small enough to lay out cleanly; the Mermaid source for any size.
- render_callgraph_diagram(s, r);
+ cap_note(s, r.call_graph.len(), "call edges");
+ // FEAT-028: a call-graph DIAGRAM. Inline SVG for graphs small enough to lay
+ // out cleanly; the Mermaid source otherwise. Skipped entirely for very large
+ // graphs (a Mermaid source of thousands of edges is itself megabytes of noise
+ // — the edge table above + guidance.json feed carry the data).
+ if r.call_graph.len() <= SECTION_ROW_CAP {
+ render_callgraph_diagram(s, r);
+ }
s.push_str("");
}
@@ -717,7 +733,7 @@ fn render_gaps(s: &mut String, r: &AnalysisResult) {
"{} site(s) where scry degraded a function to \u{22a4} (gave up).
",
r.gaps.len(),
);
- for g in &r.gaps {
+ for g in r.gaps.iter().take(SECTION_ROW_CAP) {
let kind = match g.kind {
GapKind::UnsupportedOp => "unsupported-op",
GapKind::UnmodeledBranch => "unmodeled-branch",
@@ -733,7 +749,9 @@ fn render_gaps(s: &mut String, r: &AnalysisResult) {
esc(&g.op),
);
}
- s.push_str("
");
+ s.push_str("");
+ cap_note(s, r.gaps.len(), "gaps");
+ s.push_str("");
}
/// FEAT-045: division/remainder trap classifications — scry's first runtime-
@@ -759,7 +777,20 @@ fn render_trap_checks(s: &mut String, r: &AnalysisResult) {
traps,
r.trap_checks.len() - traps,
);
- for t in &r.trap_checks {
+ // POTENTIAL-TRAPs first (the actionable proof obligations), then
+ // PROVEN-SAFE, capped — so the cap never hides a potential trap behind
+ // thousands of proven-safe rows. The tally above is exact; the full set is
+ // in the guidance.json feed.
+ let ordered = r
+ .trap_checks
+ .iter()
+ .filter(|t| t.verdict == TrapVerdict::PotentialTrap)
+ .chain(
+ r.trap_checks
+ .iter()
+ .filter(|t| t.verdict == TrapVerdict::ProvenSafe),
+ );
+ for t in ordered.take(SECTION_ROW_CAP) {
let (cls, verdict) = match t.verdict {
TrapVerdict::ProvenSafe => ("info", "PROVEN-SAFE"),
TrapVerdict::PotentialTrap => ("err", "POTENTIAL-TRAP"),
@@ -778,7 +809,9 @@ fn render_trap_checks(s: &mut String, r: &AnalysisResult) {
esc(&t.op),
);
}
- s.push_str("");
+ s.push_str("");
+ cap_note(s, r.trap_checks.len(), "trap checks");
+ s.push_str("");
}
/// FEAT-059/060: the remediation Guidance panel — the actionable "what to do"
@@ -895,7 +928,7 @@ fn render_handle_findings(s: &mut String, r: &AnalysisResult) {
"{} handle-lifetime fault(s).
",
r.handle_findings.len(),
);
- for h in &r.handle_findings {
+ for h in r.handle_findings.iter().take(SECTION_ROW_CAP) {
let kind = match h.kind {
HandleFindingKind::UseAfterDrop => "use-after-drop",
HandleFindingKind::DoubleDrop => "double-drop",
@@ -910,7 +943,9 @@ fn render_handle_findings(s: &mut String, r: &AnalysisResult) {
esc(&h.via),
);
}
- s.push_str("
");
+ s.push_str("");
+ cap_note(s, r.handle_findings.len(), "handle faults");
+ s.push_str("");
}
/// FEAT-047: sound float-interval facts for f32/f64 locals — the analyzer no
@@ -926,7 +961,7 @@ fn render_float_facts(s: &mut String, r: &AnalysisResult) {
"{} sound float-interval fact(s).
",
r.float_facts.len(),
);
- for f in &r.float_facts {
+ for f in r.float_facts.iter().take(SECTION_ROW_CAP) {
let _ = write!(
s,
"- f{} \
@@ -940,7 +975,9 @@ fn render_float_facts(s: &mut String, r: &AnalysisResult) {
if f.nan { " ∪ NaN" } else { "" },
);
}
- s.push_str("
");
+ s.push_str("");
+ cap_note(s, r.float_facts.len(), "float facts");
+ s.push_str("");
}
/// FEAT-044: proven Pentagons strict relations — the `index < length` guards
@@ -958,7 +995,7 @@ fn render_pentagon_facts(s: &mut String, r: &AnalysisResult) {
if region.
",
r.pentagon_facts.len(),
);
- for f in &r.pentagon_facts {
+ for f in r.pentagon_facts.iter().take(SECTION_ROW_CAP) {
let sign = if f.unsigned { "u" } else { "s" };
let _ = write!(
s,
@@ -976,7 +1013,9 @@ fn render_pentagon_facts(s: &mut String, r: &AnalysisResult) {
}
s.push_str("");
}
- s.push_str("
");
+ s.push_str("");
+ cap_note(s, r.pentagon_facts.len(), "relational guards");
+ s.push_str("");
}
fn render_diagnostics(s: &mut String, diags: &[Diagnostic]) {
@@ -986,7 +1025,7 @@ fn render_diagnostics(s: &mut String, diags: &[Diagnostic]) {
return;
}
s.push_str("");
- for d in diags {
+ for d in diags.iter().take(SECTION_ROW_CAP) {
let (cls, label) = match d.severity {
DiagnosticSeverity::Info => ("info", "info"),
DiagnosticSeverity::Warning => ("warn", "warning"),
@@ -1001,7 +1040,20 @@ fn render_diagnostics(s: &mut String, diags: &[Diagnostic]) {
esc(&d.message),
);
}
- s.push_str("
");
+ s.push_str("");
+ cap_note(s, diags.len(), "diagnostics");
+ s.push_str("");
+}
+
+/// If `total` exceeds [`SECTION_ROW_CAP`], append the "showing N of M" note that
+/// every capped flat section shares.
+fn cap_note(s: &mut String, total: usize, what: &str) {
+ if total > SECTION_ROW_CAP {
+ let _ = write!(
+ s,
+ "… showing {SECTION_ROW_CAP} of {total} {what}.
",
+ );
+ }
}
/// FEAT-030: taint (noninterference) findings. Rendered only when there ARE
@@ -1984,6 +2036,45 @@ mod tests {
);
}
+ #[test]
+ fn all_flat_sections_capped_page_stays_small() {
+ // The lesson from v3.2.2/v3.2.3: capping ONE section is not enough — the
+ // deployed self-analysis was still ~5 MB from Diagnostics / Call graph /
+ // Trap checks. This test blows up EVERY large flat section and asserts
+ // the whole page stays under 1 MB, so a future uncapped section fails
+ // here instead of on the deployed site.
+ let mut r = analyze_wat(
+ "(module (memory 1) (func $h) \
+ (func (export \"run\") (param i32) (result i32) \
+ i32.const 4 i32.const 0 i32.store \
+ call $h \
+ i32.const 10 local.get 0 i32.div_s))",
+ );
+ fn blow(v: &mut Vec, n: usize) {
+ if let Some(first) = v.first().cloned() {
+ while v.len() < n {
+ v.push(first.clone());
+ }
+ }
+ }
+ blow(&mut r.diagnostics, 4000);
+ blow(&mut r.trap_checks, 4000);
+ blow(&mut r.call_graph, 4000);
+ blow(&mut r.advisories, 4000);
+ blow(&mut r.gaps, 4000);
+ blow(&mut r.handle_findings, 4000);
+ blow(&mut r.float_facts, 4000);
+ blow(&mut r.pentagon_facts, 4000);
+ let html = render_html(&r, "big-all");
+ assert!(
+ html.len() < 1_000_000,
+ "every flat section must be capped so the page stays <1 MB; was {} bytes",
+ html.len()
+ );
+ // Cap notes prove the sections were actually large-but-capped.
+ assert!(html.contains("… showing 100 of"), "cap notes present");
+ }
+
#[test]
fn guidance_json_is_well_formed_and_carries_advisories() {
// A div by an unknown divisor yields an UnprovenObligation advisory +