From 003ed1e737d30116d3f185e4f1ea1296ab2b994d Mon Sep 17 00:00:00 2001 From: konojunya Date: Sat, 5 Sep 2026 20:16:57 +0900 Subject: [PATCH 1/3] test: add representative layout corpus --- crates/stack-engine/tests/layout_corpus.rs | 443 ++++++++++++++++++ layout-corpus/catalog.json | 136 ++++++ layout-corpus/schema.json | 101 ++++ layout-corpus/snapshots/dense-commerce.svg | 239 ++++++++++ .../snapshots/fanout-cross-edges.svg | 162 +++++++ layout-corpus/snapshots/medium-group-flow.svg | 115 +++++ .../snapshots/multilingual-long-labels.svg | 79 ++++ layout-corpus/snapshots/nested-platform.svg | 138 ++++++ .../snapshots/provider-icon-boundary.svg | 62 +++ .../snapshots/small-request-path.svg | 58 +++ layout-corpus/sources/dense-commerce.stack | 125 +++++ .../sources/fanout-cross-edges.stack | 79 ++++ layout-corpus/sources/medium-group-flow.stack | 65 +++ .../sources/multilingual-long-labels.stack | 41 ++ layout-corpus/sources/nested-platform.stack | 80 ++++ .../sources/provider-icon-boundary.stack | 31 ++ .../sources/small-request-path.stack | 29 ++ 17 files changed, 1983 insertions(+) create mode 100644 crates/stack-engine/tests/layout_corpus.rs create mode 100644 layout-corpus/catalog.json create mode 100644 layout-corpus/schema.json create mode 100644 layout-corpus/snapshots/dense-commerce.svg create mode 100644 layout-corpus/snapshots/fanout-cross-edges.svg create mode 100644 layout-corpus/snapshots/medium-group-flow.svg create mode 100644 layout-corpus/snapshots/multilingual-long-labels.svg create mode 100644 layout-corpus/snapshots/nested-platform.svg create mode 100644 layout-corpus/snapshots/provider-icon-boundary.svg create mode 100644 layout-corpus/snapshots/small-request-path.svg create mode 100644 layout-corpus/sources/dense-commerce.stack create mode 100644 layout-corpus/sources/fanout-cross-edges.stack create mode 100644 layout-corpus/sources/medium-group-flow.stack create mode 100644 layout-corpus/sources/multilingual-long-labels.stack create mode 100644 layout-corpus/sources/nested-platform.stack create mode 100644 layout-corpus/sources/provider-icon-boundary.stack create mode 100644 layout-corpus/sources/small-request-path.stack diff --git a/crates/stack-engine/tests/layout_corpus.rs b/crates/stack-engine/tests/layout_corpus.rs new file mode 100644 index 0000000..9406681 --- /dev/null +++ b/crates/stack-engine/tests/layout_corpus.rs @@ -0,0 +1,443 @@ +use std::collections::BTreeSet; +use std::error::Error; +use std::ffi::OsString; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::Instant; + +use serde::Deserialize; +use serde_json::json; +use stack_engine::{Engine, ProviderAsset, ProviderPack}; + +const REQUIRED_DENSITIES: [&str; 3] = ["small", "medium", "dense"]; +const REQUIRED_FEATURES: [&str; 8] = [ + "groups", + "nested-groups", + "rank-constraints", + "order-constraints", + "cross-edges", + "edge-labels", + "long-labels", + "provider-icons", +]; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct LayoutCatalog { + #[serde(rename = "$schema")] + schema: String, + schema_version: String, + engine_version: String, + performance: PerformanceBudget, + cases: Vec, +} + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PerformanceBudget { + warmup_iterations: usize, + measured_iterations: usize, + max_p95_milliseconds: f64, + max_suite_milliseconds: f64, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct LayoutCase { + id: String, + title: String, + summary: String, + density: String, + source: String, + snapshot: String, + features: Vec, + provider_fixture: Option, + expected: ExpectedOutput, + alt: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ExpectedOutput { + nodes: usize, + groups: usize, + edges: usize, + provider_notices: usize, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ProviderPackInput { + manifest: stack_theme::ProviderPack, + assets: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ProviderAssetInput { + path: String, + svg: String, +} + +#[test] +fn layout_corpus_matches_approved_snapshots() -> Result<(), Box> { + let root = repository_root(); + let catalog = load_catalog(&root)?; + validate_catalog(&catalog)?; + + let candidate_root = root.join("target/layout-corpus/candidate"); + if candidate_root.exists() { + fs::remove_dir_all(&candidate_root)?; + } + fs::create_dir_all(&candidate_root)?; + + let update_snapshots = + std::env::var_os("UPDATE_STACK_LAYOUT_SNAPSHOTS") == Some(OsString::from("1")); + for case in &catalog.cases { + let source = fs::read(root.join("layout-corpus").join(&case.source))?; + let packs = load_provider_packs(&root, case.provider_fixture.as_deref())?; + let engine = if packs.is_empty() { + Engine::bundled() + } else { + Engine::with_provider_packs(&packs)? + }; + let output = engine.render(&source)?; + if !output.diagnostics.is_empty() { + return Err( + format!("{} produced diagnostics: {:?}", case.id, output.diagnostics).into(), + ); + } + if output.provider_notices.len() != case.expected.provider_notices { + return Err(format!( + "{} produced {} provider notices instead of {}", + case.id, + output.provider_notices.len(), + case.expected.provider_notices + ) + .into()); + } + let svg = output + .svg + .ok_or_else(|| format!("{} produced no SVG", case.id))?; + validate_svg(case, &svg)?; + + let candidate = candidate_root.join(format!("{}.svg", case.id)); + fs::write(&candidate, &svg)?; + let approved = root.join("layout-corpus").join(&case.snapshot); + if update_snapshots { + if let Some(parent) = approved.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&approved, &svg)?; + } else if fs::read_to_string(&approved)? != svg { + return Err(format!( + "{} differs from its approved snapshot; review target/layout-gallery before updating", + case.id + ) + .into()); + } + } + + assert_inventory(&root, &catalog)?; + Ok(()) +} + +#[test] +#[ignore = "run explicitly in release mode to enforce the layout runtime budget"] +fn layout_runtime_stays_within_budget() -> Result<(), Box> { + let root = repository_root(); + let catalog = load_catalog(&root)?; + validate_catalog(&catalog)?; + let budget = catalog.performance; + let suite_start = Instant::now(); + let mut results = Vec::new(); + + for case in &catalog.cases { + let source = fs::read(root.join("layout-corpus").join(&case.source))?; + let packs = load_provider_packs(&root, case.provider_fixture.as_deref())?; + let engine = if packs.is_empty() { + Engine::bundled() + } else { + Engine::with_provider_packs(&packs)? + }; + for _ in 0..budget.warmup_iterations { + require_render(&engine, &source, &case.id)?; + } + + let mut durations = Vec::with_capacity(budget.measured_iterations); + for _ in 0..budget.measured_iterations { + let started = Instant::now(); + require_render(&engine, &source, &case.id)?; + durations.push(started.elapsed().as_secs_f64() * 1000.0); + } + durations.sort_by(f64::total_cmp); + let percentile_index = (durations.len() * 95).div_ceil(100) - 1; + let p95_milliseconds = durations[percentile_index]; + if p95_milliseconds > budget.max_p95_milliseconds { + return Err(format!( + "{} p95 {:.3} ms exceeds {:.3} ms", + case.id, p95_milliseconds, budget.max_p95_milliseconds + ) + .into()); + } + results.push(json!({ + "id": case.id, + "p95Milliseconds": rounded_milliseconds(p95_milliseconds), + "minimumMilliseconds": rounded_milliseconds(durations[0]), + "maximumMilliseconds": rounded_milliseconds(durations[durations.len() - 1]) + })); + } + + let suite_milliseconds = suite_start.elapsed().as_secs_f64() * 1000.0; + if suite_milliseconds > budget.max_suite_milliseconds { + return Err(format!( + "layout corpus suite {:.3} ms exceeds {:.3} ms", + suite_milliseconds, budget.max_suite_milliseconds + ) + .into()); + } + + let report = json!({ + "schemaVersion": "1.0", + "profile": "release", + "warmupIterations": budget.warmup_iterations, + "measuredIterations": budget.measured_iterations, + "maxP95Milliseconds": budget.max_p95_milliseconds, + "maxSuiteMilliseconds": budget.max_suite_milliseconds, + "suiteMilliseconds": rounded_milliseconds(suite_milliseconds), + "cases": results + }); + let report_root = root.join("target/layout-corpus"); + fs::create_dir_all(&report_root)?; + let mut document = serde_json::to_vec_pretty(&report)?; + document.push(b'\n'); + fs::write(report_root.join("performance.json"), document)?; + eprintln!( + "layout corpus: {} cases, {:.3} ms suite, {:.3} ms p95 budget", + catalog.cases.len(), + suite_milliseconds, + budget.max_p95_milliseconds + ); + Ok(()) +} + +fn repository_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn load_catalog(root: &Path) -> Result> { + let document = fs::read(root.join("layout-corpus/catalog.json"))?; + Ok(serde_json::from_slice(&document)?) +} + +fn validate_catalog(catalog: &LayoutCatalog) -> Result<(), Box> { + if catalog.schema != "./schema.json" + || catalog.schema_version != "1.0" + || catalog.engine_version != stack_engine::ENGINE_VERSION + || catalog.cases.len() < 6 + || catalog.performance.warmup_iterations == 0 + || catalog.performance.measured_iterations < 5 + || catalog.performance.max_p95_milliseconds <= 0.0 + || catalog.performance.max_suite_milliseconds <= 0.0 + { + return Err("layout corpus metadata is invalid or incompatible".into()); + } + + let mut ids = BTreeSet::new(); + let mut densities = BTreeSet::new(); + let mut features = BTreeSet::new(); + for case in &catalog.cases { + if !ids.insert(case.id.as_str()) + || case.title.is_empty() + || case.summary.is_empty() + || case.alt.is_empty() + || case.source != format!("sources/{}.stack", case.id) + || case.snapshot != format!("snapshots/{}.svg", case.id) + || case.features.is_empty() + || case.provider_fixture.is_some() + != case.features.iter().any(|item| item == "provider-icons") + { + return Err(format!("{} has invalid or duplicate catalog metadata", case.id).into()); + } + densities.insert(case.density.as_str()); + features.extend(case.features.iter().map(String::as_str)); + } + for required in REQUIRED_DENSITIES { + if !densities.contains(required) { + return Err(format!("layout corpus does not cover {required} density").into()); + } + } + for required in REQUIRED_FEATURES { + if !features.contains(required) { + return Err(format!("layout corpus does not cover {required}").into()); + } + } + Ok(()) +} + +fn load_provider_packs( + root: &Path, + fixture: Option<&str>, +) -> Result, Box> { + let Some(fixture) = fixture else { + return Ok(Vec::new()); + }; + let document = fs::read(root.join("layout-corpus").join(fixture))?; + let inputs: Vec = serde_json::from_slice(&document)?; + inputs + .into_iter() + .map(|input| { + ProviderPack::new( + input.manifest, + input + .assets + .into_iter() + .map(|asset| ProviderAsset::new(asset.path, asset.svg)) + .collect(), + ) + .map_err(|error| Box::new(error) as Box) + }) + .collect() +} + +fn require_render(engine: &Engine<'_>, source: &[u8], case_id: &str) -> Result<(), Box> { + let output = engine.render(source)?; + if !output.diagnostics.is_empty() || output.svg.is_none() { + return Err(format!("{case_id} did not produce a clean SVG during benchmarking").into()); + } + Ok(()) +} + +fn validate_svg(case: &LayoutCase, svg: &str) -> Result<(), Box> { + let document = roxmltree::Document::parse(svg)?; + let root = document.root_element(); + if !root.has_tag_name("svg") + || root.attribute("role") != Some("img") + || root.attribute("aria-labelledby") != Some("stack-title stack-description") + || !document + .descendants() + .any(|node| node.attribute("id") == Some("stack-title")) + || !document + .descendants() + .any(|node| node.attribute("id") == Some("stack-description")) + { + return Err(format!("{} has an invalid accessible SVG root", case.id).into()); + } + let view_box = root + .attribute("viewBox") + .ok_or_else(|| format!("{} has no viewBox", case.id))? + .split_ascii_whitespace() + .map(str::parse::) + .collect::, _>>()?; + if view_box.len() != 4 + || view_box[0] != 0 + || view_box[1] != 0 + || view_box[2] <= 0 + || view_box[3] <= 0 + { + return Err(format!("{} has invalid positive geometry bounds", case.id).into()); + } + + let nodes = document + .descendants() + .filter(|node| node.has_tag_name("g") && node.attribute("data-node-kind").is_some()) + .count(); + let groups = document + .descendants() + .filter(|node| { + node.has_tag_name("g") + && node.attribute("data-stack-id").is_some() + && node.attribute("data-node-kind").is_none() + }) + .count(); + let edges = document + .descendants() + .filter(|node| node.has_tag_name("g") && node.attribute("data-edge-kind").is_some()) + .count(); + if (nodes, groups, edges) + != ( + case.expected.nodes, + case.expected.groups, + case.expected.edges, + ) + { + return Err(format!( + "{} geometry inventory is ({nodes}, {groups}, {edges}) instead of ({}, {}, {})", + case.id, case.expected.nodes, case.expected.groups, case.expected.edges + ) + .into()); + } + + let mut identifiers = BTreeSet::new(); + for node in document + .descendants() + .filter(|node| node.attribute("data-stack-id").is_some()) + { + let identifier = node + .attribute("data-stack-id") + .ok_or("filtered Stack identifier is absent")?; + if !identifiers.insert(identifier) { + return Err(format!("{} repeats Stack identifier {identifier}", case.id).into()); + } + } + let unsafe_attribute = document.descendants().any(|node| { + node.attributes().any(|attribute| { + attribute.name().to_ascii_lowercase().starts_with("on") + || matches!(attribute.name(), "href" | "src") + }) + }); + if document.descendants().any(|node| { + matches!( + node.tag_name().name(), + "script" | "foreignObject" | "iframe" | "object" | "embed" + ) + }) || unsafe_attribute + || svg.contains("href=\"http") + || svg.contains("src=\"http") + { + return Err(format!("{} contains active or external SVG content", case.id).into()); + } + if case.provider_fixture.is_some() && !svg.contains("data-icon-id=\"example:storage\"") { + return Err(format!("{} did not embed its caller-owned provider icon", case.id).into()); + } + Ok(()) +} + +fn assert_inventory(root: &Path, catalog: &LayoutCatalog) -> Result<(), Box> { + let expected_sources = catalog + .cases + .iter() + .map(|case| case.source.trim_start_matches("sources/").to_owned()) + .collect::>(); + let expected_snapshots = catalog + .cases + .iter() + .map(|case| case.snapshot.trim_start_matches("snapshots/").to_owned()) + .collect::>(); + let actual_sources = file_inventory(&root.join("layout-corpus/sources"), "stack")?; + let actual_snapshots = file_inventory(&root.join("layout-corpus/snapshots"), "svg")?; + if actual_sources != expected_sources || actual_snapshots != expected_snapshots { + return Err("layout corpus source or snapshot inventory has drifted".into()); + } + Ok(()) +} + +fn file_inventory(root: &Path, extension: &str) -> Result, Box> { + let mut inventory = BTreeSet::new(); + for entry in fs::read_dir(root)? { + let entry = entry?; + if !entry.file_type()?.is_file() + || entry.path().extension().and_then(|value| value.to_str()) != Some(extension) + { + return Err( + format!("{} has an unexpected corpus entry", entry.path().display()).into(), + ); + } + inventory.insert(entry.file_name().to_string_lossy().into_owned()); + } + Ok(inventory) +} + +fn rounded_milliseconds(value: f64) -> f64 { + (value * 1000.0).round() / 1000.0 +} diff --git a/layout-corpus/catalog.json b/layout-corpus/catalog.json new file mode 100644 index 0000000..af2c47d --- /dev/null +++ b/layout-corpus/catalog.json @@ -0,0 +1,136 @@ +{ + "$schema": "./schema.json", + "schemaVersion": "1.0", + "engineVersion": "0.6.0", + "performance": { + "warmupIterations": 3, + "measuredIterations": 20, + "maxP95Milliseconds": 50, + "maxSuiteMilliseconds": 2500 + }, + "cases": [ + { + "id": "small-request-path", + "title": "Small request path", + "summary": "A compact request and data path catches rank spacing drift in the smallest useful diagram.", + "density": "small", + "source": "sources/small-request-path.stack", + "snapshot": "snapshots/small-request-path.svg", + "features": ["layout-direction", "edge-labels", "mixed-edge-kinds"], + "providerFixture": null, + "expected": { "nodes": 3, "groups": 0, "edges": 2, "providerNotices": 0 }, + "alt": "A browser connects to an API, which connects to a primary database." + }, + { + "id": "medium-group-flow", + "title": "Medium group flow", + "summary": "Two client nodes converge across group boundaries before service and data processing.", + "density": "medium", + "source": "sources/medium-group-flow.stack", + "snapshot": "snapshots/medium-group-flow.svg", + "features": [ + "groups", + "layout-direction", + "rank-constraints", + "order-constraints", + "cross-edges", + "edge-labels", + "mixed-edge-kinds" + ], + "providerFixture": null, + "expected": { "nodes": 6, "groups": 2, "edges": 5, "providerNotices": 0 }, + "alt": "Browser and mobile clients cross into a platform group through a gateway." + }, + { + "id": "dense-commerce", + "title": "Dense commerce platform", + "summary": "A production-sized graph stresses group sizing, fan-out, asynchronous paths, and labeled routes.", + "density": "dense", + "source": "sources/dense-commerce.stack", + "snapshot": "snapshots/dense-commerce.svg", + "features": [ + "groups", + "layout-direction", + "rank-constraints", + "order-constraints", + "cross-edges", + "edge-labels", + "dense-graph", + "mixed-edge-kinds" + ], + "providerFixture": null, + "expected": { "nodes": 13, "groups": 5, "edges": 12, "providerNotices": 0 }, + "alt": "A dense commerce architecture spans storefront, services, processing, data, and partner groups." + }, + { + "id": "nested-platform", + "title": "Nested platform boundaries", + "summary": "Nested groups and cross-boundary edges expose containment, padding, and route ordering failures.", + "density": "medium", + "source": "sources/nested-platform.stack", + "snapshot": "snapshots/nested-platform.svg", + "features": [ + "groups", + "nested-groups", + "layout-direction", + "rank-constraints", + "order-constraints", + "cross-edges", + "edge-labels", + "mixed-edge-kinds" + ], + "providerFixture": null, + "expected": { "nodes": 7, "groups": 4, "edges": 6, "providerNotices": 0 }, + "alt": "A customer crosses nested experience, processing, and operations groups in one platform boundary." + }, + { + "id": "multilingual-long-labels", + "title": "Multilingual long labels", + "summary": "Wide Unicode node and edge labels catch text measurement and label-background regressions.", + "density": "medium", + "source": "sources/multilingual-long-labels.stack", + "snapshot": "snapshots/multilingual-long-labels.svg", + "features": [ + "layout-direction", + "edge-labels", + "long-labels", + "multilingual-text", + "mixed-edge-kinds" + ], + "providerFixture": null, + "expected": { "nodes": 4, "groups": 0, "edges": 3, "providerNotices": 0 }, + "alt": "Four services with Japanese, Korean, and English labels form a vertical data pipeline." + }, + { + "id": "fanout-cross-edges", + "title": "Fan-out and cross edges", + "summary": "Repeated fan-out and fan-in around aligned ranks exercise obstacle avoidance and route stability.", + "density": "medium", + "source": "sources/fanout-cross-edges.stack", + "snapshot": "snapshots/fanout-cross-edges.svg", + "features": [ + "layout-direction", + "rank-constraints", + "order-constraints", + "cross-edges", + "edge-labels", + "mixed-edge-kinds" + ], + "providerFixture": null, + "expected": { "nodes": 8, "groups": 0, "edges": 10, "providerNotices": 0 }, + "alt": "An ingress fans out to three services whose labeled routes converge on storage and audit nodes." + }, + { + "id": "provider-icon-boundary", + "title": "Provider icon boundary", + "summary": "A caller-owned provider icon verifies that branded artwork does not change semantic geometry.", + "density": "medium", + "source": "sources/provider-icon-boundary.stack", + "snapshot": "snapshots/provider-icon-boundary.svg", + "features": ["groups", "layout-direction", "cross-edges", "edge-labels", "provider-icons"], + "providerFixture": "../tests/fixtures/provider-pack-input.json", + "expected": { "nodes": 3, "groups": 1, "edges": 2, "providerNotices": 1 }, + "alt": "An upload client sends an object to provider storage before a worker processes it." + } + ] +} diff --git a/layout-corpus/schema.json b/layout-corpus/schema.json new file mode 100644 index 0000000..f9a9889 --- /dev/null +++ b/layout-corpus/schema.json @@ -0,0 +1,101 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Stack Engine layout regression corpus", + "type": "object", + "additionalProperties": false, + "required": ["$schema", "schemaVersion", "engineVersion", "performance", "cases"], + "properties": { + "$schema": { "const": "./schema.json" }, + "schemaVersion": { "const": "1.0" }, + "engineVersion": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "performance": { "$ref": "#/$defs/performance" }, + "cases": { + "type": "array", + "minItems": 6, + "maxItems": 24, + "items": { "$ref": "#/$defs/case" } + } + }, + "$defs": { + "performance": { + "type": "object", + "additionalProperties": false, + "required": [ + "warmupIterations", + "measuredIterations", + "maxP95Milliseconds", + "maxSuiteMilliseconds" + ], + "properties": { + "warmupIterations": { "type": "integer", "minimum": 1, "maximum": 20 }, + "measuredIterations": { "type": "integer", "minimum": 5, "maximum": 100 }, + "maxP95Milliseconds": { "type": "integer", "minimum": 1, "maximum": 1000 }, + "maxSuiteMilliseconds": { "type": "integer", "minimum": 10, "maximum": 10000 } + } + }, + "case": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "title", + "summary", + "density", + "source", + "snapshot", + "features", + "providerFixture", + "expected", + "alt" + ], + "properties": { + "id": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" }, + "title": { "type": "string", "minLength": 1, "maxLength": 80 }, + "summary": { "type": "string", "minLength": 1, "maxLength": 180 }, + "density": { "enum": ["small", "medium", "dense"] }, + "source": { "type": "string", "pattern": "^sources/[a-z0-9-]+\\.stack$" }, + "snapshot": { "type": "string", "pattern": "^snapshots/[a-z0-9-]+\\.svg$" }, + "features": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "enum": [ + "groups", + "nested-groups", + "layout-direction", + "rank-constraints", + "order-constraints", + "cross-edges", + "edge-labels", + "long-labels", + "dense-graph", + "provider-icons", + "mixed-edge-kinds", + "multilingual-text" + ] + } + }, + "providerFixture": { + "oneOf": [ + { "type": "null" }, + { "type": "string", "pattern": "^\\.\\./tests/fixtures/[a-z0-9-]+\\.json$" } + ] + }, + "expected": { "$ref": "#/$defs/expected" }, + "alt": { "type": "string", "minLength": 1, "maxLength": 180 } + } + }, + "expected": { + "type": "object", + "additionalProperties": false, + "required": ["nodes", "groups", "edges", "providerNotices"], + "properties": { + "nodes": { "type": "integer", "minimum": 1, "maximum": 40 }, + "groups": { "type": "integer", "minimum": 0, "maximum": 12 }, + "edges": { "type": "integer", "minimum": 0, "maximum": 80 }, + "providerNotices": { "type": "integer", "minimum": 0, "maximum": 8 } + } + } + } +} diff --git a/layout-corpus/snapshots/dense-commerce.svg b/layout-corpus/snapshots/dense-commerce.svg new file mode 100644 index 0000000..e070084 --- /dev/null +++ b/layout-corpus/snapshots/dense-commerce.svg @@ -0,0 +1,239 @@ + + + Dense commerce platform + Architecture diagram with 13 nodes, 5 groups, and 12 relationships. + stack-engine 0.6.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 + + + + + + + Dense commerce platform + + + Storefront + + Storefront + + + Commerce services + + Commerce services + + + Asynchronous processing + + Asynchronous processing + + + Data + + Data + + + External systems + + External systems + + + + + Customer flows to Web storefront: Browse and buy + + + + Web storefront flows to Edge gateway: HTTPS + + + + Edge gateway flows to Catalog API: Catalog requests + + + + Edge gateway flows to Checkout API: Checkout requests + + + + Catalog API flows to Product database: SQL + + + + Catalog API flows to Product media: Media URLs + + + + Checkout API flows to Order database: Transactions + + + + Checkout API flows to Payment provider: Payment API + + + + Checkout API flows to Event bus: OrderPlaced + + + + Event bus flows to Fulfillment worker: OrderPlaced + + + + Event bus flows to Notification worker: OrderPlaced + + + + Notification worker flows to Email provider: Send receipt + + + + + + Customer + + + Customer + + + Web storefront: Next.js + + + Web storefront + Next.js + + + Edge gateway + + + Edge gateway + + + Catalog API: Products and pricing + + + Catalog API + Products and pricing + + + Checkout API: Order orchestration + + + Checkout API + Order orchestration + + + Event bus + + + Event bus + + + Fulfillment worker + + + Fulfillment worker + + + Notification worker + + + Notification worker + + + Product database + + + + Product database + + + Order database + + + + Order database + + + Product media + + + Product media + + + Payment provider + + + Payment provider + + + Email provider + + + Email provider + + + + diff --git a/layout-corpus/snapshots/fanout-cross-edges.svg b/layout-corpus/snapshots/fanout-cross-edges.svg new file mode 100644 index 0000000..e4dc45f --- /dev/null +++ b/layout-corpus/snapshots/fanout-cross-edges.svg @@ -0,0 +1,162 @@ + + + Fan-out and cross edges + Architecture diagram with 8 nodes, 0 groups, and 10 relationships. + stack-engine 0.6.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 + + + + + + + Fan-out and cross edges + + + + + Ingress flows to Identity service: Authenticate + + + + Ingress flows to Catalog service: Browse + + + + Ingress flows to Checkout service: Purchase + + + + Identity service flows to Primary database: Session + + + + Catalog service flows to Primary database: Products + + + + Checkout service flows to Order events: OrderPlaced + + + + Order events flows to Order worker: Dispatch + + + + Order worker flows to Primary database: Persist + + + + Identity service flows to Audit archive: Access log + + + + Checkout service flows to Audit archive: Checkout log + + + + + + Ingress + + + Ingress + + + Identity service + + + Identity service + + + Catalog service + + + Catalog service + + + Checkout service + + + Checkout service + + + Order events + + + Order events + + + Order worker + + + Order worker + + + Primary database + + + + Primary database + + + Audit archive + + + Audit archive + + + + diff --git a/layout-corpus/snapshots/medium-group-flow.svg b/layout-corpus/snapshots/medium-group-flow.svg new file mode 100644 index 0000000..7c44e5d --- /dev/null +++ b/layout-corpus/snapshots/medium-group-flow.svg @@ -0,0 +1,115 @@ + + + Medium group flow + Architecture diagram with 6 nodes, 2 groups, and 5 relationships. + stack-engine 0.6.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 + + + + + + + Medium group flow + + + Clients + + Clients + + + Platform + + Platform + + + + + Browser flows to Edge gateway: Browser HTTPS + + + + Mobile app flows to Edge gateway: Mobile HTTPS + + + + Edge gateway flows to Application API: Route + + + + Application API flows to Background worker: Dispatch + + + + Background worker flows to Primary database: Persist + + + + + + Browser + + + Browser + + + Mobile app + + + Mobile app + + + Edge gateway + + + Edge gateway + + + Application API + + + Application API + + + Background worker + + + Background worker + + + Primary database + + + + Primary database + + + + diff --git a/layout-corpus/snapshots/multilingual-long-labels.svg b/layout-corpus/snapshots/multilingual-long-labels.svg new file mode 100644 index 0000000..42c8a75 --- /dev/null +++ b/layout-corpus/snapshots/multilingual-long-labels.svg @@ -0,0 +1,79 @@ + + + Multilingual long labels + Architecture diagram with 4 nodes, 0 groups, and 3 relationships. + stack-engine 0.6.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 + + + + + + + Multilingual long labels + + + + + 注文受付サービス flows to 상품 정보 보강 워커: 注文内容を非同期で検証 + + + + 상품 정보 보강 워커 flows to Real-time analytical event warehouse: 정규화된 분석 이벤트 적재 + + + + Real-time analytical event warehouse flows to 長期監査ログアーカイブ: Immutable compliance snapshot export + + + + + + 注文受付サービス: Customer order intake + + + 注文受付サービス + Customer order intake + + + 상품 정보 보강 워커: Catalog and inventory enrichment + + + 상품 정보 보강 워커 + Catalog and inventory enrichment + + + Real-time analytical event warehouse: Cross-region reporting dataset + + + + Real-time analytical event warehouse + Cross-region reporting dataset + + + 長期監査ログアーカイブ: Seven-year retention + + + 長期監査ログアーカイブ + Seven-year retention + + + + diff --git a/layout-corpus/snapshots/nested-platform.svg b/layout-corpus/snapshots/nested-platform.svg new file mode 100644 index 0000000..3a68604 --- /dev/null +++ b/layout-corpus/snapshots/nested-platform.svg @@ -0,0 +1,138 @@ + + + Nested platform boundaries + Architecture diagram with 7 nodes, 4 groups, and 6 relationships. + stack-engine 0.6.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 + + + + + + + Nested platform boundaries + + + Platform + + Platform + + + Experience + + Experience + + + Processing + + Processing + + + Operations + + Operations + + + + + Customer flows to Web application: HTTPS + + + + Web application is connected bidirectionally with Public API: Live checkout + + + + Public API flows to Checkout: Invoke + + + + Checkout flows to Order events: OrderPlaced + + + + Checkout flows to Orders: Transaction + + + + Public API is associated with Error monitoring: Telemetry + + + + + + Customer + + + Customer + + + Web application + + + Web application + + + Public API + + + Public API + + + Checkout + + + Checkout + + + Order events + + + Order events + + + Orders + + + + Orders + + + Error monitoring + + + Error monitoring + + + + diff --git a/layout-corpus/snapshots/provider-icon-boundary.svg b/layout-corpus/snapshots/provider-icon-boundary.svg new file mode 100644 index 0000000..d454fab --- /dev/null +++ b/layout-corpus/snapshots/provider-icon-boundary.svg @@ -0,0 +1,62 @@ + + + Provider icon boundary + Architecture diagram with 3 nodes, 1 group, and 2 relationships. + stack-engine 0.6.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53; providers example at sha256:6e05b396567a5fa3f141df079c515a8866033b98d11ec4027f486af31f14fa43 using example:storage + + + + + + + Provider icon boundary + + + Provider services + + Provider services + + + + + Upload client flows to Example Storage: Upload + + + + Example Storage flows to Object processor: Object created + + + + + + Upload client + + + Upload client + + + Example Storage + + + Example Storage + + + Object processor + + + Object processor + + + + diff --git a/layout-corpus/snapshots/small-request-path.svg b/layout-corpus/snapshots/small-request-path.svg new file mode 100644 index 0000000..c95110e --- /dev/null +++ b/layout-corpus/snapshots/small-request-path.svg @@ -0,0 +1,58 @@ + + + Small request path + Architecture diagram with 3 nodes, 0 groups, and 2 relationships. + stack-engine 0.6.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 + + + + + + + Small request path + + + + + Browser flows to Public API: HTTPS + + + + Public API flows to Primary database: SQL + + + + + + Browser + + + Browser + + + Public API + + + Public API + + + Primary database + + + + Primary database + + + + diff --git a/layout-corpus/sources/dense-commerce.stack b/layout-corpus/sources/dense-commerce.stack new file mode 100644 index 0000000..f22310f --- /dev/null +++ b/layout-corpus/sources/dense-commerce.stack @@ -0,0 +1,125 @@ +stack 1.0 + +diagram "Dense commerce platform" { + layout { + direction right + } + + node customer "Customer" { + kind actor + } + + group storefront "Storefront" { + node web "Web storefront" { + kind client + icon "web" + detail "Next.js" + } + + node gateway "Edge gateway" { + icon "gateway" + } + } + + group commerce "Commerce services" { + layout { + direction down + rank same [catalog, checkout] + order [catalog, checkout] + } + + node catalog "Catalog API" { + detail "Products and pricing" + } + + node checkout "Checkout API" { + detail "Order orchestration" + } + } + + group asynchronous "Asynchronous processing" { + node events "Event bus" { + kind queue + } + + node fulfillment "Fulfillment worker" { + kind worker + } + + node notifications "Notification worker" { + kind worker + } + } + + group data "Data" { + node products "Product database" { + kind database + } + + node orders "Order database" { + kind database + } + + node assets "Product media" { + kind storage + } + } + + group partners "External systems" { + node payment "Payment provider" { + kind external + } + + node email "Email provider" { + kind external + } + } + + edge customer -> web "Browse and buy" { + kind request + } + + edge web -> gateway "HTTPS" { + kind request + } + + edge gateway -> catalog "Catalog requests" { + kind request + } + + edge gateway -> checkout "Checkout requests" { + kind request + } + + edge catalog -> products "SQL" { + kind data + } + + edge catalog -> assets "Media URLs" { + kind data + } + + edge checkout -> orders "Transactions" { + kind data + } + + edge checkout -> payment "Payment API" { + kind request + } + + edge checkout -> events "OrderPlaced" { + kind event + } + + edge events -> fulfillment "OrderPlaced" { + kind event + } + + edge events -> notifications "OrderPlaced" { + kind event + } + + edge notifications -> email "Send receipt" { + kind request + } +} diff --git a/layout-corpus/sources/fanout-cross-edges.stack b/layout-corpus/sources/fanout-cross-edges.stack new file mode 100644 index 0000000..91a59a5 --- /dev/null +++ b/layout-corpus/sources/fanout-cross-edges.stack @@ -0,0 +1,79 @@ +stack 1.0 + +diagram "Fan-out and cross edges" { + layout { + direction right + rank same [auth, catalog, checkout] + rank same [database, audit] + order [auth, catalog, checkout] + } + + node ingress "Ingress" { + icon "gateway" + } + + node auth "Identity service" { + kind service + icon "identity" + } + + node catalog "Catalog service" + + node checkout "Checkout service" + + node events "Order events" { + kind queue + } + + node worker "Order worker" { + kind worker + } + + node database "Primary database" { + kind database + } + + node audit "Audit archive" { + kind storage + } + + edge ingress -> auth "Authenticate" { + kind request + } + + edge ingress -> catalog "Browse" { + kind request + } + + edge ingress -> checkout "Purchase" { + kind request + } + + edge auth -> database "Session" { + kind data + } + + edge catalog -> database "Products" { + kind data + } + + edge checkout -> events "OrderPlaced" { + kind event + } + + edge events -> worker "Dispatch" { + kind event + } + + edge worker -> database "Persist" { + kind data + } + + edge auth -> audit "Access log" { + kind data + } + + edge checkout -> audit "Checkout log" { + kind data + } +} diff --git a/layout-corpus/sources/medium-group-flow.stack b/layout-corpus/sources/medium-group-flow.stack new file mode 100644 index 0000000..b0de4a9 --- /dev/null +++ b/layout-corpus/sources/medium-group-flow.stack @@ -0,0 +1,65 @@ +stack 1.0 + +diagram "Medium group flow" { + layout { + direction right + } + + group clients "Clients" { + layout { + direction down + rank same [browser, mobile] + order [browser, mobile] + } + + node browser "Browser" { + kind client + icon "web" + } + + node mobile "Mobile app" { + kind client + icon "mobile" + } + } + + node gateway "Edge gateway" { + icon "gateway" + } + + group platform "Platform" { + layout { + direction down + } + + node api "Application API" + + node worker "Background worker" { + kind worker + } + + node database "Primary database" { + kind database + } + } + + edge browser -> gateway "Browser HTTPS" { + kind request + } + + edge mobile -> gateway "Mobile HTTPS" { + kind request + } + + edge gateway -> api "Route" { + kind request + } + + edge api -> worker "Dispatch" { + kind event + } + + edge worker -> database "Persist" { + kind data + } +} diff --git a/layout-corpus/sources/multilingual-long-labels.stack b/layout-corpus/sources/multilingual-long-labels.stack new file mode 100644 index 0000000..4d574f5 --- /dev/null +++ b/layout-corpus/sources/multilingual-long-labels.stack @@ -0,0 +1,41 @@ +stack 1.0 + +diagram "Multilingual long labels" { + theme light + + layout { + direction down + } + + node intake "注文受付サービス" { + kind service + detail "Customer order intake" + } + + node enrichment "상품 정보 보강 워커" { + kind worker + detail "Catalog and inventory enrichment" + } + + node analytics "Real-time analytical event warehouse" { + kind database + detail "Cross-region reporting dataset" + } + + node archive "長期監査ログアーカイブ" { + kind storage + detail "Seven-year retention" + } + + edge intake -> enrichment "注文内容を非同期で検証" { + kind event + } + + edge enrichment -> analytics "정규화된 분석 이벤트 적재" { + kind data + } + + edge analytics -> archive "Immutable compliance snapshot export" { + kind data + } +} diff --git a/layout-corpus/sources/nested-platform.stack b/layout-corpus/sources/nested-platform.stack new file mode 100644 index 0000000..5f5dbab --- /dev/null +++ b/layout-corpus/sources/nested-platform.stack @@ -0,0 +1,80 @@ +stack 1.0 + +diagram "Nested platform boundaries" { + layout { + direction right + } + + node customer "Customer" { + kind actor + } + + group platform "Platform" { + layout { + direction down + } + + group experience "Experience" { + node web "Web application" { + kind client + icon "web" + } + + node api "Public API" { + kind service + icon "api" + } + } + + group processing "Processing" { + layout { + direction down + rank same [checkout, events] + order [checkout, events] + } + + node checkout "Checkout" { + kind function + } + + node events "Order events" { + kind queue + } + } + + group operations "Operations" { + node database "Orders" { + kind database + } + + node monitoring "Error monitoring" { + kind external + icon "observability" + } + } + } + + edge customer -> web "HTTPS" { + kind request + } + + edge web <-> api "Live checkout" { + kind flow + } + + edge api -> checkout "Invoke" { + kind dependency + } + + edge checkout -> events "OrderPlaced" { + kind event + } + + edge checkout -> database "Transaction" { + kind data + } + + edge api -- monitoring "Telemetry" { + kind data + } +} diff --git a/layout-corpus/sources/provider-icon-boundary.stack b/layout-corpus/sources/provider-icon-boundary.stack new file mode 100644 index 0000000..f6841f9 --- /dev/null +++ b/layout-corpus/sources/provider-icon-boundary.stack @@ -0,0 +1,31 @@ +stack 1.0 + +diagram "Provider icon boundary" { + layout { + direction right + } + + node upload "Upload client" { + kind client + icon "web" + } + + group provider "Provider services" { + node storage "Example Storage" { + kind storage + icon "example:storage" + } + + node worker "Object processor" { + kind worker + } + } + + edge upload -> storage "Upload" { + kind request + } + + edge storage -> worker "Object created" { + kind event + } +} diff --git a/layout-corpus/sources/small-request-path.stack b/layout-corpus/sources/small-request-path.stack new file mode 100644 index 0000000..777fe73 --- /dev/null +++ b/layout-corpus/sources/small-request-path.stack @@ -0,0 +1,29 @@ +stack 1.0 + +diagram "Small request path" { + layout { + direction right + } + + node browser "Browser" { + kind client + icon "web" + } + + node api "Public API" { + kind service + icon "api" + } + + node database "Primary database" { + kind database + } + + edge browser -> api "HTTPS" { + kind request + } + + edge api -> database "SQL" { + kind data + } +} From 91f415308bd06130573086e531a27767f7fdc4ee Mon Sep 17 00:00:00 2001 From: konojunya Date: Sat, 5 Sep 2026 20:17:21 +0900 Subject: [PATCH 2/3] feat: build layout review gallery --- THIRD_PARTY_LICENSES.md | 9 +- layout-corpus/README.md | 40 ++++++ package-lock.json | 59 ++++++++ package.json | 5 +- scripts/build-layout-gallery.mjs | 223 +++++++++++++++++++++++++++++ scripts/layout-corpus.test.mjs | 74 ++++++++++ scripts/validate-layout-corpus.mjs | 141 ++++++++++++++++++ 7 files changed, 548 insertions(+), 3 deletions(-) create mode 100644 layout-corpus/README.md create mode 100644 scripts/build-layout-gallery.mjs create mode 100644 scripts/layout-corpus.test.mjs create mode 100644 scripts/validate-layout-corpus.mjs diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index f907e24..e82a98c 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -4,8 +4,8 @@ | Component | Revision | License | Source | Notes | | --- | --- | --- | --- | --- | -| `stack-compiler` | `4a18fac42afc2256a1bb3a6ff13d12d732a391e7` | Apache-2.0 | | Unmodified Rust dependency; its license and notice obligations apply to distributions that include it. | -| `stack-theme` | `2347315e6e86ab9d2708e05fd3f9b5f3d87e1241` | Apache-2.0 | | Unmodified Rust dependency with repository-authored core assets and the asset-free provider-pack contract. | +| `stack-compiler` | `84ab5663a7f7c5b7dc0b5e9e2f04c8894ed02820` | Apache-2.0 | | Unmodified Rust dependency; its license and notice obligations apply to distributions that include it. | +| `stack-theme` | `7e208d6a3c90d255799f390a4e8b86248c73caee` | Apache-2.0 | | Unmodified Rust dependency with repository-authored core assets and the asset-free provider-pack contract. | | `roxmltree` | `0.21.1` | MIT OR Apache-2.0 | | Parses caller-owned processed provider SVG before allowlisted in-memory embedding. | | `sha2`, `digest`, `block-buffer`, `crypto-common`, `hybrid-array`, `const-oid`, `typenum` | `0.11.0`, `0.11.3`, `0.12.1`, `0.2.2`, `0.4.14`, `0.10.2`, `1.20.1` | MIT OR Apache-2.0 | | Verifies provider asset hashes and computes deterministic provider-pack revisions. | | `libc` / `cpufeatures` | `0.2.189`, `0.3.1` | MIT OR Apache-2.0 | , | Target-specific SHA-256 acceleration support. | @@ -34,6 +34,11 @@ | `syn` | `2.0.119` | MIT OR Apache-2.0 | | Transitive procedural-macro build dependency of `wasm-bindgen`. | | `wasm-bindgen-cli` | `0.2.127` | MIT OR Apache-2.0 | | Version-matched build tool; not shipped in the npm package. | | `typescript` | `7.0.2` | Apache-2.0 | | Type-check tool; not shipped in the npm package. | +| `ajv` | `8.20.0` | MIT | | Validates the checked-in layout corpus schema during development and CI; not shipped in the npm package. | +| `fast-deep-equal` | `3.1.3` | MIT | | Transitive build-only dependency of `ajv`. | +| `fast-uri` | `3.1.7` | BSD-3-Clause | | Transitive build-only dependency of `ajv`. | +| `json-schema-traverse` | `1.0.0` | MIT | | Transitive build-only dependency of `ajv`. | +| `require-from-string` | `2.0.2` | MIT | | Transitive build-only dependency of `ajv`. | No third-party visual asset is bundled in a Stack Engine distribution. The bundled fallback and 30 explicit icons are Stack-authored Apache-2.0 assets from `stack-theme`. The npm package includes this inventory and the Apache-2.0, MIT, and Unicode-3.0 license texts required by its compiled dependency choices. diff --git a/layout-corpus/README.md b/layout-corpus/README.md new file mode 100644 index 0000000..589a31a --- /dev/null +++ b/layout-corpus/README.md @@ -0,0 +1,40 @@ +# Layout regression corpus + +This versioned corpus makes layout changes measurable before they become approved Engine behavior. It is an evaluation contract, not a language conformance suite or a replacement for the user-facing example gallery. + +## Coverage + +[`catalog.json`](./catalog.json) covers small, medium, and dense diagrams plus the layout failure modes that have the highest review value: + +- direct and nested groups, containment, and padding; +- same-rank and cross-axis order constraints; +- fan-out, fan-in, cross-boundary edges, mixed edge kinds, and labels; +- wide multilingual node and edge labels; +- caller-owned provider artwork embedded through the existing audited fixture. + +Every case declares exact node, group, edge, and provider-notice counts. The Rust integration test renders through the public `Engine` facade, confirms clean diagnostics and positive SVG bounds, checks accessible and local-only SVG structure, verifies element inventory and unique Stack identifiers, and writes the current candidate before comparing it byte-for-byte with the approved snapshot. Internal scene validation continues to reject node overlap, group-containment, and route geometry failures before SVG serialization. + +## Review and snapshot updates + +Run the current Engine first without changing approved references: + +```sh +cargo test -p stack-engine --test layout_corpus --locked +cargo test --release -p stack-engine --test layout_corpus layout_runtime_stays_within_budget --locked -- --ignored --nocapture +npm run layout:gallery +``` + +The first command writes current candidates to `target/layout-corpus/candidate`, even when exact comparison fails. The release-mode benchmark writes `target/layout-corpus/performance.json`. The gallery then builds at `target/layout-gallery/index.html` with the approved reference and current candidate side by side under identical source, Engine, theme, and provider-pack inputs. + +Review every changed case in the gallery and the SVG diff. Only after the geometry is intentionally approved, replace the references explicitly: + +```sh +UPDATE_STACK_LAYOUT_SNAPSHOTS=1 \ + cargo test -p stack-engine --test layout_corpus --locked +``` + +Commit the source, catalog, and updated snapshots together. Never update a snapshot only to make CI green. Schema version 1 rejects undeclared fields, unsafe paths, duplicate inventory, missing density coverage, and missing required failure-mode coverage. + +## Runtime budget + +The benchmark warms each case three times and measures twenty release-mode renders. Every case must remain at or below 50 ms p95, and the complete seven-case suite including fixture loading must remain at or below 2,500 ms. These intentionally broad CI-safe ceilings detect algorithmic regressions without treating small host timing differences as product changes. The report shown in the gallery records observed timings; it is not a checked-in benchmark claim. diff --git a/package-lock.json b/package-lock.json index 6166268..bdb8800 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "packages/engine" ], "devDependencies": { + "ajv": "8.20.0", "typescript": "7.0.2" } }, @@ -358,6 +359,64 @@ "node": ">=16.20.0" } }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/typescript": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", diff --git a/package.json b/package.json index 973f49e..402d829 100644 --- a/package.json +++ b/package.json @@ -7,11 +7,14 @@ ], "scripts": { "build:wasm": "bash scripts/build-wasm.sh", + "layout:gallery": "node scripts/build-layout-gallery.mjs", + "layout:validate": "node scripts/validate-layout-corpus.mjs", "pack:check": "node scripts/validate-npm-pack.mjs", - "test": "node --test tests/wasm.test.mjs && node scripts/validate-wasm-package.mjs", + "test": "node --test scripts/layout-corpus.test.mjs tests/wasm.test.mjs && node scripts/validate-wasm-package.mjs", "typecheck": "tsc --project tsconfig.json" }, "devDependencies": { + "ajv": "8.20.0", "typescript": "7.0.2" } } diff --git a/scripts/build-layout-gallery.mjs b/scripts/build-layout-gallery.mjs new file mode 100644 index 0000000..827d11d --- /dev/null +++ b/scripts/build-layout-gallery.mjs @@ -0,0 +1,223 @@ +import assert from "node:assert/strict"; +import { copyFile, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import path from "node:path"; + +import { validateRepositoryLayoutCorpus } from "./validate-layout-corpus.mjs"; + +const repositoryRoot = path.resolve(fileURLToPath(new URL("../", import.meta.url))); + +export function renderGallery(catalog, comparisons, performance) { + const changed = comparisons.filter((comparison) => !comparison.matches).length; + const cards = comparisons + .map(({ layoutCase, dimensions, matches, performanceCase }) => { + const features = layoutCase.features + .map((feature) => `
  • ${escapeHtml(feature)}
  • `) + .join(""); + const performanceText = performanceCase + ? `${performanceCase.p95Milliseconds.toFixed(3)} ms p95` + : "Not measured"; + return ` +
    +
    +
    +

    ${escapeHtml(layoutCase.density)} · ${dimensions}

    +

    ${escapeHtml(layoutCase.title)}

    +

    ${escapeHtml(layoutCase.summary)}

    +
    +

    ${matches ? "Matches approved geometry" : "Candidate differs from approved geometry"}

    +
    +
      ${features}
    +
    +
    +
    Approved reference
    + Approved reference: ${escapeHtml(layoutCase.alt)} +
    +
    +
    Current engine
    + Current engine: ${escapeHtml(layoutCase.alt)} +
    +
    +
    + ${quantity(layoutCase.expected.nodes, "node")} · ${quantity(layoutCase.expected.groups, "group")} · ${quantity(layoutCase.expected.edges, "edge")} + ${performanceText} +
    +
    + Review source +
    ${escapeHtml(layoutCase.sourceText)}
    +
    +
    `; + }) + .join(""); + + return ` + + + + + + + Stack layout regression gallery + + + +
    +

    Engine ${escapeHtml(catalog.engineVersion)} · corpus ${escapeHtml(catalog.schemaVersion)}

    +

    Layout regression gallery

    +

    Approved snapshots and current-engine candidates are rendered from the same versioned Stack sources. Exact SVG comparison catches every geometry change; this page makes an intentional change reviewable before the approved references move.

    +
    + ${catalog.cases.length} representative cases + ${changed} changed candidates + ${performance.suiteMilliseconds.toFixed(3)} ms benchmark suite + ${performance.maxP95Milliseconds.toFixed(3)} ms p95 budget +
    + ${cards} +
    + + +`; +} + +export async function buildLayoutGallery() { + const { catalog, corpusRoot } = await validateRepositoryLayoutCorpus(); + const candidateRoot = path.join(repositoryRoot, "target/layout-corpus/candidate"); + const performance = JSON.parse( + await readFile(path.join(repositoryRoot, "target/layout-corpus/performance.json"), "utf8"), + ); + validatePerformance(catalog, performance); + const performanceById = new Map(performance.cases.map((entry) => [entry.id, entry])); + + const comparisons = []; + for (const layoutCase of catalog.cases) { + const [approved, current, sourceText] = await Promise.all([ + readFile(path.join(corpusRoot, layoutCase.snapshot), "utf8"), + readFile(path.join(candidateRoot, `${layoutCase.id}.svg`), "utf8"), + readFile(path.join(corpusRoot, layoutCase.source), "utf8"), + ]); + comparisons.push({ + layoutCase: { ...layoutCase, sourceText }, + dimensions: svgDimensions(current), + matches: approved === current, + performanceCase: performanceById.get(layoutCase.id), + approvedPath: path.join(corpusRoot, layoutCase.snapshot), + currentPath: path.join(candidateRoot, `${layoutCase.id}.svg`), + }); + } + + const targetRoot = path.join(repositoryRoot, "target"); + await mkdir(targetRoot, { recursive: true }); + const stagingRoot = await mkdtemp(path.join(targetRoot, ".layout-gallery-")); + const assetRoot = path.join(stagingRoot, "assets"); + await mkdir(assetRoot); + for (const comparison of comparisons) { + await Promise.all([ + copyFile( + comparison.approvedPath, + path.join(assetRoot, `${comparison.layoutCase.id}-approved.svg`), + ), + copyFile( + comparison.currentPath, + path.join(assetRoot, `${comparison.layoutCase.id}-current.svg`), + ), + ]); + } + const html = renderGallery(catalog, comparisons, performance); + assert.doesNotMatch( + html, + / !comparison.matches).length; + console.log( + `Built ${outputRoot}/index.html with ${comparisons.length} approved/current comparisons and ${changed} geometry changes.`, + ); + return { outputRoot, comparisons }; +} + +function validatePerformance(catalog, performance) { + assert.equal(performance.schemaVersion, "1.0"); + assert.equal(performance.profile, "release"); + assert.equal(performance.warmupIterations, catalog.performance.warmupIterations); + assert.equal(performance.measuredIterations, catalog.performance.measuredIterations); + assert.equal(performance.maxP95Milliseconds, catalog.performance.maxP95Milliseconds); + assert.equal(performance.maxSuiteMilliseconds, catalog.performance.maxSuiteMilliseconds); + assert.ok(performance.suiteMilliseconds <= catalog.performance.maxSuiteMilliseconds); + assert.deepEqual( + performance.cases.map(({ id }) => id), + catalog.cases.map(({ id }) => id), + "performance case inventory drift", + ); + for (const entry of performance.cases) { + assert.ok(Number.isFinite(entry.p95Milliseconds) && entry.p95Milliseconds >= 0); + assert.ok(entry.p95Milliseconds <= catalog.performance.maxP95Milliseconds); + } +} + +function svgDimensions(svg) { + const match = svg.match(/]*\bwidth="([^"]+)"[^>]*\bheight="([^"]+)"/); + assert.ok(match, "candidate SVG has no width and height"); + return `${match[1]} × ${match[2]}`; +} + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function quantity(count, singular) { + return `${count} ${count === 1 ? singular : `${singular}s`}`; +} + +const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : null; +if (invokedPath === import.meta.url) await buildLayoutGallery(); diff --git a/scripts/layout-corpus.test.mjs b/scripts/layout-corpus.test.mjs new file mode 100644 index 0000000..7dcb423 --- /dev/null +++ b/scripts/layout-corpus.test.mjs @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +import { renderGallery } from "./build-layout-gallery.mjs"; +import { validateCatalogDocument } from "./validate-layout-corpus.mjs"; + +const catalog = JSON.parse(await readFile(new URL("../layout-corpus/catalog.json", import.meta.url))); +const schema = JSON.parse(await readFile(new URL("../layout-corpus/schema.json", import.meta.url))); +const packageDocument = JSON.parse(await readFile(new URL("../package.json", import.meta.url))); +const engineVersion = packageDocument.version; + +test("the checked-in layout catalog matches its versioned schema", () => { + assert.equal(validateCatalogDocument(structuredClone(catalog), schema, engineVersion).cases.length, 7); +}); + +test("schema version 1 rejects unknown fields and invalid paths", () => { + const unknown = structuredClone(catalog); + unknown.cases[0].undocumented = true; + assert.throws(() => validateCatalogDocument(unknown, schema, engineVersion), /additional properties/); + + const unsafe = structuredClone(catalog); + unsafe.cases[0].source = "../outside.stack"; + assert.throws(() => validateCatalogDocument(unsafe, schema, engineVersion), /must match pattern/); +}); + +test("duplicate cases and missing required coverage are rejected", () => { + const duplicate = structuredClone(catalog); + duplicate.cases[1].id = duplicate.cases[0].id; + assert.throws(() => validateCatalogDocument(duplicate, schema, engineVersion), /IDs must be unique/); + + const missing = structuredClone(catalog); + for (const layoutCase of missing.cases) { + layoutCase.features = layoutCase.features.filter((feature) => feature !== "long-labels"); + } + assert.throws(() => validateCatalogDocument(missing, schema, engineVersion), /cover long-labels/); +}); + +test("provider fixtures and declared provider coverage cannot drift", () => { + const invalid = structuredClone(catalog); + invalid.cases.at(-1).providerFixture = null; + assert.throws(() => validateCatalogDocument(invalid, schema, engineVersion), /must agree/); +}); + +test("the static gallery escapes source and exposes accessible comparisons", () => { + const fixtureCatalog = { + engineVersion: "0.6.0", + schemaVersion: "1.0", + cases: [{ id: "fixture" }], + }; + const layoutCase = { + id: "fixture", + title: "A < B", + summary: "Safe & local", + density: "small", + features: ["edge-labels"], + expected: { nodes: 2, groups: 0, edges: 1 }, + alt: 'Diagram "fixture"', + sourceText: "node ", + }; + const performance = { suiteMilliseconds: 1, maxP95Milliseconds: 50 }; + const html = renderGallery( + fixtureCatalog, + [{ layoutCase, dimensions: "1 × 1", matches: false, performanceCase: null }], + performance, + ); + assert.match(html, /1 changed candidates/); + assert.match(html, /Candidate differs from approved geometry/); + assert.match(html, /Approved reference/); + assert.match(html, /Current engine/); + assert.match(html, /Approved reference: Diagram "fixture"/); + assert.match(html, /node <unsafe>/); + assert.doesNotMatch(html, / id); + const sources = catalog.cases.map(({ source }) => source); + const snapshots = catalog.cases.map(({ snapshot }) => snapshot); + assert.equal(new Set(ids).size, ids.length, "layout corpus case IDs must be unique"); + assert.equal(new Set(sources).size, sources.length, "layout corpus source paths must be unique"); + assert.equal( + new Set(snapshots).size, + snapshots.length, + "layout corpus snapshot paths must be unique", + ); + + const densities = new Set(catalog.cases.map(({ density }) => density)); + const features = new Set(catalog.cases.flatMap(({ features: values }) => values)); + for (const density of requiredDensities) { + assert.ok(densities.has(density), `layout corpus must cover ${density} density`); + } + for (const feature of requiredFeatures) { + assert.ok(features.has(feature), `layout corpus must cover ${feature}`); + } + + for (const layoutCase of catalog.cases) { + assert.equal( + layoutCase.source, + `sources/${layoutCase.id}.stack`, + `${layoutCase.id} source path must follow its ID`, + ); + assert.equal( + layoutCase.snapshot, + `snapshots/${layoutCase.id}.svg`, + `${layoutCase.id} snapshot path must follow its ID`, + ); + const usesProvider = layoutCase.features.includes("provider-icons"); + assert.equal( + layoutCase.providerFixture !== null, + usesProvider, + `${layoutCase.id} provider fixture and feature must agree`, + ); + } + return catalog; +} + +export async function validateRepositoryLayoutCorpus() { + const [catalog, schema, packageDocument] = await Promise.all([ + readJson(path.join(corpusRoot, "catalog.json")), + readJson(path.join(corpusRoot, "schema.json")), + readJson(path.join(repositoryRoot, "package.json")), + ]); + validateCatalogDocument(catalog, schema, packageDocument.version); + + const expectedSources = catalog.cases.map(({ source }) => path.basename(source)).sort(); + const expectedSnapshots = catalog.cases.map(({ snapshot }) => path.basename(snapshot)).sort(); + const actualSources = await inventory(path.join(corpusRoot, "sources"), ".stack"); + const actualSnapshots = await inventory(path.join(corpusRoot, "snapshots"), ".svg"); + assert.deepEqual(actualSources, expectedSources, "layout source inventory drift"); + assert.deepEqual(actualSnapshots, expectedSnapshots, "layout snapshot inventory drift"); + + for (const layoutCase of catalog.cases) { + const [source, snapshot] = await Promise.all([ + readBoundedRegularFile(path.join(corpusRoot, layoutCase.source), 64 * 1024), + readBoundedRegularFile(path.join(corpusRoot, layoutCase.snapshot), 1024 * 1024), + ]); + assert.match(source, /^stack 1\.0\n/, `${layoutCase.id} has no language header`); + assert.doesNotMatch(source, /\r/, `${layoutCase.id} must use LF newlines`); + assert.match(snapshot, /^<\?xml version="1\.0" encoding="UTF-8"\?>\n 0 && metadata.size <= maximumBytes, `${file} has an invalid size`); + return readFile(file, "utf8"); +} + +function formatErrors(errors) { + return errors?.map((error) => `${error.instancePath || "/"} ${error.message}`).join("; ") ?? ""; +} + +function escapeRegex(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : null; +if (invokedPath === import.meta.url) { + const { catalog } = await validateRepositoryLayoutCorpus(); + console.log( + `Validated ${catalog.cases.length} versioned layout cases across ${requiredDensities.length} densities and ${requiredFeatures.length} required failure-mode features.`, + ); +} From 02a5c7c2e9906d717b6e89cbdc71b44f875f57f8 Mon Sep 17 00:00:00 2001 From: konojunya Date: Sat, 5 Sep 2026 20:17:33 +0900 Subject: [PATCH 3/3] ci: enforce layout regression gates --- .github/workflows/ci.yaml | 9 +++++++++ README.md | 13 ++++++++++++- docs/dependency-audit.md | 12 ++++++++---- scripts/validate-svg.py | 13 ++++++++++--- 4 files changed, 39 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8461007..537fbbd 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -43,6 +43,9 @@ jobs: - name: Install JavaScript development dependencies run: npm ci + - name: Validate layout corpus contract + run: npm run layout:validate + - name: Install WebAssembly binding generator uses: taiki-e/install-action@e67fa11c4b9316fa714ddf0abed07a0c3143b95b # v2.87.4 with: @@ -68,6 +71,12 @@ jobs: - name: Validate standalone SVG snapshots run: python3 scripts/validate-svg.py + - name: Enforce layout runtime budget + run: cargo +stable test --release -p stack-engine --test layout_corpus layout_runtime_stays_within_budget --locked -- --ignored --nocapture + + - name: Build layout regression gallery + run: npm run layout:gallery + - name: Build browser WebAssembly package run: | rustup target add wasm32-unknown-unknown wasm32-wasip1 --toolchain stable diff --git a/README.md b/README.md index 6a32457..207da82 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ rustup target add wasm32-unknown-unknown wasm32-wasip1 cargo build -p stack-engine-wasm --target wasm32-unknown-unknown wasm-bindgen --version npm ci +npm run layout:validate npm run build:wasm npm test npm run typecheck @@ -41,11 +42,21 @@ cargo clippy --workspace --all-targets --all-features -- -D warnings cargo doc --workspace --no-deps ``` +The versioned representative layout corpus covers small, medium, and dense diagrams plus groups, nested groups, authored rank and order constraints, cross-boundary edges, labels, and caller-owned provider icons. Run its exact geometry comparison, release-mode performance budget, and local static review gallery with: + +```sh +cargo test -p stack-engine --test layout_corpus --locked +cargo test --release -p stack-engine --test layout_corpus layout_runtime_stays_within_budget --locked -- --ignored --nocapture +npm run layout:gallery +``` + +The review-first snapshot policy and corpus contract are documented in [`layout-corpus/README.md`](./layout-corpus/README.md). + `stack-formatter` is pure and accepts source bytes or UTF-8 text. Lexical and syntax errors return diagnostics without formatted output. Syntactically valid source remains formattable when semantic diagnostics exist. `stack-engine` exposes byte-oriented `format`, `check`, and `render` methods through an engine bound to the embedded or a caller-provided validated catalog. `ProviderPack::new` accepts a typed user-imported manifest and caller-owned SVG strings, verifies exact asset hashes and safe SVG structure, and computes a deterministic content revision before `Engine::with_provider_packs` can resolve namespaced IDs. Every normal output carries engine, authored language, theme catalog version, and theme catalog revision metadata. User-source failures stay in ordered portable diagnostics. Invalid provided catalogs or provider packs and violated normalized pipeline invariants use a separate operational-error channel. Checks and renders resolve the requested theme and provider packs, validate deterministic integer geometry, and route ordered edges outside node interiors. Missing themes and icons produce source-mapped `STK6001` and `STK5001` warnings while a fallback SVG remains available. An unsatisfied authored order hint produces `STK4001` at its source-map range; a satisfied hint does not. -The renderer emits fixed-dimension standalone SVG with embedded catalog or provider icons, local marker references, escaped authored text, accessible title and description metadata, and no script, event handler, external URL, host font measurement, or runtime I/O. Provider artwork preserves the authored node `kind`; each render returns the exact used-asset notices and writes provider ID, icon IDs, and pack revision into SVG metadata. The bundled catalog provides 30 first-party explicit icon identifiers in every core theme: `api`, `web`, `mobile`, `desktop`, `server`, `container`, `cluster`, `cloud`, `scheduler`, `webhook`, `identity`, `observability`, `gateway`, `load-balancer`, `dns`, `cdn`, `firewall`, `network`, `event`, `stream`, `search`, `analytics`, `repository`, `pipeline`, `secret`, `document`, `task`, `chat`, `email`, and `ai`. Canonical SVG snapshots are byte-stable and parsed by `scripts/validate-svg.py`; set `UPDATE_STACK_SNAPSHOTS=1` only when intentionally regenerating them. CI also executes one exact numeric geometry fixture in both the native suite and a WASI build. +The renderer emits fixed-dimension standalone SVG with embedded catalog or provider icons, local marker references, escaped authored text, accessible title and description metadata, and no script, event handler, external URL, host font measurement, or runtime I/O. Provider artwork preserves the authored node `kind`; each render returns the exact used-asset notices and writes provider ID, icon IDs, and pack revision into SVG metadata. The bundled catalog provides 30 first-party explicit icon identifiers in every core theme: `api`, `web`, `mobile`, `desktop`, `server`, `container`, `cluster`, `cloud`, `scheduler`, `webhook`, `identity`, `observability`, `gateway`, `load-balancer`, `dns`, `cdn`, `firewall`, `network`, `event`, `stream`, `search`, `analytics`, `repository`, `pipeline`, `secret`, `document`, `task`, `chat`, `email`, and `ai`. Canonical renderer and representative-layout SVG snapshots are byte-stable and parsed by `scripts/validate-svg.py`; set `UPDATE_STACK_SNAPSHOTS=1` or `UPDATE_STACK_LAYOUT_SNAPSHOTS=1` only when intentionally regenerating the corresponding reviewed references. CI also executes one exact numeric geometry fixture in both the native suite and a WASI build. The npm package exports synchronous `format`, `check`, `render`, `checkWithProviderPacks`, and `renderWithProviderPacks` functions after asynchronous module initialization. Provider-pack operations accept JSON-compatible local manifest and SVG data; they never discover a path or initiate a request. Each operation accepts `string | Uint8Array` source and returns a specific typed result with camel-case metadata and portable diagnostics. Diagnostics preserve the compiler's primary range, ordered `expected` values, corrective help, and related source locations. Invalid UTF-8 remains a normal `STK1001` result. Unsupported JavaScript input types and internal operational failures throw at the adapter boundary. Shared fixtures exercise native and WebAssembly provider resolution. Artifact validation audits WebAssembly imports and package contents; browser consumers retain responsibility for loading the module and performing any DOM, filesystem, network, or clock work. diff --git a/docs/dependency-audit.md b/docs/dependency-audit.md index 12ba520..0576731 100644 --- a/docs/dependency-audit.md +++ b/docs/dependency-audit.md @@ -1,14 +1,14 @@ # Dependency and host-I/O audit -Audit date: 2026-09-04 +Audit date: 2026-09-05 ## Runtime graph `stack-engine` has six direct dependencies: -- `stack-compiler` at `4a18fac42afc2256a1bb3a6ff13d12d732a391e7` for byte decoding, parsing, validation, normalized IR, source maps, and compiler diagnostics; +- `stack-compiler` at `84ab5663a7f7c5b7dc0b5e9e2f04c8894ed02820` for byte decoding, parsing, validation, normalized IR, source maps, and compiler diagnostics; - the workspace-local `stack-formatter` for canonical source output; -- `stack-theme` at `2347315e6e86ab9d2708e05fd3f9b5f3d87e1241` for the `0.4.0` embedded core catalog, 30 provider-neutral explicit icons, the local-only provider-pack contract, SVG bytes, deterministic font metrics, catalog version, and catalog revision; +- `stack-theme` at `7e208d6a3c90d255799f390a4e8b86248c73caee` for the `0.5.0` embedded core catalog, 30 provider-neutral explicit icons, the local-only provider-pack contract, SVG bytes, deterministic font metrics, catalog version, and catalog revision; - `roxmltree`, `serde_json`, and `sha2` for pure in-memory provider manifest serialization, processed-asset hash verification, pack revision computation, and defensive SVG validation. Vendor asset bytes are not included. `stack-engine-wasm` adds `serde`, `serde_json`, and the asset-free `stack-theme` types for its serializable native parity model and local provider-pack input, plus, only on `wasm32`, version-matched `wasm-bindgen` and `js-sys` for the JavaScript ABI, typed-array input, JSON-compatible local data, and plain object construction. It does not use `web-sys` or a WASI target. @@ -23,7 +23,7 @@ Exact versions and licenses are recorded in [`THIRD_PARTY_LICENSES.md`](../THIRD - No runtime dependency discovers a path, opens a socket, reads process state, observes time, samples randomness, queries a DOM, or measures a system font. - The generated WebAssembly imports only the audited `wasm-bindgen` object, string, array, typed-array, exception, and extern-reference glue from its sibling JavaScript module. The import validator rejects WASI and names associated with filesystem, network, DOM, clock, random, process, environment, or storage capabilities. -Tests and CI may read the pinned specification checkout and invoke toolchains. Those development actions are outside the runtime library boundary. +Tests and CI may read the pinned specification checkout and invoke toolchains. The exact `ajv` development dependency validates the layout corpus JSON Schema; it and its four transitive dependencies are not shipped in the npm package. Those development actions are outside the runtime library boundary. ## Reproduction @@ -32,9 +32,13 @@ cargo tree -p stack-engine --edges normal --locked cargo metadata --format-version 1 --locked cargo test --workspace --locked STACK_SPECIFICATION_DIR=../specification cargo test -p stack-engine --features conformance --locked +cargo test -p stack-engine --test layout_corpus --locked +cargo test --release -p stack-engine --test layout_corpus layout_runtime_stays_within_budget --locked -- --ignored --nocapture python3 scripts/validate-svg.py cargo build -p stack-engine-wasm --target wasm32-unknown-unknown --release --locked npm ci +npm run layout:validate +npm run layout:gallery npm run build:wasm npm test npm run typecheck diff --git a/scripts/validate-svg.py b/scripts/validate-svg.py index 6789a9b..3a1ef02 100644 --- a/scripts/validate-svg.py +++ b/scripts/validate-svg.py @@ -6,7 +6,10 @@ ROOT = Path(__file__).resolve().parents[1] -SNAPSHOTS = ROOT / "crates" / "stack-engine" / "tests" / "snapshots" / "render" +RENDER_SNAPSHOTS = ( + ROOT / "crates" / "stack-engine" / "tests" / "snapshots" / "render" +) +LAYOUT_SNAPSHOTS = ROOT / "layout-corpus" / "snapshots" SVG_NAMESPACE = "http://www.w3.org/2000/svg" FORBIDDEN_ELEMENTS = {"script", "foreignObject", "iframe", "object", "embed"} URL_PATTERN = re.compile(r"url\(([^)]+)\)", re.IGNORECASE) @@ -58,9 +61,13 @@ def values(root: ET.Element, attribute: str) -> set[str]: def main() -> None: - snapshots = sorted(SNAPSHOTS.glob("*.svg")) - assert snapshots, "no render snapshots found" + render_snapshots = sorted(RENDER_SNAPSHOTS.glob("*.svg")) + layout_snapshots = sorted(LAYOUT_SNAPSHOTS.glob("*.svg")) + assert render_snapshots, "no render snapshots found" + assert layout_snapshots, "no layout corpus snapshots found" + snapshots = render_snapshots + layout_snapshots documents = {path.stem: validate(path) for path in snapshots} + assert len(documents) == len(snapshots), "snapshot file stems must be unique" complete = documents["complete-semantics"] explicit_icon = documents["explicit-core-icon"] assert values(complete, "data-node-kind") == {