diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 2c6244c..09ff130 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -404,6 +404,8 @@ jobs:
# FEAT-031 gate first: a malformed self-analysis fails the deploy.
cargo run --release -p scry-sai-viz -- check \
crates/scry-mcdc/target/wasm32-wasip1/release/scry_mcdc.wasm
+ # Emits BOTH dist/self-analysis.html (capped, human) and
+ # dist/self-analysis.guidance.json (full, machine-consumable feed).
cargo run --release -p scry-sai-viz -- \
crates/scry-mcdc/target/wasm32-wasip1/release/scry_mcdc.wasm \
-o "$GITHUB_WORKSPACE/dist/self-analysis.html" \
@@ -421,6 +423,10 @@ jobs:
set -euo pipefail
test -f dist/index.html
test -f dist/self-analysis.html
+ # The structured feed must exist, be non-empty, and start as a JSON object.
+ test -s dist/self-analysis.guidance.json
+ head -c 1 dist/self-analysis.guidance.json | grep -q '{' \
+ || (echo "::error::guidance.json is not a JSON object" && exit 1)
find dist -name '*.html' -print0 \
| xargs -0 -I{} sh -c 'head -c 64 "{}" | grep -qi "" || (echo "::error::{} missing doctype" && exit 1)'
echo "=== site tree (top level) ===" && find dist -maxdepth 1
diff --git a/crates/scry-viz/src/lib.rs b/crates/scry-viz/src/lib.rs
index 259c9b0..33d39d1 100644
--- a/crates/scry-viz/src/lib.rs
+++ b/crates/scry-viz/src/lib.rs
@@ -35,6 +35,21 @@ use scry_analyze_core::{
SoundnessTag, StackBound, TaintFindingKind, TrapKind, TrapVerdict,
};
+/// The hero / page title — a precise, scoped one-liner (not "verification
+/// dashboard"). Used in both the `
` and the ``.
+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).
+const POINTS_PER_FN_CAP: usize = 20;
+
+/// 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.
+const ADVISORY_PER_CLASS_CAP: usize = 10;
+
/// FEAT-027: metadata for one function index, if scry resolved any.
fn fn_meta(r: &AnalysisResult, idx: u32) -> Option<&FunctionMeta> {
r.function_meta.iter().find(|m| m.func_index == idx)
@@ -148,12 +163,14 @@ fn kind_badges(m: Option<&FunctionMeta>) -> String {
pub fn render_html(result: &AnalysisResult, title: &str) -> String {
let mut s = String::with_capacity(8 * 1024);
let _ = write!(s, "{}", DOCTYPE_AND_HEAD_OPEN);
- let _ = write!(s, "scry-viz — {}", esc(title));
+ // The scoped hero title (`HERO_TITLE`), then the per-page title.
+ let _ = write!(s, "{} — {}", esc(HERO_TITLE), esc(title));
let _ = write!(s, "{}", STYLE);
s.push_str("");
- let _ = write!(s, "scry analysis — {}
", esc(title));
+ let _ = write!(s, "{} — {}
", esc(HERO_TITLE), esc(title));
render_header(&mut s, result);
+ render_scope(&mut s, result);
render_advisories(&mut s, result);
render_functions(&mut s, result);
render_call_graph(&mut s, result);
@@ -199,8 +216,13 @@ pub fn render_index(site_title: &str, entries: &[IndexEntry]) -> String {
s.push_str("");
let _ = write!(s, "{}
", esc(site_title));
s.push_str(
- "
scry verification dashboard — a faithful projection \
- of the analyzer's own output. Nothing here is re-derived.
",
+ "scry is a sound (over-approximating) \
+ static analyzer for WebAssembly core modules: it proves properties that \
+ hold on every run. It catches out-of-bounds / divide-by-zero / \
+ overflow traps (proven-safe vs potential), use-after-drop on component \
+ handles, and bounds on the shadow stack. A ⊤ (\"top\") or POTENTIAL-TRAP \
+ verdict means unknown — never \"safe\". These pages are a \
+ faithful projection of the analyzer's own output; nothing is re-derived.
",
);
if entries.is_empty() {
s.push_str("No views available.
");
@@ -275,6 +297,50 @@ fn render_header(s: &mut String, r: &AnalysisResult) {
s.push_str("");
}
+/// A scope & limitations block. The copy is intentionally a placeholder — the
+/// maintainer writes the precise soundness claims (what scry proves, its
+/// abstract-domain limits, what a gap/advisory does and does NOT assert). We
+/// only lay out the section so the page has a home for it.
+fn render_scope(s: &mut String, _r: &AnalysisResult) {
+ s.push_str(
+ "Scope & limitations
\
+ scry is a sound abstract interpreter: every reported \
+ invariant, bound, and PROVEN-SAFE verdict holds on all runs — it never \
+ misses a real behaviour. The price is over-approximation: \
+ ⊤ (\"top\") and POTENTIAL-TRAP mean \"scry could not decide\", never \
+ \"safe\". An analysis gap records where a domain gave up; \
+ it asserts nothing about the code, only that scry was imprecise there.
\
+ Evidence kinds (strongest first)
\
+ \
+ - Mechanized (Rocq, admit-free, CI-gated): the \
+ abstract-domain lattices and core transfers — interval soundness over ℤ; \
+
i32.add vs the OFFICIAL two's-complement wrapping semantics \
+ including the wrap case (WrapAdd.v); region, call-graph, \
+ reachability, octagon, pentagon, float-lattice, known-bits, handle-state, \
+ linear-memory segmentation, and convex-polyhedra lattice proofs. \
+ - γ-sweep-validated (tested, NOT mechanized): the harder \
+ transfer algorithms — float round-to-nearest arithmetic, known-bits value \
+ transfers at w=32/64 (tracked: issue #105), and the polyhedra \
+ Fourier–Motzkin entailment + hull over-approximation. Exhaustively checked \
+ against a concrete oracle on a value grid, but not machine-proven.
\
+ - Runnable / attested: the shadow-stack bound is \
+ cross-checked against a real wasmtime run; releases are cosign-signed.
\
+
\
+ What scry does NOT (yet) prove / where it is conservative
\
+ \
+ - It models the official Wasm semantics directly; it does not \
+ yet import the canonical WasmCert-Coq mechanization, and the \
+ official-semantics proof so far covers
i32.add, not every \
+ transfer. \
+ - Linear-memory content is tracked only for singleton in-bounds i32 \
+ accesses; a loop-range fill is soundly forgotten (⊤), and any call forgets \
+ memory content.
\
+ - scry ships a DO-330 / ISO 26262 evidence dossier but is not \
+ itself a qualified tool — this dashboard makes no TQL / TCL claim.
\
+
",
+ );
+}
+
fn render_functions(s: &mut String, r: &AnalysisResult) {
s.push_str("Functions
");
if r.function_summaries.is_empty()
@@ -727,44 +793,86 @@ fn render_advisories(s: &mut String, r: &AnalysisResult) {
count(AdvisoryClass::PrecisionGap),
count(AdvisoryClass::LeverageableFact),
);
- for a in &r.advisories {
- let (cls, label) = match a.class {
- AdvisoryClass::DefiniteFault => ("err", "FIX"),
- AdvisoryClass::UnprovenObligation => ("warn", "PROVE/GUARD"),
- AdvisoryClass::PrecisionGap => ("info", "PRECISION"),
- AdvisoryClass::LeverageableFact => ("info", "LEVERAGE"),
+ // The boilerplate problem: on a real module, thousands of identical
+ // `UnprovenObligation` rows drown the handful of proven-fault items. So we
+ // render every `DefiniteFault` (a proven bug — never elide), but cap every
+ // OTHER class at `ADVISORY_PER_CLASS_CAP` rows and print a "… and N more"
+ // line pointing at the JSON feed, which carries the full set.
+ for class in [
+ AdvisoryClass::DefiniteFault,
+ AdvisoryClass::UnprovenObligation,
+ AdvisoryClass::PrecisionGap,
+ AdvisoryClass::LeverageableFact,
+ ] {
+ let cap = if class == AdvisoryClass::DefiniteFault {
+ usize::MAX
+ } else {
+ ADVISORY_PER_CLASS_CAP
};
- let _ = write!(
- s,
- "{label} \
- fn{}:{} {} — {}
Action: {}
Verify: {}",
- a.func_index,
- a.pc,
- esc(&a.code),
- esc(&a.detail),
- esc(&a.suggested_action),
- esc(&a.verification),
- );
- if let Some(cx) = &a.counterexample {
+ let total = r.advisories.iter().filter(|a| a.class == class).count();
+ for a in r.advisories.iter().filter(|a| a.class == class).take(cap) {
+ render_advisory_row(s, a);
+ }
+ if total > cap {
let _ = write!(
s,
- "
Counterexample (candidate): {}",
- esc(&cx.trigger)
+ "… and {} more {} (see the JSON feed)",
+ total - cap,
+ advisory_class_name(class),
);
- if !cx.witness.is_empty() {
- s.push_str(" [");
- for (i, w) in cx.witness.iter().enumerate() {
- if i > 0 {
- s.push_str(", ");
- }
- let _ = write!(s, "{}={}", esc(&w.operand), w.value);
+ }
+ }
+ s.push_str("");
+}
+
+/// A machine-stable class name used in the collapse note and the JSON feed.
+fn advisory_class_name(c: AdvisoryClass) -> &'static str {
+ match c {
+ AdvisoryClass::DefiniteFault => "definite-fault",
+ AdvisoryClass::UnprovenObligation => "unproven-obligation",
+ AdvisoryClass::PrecisionGap => "precision-gap",
+ AdvisoryClass::LeverageableFact => "leverageable-fact",
+ }
+}
+
+/// Render one advisory as a `` — the shared body of the (capped) HTML
+/// Guidance list.
+fn render_advisory_row(s: &mut String, a: &scry_analyze_core::Advisory) {
+ let (cls, label) = match a.class {
+ AdvisoryClass::DefiniteFault => ("err", "FIX"),
+ AdvisoryClass::UnprovenObligation => ("warn", "PROVE/GUARD"),
+ AdvisoryClass::PrecisionGap => ("info", "PRECISION"),
+ AdvisoryClass::LeverageableFact => ("info", "LEVERAGE"),
+ };
+ let _ = write!(
+ s,
+ "{label} \
+ fn{}:{} {} — {}
Action: {}
Verify: {}",
+ a.func_index,
+ a.pc,
+ esc(&a.code),
+ esc(&a.detail),
+ esc(&a.suggested_action),
+ esc(&a.verification),
+ );
+ if let Some(cx) = &a.counterexample {
+ let _ = write!(
+ s,
+ "
Counterexample (candidate): {}",
+ esc(&cx.trigger)
+ );
+ if !cx.witness.is_empty() {
+ s.push_str(" [");
+ for (i, w) in cx.witness.iter().enumerate() {
+ if i > 0 {
+ s.push_str(", ");
}
- s.push(']');
+ let _ = write!(s, "{}={}", esc(&w.operand), w.value);
}
+ s.push(']');
}
- s.push_str("");
}
- s.push_str("");
+ s.push_str("");
}
/// FEAT-049: Component-Model handle-lifetime faults — use-after-drop /
@@ -1007,17 +1115,25 @@ fn render_points(s: &mut String, r: &AnalysisResult) {
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);
let _ = write!(
s,
"{heading} \
- ↑ row
",
+ ↑ row\
+ {n_points} program point(s) · up to {n_locals} local(s) tracked.
",
);
s.push_str(
"| pc | locals | \
operand stack (bottom → top) | \
memory (offset → value) |
",
);
- for p in points.iter().filter(|p| p.func_index == idx) {
+ for p in fn_points.iter().take(POINTS_PER_FN_CAP) {
let locals = if p.locals.is_empty() {
"(none)".to_string()
} else {
@@ -1070,6 +1186,14 @@ fn render_points(s: &mut String, r: &AnalysisResult) {
);
}
s.push_str("
");
+ if n_points > POINTS_PER_FN_CAP {
+ let _ = write!(
+ s,
+ "… showing {} of {} points; full data in the JSON feed \
+ (guidance.json).
",
+ POINTS_PER_FN_CAP, n_points,
+ );
+ }
}
s.push_str("");
}
@@ -1206,6 +1330,123 @@ fn esc(raw: &str) -> String {
out
}
+// ── structured guidance feed ────────────────────────────────────────────────
+
+/// Serialize the actionable findings as a machine-consumable JSON document — the
+/// feed an AI-agent consumer reads instead of scraping the (now capped) HTML.
+///
+/// Shape (stable): a top-level object
+/// `{ "module_sha256": "…", "schema": "…", "advisories": [ … ], "trap_checks": [ … ] }`.
+/// Each advisory is
+/// `{ "func_index", "pc", "class", "code", "detail", "suggested_action",
+/// "verification", "counterexample"? }`, mirroring the [`Advisory`] fields
+/// (`class` uses the machine-stable name, e.g. `"unproven-obligation"`). Each
+/// trap check is `{ "func_index", "pc", "op", "kind", "verdict" }`.
+///
+/// The crate has no serde dependency, so this is hand-rolled with proper JSON
+/// string escaping ([`json_esc`]) — the full (un-capped) set is emitted, unlike
+/// the HTML.
+pub fn render_guidance_json(result: &AnalysisResult) -> String {
+ let mut s = String::with_capacity(4 * 1024);
+ s.push('{');
+ let _ = write!(
+ s,
+ "\"module_sha256\":\"{}\",\"schema\":\"{}\",",
+ json_esc(&result.invariants.module_sha256),
+ json_esc(&result.invariants.schema),
+ );
+ // advisories
+ s.push_str("\"advisories\":[");
+ for (i, a) in result.advisories.iter().enumerate() {
+ if i > 0 {
+ s.push(',');
+ }
+ let _ = write!(
+ s,
+ "{{\"func_index\":{},\"pc\":{},\"class\":\"{}\",\"code\":\"{}\",\
+ \"detail\":\"{}\",\"suggested_action\":\"{}\",\"verification\":\"{}\"",
+ a.func_index,
+ a.pc,
+ advisory_class_name(a.class),
+ json_esc(&a.code),
+ json_esc(&a.detail),
+ json_esc(&a.suggested_action),
+ json_esc(&a.verification),
+ );
+ if let Some(cx) = &a.counterexample {
+ let _ = write!(
+ s,
+ ",\"counterexample\":{{\"trigger\":\"{}\",\"witness\":[",
+ json_esc(&cx.trigger)
+ );
+ for (j, w) in cx.witness.iter().enumerate() {
+ if j > 0 {
+ s.push(',');
+ }
+ let _ = write!(
+ s,
+ "{{\"operand\":\"{}\",\"value\":{}}}",
+ json_esc(&w.operand),
+ w.value,
+ );
+ }
+ s.push_str("]}");
+ }
+ s.push('}');
+ }
+ s.push_str("],");
+ // trap checks
+ s.push_str("\"trap_checks\":[");
+ for (i, t) in result.trap_checks.iter().enumerate() {
+ if i > 0 {
+ s.push(',');
+ }
+ let kind = match t.kind {
+ TrapKind::DivByZero => "div-by-zero",
+ TrapKind::SignedOverflow => "signed-overflow",
+ TrapKind::OutOfBounds => "out-of-bounds",
+ };
+ let verdict = match t.verdict {
+ TrapVerdict::ProvenSafe => "proven-safe",
+ TrapVerdict::PotentialTrap => "potential-trap",
+ };
+ let _ = write!(
+ s,
+ "{{\"func_index\":{},\"pc\":{},\"op\":\"{}\",\"kind\":\"{}\",\"verdict\":\"{}\"}}",
+ t.func_index,
+ t.pc,
+ json_esc(&t.op),
+ kind,
+ verdict,
+ );
+ }
+ s.push_str("]}");
+ s
+}
+
+/// JSON string-content escaping (RFC 8259): the two mandatory escapes `"` and
+/// `\`, plus all control chars U+0000–U+001F as `\uXXXX` (with the short forms
+/// for the common ones). The caller supplies the surrounding quotes.
+fn json_esc(raw: &str) -> String {
+ let mut out = String::with_capacity(raw.len() + 2);
+ for c in raw.chars() {
+ match c {
+ '"' => out.push_str("\\\""),
+ '\\' => out.push_str("\\\\"),
+ '\n' => out.push_str("\\n"),
+ '\r' => out.push_str("\\r"),
+ '\t' => out.push_str("\\t"),
+ '\u{08}' => out.push_str("\\b"),
+ '\u{0c}' => out.push_str("\\f"),
+ c if (c as u32) < 0x20 => {
+ let _ = write!(out, "\\u{:04x}", c as u32);
+ }
+ c => out.push(c),
+ }
+ }
+ out
+}
+
const DOCTYPE_AND_HEAD_OPEN: &str = "\
";
@@ -1630,4 +1871,222 @@ mod tests {
assert!(html.contains("No functions.") || html.contains("Functions"));
assert!(html.ends_with(""));
}
+
+ #[test]
+ fn hero_and_scope_copy_finalized() {
+ // Retitle away from bare "verification dashboard", and the scope block
+ // carries the precise soundness copy (mechanized vs γ-swept, ⊤=unknown).
+ let r = analyze_wat("(module (func (export \"run\") nop))");
+ let html = render_html(&r, "demo");
+ // No leftover placeholders.
+ assert!(
+ !html.contains("SCOPE_TAGLINE_PLACEHOLDER"),
+ "tagline filled"
+ );
+ assert!(
+ !html.contains("SCOPE_COPY_PLACEHOLDER"),
+ "scope copy filled"
+ );
+ assert!(!html.contains("verification dashboard"), "retitled");
+ // Precise, scoped claims present.
+ assert!(
+ html.contains("sound static analyzer for WebAssembly"),
+ "hero states the scoped claim"
+ );
+ assert!(
+ html.contains(""),
+ "scope section present"
+ );
+ assert!(html.contains("Scope & limitations"), "scope heading");
+ assert!(
+ html.contains("Mechanized (Rocq, admit-free"),
+ "evidence-kind: mechanized"
+ );
+ assert!(
+ html.contains("γ-sweep-validated (tested, NOT mechanized)"),
+ "evidence-kind: γ-swept vs mechanized distinction is legible"
+ );
+ assert!(
+ html.contains("never \"safe\""),
+ "the ⊤=unknown-not-safe soundness caveat is stated"
+ );
+ }
+
+ #[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).
+ 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);
+ }
+ let total = r.invariants.points.len();
+ assert!(
+ total > POINTS_PER_FN_CAP * 5,
+ "fixture must produce many points (got {total})"
+ );
+ let html = render_html(&r, "big");
+ // The cap note is present …
+ assert!(
+ html.contains("full data in the JSON feed"),
+ "capped points note present"
+ );
+ assert!(
+ html.contains("program point(s)"),
+ "per-function summary line"
+ );
+ // … and the page is small despite thousands of points.
+ assert!(
+ html.len() < 1_000_000,
+ "page must stay under 1 MB even for many points; was {} bytes",
+ html.len()
+ );
+ }
+
+ #[test]
+ fn guidance_json_is_well_formed_and_carries_advisories() {
+ // A div by an unknown divisor yields an UnprovenObligation advisory +
+ // a POTENTIAL-TRAP check; both must appear in the JSON feed, and the
+ // document must be structurally well-formed JSON.
+ let r = analyze_wat(
+ "(module (func (export \"run\") (param i32) (result i32) \
+ i32.const 10 local.get 0 i32.div_s))",
+ );
+ assert!(
+ !r.advisories.is_empty(),
+ "fixture must produce at least one advisory"
+ );
+ let json = render_guidance_json(&r);
+ assert!(json.starts_with('{') && json.ends_with('}'), "JSON object");
+ assert!(json.contains("\"advisories\":["), "advisories array");
+ assert!(json.contains("\"trap_checks\":["), "trap_checks array");
+ assert!(json.contains("\"module_sha256\":\""), "carries module hash");
+ // The class name is the machine-stable form.
+ assert!(
+ json.contains("\"class\":\"unproven-obligation\""),
+ "expected advisory class present, json was: {json}"
+ );
+ assert!(json.contains("\"code\":\""), "advisory code field present");
+ assert!(json.contains("\"verification\":\""), "verification field");
+ // Structural well-formedness: balanced braces/brackets and balanced
+ // quotes (accounting for escapes).
+ assert_json_balanced(&json);
+ }
+
+ #[test]
+ fn guidance_json_escapes_control_and_quote_chars() {
+ // The JSON escaper must handle `"`, `\`, and control chars. We drive an
+ // advisory through and additionally unit-test the escaper directly.
+ assert_eq!(json_esc("a\"b\\c"), "a\\\"b\\\\c");
+ assert_eq!(json_esc("line\nbreak\ttab"), "line\\nbreak\\ttab");
+ assert_eq!(json_esc("\u{01}"), "\\u0001");
+ }
+
+ #[test]
+ fn guidance_html_collapses_boilerplate_but_keeps_faults() {
+ // The Guidance panel keeps the tally line and does not inline thousands
+ // of identical non-fault rows: with more than the cap of one non-fault
+ // class, a "… and N more" line must appear.
+ let mut r = analyze_wat("(module (func (export \"run\") nop))");
+ // Synthesize many UnprovenObligation advisories.
+ let mk = |i: u32| scry_analyze_core::Advisory {
+ func_index: 0,
+ pc: i,
+ class: AdvisoryClass::UnprovenObligation,
+ code: "div-by-zero".into(),
+ detail: "divisor may be zero".into(),
+ suggested_action: "guard it".into(),
+ verification: "re-run scry".into(),
+ counterexample: None,
+ };
+ for i in 0..(ADVISORY_PER_CLASS_CAP as u32 + 25) {
+ r.advisories.push(mk(i));
+ }
+ let html = render_html(&r, "many");
+ assert!(
+ html.contains("unproven obligation(s) to prove/guard"),
+ "tally line kept"
+ );
+ assert!(
+ html.contains("more unproven-obligation"),
+ "boilerplate collapsed with a 'and N more' line"
+ );
+ // The capped HTML must NOT inline all of them.
+ let shown = html.matches("PROVE/GUARD").count();
+ assert!(
+ shown <= ADVISORY_PER_CLASS_CAP,
+ "at most the cap of non-fault rows inlined, got {shown}"
+ );
+ // …but the JSON feed carries the full set.
+ let json = render_guidance_json(&r);
+ let in_json = json.matches("\"class\":\"unproven-obligation\"").count();
+ assert_eq!(
+ in_json,
+ ADVISORY_PER_CLASS_CAP + 25,
+ "JSON feed carries every advisory"
+ );
+ }
+
+ #[test]
+ fn existing_panels_still_render() {
+ // Regression: the redesign is additive — the core panels must survive.
+ let r = analyze_wat(
+ "(module (func $compute (export \"run\") (result i32) call $helper i32.const 7) \
+ (func $helper nop))",
+ );
+ let html = render_html(&r, "panels");
+ for needle in [
+ "Summary",
+ "Functions",
+ "Call graph",
+ "Diagnostics",
+ "Program points",
+ "Guidance — how to improve this code",
+ "Trap checks",
+ ] {
+ assert!(html.contains(needle), "panel missing: {needle}");
+ }
+ assert!(html.starts_with(""));
+ assert!(html.ends_with(""));
+ }
+
+ /// Minimal structural JSON validator: braces/brackets balance and string
+ /// quotes are balanced (respecting `\"` escapes). Enough to catch a
+ /// hand-rolled-emitter mistake without pulling in a JSON parser dep.
+ fn assert_json_balanced(json: &str) {
+ let mut depth: i32 = 0;
+ let mut in_str = false;
+ let mut escaped = false;
+ for c in json.chars() {
+ if in_str {
+ if escaped {
+ escaped = false;
+ } else if c == '\\' {
+ escaped = true;
+ } else if c == '"' {
+ in_str = false;
+ }
+ continue;
+ }
+ match c {
+ '"' => in_str = true,
+ '{' | '[' => depth += 1,
+ '}' | ']' => depth -= 1,
+ _ => {}
+ }
+ assert!(depth >= 0, "unbalanced closer in JSON");
+ }
+ assert_eq!(depth, 0, "unbalanced braces/brackets in JSON");
+ assert!(!in_str, "unterminated string in JSON");
+ }
}
diff --git a/crates/scry-viz/src/main.rs b/crates/scry-viz/src/main.rs
index 515edf3..6d3aef3 100644
--- a/crates/scry-viz/src/main.rs
+++ b/crates/scry-viz/src/main.rs
@@ -162,6 +162,14 @@ fn run(args: &[String]) -> Result {
let html = scry_viz::render_html(&result, &title);
let out = output.unwrap_or_else(|| input.with_extension("html"));
std::fs::write(&out, html).map_err(|e| err(4, format!("writing {}: {e}", out.display())))?;
+
+ // Structured, machine-consumable feed alongside the HTML: `.guidance.json`
+ // (the full, un-capped advisories + trap verdicts an AI-agent consumer reads).
+ let json = scry_viz::render_guidance_json(&result);
+ let json_out = out.with_extension("guidance.json");
+ std::fs::write(&json_out, json)
+ .map_err(|e| err(4, format!("writing {}: {e}", json_out.display())))?;
+ eprintln!("scry-viz: wrote {}", json_out.display());
Ok(out)
}