fabro/lib/foundation/fabro-template/src/static_reference.rs
Scott Werner 8dfbfb9aa5 Classify graph attributes with a graph-only reference kind
reference_kind_for_attribute returned the full ReferenceKind, which
includes the config-sourced Dockerfile kind the classifier can never
yield, so the shared graph walker carried a silent `continue` and an
`unreachable!` for impossible kinds; each new config-sourced kind widens
those filler arms, and a classifier extension that reuses an existing
kind would be dropped by the walker without validation, visitation, or a
compiler error. Return a GraphReferenceKind subset instead (converting
into ReferenceKind for validation), making the walker's matches total
with every arm meaningful.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 17:58:46 -04:00

249 lines
9.1 KiB
Rust

//! Static file references in workflow graphs.
//!
//! Workflow graphs name other files through a fixed attribute vocabulary
//! (`import`, `stack.child_workflow`, `@`-prefixed `prompt`/`output_schema`
//! values, and the graph `goal`). These references are *static*: they may not
//! contain template syntax, because they are resolved before any template
//! rendering happens.
//!
//! [`visit_graph_references`] is the one walker over that vocabulary. The
//! manifest bundler and workflow-version validation both consume it, so a new
//! reference-bearing attribute is added here once instead of drifting between
//! per-crate walkers.
use fabro_types::graph::{
AttributeScope, Graph, GraphReferenceKind, ReferenceKind, reference_kind_for_attribute,
};
use crate::contains_template_syntax;
/// A static file reference that unexpectedly contains template syntax.
#[derive(Debug, thiserror::Error)]
#[error("templates are not supported in {kind}s: {value}")]
pub struct StaticReferenceError {
kind: ReferenceKind,
value: String,
}
impl StaticReferenceError {
#[must_use]
pub fn new(kind: ReferenceKind, value: impl Into<String>) -> Self {
Self {
kind,
value: value.into(),
}
}
#[must_use]
pub fn kind(&self) -> ReferenceKind {
self.kind
}
#[must_use]
pub fn value(&self) -> &str {
&self.value
}
}
/// Reject static file references (imports, child workflows, `@` file values)
/// that contain template syntax.
pub fn validate_static_reference(
value: &str,
kind: ReferenceKind,
) -> Result<(), StaticReferenceError> {
if contains_template_syntax(value) {
return Err(StaticReferenceError::new(kind, value));
}
Ok(())
}
/// One file reference or inline template discovered in a workflow graph.
///
/// `@` prefixes are already stripped from file references; inline variants
/// carry template content that the consumer should feed to template-dependency
/// discovery.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum GraphReference<'graph> {
/// `graph [goal="@<reference>"]`.
GoalFile { reference: &'graph str },
/// A non-`@` graph `goal`: inline template content.
GoalInline { content: &'graph str },
/// `node [import="<reference>"]` — another graph file to walk.
Import { reference: &'graph str },
/// `node [stack.child_workflow="<reference>"]`.
ChildWorkflow { reference: &'graph str },
/// `node [<key>="@<reference>"]` for file-inlined attributes
/// (`prompt`, `output_schema`).
FileInline {
key: &'graph str,
reference: &'graph str,
},
/// A non-`@` node prompt: inline template content.
InlinePrompt { content: &'graph str },
}
/// Error from [`visit_graph_references`].
#[derive(Debug, thiserror::Error)]
pub enum GraphReferenceError<E> {
#[error(transparent)]
StaticReference(StaticReferenceError),
#[error(transparent)]
Visit(E),
}
/// Walk every static file reference and inline template in one parsed graph,
/// validating that file references are template-free before emitting them.
///
/// The walker covers a single graph; recursion into `Import` targets and
/// resolution of references against a file source are the consumer's job.
pub fn visit_graph_references<'graph, E>(
graph: &'graph Graph,
mut visit: impl FnMut(GraphReference<'graph>) -> Result<(), E>,
) -> Result<(), GraphReferenceError<E>> {
let goal = graph.goal();
if !goal.is_empty() {
if let Some(reference) = goal.strip_prefix('@') {
validate_static_reference(reference, ReferenceKind::GraphGoalFile)
.map_err(GraphReferenceError::StaticReference)?;
visit(GraphReference::GoalFile { reference }).map_err(GraphReferenceError::Visit)?;
} else {
visit(GraphReference::GoalInline { content: goal })
.map_err(GraphReferenceError::Visit)?;
}
}
for node in graph.nodes.values() {
for (key, value) in &node.attrs {
let Some(value) = value.as_str() else {
continue;
};
let Some(kind) = reference_kind_for_attribute(AttributeScope::Node, key, value) else {
continue;
};
let reference = match kind {
GraphReferenceKind::Import | GraphReferenceKind::ChildWorkflow => value,
// Classification only yields these kinds for `@` values.
GraphReferenceKind::FileInline | GraphReferenceKind::GraphGoalFile => value
.strip_prefix('@')
.expect("file reference classification requires a leading '@'"),
};
validate_static_reference(reference, kind.into())
.map_err(GraphReferenceError::StaticReference)?;
let event = match kind {
GraphReferenceKind::Import => GraphReference::Import { reference },
GraphReferenceKind::ChildWorkflow => GraphReference::ChildWorkflow { reference },
GraphReferenceKind::FileInline => GraphReference::FileInline { key, reference },
GraphReferenceKind::GraphGoalFile => GraphReference::GoalFile { reference },
};
visit(event).map_err(GraphReferenceError::Visit)?;
}
if let Some(prompt) = node.prompt().filter(|prompt| !prompt.starts_with('@')) {
visit(GraphReference::InlinePrompt { content: prompt })
.map_err(GraphReferenceError::Visit)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use fabro_types::graph::{AttrValue, Graph, Node, ReferenceKind};
use super::{GraphReference, GraphReferenceError, validate_static_reference};
#[test]
fn static_reference_rejects_template_syntax() {
let error = validate_static_reference(
"@schemas/{{ inputs.schema }}.json",
ReferenceKind::FileInline,
)
.unwrap_err();
assert_eq!(error.kind(), ReferenceKind::FileInline);
assert_eq!(error.value(), "@schemas/{{ inputs.schema }}.json");
assert!(
error
.to_string()
.contains("templates are not supported in file inline references"),
"unexpected error: {error}",
);
assert!(
validate_static_reference("@schemas/result.json", ReferenceKind::FileInline).is_ok()
);
}
fn node_with(id: &str, attrs: &[(&str, &str)]) -> Node {
let mut node = Node::new(id);
for (key, value) in attrs {
node.attrs
.insert((*key).to_string(), AttrValue::String((*value).to_string()));
}
node
}
#[test]
fn visits_every_reference_kind_once() {
let mut graph = Graph::new("test");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("@goal.md".to_string()),
);
for node in [
node_with("imported", &[("import", "graphs/child.fabro")]),
node_with("child", &[("stack.child_workflow", "children/check.fabro")]),
node_with("file_prompt", &[("prompt", "@prompts/task.md")]),
node_with("inline", &[("prompt", "Do the {{ thing }}")]),
] {
graph.nodes.insert(node.id.clone(), node);
}
let mut seen = BTreeSet::new();
super::visit_graph_references(
&graph,
|reference| -> Result<(), std::convert::Infallible> {
seen.insert(match reference {
GraphReference::GoalFile { reference } => format!("goal-file:{reference}"),
GraphReference::GoalInline { content } => format!("goal-inline:{content}"),
GraphReference::Import { reference } => format!("import:{reference}"),
GraphReference::ChildWorkflow { reference } => format!("child:{reference}"),
GraphReference::FileInline { key, reference } => {
format!("file:{key}:{reference}")
}
GraphReference::InlinePrompt { content } => format!("inline:{content}"),
});
Ok(())
},
)
.unwrap();
assert_eq!(
seen,
BTreeSet::from([
"goal-file:goal.md".to_string(),
"import:graphs/child.fabro".to_string(),
"child:children/check.fabro".to_string(),
"file:prompt:prompts/task.md".to_string(),
"inline:Do the {{ thing }}".to_string(),
])
);
}
#[test]
fn rejects_template_syntax_in_references_before_visiting() {
let mut graph = Graph::new("test");
graph.nodes.insert(
"imported".to_string(),
node_with("imported", &[("import", "graphs/{{ name }}.fabro")]),
);
let error =
super::visit_graph_references(&graph, |_| -> Result<(), std::convert::Infallible> {
panic!("references with template syntax must not be visited")
})
.unwrap_err();
assert!(matches!(error, GraphReferenceError::StaticReference(_)));
}
}