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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Thumbs.db

# Renderflow build outputs and cache
dist/
gallery-output/

# MkDocs build output
site/
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

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

14 changes: 14 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,20 @@ supersedes: []

# Renderflow Roadmap

## 2026-09-28 artifact gallery review handoff

[#433](https://github.com/egohygiene/renderflow/issues/433) is the first bounded
[#412](https://github.com/egohygiene/renderflow/issues/412) corpus checkpoint.
Its explicit local runner creates HTML from one synthetic Markdown input and a
two-page print PDF from two synthetic RGB PNG inputs through the public CLI and
real providers. It retains both builds and compares the outputs to reviewed
oracles, independent PDF structure evidence, and typed run manifests. The normal
workspace suite checks the corpus contract without treating missing external
providers as a pass. Later fixture expansion remains in #412. The independent
fixed-layout EPUB lane [#417](https://github.com/egohygiene/renderflow/issues/417)
remains the next production capability after this review; #433 does not claim
EPUB or physical print validation.

## 2026-09-28 print-interior PDF review handoff

Checkpoint #416 adds the exact `publication.generate.pdf.interior` capability
Expand Down
5 changes: 5 additions & 0 deletions crates/renderflow-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ renderflow = { path = "../renderflow-core", package = "renderflow" }
[dev-dependencies]
tempfile = "3"
serde_json = "1"
sha2 = "0.10"

[[test]]
name = "cli_tests"
Expand All @@ -31,6 +32,10 @@ path = "../../tests/cli_tests.rs"
name = "ordered_collection_cli"
path = "../../tests/ordered_collection_cli.rs"

[[test]]
name = "artifact_gallery_cli"
path = "../../tests/artifact_gallery_cli.rs"

[[test]]
name = "graph_integration_test"
path = "../../tests/graph_integration_test.rs"
Expand Down
145 changes: 142 additions & 3 deletions crates/renderflow-core/tests/golden_conformance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ struct Fixture {
filename: String,
encoding: String,
payload: String,
sha256: Option<String>,
expected_format: Option<String>,
expected_media_type: String,
family: String,
Expand All @@ -72,6 +73,21 @@ struct Scenario {
id: String,
tier: String,
fixtures: Vec<String>,
#[serde(default)]
expected_artifacts: Vec<ExpectedArtifact>,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ExpectedArtifact {
case_id: String,
role: String,
format: String,
media_type: String,
provider_id: String,
provider_version: Option<String>,
oracle_path: String,
source_ids: Vec<String>,
}

#[derive(Debug, Serialize)]
Expand Down Expand Up @@ -224,6 +240,21 @@ fn materialize_fixture(root: &Path, fixture: &Fixture) -> Result<PathBuf> {
"hex" => decode_hex(&fixture.payload)?,
other => anyhow::bail!("unsupported fixture encoding '{other}'"),
};
if let Some(expected) = &fixture.sha256 {
anyhow::ensure!(
expected.len() == 64
&& expected
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)),
"fixture '{}' has an invalid lowercase SHA-256 declaration",
fixture.id
);
anyhow::ensure!(
format!("{:x}", Sha256::digest(&bytes)) == *expected,
"fixture '{}' differs from its declared SHA-256",
fixture.id
);
}
let path = root.join(&fixture.filename);
fs::write(&path, bytes)?;
Ok(path)
Expand Down Expand Up @@ -306,8 +337,8 @@ fn golden_artifact_forest_conformance() -> Result<()> {
.map(|fixture| fixture.id.as_str())
.collect::<BTreeSet<_>>();
assert_eq!(fixture_ids.len(), corpus.fixtures.len());
assert_eq!(corpus.fixtures.len(), 12);
assert_eq!(corpus.collections.len(), 1);
assert_eq!(corpus.fixtures.len(), 14);
assert_eq!(corpus.collections.len(), 2);
for collection in &corpus.collections {
assert!(collection.id.starts_with("fixture.collection."));
assert!(collection
Expand All @@ -325,7 +356,93 @@ fn golden_artifact_forest_conformance() -> Result<()> {
fixture_ids.contains(fixture.as_str())
|| corpus.collections.iter().any(|value| value.id == *fixture)
}));
let mut included_sources = BTreeSet::new();
for member in &declared.fixtures {
if let Some(collection) = corpus.collections.iter().find(|item| item.id == *member) {
included_sources.extend(collection.members.iter().map(String::as_str));
} else {
included_sources.insert(member.as_str());
}
}
let mut case_ids = BTreeSet::new();
for artifact in &declared.expected_artifacts {
anyhow::ensure!(
case_ids.insert(artifact.case_id.as_str()),
"scenario '{}' repeats case '{}'",
declared.id,
artifact.case_id
);
anyhow::ensure!(
!artifact.role.is_empty()
&& !artifact.format.is_empty()
&& artifact.media_type.contains('/')
&& artifact.provider_id.starts_with("tool.")
&& artifact
.provider_version
.as_deref()
.is_none_or(|value| !value.is_empty()),
"scenario '{}' has incomplete expected artifact '{}'",
declared.id,
artifact.case_id
);
anyhow::ensure!(
!artifact.source_ids.is_empty()
&& artifact
.source_ids
.iter()
.all(|source| included_sources.contains(source.as_str())),
"scenario '{}' case '{}' names a source outside the scenario",
declared.id,
artifact.case_id
);
let relative = Path::new(&artifact.oracle_path);
anyhow::ensure!(
relative.starts_with("expected")
&& relative
.components()
.all(|part| matches!(part, std::path::Component::Normal(_))),
"scenario '{}' has an unsafe oracle locator",
declared.id
);
let oracle = corpus_path().parent().unwrap().join(relative);
let metadata = fs::symlink_metadata(&oracle).with_context(|| {
format!(
"scenario '{}' oracle '{}' is absent",
declared.id,
oracle.display()
)
})?;
anyhow::ensure!(
metadata.file_type().is_file()
&& metadata.len() > 0
&& metadata.len() <= 1024 * 1024,
"scenario '{}' oracle '{}' must be a nonempty regular file within 1 MiB",
declared.id,
oracle.display()
);
}
}
let gallery = corpus
.scenarios
.iter()
.find(|item| item.id == "real_artifact_gallery")
.context("real artifact gallery scenario missing")?;
assert_eq!(gallery.tier, "tool_backed");
assert_eq!(gallery.fixtures.len(), 2);
assert_eq!(gallery.expected_artifacts.len(), 2);
let gallery_sources = gallery
.expected_artifacts
.iter()
.flat_map(|artifact| artifact.source_ids.iter().map(String::as_str))
.collect::<BTreeSet<_>>();
assert_eq!(
gallery_sources,
BTreeSet::from([
"fixture.document.markdown",
"fixture.image.print-page001",
"fixture.image.print-page002",
])
);

let work = tempfile::tempdir()?;
let fixture_root = work.path().join("fixtures");
Expand Down Expand Up @@ -953,6 +1070,28 @@ fn golden_artifact_forest_conformance() -> Result<()> {
.then(|| "one or more optional tool providers were not installed".to_string()),
},
));
results.insert((
"real_artifact_gallery".to_string(),
ScenarioEvidence {
id: "real_artifact_gallery".to_string(),
status: if requested_rank == 0 {
"excluded"
} else {
"unavailable"
}
.to_string(),
assertions: vec![
"source digests and expected oracle files are checked in the fast tier; real CLI artifact generation requires the separate gallery runner".to_string(),
],
providers: Vec::new(),
reason: Some(if requested_rank == 0 {
"tool-backed gallery execution was not requested"
} else {
"this API corpus does not execute the real artifact gallery; run its CLI harness for generation evidence"
}
.to_string()),
},
));
results.insert((
"maximal_release_matrix".to_string(),
ScenarioEvidence {
Expand Down Expand Up @@ -995,7 +1134,7 @@ fn golden_artifact_forest_conformance() -> Result<()> {
let encoded = serde_json::to_vec_pretty(&report)?;
let decoded: serde_json::Value = serde_json::from_slice(&encoded)?;
assert_eq!(decoded["schema_version"], REPORT_SCHEMA);
assert_eq!(decoded["fixtures"].as_array().map(Vec::len), Some(12));
assert_eq!(decoded["fixtures"].as_array().map(Vec::len), Some(14));
if let Some(path) = std::env::var_os("RENDERFLOW_CONFORMANCE_REPORT") {
fs::write(path, encoded)?;
}
Expand Down
58 changes: 58 additions & 0 deletions docs/artifact-gallery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Inspectable artifact gallery

The gallery is a small synthetic, source-to-output proof. Its first cases use
one Markdown document and two ordered image pages. They run the public
Renderflow CLI with the real Pandoc and `img2pdf` providers, check generated
artifacts and evidence, and leave the complete output tree available for you
to inspect. They never process publication or personal media.

## Run locally

From the repository root, provide a new or empty output directory:

```bash
scripts/run-artifact-gallery.sh --output-dir "$PWD/gallery-output"
```

When `img2pdf` is installed outside `PATH`, specify its executable:

```bash
scripts/run-artifact-gallery.sh \
--output-dir "$PWD/gallery-output" \
--img2pdf "/absolute/path/to/img2pdf"
```

The runner requires Pandoc 2.0.0 or newer, exactly `img2pdf` 0.6.3, and a
Rust toolchain compatible with this checkout. Missing or incompatible
providers are reported as **unavailable** and return a nonzero exit code;
they never count as a successful test. The runner does not install tools,
access the network, trigger hosted CI, replace a nonempty output directory,
or approve a changed expected result. Pick a new output directory for each
run to preserve earlier evidence.

The reviewed HTML baseline was generated with Pandoc 3.1.3. Other Pandoc
versions are probed and reported, and an output difference fails for review;
the runner never rewrites the baseline to accommodate provider drift.

The retained tree includes the synthetic inputs, the generated HTML and PDF,
the corresponding `renderflow-run.json` manifests, two synthetic refusal cases,
and `comparison.json` on success. Open the HTML and PDF, then review the report and run
manifests for provider versions, ordered sources, output digests, and validation
state. The PDF case verifies page order, boxes, embedded image streams, and
lineage; this is a synthetic capability proof, not a print approval.

## Test tiers and expected changes

Normal tests can run without the external providers and exercise fixtures,
planning, preflight, refusal, and validation behavior. The explicit runner
executes the ignored real-provider gallery test and keeps its output. Output
bytes may be compared exactly where a provider version and output contract
make them deterministic; otherwise tests compare the relevant structure and
semantics. A missing provider is neither a pass nor an expected-output update.

Reviewed baselines and fixtures live in source control. A difference should
fail with enough evidence to diagnose it, and any update requires a deliberate
code review. We can add more source formats, target formats, configurations,
and failure cases as their capabilities graduate, while keeping this first
local run small and inspectable. The broader conformance tiers are described
in [Golden artifact conformance](conformance-corpus.md).
5 changes: 5 additions & 0 deletions docs/conformance-corpus.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ redistribution-safe acceptance harness. The corpus contains synthetic payloads
only. It does not contain publication, comic, customer, or third-party content,
and no fixture requires network or AI access.

For a small, retained source-to-output run through the public CLI and real
local providers, see the [inspectable artifact gallery](artifact-gallery.md).
The gallery is explicitly invoked on a local machine; it complements this
versioned conformance corpus and does not silently bless changed outputs.

## Contracts

The canonical manifest is
Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ nav:
- Resumable Provider Contract: provider-contract.md
- Artifact Validation: user-guide/artifact-validation.md
- Golden Conformance Corpus: conformance-corpus.md
- Inspectable Artifact Gallery: artifact-gallery.md
- Plugin Architecture: architecture/plugin-architecture.md
- Execution Plans: architecture/execution-plans.md
- CLI Reference:
Expand Down
28 changes: 27 additions & 1 deletion schemas/renderflow-golden-corpus-v1.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"filename": { "type": "string", "minLength": 1 },
"encoding": { "enum": ["utf8", "hex"] },
"payload": { "type": "string" },
"sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
"expected_format": { "type": "string", "minLength": 1 },
"expected_media_type": { "type": "string", "pattern": "^[^/]+/[^/]+$" },
"family": { "type": "string", "minLength": 1 },
Expand All @@ -58,7 +59,32 @@
"properties": {
"id": { "type": "string", "pattern": "^[a-z0-9_]+$" },
"tier": { "enum": ["fast", "tool_backed", "maximal"] },
"fixtures": { "type": "array", "minItems": 1, "items": { "type": "string" } }
"fixtures": { "type": "array", "minItems": 1, "items": { "type": "string" } },
"expected_artifacts": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/expectedArtifact" }
}
}
},
"expectedArtifact": {
"type": "object",
"additionalProperties": false,
"required": ["case_id", "role", "format", "media_type", "provider_id", "oracle_path", "source_ids"],
"properties": {
"case_id": { "type": "string", "pattern": "^[a-z0-9_]+$" },
"role": { "type": "string", "pattern": "^[a-z0-9_-]+$" },
"format": { "type": "string", "minLength": 1 },
"media_type": { "type": "string", "pattern": "^[^/]+/[^/]+$" },
"provider_id": { "type": "string", "pattern": "^tool\\.[a-z0-9.-]+$" },
"provider_version": { "type": "string", "minLength": 1 },
"oracle_path": { "type": "string", "pattern": "^expected/(?:[a-z0-9_-]+/)*[a-z0-9_-]+(?:\\.[a-z0-9_-]+)+$" },
"source_ids": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "type": "string", "pattern": "^fixture\\.[a-z0-9.-]+$" }
}
}
}
}
Expand Down
Loading
Loading