From 2f2097be548a137696a88c053422eed5f6398608 Mon Sep 17 00:00:00 2001 From: Scott Werner Date: Mon, 17 Aug 2026 16:39:29 -0400 Subject: [PATCH] Anchor run-goal template validation at the version entrypoint Create-time validation of workflow.toml run goals anchored includes at workflow.toml for inline goals and at the goal file's directory for file goals, while the run engine inlines the effective goal into the entrypoint graph and renders it under the entrypoint's template source. That divergence rejected layouts `fabro run` executes fine and accepted layouts that fail at render time. Anchor both goal forms at the entrypoint so validation matches the runtime, and pin the anchor with a nested-entrypoint test. Co-Authored-By: Claude Fable 5 --- .../fabro-workflow-version/src/lib.rs | 60 ++++++++++++++++--- 1 file changed, 51 insertions(+), 9 deletions(-) diff --git a/lib/components/fabro-workflow-version/src/lib.rs b/lib/components/fabro-workflow-version/src/lib.rs index 226a1d2e3..7e5bbe64a 100644 --- a/lib/components/fabro-workflow-version/src/lib.rs +++ b/lib/components/fabro-workflow-version/src/lib.rs @@ -167,18 +167,21 @@ fn validate_config( validate_dockerfile(version, &config_path, image)?; } + // The run engine inlines the effective goal (file contents included) into + // the entrypoint graph and renders it under the entrypoint's template + // source, so goal includes anchor at the entrypoint for both goal forms. match layer.run.as_ref().and_then(|run| run.goal.as_ref()) { Some(RunGoalLayer::Inline(goal)) => { - template_roots.push(&config_path, unresolved_source(goal)); + template_roots.push(version.entrypoint(), unresolved_source(goal)); } Some(RunGoalLayer::File { file }) => { - let (target, content) = validate_config_file_reference( + let (_, content) = validate_config_file_reference( version, &config_path, ReferenceKind::RunGoalFile, &unresolved_source(file), )?; - template_roots.push(&target, content); + template_roots.push(version.entrypoint(), content); } None => {} } @@ -568,9 +571,12 @@ mod tests { #[test] fn accepts_file_workflow_goal_with_transitive_template_closure() { + // The goal file's own includes anchor at the entrypoint's directory + // (the package root here), not at the goal file's directory; loaded + // dependencies then anchor at their own directories as usual. let version = version_with_config("_version = 1\n[run.goal]\nfile = \"prompts/goal.md\"\n", [ - ("prompts/goal.md", r#"{% include "partial.md" %}"#), + ("prompts/goal.md", r#"{% include "prompts/partial.md" %}"#), ("prompts/partial.md", r#"{% include "nested/detail.md" %}"#), ("prompts/nested/detail.md", "Use {{ vars.detail }}"), ]) @@ -579,6 +585,43 @@ mod tests { assert_eq!(version.version().files().len(), 5); } + #[test] + fn anchors_workflow_goal_includes_at_the_entrypoint() { + let version_with_entrypoint = |goal_include_target: &'static str| { + ValidatedWorkflowVersion::new( + WorkflowVersion::new( + path("graphs/main.fabro"), + BTreeMap::from([ + (path("graphs/main.fabro"), "digraph W {}".to_owned()), + ( + path("workflow.toml"), + "_version = 1\n[run]\ngoal = \"{% include \\\"shared.md\\\" %}\"\n" + .to_owned(), + ), + (path(goal_include_target), "shared".to_owned()), + ]), + BTreeMap::default(), + ) + .expect("test fixtures must be structurally valid"), + ) + }; + + // The include resolves beside the entrypoint graph, matching where + // the run engine renders the inlined goal. + version_with_entrypoint("graphs/shared.md").unwrap(); + + let error = version_with_entrypoint("shared.md").unwrap_err(); + assert!(matches!( + error, + WorkflowVersionError::Template { path: source_path, source } + if source_path == path("graphs/main.fabro") + && matches!( + source.as_ref(), + TemplateDiscoveryError::Missing { reference, .. } if reference == "shared.md" + ) + )); + } + #[test] fn rejects_non_static_or_nonportable_workflow_goal_file_references() { for reference in ["{{ vars.NAME }}", "{% include \"goal.md\" %}"] { @@ -630,11 +673,11 @@ mod tests { else { panic!("expected missing template dependency"); }; - assert_eq!(source_path, path("workflow.toml")); + assert_eq!(source_path, path("workflow.fabro")); assert!(matches!( source.as_ref(), TemplateDiscoveryError::Missing { parent, reference } - if parent.to_string() == "workflow.toml" && reference == "missing.md" + if parent.to_string() == "workflow.fabro" && reference == "missing.md" )); let dynamic = version_with_inline_goal(r"{% include inputs.partial %}", []).unwrap_err(); @@ -644,7 +687,7 @@ mod tests { assert!(matches!( source.as_ref(), TemplateDiscoveryError::Dynamic { parent } - if parent.to_string() == "workflow.toml" + if parent.to_string() == "workflow.fabro" )); let escaping = @@ -657,8 +700,7 @@ mod tests { TemplateDiscoveryError::Load { source: TemplateLoadError::EscapesRoot { parent, .. }, .. - } - if parent.to_string() == "workflow.toml" + } if parent.to_string() == "workflow.fabro" )); }