Delete fabro-validate and fabro-acp; validate on Petri's check

Petri judges a workflow at admission, so Fabro's lint rules go:
`fabro-validate` (its 35 rules and the `LintRule` trait) is deleted, and
with it `fabro-acp` (only a rule and two legacy executor tests used it),
the model-resolution transform, the legacy `create`, `compile_create_run`
and `materialize_create_run` stages, and `fabro-graphviz`'s `condition`
and `fidelity` modules. `Diagnostic`, `RelatedDiagnostic` and `Severity`
move to `fabro_types::diagnostic`, the one shape every diagnostic takes.

Validation is now the same question the create handler asks. A new
server module, `petri_check`, builds Petri's check request from a
workflow bundle and the run's settings (every workflow of the bundle at
its bundle-relative path, the inputs, the run variables, the launch),
runs the check, and maps the diagnostics; Fabro adds one rule of its
own, `fabro.model.no_ready_provider`, refusing a model node when no
provider is ready. Admission, the validate and preflight endpoints and
the offline `fabro validate` all go through it:

- `validate_prepared_manifest` runs Fabro's structural pass (parse and
  transform, whose diagnostics stay) and then Petri's check, on the
  blocking pool from the handlers;
- the offline `validate_manifest` checks with no model client and with
  an unbound input as a warning (`CheckRequest.unbound_is_warning`), so
  a workflow validates before its inputs exist; a collected workflow
  before upload checks with unbound inputs as errors, as before;
- preflight resolves each LLM node's selector against the ready
  providers and the catalog for its probe, as the deleted transform did,
  and no longer probes a model Petri refused;
- the graph render endpoint needs only the structural pass;
- a run manifest now carries its `[run.goal] file`, which Petri reads
  from the bundle as it does for a version.

The transforms keep the authored model selector (`sonnet` stays
`sonnet`): Petri pins the catalog model in the admitted graph, not in
the graph Fabro displays or in the settings snapshot. Tests assert that,
and the CLI's validate, preflight and graph snapshots carry Petri's
diagnostics (`attractor.no_start`, `attractor.undeclared_node`,
`attractor.bad_on_failure`, ...) in place of the lint rules' text.

Known gaps, Petri's side: a `workflow.toml` whose `[run.environment]`
names an environment the server catalog defines but the file does not
is refused (`unsupported.workflow_toml.run.environment`), as admission
already refused it; an unbound input inside an included template
partial is a render error rather than the unbound-input warning.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-09-18 11:32:04 -04:00
parent 5df733a22b
commit af38d68942
No known key found for this signature in database
99 changed files with 1048 additions and 12212 deletions

135
Cargo.lock generated
View file

@ -58,72 +58,6 @@ dependencies = [
"subtle",
]
[[package]]
name = "agent-client-protocol"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af62fb84df2af0f933d8f5fd78b843fa5eb0ec5a48fa1b528c41951d0bbe36c"
dependencies = [
"agent-client-protocol-derive",
"agent-client-protocol-schema",
"anyhow",
"futures",
"futures-concurrency",
"jsonrpcmsg",
"rmcp",
"rustc-hash",
"schemars 1.2.1",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tokio-util",
"tracing",
"uuid",
]
[[package]]
name = "agent-client-protocol-derive"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce42c2d3c048c12897eef2e577dfff1e3355c632c9f1625cc953b9df48b44631"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "agent-client-protocol-schema"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49bae57dad1c28a362fbdcf7bab0583316a02b45a70792109fced55780a3b63c"
dependencies = [
"anyhow",
"derive_more",
"schemars 1.2.1",
"serde",
"serde_json",
"serde_with",
"strum 0.28.0",
"tracing",
]
[[package]]
name = "agent-client-protocol-tokio"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e1572b219f22c4b3be0f20f934c8b6f1d1457126ce72923c4f6608f96153b65"
dependencies = [
"agent-client-protocol",
"futures",
"serde",
"serde_json",
"shell-words",
"tokio",
"tokio-util",
]
[[package]]
name = "ahash"
version = "0.8.12"
@ -2006,7 +1940,6 @@ dependencies = [
"quote",
"rustc_version",
"syn 2.0.117",
"unicode-xid",
]
[[package]]
@ -2266,26 +2199,6 @@ dependencies = [
"libc",
]
[[package]]
name = "fabro-acp"
version = "0.357.0-nightly.0"
dependencies = [
"agent-client-protocol",
"agent-client-protocol-tokio",
"fabro-sandbox",
"fabro-types",
"fabro-util",
"futures",
"pebble-coding-agent",
"serde_json",
"shlex",
"tempfile",
"thiserror 2.0.18",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "fabro-api"
version = "0.357.0-nightly.0"
@ -2389,7 +2302,6 @@ dependencies = [
"dialoguer",
"dirs",
"dotenvy",
"fabro-acp",
"fabro-api",
"fabro-auth",
"fabro-build-support",
@ -2422,7 +2334,6 @@ dependencies = [
"fabro-tool",
"fabro-types",
"fabro-util",
"fabro-validate",
"fabro-vault",
"fabro-workflow",
"fs2",
@ -2637,7 +2548,6 @@ dependencies = [
"regex",
"serde",
"serde_json",
"strum 0.28.0",
"thiserror 2.0.18",
]
@ -2986,7 +2896,6 @@ dependencies = [
"fabro-tool",
"fabro-types",
"fabro-util",
"fabro-validate",
"fabro-variable",
"fabro-vault",
"fabro-workflow",
@ -3256,19 +3165,6 @@ dependencies = [
"tracing-subscriber",
]
[[package]]
name = "fabro-validate"
version = "0.357.0-nightly.0"
dependencies = [
"fabro-acp",
"fabro-graphviz",
"fabro-llm",
"fabro-types",
"serde",
"thiserror 2.0.18",
"toml 0.8.23",
]
[[package]]
name = "fabro-variable"
version = "0.357.0-nightly.0"
@ -3335,7 +3231,6 @@ dependencies = [
"fabro-tool",
"fabro-types",
"fabro-util",
"fabro-validate",
"fabro-vault",
"fabro-workflow",
"fabro-workflow-version",
@ -3447,12 +3342,6 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flatbuffers"
version = "25.12.19"
@ -3734,19 +3623,6 @@ dependencies = [
"futures-sink",
]
[[package]]
name = "futures-concurrency"
version = "7.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6"
dependencies = [
"fixedbitset",
"futures-core",
"futures-lite",
"pin-project",
"smallvec",
]
[[package]]
name = "futures-core"
version = "0.3.32"
@ -4719,16 +4595,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "jsonrpcmsg"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d833a15225c779251e13929203518c2ff26e2fe0f322d584b213f4f4dad37bd"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "jsonschema"
version = "0.42.2"
@ -6931,7 +6797,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0810a9f717d9828f475fe1f629f4c305c8464b7f496c3a854b58d29e65f4058e"
dependencies = [
"async-trait",
"base64",
"chrono",
"futures",
"http 1.4.0",

View file

@ -38,7 +38,6 @@ fabro-proc = { path = "../../foundation/fabro-proc" }
fabro-sandbox = { path = "../../components/fabro-sandbox" }
fabro-checkpoint = { path = "../../components/fabro-checkpoint" }
fabro-graphviz = { path = "../../components/fabro-graphviz" }
fabro-validate = { path = "../../components/fabro-validate" }
fabro-workflow = { path = "../../components/fabro-workflow" }
fabro-server = { path = "../fabro-server" }
fabro-client = { path = "../../foundation/fabro-client" }
@ -118,7 +117,6 @@ chrono = { workspace = true }
assert_cmd = "2"
fabro-db = { path = "../../foundation/fabro-db" }
walkdir.workspace = true
fabro-acp = { path = "../../components/fabro-acp", features = ["test-support"] }
rmcp = { workspace = true, features = ["client", "transport-child-process"] }
fabro-build-support = { path = "../../foundation/build-support" }
fabro-sandbox = { path = "../../components/fabro-sandbox", features = ["test-support"] }

View file

@ -14,6 +14,7 @@ use fabro_api::types;
use fabro_config::user::active_settings_path;
use fabro_graphviz::render;
use fabro_manifest::{ManifestBuildInput, build_run_manifest};
use fabro_types::diagnostic::Severity;
use fabro_util::terminal::Styles;
use tracing::debug;
@ -47,7 +48,7 @@ pub(crate) async fn run(
print_diagnostics(&diagnostics, styles, printer);
let has_errors = diagnostics
.iter()
.any(|diagnostic| diagnostic.severity == fabro_validate::Severity::Error);
.any(|diagnostic| diagnostic.severity == Severity::Error);
if has_errors && !args.allow_invalid {
bail!("Validation failed");
}

View file

@ -1,6 +1,7 @@
use anyhow::bail;
use fabro_config::user::active_settings_path;
use fabro_manifest::{ManifestBuildInput, build_run_manifest};
use fabro_types::diagnostic::Severity;
use fabro_util::terminal::Styles;
use crate::args::PreflightArgs;
@ -57,7 +58,7 @@ pub(crate) async fn execute(
);
if diagnostics
.iter()
.any(|diagnostic| diagnostic.severity == fabro_validate::Severity::Error)
.any(|diagnostic| diagnostic.severity == Severity::Error)
{
bail!("Validation failed");
}
@ -75,7 +76,7 @@ pub(crate) async fn execute(
if diagnostics
.iter()
.any(|diagnostic| diagnostic.severity == fabro_validate::Severity::Error)
.any(|diagnostic| diagnostic.severity == Severity::Error)
{
bail!("Validation failed");
}

View file

@ -5,6 +5,7 @@ use anyhow::{Context as _, Result};
use cli_table::format::{Border, Justify, Separator};
use cli_table::{Cell, CellStruct, Style, Table};
use fabro_api::types;
use fabro_types::diagnostic::{Diagnostic, RelatedDiagnostic, Severity};
use fabro_types::{PullRequestLink, RunId, StageId, parse_blob_ref};
use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus};
use fabro_util::error::render_with_causes;
@ -68,13 +69,13 @@ pub(crate) fn print_workflow_summary(
print_diagnostics(&diagnostics, styles, printer);
}
fn api_diagnostic_to_local(diagnostic: &types::WorkflowDiagnostic) -> fabro_validate::Diagnostic {
fabro_validate::Diagnostic {
fn api_diagnostic_to_local(diagnostic: &types::WorkflowDiagnostic) -> Diagnostic {
Diagnostic {
rule: diagnostic.rule.clone(),
severity: match diagnostic.severity {
types::WorkflowDiagnosticSeverity::Error => fabro_validate::Severity::Error,
types::WorkflowDiagnosticSeverity::Warning => fabro_validate::Severity::Warning,
types::WorkflowDiagnosticSeverity::Info => fabro_validate::Severity::Info,
types::WorkflowDiagnosticSeverity::Error => Severity::Error,
types::WorkflowDiagnosticSeverity::Warning => Severity::Warning,
types::WorkflowDiagnosticSeverity::Info => Severity::Info,
},
message: diagnostic.message.clone(),
node_id: diagnostic.node_id.clone(),
@ -97,7 +98,7 @@ fn api_diagnostic_to_local(diagnostic: &types::WorkflowDiagnostic) -> fabro_vali
related: diagnostic
.related
.iter()
.map(|related| fabro_validate::RelatedDiagnostic {
.map(|related| RelatedDiagnostic {
message: related.message.clone(),
source_path: related.source_path.clone(),
line: related.line.and_then(|value| u32::try_from(value).ok()),
@ -109,7 +110,7 @@ fn api_diagnostic_to_local(diagnostic: &types::WorkflowDiagnostic) -> fabro_vali
pub(crate) fn api_diagnostics_to_local(
diagnostics: &[types::WorkflowDiagnostic],
) -> Vec<fabro_validate::Diagnostic> {
) -> Vec<Diagnostic> {
diagnostics.iter().map(api_diagnostic_to_local).collect()
}

View file

@ -3,6 +3,7 @@ use fabro_config::RunLayer;
use fabro_config::user::active_settings_path;
use fabro_manifest::{ManifestBuildInput, build_run_manifest};
use fabro_server::manifest_validation;
use fabro_types::diagnostic::Severity;
use fabro_util::terminal::Styles;
use crate::args::ValidateArgs;
@ -31,13 +32,13 @@ pub(crate) fn run(
"workflow_name": response.workflow.name,
"nodes": response.workflow.nodes,
"edges": response.workflow.edges,
"valid": !diagnostics.iter().any(|d| d.severity == fabro_validate::Severity::Error),
"valid": !diagnostics.iter().any(|d| d.severity == Severity::Error),
"diagnostics": diagnostics,
}))?;
if diagnostics
.iter()
.any(|diagnostic| diagnostic.severity == fabro_validate::Severity::Error)
.any(|diagnostic| diagnostic.severity == Severity::Error)
{
bail!("Validation failed");
}
@ -64,7 +65,7 @@ pub(crate) fn run(
if diagnostics
.iter()
.any(|diagnostic| diagnostic.severity == fabro_validate::Severity::Error)
.any(|diagnostic| diagnostic.severity == Severity::Error)
{
bail!("Validation failed");
}

View file

@ -14,9 +14,9 @@ use std::time::Duration;
use anyhow::Context as _;
use cli_table::Color;
use fabro_types::RunStatus;
use fabro_types::diagnostic::{Diagnostic, Severity};
use fabro_util::printer::Printer;
use fabro_util::terminal::Styles;
use fabro_validate::{Diagnostic, Severity};
use indicatif::{ProgressBar, ProgressStyle};
use serde::Serialize;

View file

@ -94,10 +94,7 @@ fn graph_allow_invalid_renders_after_diagnostics() {
exit_code: 0
----- stdout -----
----- stderr -----
error: Pipeline must have exactly one start node (shape=Mdiamond or id start/Start) (start_node)
fix: Add a node with shape=Mdiamond or id 'start'
error [node: exit]: Exit node 'exit' has 1 outgoing edge(s) but must have none (exit_no_outgoing)
fix: Remove outgoing edges from the exit node
error: [FIXTURES]/invalid.fabro:1:9: the workflow has no start node (`shape=Mdiamond`, `type=start`, or an id of `start`) (attractor.no_start)
");
let svg = read_text(&output_path);
@ -120,10 +117,7 @@ fn graph_invalid_workflow_fails_after_diagnostics() {
exit_code: 1
----- stdout -----
----- stderr -----
error: Pipeline must have exactly one start node (shape=Mdiamond or id start/Start) (start_node)
fix: Add a node with shape=Mdiamond or id 'start'
error [node: exit]: Exit node 'exit' has 1 outgoing edge(s) but must have none (exit_no_outgoing)
fix: Remove outgoing edges from the exit node
error: [FIXTURES]/invalid.fabro:1:9: the workflow has no start node (`shape=Mdiamond`, `type=start`, or an id of `start`) (attractor.no_start)
× Validation failed
");
}

View file

@ -51,10 +51,7 @@ fn preflight_invalid_workflow_fails_with_validation_output() {
----- stderr -----
Workflow: Invalid (2 nodes, 1 edges)
Graph: [FIXTURES]/invalid.fabro
error: Pipeline must have exactly one start node (shape=Mdiamond or id start/Start) (start_node)
fix: Add a node with shape=Mdiamond or id 'start'
error [node: exit]: Exit node 'exit' has 1 outgoing edge(s) but must have none (exit_no_outgoing)
fix: Remove outgoing edges from the exit node
error: [FIXTURES]/invalid.fabro:1:9: the workflow has no start node (`shape=Mdiamond`, `type=start`, or an id of `start`) (attractor.no_start)
× Validation failed
");
}
@ -79,6 +76,10 @@ fn preflight_rejects_unbound_template_inputs() {
fix: bind `app_dir` via `[run.inputs]` in workflow.toml, or pass `--input app_dir=<value>`
error: [FIXTURES]/templated_unbound.fabro:7:44: undefined template variable `inputs.app_dir` in node `work` attribute `prompt` [node: work] (template_undefined_variable)
fix: bind `app_dir` via `[run.inputs]` in workflow.toml, or pass `--input app_dir=<value>`
error: [FIXTURES]/templated_unbound.fabro:2:12: the graph `goal` reads `{{ inputs.app_dir }}`, which no input binds (unsupported.template.unbound_input)
fix: pass `--input app_dir=VALUE`, or add a default under `[run.inputs]` in workflow.toml
error: [FIXTURES]/templated_unbound.fabro:7:25: node `work` `prompt` reads `{{ inputs.app_dir }}`, which no input binds (unsupported.template.unbound_input)
fix: pass `--input app_dir=VALUE`, or add a default under `[run.inputs]` in workflow.toml
× Validation failed
");
}

View file

@ -116,8 +116,8 @@ fn branching() {
----- stderr -----
Workflow: Branch (6 nodes, 6 edges)
Graph: [FIXTURES]/branching.fabro
warning [node: implement]: Node 'implement' has goal_gate=true but no retry_target or fallback_retry_target (goal_gate_has_retry)
fix: Add retry_target or fallback_retry_target attribute
warning: [FIXTURES]/branching.fabro:9:5: goal gate `implement` has no retry target that exists; when it fails the run ends failed (attractor.goal_gate_without_target)
warning: [FIXTURES]/branching.fabro:7:5: `exit` is in a loop with no `max_visits`; Fabro's unlimited visits lower to the hard maximum of 500 firings (info.budget.default)
Validation: OK
");
}
@ -134,6 +134,8 @@ fn conditions() {
----- stderr -----
Workflow: Conditions (5 nodes, 5 edges)
Graph: [FIXTURES]/conditions.fabro
warning: [FIXTURES]/conditions.fabro:8:5: agent node `path_a` has no `prompt`; its label is the prompt (attractor.prompt_missing)
warning: [FIXTURES]/conditions.fabro:9:5: agent node `path_b` has no `prompt`; its label is the prompt (attractor.prompt_missing)
Validation: OK
");
}
@ -150,6 +152,9 @@ fn parallel() {
----- stderr -----
Workflow: Parallel (7 nodes, 7 edges)
Graph: [FIXTURES]/parallel.fabro
warning: [FIXTURES]/parallel.fabro:8:5: agent node `branch1` has no `prompt`; its label is the prompt (attractor.prompt_missing)
warning: [FIXTURES]/parallel.fabro:9:5: agent node `branch2` has no `prompt`; its label is the prompt (attractor.prompt_missing)
warning: [FIXTURES]/parallel.fabro:11:5: agent node `review` has no `prompt`; its label is the prompt (attractor.prompt_missing)
Validation: OK
");
}
@ -166,6 +171,9 @@ fn styled() {
----- stderr -----
Workflow: Styled (5 nodes, 4 edges)
Graph: [FIXTURES]/styled.fabro
warning: [FIXTURES]/styled.fabro:14:5: agent node `plan` has no `prompt`; its label is the prompt (attractor.prompt_missing)
warning: [FIXTURES]/styled.fabro:15:5: agent node `implement` has no `prompt`; its label is the prompt (attractor.prompt_missing)
warning: [FIXTURES]/styled.fabro:16:5: agent node `critical_review` has no `prompt`; its label is the prompt (attractor.prompt_missing)
Validation: OK
");
}
@ -202,6 +210,8 @@ fn bare_fabro_with_unbound_inputs_validates_structurally_with_warning() {
fix: bind `app_dir` via `[run.inputs]` in workflow.toml, or pass `--input app_dir=<value>`
warning: [FIXTURES]/templated_unbound.fabro:7:44: undefined template variable `inputs.app_dir` in node `work` attribute `prompt` [node: work] (template_undefined_variable)
fix: bind `app_dir` via `[run.inputs]` in workflow.toml, or pass `--input app_dir=<value>`
warning: [FIXTURES]/templated_unbound.fabro:2:12: the graph `goal` reads `{{ inputs.app_dir }}`, which no input binds; it is left unrendered because no inputs were given. Pass `--input app_dir=VALUE` to render it (attractor.unbound_input)
warning: [FIXTURES]/templated_unbound.fabro:7:25: node `work` `prompt` reads `{{ inputs.app_dir }}`, which no input binds; it is left unrendered because no inputs were given. Pass `--input app_dir=VALUE` to render it (attractor.unbound_input)
Validation: OK
");
}
@ -220,6 +230,7 @@ fn unbound_model_stylesheet_input_warns_without_css_error() {
Graph: [FIXTURES]/model_stylesheet_unbound.fabro
warning: [FIXTURES]/model_stylesheet_unbound.fabro:4:38: undefined template variable `inputs.effort` in graph attribute `model_stylesheet` (template_undefined_variable)
fix: bind `effort` via `[run.inputs]` in workflow.toml, or pass `--input effort=<value>`
warning: [FIXTURES]/model_stylesheet_unbound.fabro:3:9: the `model_stylesheet` reads `{{ inputs.effort }}`, which no input binds; it is left unrendered because no inputs were given. Pass `--input effort=VALUE` to render it (attractor.unbound_input)
Validation: OK
");
}
@ -243,6 +254,7 @@ fn bare_fabro_with_unbound_inputs_in_imported_prompt_validates_structurally_with
Graph: [FIXTURES]/templated_unbound_imported/workflow.fabro
warning: [FIXTURES]/templated_unbound_imported/work.md:1:12: undefined template variable `inputs.app_dir` in node `work` attribute `prompt` [node: work] (template_undefined_variable)
fix: bind `app_dir` via `[run.inputs]` in workflow.toml, or pass `--input app_dir=<value>`
warning: [FIXTURES]/templated_unbound_imported/workflow.fabro:5:25: node `work` `prompt` reads `{{ inputs.app_dir }}`, which no input binds; it is left unrendered because no inputs were given. Pass `--input app_dir=VALUE` to render it (attractor.unbound_input)
Validation: OK
");
}
@ -256,17 +268,18 @@ fn bare_fabro_with_unbound_inputs_in_template_partial_validates_structurally_wit
let context = test_context!();
let mut cmd = context.validate();
cmd.arg(fixture("templated_unbound_partial/workflow.fabro"));
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
fabro_snapshot!(context.filters(), cmd, @r#"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
Workflow: TemplatedUnboundPartial (3 nodes, 2 edges)
Graph: [FIXTURES]/templated_unbound_partial/workflow.fabro
warning: [FIXTURES]/templated_unbound_partial/test-include.partial.md:1:4: undefined template variable `inputs.hello` in node `test_imported_include` attribute `prompt` [node: test_imported_include] (template_undefined_variable)
fix: bind `hello` via `[run.inputs]` in workflow.toml, or pass `--input hello=<value>`
Validation: OK
");
error: [FIXTURES]/templated_unbound_partial/workflow.fabro:3:42: node `test_imported_include` `prompt`: template render: could not render include: error in "../../../../../../../..[FIXTURES]/templated_unbound_partial/test-include.partial.md" (in ../../../../../../../..[FIXTURES]/templated_unbound_partial/__petri_root__:1) (attractor.template)
× Validation failed
"#);
}
#[test]
@ -351,8 +364,7 @@ fn edge_only_node() {
----- stderr -----
Workflow: EdgeOnlyNode (2 nodes, 2 edges)
Graph: [FIXTURES]/edge_only_node.fabro
error [node: misspelled_node]: Node 'misspelled_node' is referenced by edge 'start -> misspelled_node' but has no node declaration (edge_target_exists)
fix: Declare node 'misspelled_node' or correct the edge endpoint
error: [FIXTURES]/edge_only_node.fabro:8:14: `misspelled_node` is named by an edge but never declared (attractor.undeclared_node)
× Validation failed
");
}
@ -369,10 +381,7 @@ fn invalid() {
----- stderr -----
Workflow: Invalid (2 nodes, 1 edges)
Graph: [FIXTURES]/invalid.fabro
error: Pipeline must have exactly one start node (shape=Mdiamond or id start/Start) (start_node)
fix: Add a node with shape=Mdiamond or id 'start'
error [node: exit]: Exit node 'exit' has 1 outgoing edge(s) but must have none (exit_no_outgoing)
fix: Remove outgoing edges from the exit node
error: [FIXTURES]/invalid.fabro:1:9: the workflow has no start node (`shape=Mdiamond`, `type=start`, or an id of `start`) (attractor.no_start)
× Validation failed
");
}
@ -389,8 +398,7 @@ fn invalid_node_on_failure_is_a_validation_failure() {
----- stderr -----
Workflow: InvalidNodeOnFailure (3 nodes, 2 edges)
Graph: [FIXTURES]/on_failure_node_invalid.fabro
error [node: work]: Node 'work' has invalid on_failure value 'stop' (on_failure_valid)
fix: Use one of: route, exit, succeed
error: [FIXTURES]/on_failure_node_invalid.fabro:4:32: `on_failure` must be `route`, `exit`, `succeed` or `partially_succeed`, not `stop` (attractor.bad_on_failure)
× Validation failed
");
}
@ -400,17 +408,16 @@ fn deprecated_auto_status_warns_with_succeed_policy_replacement() {
let context = test_context!();
let mut cmd = context.validate();
cmd.arg(fixture("auto_status_deprecated.fabro"));
fabro_snapshot!(context.filters(), cmd, @"
fabro_snapshot!(context.filters(), cmd, @r#"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Workflow: DeprecatedAutoStatus (3 nodes, 2 edges)
Graph: [FIXTURES]/auto_status_deprecated.fabro
warning [node: scan]: Node 'scan' sets deprecated 'auto_status=true' (auto_status_deprecated)
fix: Use on_failure=\"succeed\" instead
warning: [FIXTURES]/auto_status_deprecated.fabro:4:34: `auto_status=true` on node `scan` is the deprecated spelling of `on_failure="succeed"`; use `on_failure="succeed"` (deprecated.auto_status)
Validation: OK
");
"#);
}
#[test]
@ -425,8 +432,7 @@ fn invalid_on_failure_is_a_validation_failure() {
----- stderr -----
Workflow: InvalidOnFailure (2 nodes, 1 edges)
Graph: [FIXTURES]/on_failure_invalid.fabro
error: Graph has invalid on_failure value 'stop' (on_failure_valid)
fix: Use one of: route, exit, succeed
error: [FIXTURES]/on_failure_invalid.fabro:2:12: `on_failure` must be `route`, `exit`, `succeed` or `partially_succeed`, not `stop` (attractor.bad_on_failure)
× Validation failed
");
}

View file

@ -1,325 +0,0 @@
#![expect(
clippy::disallowed_methods,
reason = "integration test initializes an isolated git repository with the system git binary"
)]
use fabro_acp::test_support::fake_acp_agent_script;
use fabro_config::Storage;
use fabro_test::test_context;
use fabro_types::EventBody;
use fabro_vault::{SecretType, Vault};
use super::{find_run_dir, has_event, read_conclusion, run_events, run_id_for, run_state};
use crate::cmd::support::output_stdout;
#[test]
fn acp_backend_workflow() {
let mut context = test_context!();
context.write_home(
".fabro/settings.toml",
"[server.auth]\nmethods = [\"dev-token\"]\n",
);
context.isolated_server();
let fake_agent = write_fake_acp_agent(&context);
let acp_config = fake_acp_config_attr(&fake_agent);
let workflow = context.temp_dir.join("acp_backend.fabro");
context.write_temp(
"acp_backend.fabro",
format!(
r#"digraph ACP {{
graph [goal="Exercise ACP backend"]
start [shape=Mdiamond]
work [type="agent", backend="acp", prompt="write hello.txt", acp.config={acp_config}]
exit [shape=Msquare]
start -> work
work -> exit
}}"#
),
);
init_git_repo(&context.temp_dir);
context
.run_cmd()
.args(["--auto-approve", "--environment", "local"])
.arg(&workflow)
.assert()
.success();
let run_dir = find_run_dir(&context);
let conclusion = read_conclusion(&run_dir);
assert_eq!(conclusion["status"].as_str(), Some("succeeded"));
let events = run_events(&run_dir);
assert!(has_event(&run_dir, "agent.acp.started"));
assert!(has_event(&run_dir, "agent.acp.completed"));
let completed = events
.iter()
.find_map(|event| match &event.event.body {
EventBody::StageCompleted(props) if event.event.node_id.as_deref() == Some("work") => {
Some(props)
}
_ => None,
})
.expect("work stage should complete");
assert_eq!(completed.response.as_deref(), Some("hello from acp"));
assert!(
completed
.files_touched
.iter()
.any(|file| file == "hello.txt"),
"files_touched should include hello.txt: {:?}",
completed.files_touched
);
let state = serde_json::to_value(run_state(&run_dir)).expect("run state should serialize");
let stages = state["stages"]
.as_object()
.expect("run state should contain stages");
assert!(
stages.values().any(|stage| {
stage["provider_used"]["mode"] == "acp"
&& stage["provider_used"]["provider"] == "acp"
&& stage["provider_used"]["model"] == "fake"
&& stage["provider_used"].get("reasoning_effort").is_none()
&& stage["provider_used"].get("speed").is_none()
}),
"run projection should include ACP model usage metadata: {stages:?}"
);
}
#[test]
fn acp_backend_does_not_inject_registered_provider_credentials() {
let mut context = test_context!();
context.write_home(
".fabro/settings.toml",
"[server.auth]\nmethods = [\"dev-token\"]\n",
);
context.isolated_server();
seed_anthropic_vault(&context.storage_dir);
let fake_agent = write_fake_acp_agent(&context);
let env_record = context.temp_dir.join("acp-env.json");
let acp_config = fake_acp_config_attr_recording_env(&fake_agent, &env_record);
let workflow = context.temp_dir.join("acp_provider_env.fabro");
context.write_temp(
"acp_provider_env.fabro",
format!(
r#"digraph ACP {{
graph [goal="Exercise ACP backend with stored provider credentials"]
start [shape=Mdiamond]
work [type="agent", backend="acp", prompt="write hello.txt", acp.config={acp_config}]
exit [shape=Msquare]
start -> work
work -> exit
}}"#
),
);
init_git_repo(&context.temp_dir);
context
.run_cmd()
.env_remove("ANTHROPIC_API_KEY")
.env_remove("OPENAI_API_KEY")
.env_remove("GEMINI_API_KEY")
.args(["--auto-approve", "--environment", "local"])
.arg(&workflow)
.assert()
.success();
let run_dir = find_run_dir(&context);
let conclusion = read_conclusion(&run_dir);
assert_eq!(conclusion["status"].as_str(), Some("succeeded"));
let recorded_env: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(&env_record)
.expect("fake ACP agent should record its environment"),
)
.expect("fake ACP environment record should be valid JSON");
assert_eq!(recorded_env, serde_json::json!({}));
}
#[test]
fn acp_artifacts_are_listed_when_touched_file_mtime_precedes_attempt_start() {
let mut context = test_context!();
context.write_home(
".fabro/settings.toml",
"[server.auth]\nmethods = [\"dev-token\"]\n",
);
context.isolated_server();
let fake_agent = write_fake_acp_agent(&context);
let acp_config = fake_acp_config_attr_with_env(&fake_agent, vec![
serde_json::json!({
"name": "ACP_WRITE_PATH",
"value": "verification-artifacts/report.md",
}),
serde_json::json!({
"name": "ACP_WRITE_CONTENT",
"value": "verified\n",
}),
serde_json::json!({
"name": "ACP_WRITE_MTIME_EPOCH",
"value": "946684800",
}),
]);
context.write_temp(
"acp_artifact_collection.fabro",
format!(
r#"digraph ACPArtifacts {{
graph [goal="Capture verification artifacts"]
start [shape=Mdiamond]
verify [type="agent", backend="acp", prompt="write verification artifact", acp.config={acp_config}]
exit [shape=Msquare]
start -> verify
verify -> exit
}}"#
),
);
context.write_temp(
"workflow.toml",
r#"_version = 1
[workflow]
graph = "acp_artifact_collection.fabro"
[run]
goal = "Capture verification artifacts"
[run.checkpoint]
exclude_globs = ["verification-artifacts/**"]
[run.artifacts]
include = ["verification-artifacts/**"]
"#,
);
init_git_repo(&context.temp_dir);
context
.run_cmd()
.args(["--auto-approve", "--environment", "local"])
.arg(context.temp_dir.join("workflow.toml"))
.assert()
.success();
let run_dir = find_run_dir(&context);
let run_id = run_id_for(&run_dir);
let events = run_events(&run_dir);
let completed = events
.iter()
.find_map(|event| match &event.event.body {
EventBody::StageCompleted(props)
if event.event.node_id.as_deref() == Some("verify") =>
{
Some(props)
}
_ => None,
})
.expect("verify stage should complete");
assert!(
completed
.files_touched
.iter()
.any(|file| file == "verification-artifacts/report.md"),
"files_touched should include the artifact path: {:?}",
completed.files_touched
);
let output = context
.command()
.args(["--json", "artifact", "list", &run_id])
.output()
.expect("artifact list should execute");
assert!(
output.status.success(),
"artifact list failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let artifacts: Vec<serde_json::Value> =
serde_json::from_str(&output_stdout(&output)).expect("artifact list JSON should parse");
assert!(
artifacts.iter().any(|artifact| {
artifact["node_slug"] == "verify"
&& artifact["relative_path"] == "verification-artifacts/report.md"
}),
"artifact list should include the touched verification artifact: {artifacts:?}"
);
let completed_run = events
.iter()
.find_map(|event| match &event.event.body {
EventBody::RunCompleted(props) => Some(props),
_ => None,
})
.expect("run.completed should be emitted");
assert!(
completed_run.artifact_count > 0,
"run.completed should report captured artifacts"
);
}
fn write_fake_acp_agent(context: &fabro_test::TestContext) -> std::path::PathBuf {
context.write_temp("fake_acp_agent.py", fake_acp_agent_script());
context.temp_dir.join("fake_acp_agent.py")
}
fn fake_acp_config_attr(script_path: &std::path::Path) -> String {
fake_acp_config_attr_with_env(script_path, Vec::new())
}
fn fake_acp_config_attr_recording_env(
script_path: &std::path::Path,
env_record: &std::path::Path,
) -> String {
fake_acp_config_attr_with_env(script_path, vec![
serde_json::json!({"name": "ACP_ENV_RECORD", "value": env_record.to_string_lossy()}),
serde_json::json!({
"name": "ACP_ENV_RECORD_KEYS",
"value": "ANTHROPIC_API_KEY,OPENAI_API_KEY,GEMINI_API_KEY",
}),
])
}
fn fake_acp_config_attr_with_env(
script_path: &std::path::Path,
extra_env: Vec<serde_json::Value>,
) -> String {
let mut env = vec![serde_json::json!({"name": "ACP_MODE", "value": "write_file"})];
env.extend(extra_env);
let config = serde_json::json!({
"type": "stdio",
"name": "fake",
"command": "python3",
"args": [script_path.to_string_lossy()],
"env": env,
})
.to_string();
format!("{config:?}")
}
fn seed_anthropic_vault(storage_dir: &std::path::Path) {
let mut vault =
Vault::load(Storage::new(storage_dir).secrets_path()).expect("test vault should load");
vault
.set(
"ANTHROPIC_API_KEY",
"vault-anthropic-key",
SecretType::Token,
None,
)
.expect("Anthropic credential should store in test vault");
}
fn init_git_repo(dir: &std::path::Path) {
let output = std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(dir)
.output()
.expect("git init should run");
assert!(
output.status.success(),
"git init failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}

View file

@ -1,324 +0,0 @@
#![expect(
clippy::disallowed_methods,
reason = "integration test initializes an isolated git repository with the system git binary"
)]
use std::path::Path;
use std::process::Command;
use fabro_acp::test_support::fake_acp_agent_script;
use fabro_test::{TestContext, test_context};
use fabro_types::{EventBody, GitIdentitySource};
use super::{
dump_export, find_run_dir, read_conclusion, run_events, run_id_for, run_state, sandbox_tests,
stage_dump_dir, timeout_for,
};
fn git(dir: &Path, args: &[&str]) -> String {
let output = Command::new("git")
.args(args)
.current_dir(dir)
.output()
.unwrap_or_else(|err| panic!("git {args:?} should run: {err}"));
assert!(
output.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
fn init_repo_with_local_identity(dir: &Path) {
git(dir, &["init", "-q"]);
git(dir, &["config", "user.name", "Local Config"]);
git(dir, &["config", "user.email", "local@example.com"]);
std::fs::write(dir.join("README.md"), "seed\n").expect("seed file should write");
git(dir, &["add", "README.md"]);
git(dir, &["commit", "-q", "-m", "seed"]);
}
fn identity_of(dir: &Path, rev: &str) -> Vec<String> {
git(dir, &["show", "-s", "--format=%an%n%ae%n%cn%n%ce", rev])
.lines()
.map(str::to_string)
.collect()
}
fn setup(context: &mut TestContext) {
context.write_home(
".fabro/settings.toml",
"[server.auth]\nmethods = [\"dev-token\"]\n",
);
context.isolated_server();
}
/// A host run with no GitHub credential and no explicit author commits as
/// the generic Fabro identity: a commit a script stage creates in the
/// checkout, and a commit it creates in a repository it initializes itself. The
/// checkout's own Git configuration and the host's inherited `GIT_*` variables
/// do not leak in, and the run does not touch the isolated HOME's Git
/// configuration.
#[test]
fn script_stage_commits_carry_the_run_identity() {
let mut context = test_context!();
setup(&mut context);
init_repo_with_local_identity(&context.temp_dir);
let other_repo = context.temp_dir.join("other-repo");
let home_gitconfig = context.home_dir.join(".gitconfig");
let script = format!(
"set -e; printf work > work.txt && git add work.txt && git commit -q -m 'workflow commit' && \
git init -q {other} && cd {other} && printf x > x.txt && git add x.txt && git commit -q -m 'other commit'",
other = other_repo.display()
);
context.write_temp(
"identity.fabro",
format!(
r#"digraph Identity {{
graph [goal="Commit as the run identity"]
start [shape=Mdiamond]
work [shape=parallelogram, script="{script}"]
exit [shape=Msquare]
start -> work -> exit
}}"#
),
);
context
.run_cmd()
.env("GIT_AUTHOR_NAME", "Inherited Host")
.env("GIT_AUTHOR_EMAIL", "host@example.com")
.env("GIT_COMMITTER_NAME", "Inherited Host")
.env("GIT_COMMITTER_EMAIL", "host@example.com")
.args(["--auto-approve", "--environment", "local"])
.arg(context.temp_dir.join("identity.fabro"))
.assert()
.success();
let run_dir = find_run_dir(&context);
assert_eq!(read_conclusion(&run_dir)["status"], "succeeded");
let expected = vec![
"Fabro".to_string(),
"noreply@fabro.sh".to_string(),
"Fabro".to_string(),
"noreply@fabro.sh".to_string(),
];
// A host run without a clone has no managed run branch, so the checkout
// HEAD is the workflow's own commit. The engine checkpoint path is
// covered by the fabro-workflow git integration tests.
assert_eq!(
git(&context.temp_dir, &["log", "-1", "--format=%s"]),
"workflow commit"
);
assert_eq!(
identity_of(&context.temp_dir, "HEAD"),
expected,
"workflow commit in the checkout"
);
assert_eq!(
identity_of(&other_repo, "HEAD"),
expected,
"commit in a workflow-created repository"
);
// No configuration was written: the checkout keeps its local identity and
// the isolated HOME gained no Git configuration.
assert_eq!(
git(&context.temp_dir, &["config", "user.name"]),
"Local Config"
);
assert_eq!(
git(&context.temp_dir, &["config", "user.email"]),
"local@example.com"
);
assert!(
!home_gitconfig.exists(),
"the run must not write {}",
home_gitconfig.display()
);
// The resolved identity is recorded durably and in the event stream.
let state = run_state(&run_dir);
let identity = state
.git_identity
.expect("run state should record the identity");
assert_eq!(identity.name, "Fabro");
assert_eq!(identity.email, "noreply@fabro.sh");
assert_eq!(identity.source, GitIdentitySource::Default);
assert!(
run_events(&run_dir)
.iter()
.any(|event| matches!(&event.event.body, EventBody::GitIdentityResolved(props) if props.identity == identity)),
"git.identity.resolved should be emitted"
);
}
/// `run.git.author` overrides the identity for every path, and a run whose
/// working directory has no Git origin still receives it.
#[test]
fn explicit_author_reaches_script_stages_without_a_git_origin() {
let mut context = test_context!();
setup(&mut context);
let repo = context.temp_dir.join("fresh");
let script = format!(
"set -e; git init -q {repo} && cd {repo} && printf x > x.txt && git add x.txt && git commit -q -m 'fresh commit'",
repo = repo.display()
);
context.write_temp(
"explicit.fabro",
format!(
r#"digraph Explicit {{
graph [goal="Commit as the explicit author"]
start [shape=Mdiamond]
work [shape=parallelogram, script="{script}"]
exit [shape=Msquare]
start -> work -> exit
}}"#
),
);
context.write_temp(
"workflow.toml",
r#"_version = 1
[workflow]
graph = "explicit.fabro"
[run]
goal = "Commit as the explicit author"
[run.git.author]
name = "Release Bot"
email = "release@example.com"
[run.environment.env]
GIT_AUTHOR_NAME = "Run Env"
"#,
);
context
.run_cmd()
.args(["--auto-approve", "--environment", "local"])
.arg(context.temp_dir.join("workflow.toml"))
.assert()
.success();
let run_dir = find_run_dir(&context);
assert_eq!(read_conclusion(&run_dir)["status"], "succeeded");
assert_eq!(identity_of(&repo, "HEAD"), vec![
"Release Bot".to_string(),
"release@example.com".to_string(),
"Release Bot".to_string(),
"release@example.com".to_string(),
]);
let identity = run_state(&run_dir)
.git_identity
.expect("run state should record the identity");
assert_eq!(identity.source, GitIdentitySource::Explicit);
}
/// An ACP agent process is launched with the run identity in its environment.
#[test]
fn acp_agent_launch_env_carries_the_run_identity() {
let mut context = test_context!();
setup(&mut context);
context.write_temp("fake_acp_agent.py", fake_acp_agent_script());
let fake_agent = context.temp_dir.join("fake_acp_agent.py");
let env_record = context.temp_dir.join("acp-env.json");
let config = serde_json::json!({
"type": "stdio",
"name": "fake",
"command": "python3",
"args": [fake_agent.to_string_lossy()],
"env": [
{"name": "ACP_MODE", "value": "write_file"},
{"name": "ACP_ENV_RECORD", "value": env_record.to_string_lossy()},
{
"name": "ACP_ENV_RECORD_KEYS",
"value": "GIT_AUTHOR_NAME,GIT_AUTHOR_EMAIL,GIT_COMMITTER_NAME,GIT_COMMITTER_EMAIL",
},
],
})
.to_string();
let acp_config = format!("{config:?}");
context.write_temp(
"acp_identity.fabro",
format!(
r#"digraph ACP {{
graph [goal="Exercise ACP launch env"]
start [shape=Mdiamond]
work [type="agent", backend="acp", prompt="write hello.txt", acp.config={acp_config}]
exit [shape=Msquare]
start -> work -> exit
}}"#
),
);
git(&context.temp_dir, &["init", "-q"]);
context
.run_cmd()
.env("GIT_AUTHOR_NAME", "Inherited Host")
.args(["--auto-approve", "--environment", "local"])
.arg(context.temp_dir.join("acp_identity.fabro"))
.assert()
.success();
let run_dir = find_run_dir(&context);
assert_eq!(read_conclusion(&run_dir)["status"], "succeeded");
let recorded: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(&env_record).expect("fake ACP agent should record its env"),
)
.expect("record should be JSON");
assert_eq!(
recorded,
serde_json::json!({
"GIT_AUTHOR_NAME": "Fabro",
"GIT_AUTHOR_EMAIL": "noreply@fabro.sh",
"GIT_COMMITTER_NAME": "Fabro",
"GIT_COMMITTER_EMAIL": "noreply@fabro.sh",
})
);
}
// ---------------------------------------------------------------------------
// The same contract across sandbox providers: a script stage commits in a
// repository it initializes inside the sandbox and reports the identity the
// commit object carries. Runs on the local host, and on the host and Docker
// sandbox plugins when their executables are on PATH.
// ---------------------------------------------------------------------------
sandbox_tests!(git_identity_in_sandbox);
fn scenario_git_identity_in_sandbox(context: &TestContext, sandbox: &str) {
context.write_temp(
"sandbox_identity.fabro",
r#"digraph SandboxIdentity {
graph [goal="Commit inside the sandbox as the run identity"]
start [shape=Mdiamond]
work [shape=parallelogram, script="set -e; git init -q identity-probe && cd identity-probe && printf x > x.txt && git add x.txt && git commit -q -m probe && printf 'identity=%s|%s|%s|%s\n' \"$(git log -1 --format=%an)\" \"$(git log -1 --format=%ae)\" \"$(git log -1 --format=%cn)\" \"$(git log -1 --format=%ce)\""]
exit [shape=Msquare]
start -> work -> exit
}
"#,
);
context
.run_cmd()
.env("GIT_AUTHOR_NAME", "Inherited Host")
.env("GIT_COMMITTER_EMAIL", "host@example.com")
.args(["--auto-approve", "--environment", sandbox])
.arg(context.temp_dir.join("sandbox_identity.fabro"))
.timeout(timeout_for(sandbox))
.assert()
.success();
let run_dir = find_run_dir(context);
assert_eq!(read_conclusion(&run_dir)["status"], "succeeded");
let export_dir = dump_export(context, &run_id_for(&run_dir));
let output = std::fs::read_to_string(stage_dump_dir(&export_dir, "work@1").join("output.log"))
.expect("work output.log should exist");
assert!(
output.contains("identity=Fabro|noreply@fabro.sh|Fabro|noreply@fabro.sh"),
"{sandbox}: the sandbox commit should carry the run identity, got: {output}"
);
}

View file

@ -3,7 +3,6 @@
reason = "This test module prefers explicit type paths over extra imports."
)]
mod acp;
mod agent_linear;
mod artifacts;
mod command_agent_mixed;
@ -12,7 +11,6 @@ mod command_routing;
mod conditional_branching;
mod dry_run_examples;
mod full_stack;
mod git_identity;
mod hooks;
mod human_gate;
pub(super) mod plugin;

View file

@ -32,7 +32,6 @@ fabro-interview = { path = "../../components/fabro-interview" }
fabro-slack = { path = "../../components/fabro-slack" }
fabro-workflow = { path = "../../components/fabro-workflow" }
fabro-workflow-version = { path = "../../components/fabro-workflow-version" }
fabro-validate = { path = "../../components/fabro-validate" }
fabro-sandbox = { path = "../../components/fabro-sandbox" }
sandbox-driver.workspace = true
fabro-github = { path = "../../components/fabro-github" }

View file

@ -32,6 +32,7 @@ mod interp;
pub mod jwt_auth;
pub mod manifest_validation;
mod migrations;
mod petri_check;
mod petri_runs;
mod principal_middleware;
mod request_id;

View file

@ -5,17 +5,19 @@ use anyhow::{Result, anyhow};
use fabro_api::types;
use fabro_config::{RunLayer, WorkflowSettingsBuilder};
use fabro_manifest::CollectedWorkflowClosure;
use fabro_petri::runtime::RuntimeSpec;
use fabro_workflow::operations::{ValidateInput, WorkflowInput, validate};
use fabro_workflow::pipeline::TEMPLATE_UNDEFINED_VARIABLE_RULE;
use crate::{run_intent, run_manifest};
use crate::{petri_check, run_intent, run_manifest};
/// Validate a manifest without a model catalog.
///
/// Every caller is a client — the CLI, an MCP server, a run worker — and a
/// client's catalog is its own, not the server's. Judging model and provider
/// availability here would reject workflows the server can run, so that is
/// left to the server on create.
/// availability here would reject workflows the server can run, so Petri
/// checks the bundle with no model client: the structure, the settings and
/// the templates, with every model node left for the server on create.
pub fn validate_manifest(
manifest_run_defaults: &RunLayer,
manifest: &types::RunManifest,
@ -26,11 +28,30 @@ pub fn validate_manifest(
&HashMap::new(),
manifest,
)?;
let validated = run_manifest::validate_prepared_manifest_structural(&prepared)
.map_err(anyhow::Error::new)?;
let validated = run_manifest::validate_prepared_manifest(
&prepared,
&HashMap::new(),
petri_check::launch_without_catalog(&prepared.settings),
offline_runtime(),
true,
true,
)
.map_err(anyhow::Error::new)?;
Ok(run_manifest::validate_response(&prepared, &validated))
}
/// Petri's runtime for a check away from the server: no operator settings,
/// no model client, no Fabro home, no run tools.
fn offline_runtime() -> RuntimeSpec {
RuntimeSpec {
settings_toml: None,
model_client: None,
dry_run: false,
fabro_home: None,
run_tools: None,
}
}
/// Validate an already collected local workflow before any version upload.
///
/// The supplied run layer is complete, including any already resolved inline
@ -46,8 +67,8 @@ pub fn validate_collected_workflow(
let lowered = run_intent::lower_collected_workflow_closure(closure)?;
let workflow = lowered
.workflow_bundle
.into_workflows()
.remove(&lowered.entrypoint)
.workflow(&lowered.entrypoint)
.cloned()
.ok_or_else(|| anyhow!("lowered root workflow is missing from its bundle"))?;
let mut builder = WorkflowSettingsBuilder::new()
.server_manifest_defaults(
@ -63,14 +84,26 @@ pub fn validate_collected_workflow(
}
let mut settings = builder.build().map_err(anyhow::Error::new)?;
settings.run.inputs.extend(input_overrides.clone());
let validated = validate(ValidateInput {
workflow: WorkflowInput::Bundled(workflow),
settings,
vars: HashMap::new(),
cwd: PathBuf::from("/workspace"),
let mut validated = validate(ValidateInput {
workflow: WorkflowInput::Bundled(workflow),
settings: settings.clone(),
vars: HashMap::new(),
cwd: PathBuf::from("/workspace"),
custom_transforms: Vec::new(),
})
.map_err(anyhow::Error::new)?;
let request = petri_check::check_request(
&lowered.workflow_bundle,
&lowered.entrypoint,
&settings,
&HashMap::new(),
petri_check::launch_without_catalog(&settings),
offline_runtime(),
false,
)
.map_err(anyhow::Error::new)?;
let checked = petri_check::check(&request, true).map_err(anyhow::Error::new)?;
validated.extend_diagnostics(checked.diagnostics);
let mut response = types::ValidateResponse {
ok: !validated.has_errors(),
workflow: run_manifest::workflow_summary(&validated, lowered.entrypoint.as_path()),
@ -120,6 +153,8 @@ mod tests {
r#"_version = 1
[workflow]
graph = "workflow.fabro"
[environments.default]
provider = "local"
[run.goal]
file = "goal.md"
[run.environment.image]
@ -240,7 +275,7 @@ dockerfile = { path = "Dockerfile" }
.iter()
.all(|diagnostic| { diagnostic.rule != TEMPLATE_UNDEFINED_VARIABLE_RULE })
);
assert!(present.ok);
assert!(present.ok, "{:?}", present.workflow.diagnostics);
assert_eq!(present.workflow.goal, "resolved inline goal");
assert!(present.workflow.diagnostics.iter().all(|diagnostic| {
!diagnostic.message.contains("future-provider")

View file

@ -0,0 +1,194 @@
//! Petri's check as Fabro's judge of a workflow.
//!
//! The create handler, the validate and preflight endpoints and the offline
//! `fabro validate` all ask Petri the same question: does this bundle, under
//! these settings, lower to a graph Petri admits? This module builds the
//! request from a workflow bundle and the run's settings, runs the check,
//! and hands back the admitted graphs with Petri's diagnostics in Fabro's
//! shape. Fabro adds one rule of its own: a workflow with a node that runs a
//! model is refused when no LLM provider is ready, since Petri admits the
//! model nodes unchecked without a model client.
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::PathBuf;
use fabro_llm::lithos_catalog::Catalog;
use fabro_llm::selection;
use fabro_petri::check::{self, Admitted, Bundle, CheckError, CheckRequest, Diagnostic, Launch};
use fabro_petri::runtime::RuntimeSpec;
use fabro_types::diagnostic::{Diagnostic as FabroDiagnostic, Severity};
use fabro_types::{ManifestPath, WorkflowSettings};
use fabro_workflow::Error as WorkflowError;
use fabro_workflow::workflow_bundle::WorkflowBundle;
use lithos_llm::catalog::ProviderId;
/// Fabro's rule for a model node with no provider ready to run it.
pub(crate) const NO_READY_PROVIDER_RULE: &str = "fabro.model.no_ready_provider";
/// The launch Fabro binds below the settings: the run's model and provider.
/// When the settings name neither, the default offering of the eligible
/// providers is bound as the launch model alone: a node that names no model
/// runs on it, and a node that names a model the catalog lacks stays
/// unqualified, so Petri's admission refuses it.
pub(crate) fn launch(
catalog: &Catalog,
settings: &WorkflowSettings,
eligible: &[ProviderId],
repository: Option<PathBuf>,
) -> Launch {
let model = settings.run.model.name.clone().or_else(|| {
if settings.run.model.provider.is_some() {
return None;
}
let eligible = eligible.iter().cloned().collect::<HashSet<_>>();
selection::select_default(catalog, &eligible)
.ok()
.map(|offering| offering.model.id().to_string())
});
Launch {
model,
provider: settings.run.model.provider.clone(),
repository,
}
}
/// The launch with no catalog to pick a default from: what the settings
/// name, for a check away from the server.
pub(crate) fn launch_without_catalog(settings: &WorkflowSettings) -> Launch {
Launch {
model: settings.run.model.name.clone(),
provider: settings.run.model.provider.clone(),
repository: None,
}
}
/// The check request for `bundle`'s `entrypoint`: every file of every
/// workflow in the bundle at its bundle-relative path, the run's inputs and
/// variables, the launch and the runtime. `unbound_is_warning` makes a
/// template that reads an input nothing binds a warning, for a validation
/// before the run's inputs exist; a run's admission never sets it.
pub(crate) fn check_request(
bundle: &WorkflowBundle,
entrypoint: &ManifestPath,
settings: &WorkflowSettings,
vars: &HashMap<String, String>,
launch: Launch,
runtime: RuntimeSpec,
unbound_is_warning: bool,
) -> Result<CheckRequest, WorkflowError> {
let mut files = BTreeMap::new();
for workflow in bundle.workflows().values() {
for (path, text) in &workflow.files {
files.insert(path.to_string(), text.clone());
}
files.insert(workflow.path.to_string(), workflow.source.clone());
if let Some(config) = &workflow.config {
files.insert(config.path.to_string(), config.source.clone());
}
}
let mut inputs = BTreeMap::new();
for (name, value) in &settings.run.inputs {
let value = serde_json::to_value(value).map_err(|err| {
WorkflowError::engine_with_source(
format!("run input `{name}` does not encode as JSON"),
err,
)
})?;
inputs.insert(name.clone(), value);
}
Ok(CheckRequest {
bundle: Bundle {
files,
entrypoint: entrypoint.to_string(),
project_toml: None,
},
inputs,
vars: vars
.iter()
.map(|(name, value)| (name.clone(), value.clone()))
.collect(),
launch,
runtime,
unbound_is_warning,
})
}
/// What a check said: the admitted graphs when Petri admitted the workflow,
/// and every diagnostic, Petri's and Fabro's, in Fabro's shape.
pub(crate) struct Checked {
pub(crate) admitted: Option<Admitted>,
pub(crate) diagnostics: Vec<FabroDiagnostic>,
}
impl Checked {
pub(crate) fn has_errors(&self) -> bool {
self.diagnostics
.iter()
.any(|diagnostic| diagnostic.severity == Severity::Error)
}
}
/// Run Petri's check. A refusal comes back as its diagnostics with no
/// graphs; an admission as the graphs with Petri's warnings, plus Fabro's
/// refusal of a model node when `has_ready_provider` is false. Blocking: it
/// lowers the graph and runs the admission passes synchronously.
pub(crate) fn check(
request: &CheckRequest,
has_ready_provider: bool,
) -> Result<Checked, WorkflowError> {
match check::check(request) {
Ok(admitted) => {
let mut diagnostics = admitted
.warnings
.iter()
.map(fabro_diagnostic)
.collect::<Vec<_>>();
if !has_ready_provider && admitted.needs_model() {
diagnostics.push(FabroDiagnostic {
rule: NO_READY_PROVIDER_RULE.to_string(),
severity: Severity::Error,
message: "no default model is available: no LLM provider is ready, and the \
workflow has a node that runs a model"
.to_string(),
fix: Some(
"configure a provider credential (for example `OPENAI_API_KEY`) or a \
`[run.model]`"
.to_string(),
),
..FabroDiagnostic::default()
});
}
Ok(Checked {
admitted: Some(admitted),
diagnostics,
})
}
Err(CheckError::Rejected(diagnostics)) => Ok(Checked {
admitted: None,
diagnostics: diagnostics.iter().map(fabro_diagnostic).collect(),
}),
Err(other) => Err(WorkflowError::engine_with_source(
"Petri could not check the workflow",
other,
)),
}
}
/// Petri's diagnostic in Fabro's shape: the code is the rule, the hint is
/// the fix, the bundle-relative file and position are the source location.
fn fabro_diagnostic(diagnostic: &Diagnostic) -> FabroDiagnostic {
FabroDiagnostic {
rule: diagnostic.code.clone(),
severity: if diagnostic.is_error() {
Severity::Error
} else {
Severity::Warning
},
message: diagnostic.message.clone(),
fix: diagnostic.hint.clone(),
source_path: Some(diagnostic.file.clone()),
line: diagnostic.line,
column: diagnostic.column,
..FabroDiagnostic::default()
}
}

View file

@ -407,7 +407,6 @@ pub(crate) async fn compile_admitted(
cwd,
workflow_path: Some(entrypoint),
workflow_bundle: Some(workflow_bundle),
configured_providers: Vec::new(),
})?;
Ok(PinnedRun {
materialized: operations::materialize_admitted_run(compiled),

View file

@ -1,4 +1,4 @@
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::future::Future;
use std::path::{Path, PathBuf};
use std::sync::Arc;
@ -14,14 +14,17 @@ use fabro_config::{
use fabro_github::token_source::{InstallationTokenSource, ResolvedToken, TokenSnapshot};
use fabro_graphviz::graph::{Graph, is_llm_handler_type};
use fabro_graphviz::render::apply_direction;
use fabro_llm::FabroClient;
use fabro_llm::lithos_catalog::Catalog;
use fabro_llm::probe::{self, ModelTestStatus};
use fabro_llm::{FabroClient, selection};
use fabro_petri::check::Launch;
use fabro_petri::runtime::RuntimeSpec;
use fabro_proc::ProcessError;
use fabro_sandbox::{
CloneRequest, ProviderAccess, RunSandbox, SandboxSpec, sandbox_spec_for_environment,
};
use fabro_static::EnvVars;
use fabro_types::diagnostic::{Diagnostic, Severity};
use fabro_types::settings::cli::OutputVerbosity;
use fabro_types::settings::interp::InterpString;
use fabro_types::settings::run::{McpServerSettings, RunGoal, RunNamespace};
@ -29,13 +32,9 @@ use fabro_types::{
BundledProvider, ManifestPath, RunId, SandboxProviderKind, ServerSettings, WorkflowSettings,
};
use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus};
use fabro_validate::Severity;
use fabro_workflow::Error as WorkflowError;
use fabro_workflow::operations::{
ValidateInput, WorkflowInput, validate, validate_with_catalog, validate_with_ready_providers,
};
use fabro_workflow::operations::{ValidateInput, WorkflowInput, validate};
use fabro_workflow::pipeline::Validated;
use fabro_workflow::run_materialization::materialize_run_with_ready_providers;
use fabro_workflow::workflow_bundle::{BundledWorkflow, ParsedWorkflowConfig, WorkflowBundle};
use futures_util::stream::{self, StreamExt};
use lithos_llm::catalog::ProviderId;
@ -44,8 +43,8 @@ use tokio::process::Command;
use tokio::time;
use tokio_util::sync::CancellationToken;
use crate::run_compiler;
use crate::server::AppState;
use crate::{petri_check, run_compiler};
#[derive(Clone)]
pub(crate) struct PreparedManifest {
@ -54,6 +53,7 @@ pub(crate) struct PreparedManifest {
pub root_source: String,
pub settings: WorkflowSettings,
pub target_path: ManifestPath,
pub workflow_bundle: WorkflowBundle,
pub workflow_input: BundledWorkflow,
pub source_directory: PathBuf,
}
@ -168,43 +168,87 @@ pub(crate) fn prepare_manifest_with_environment_defaults(
root_source,
settings,
target_path,
workflow_bundle,
workflow_input,
source_directory,
})
}
pub(crate) fn validate_prepared_manifest(
prepared: &PreparedManifest,
catalog: Arc<Catalog>,
) -> Result<Validated, WorkflowError> {
validate_prepared_manifest_with_vars(prepared, catalog, HashMap::new())
}
/// Fabro's own structural pass: parse and transform the root workflow, with
/// the transforms' diagnostics. Enough to render a graph.
pub(crate) fn validate_prepared_manifest_structural(
prepared: &PreparedManifest,
) -> Result<Validated, WorkflowError> {
validate(manifest_validate_input(prepared, HashMap::new()))
}
pub(crate) fn validate_prepared_manifest_with_vars(
/// The structural pass, then Petri's check of the whole bundle under
/// `launch` and `runtime`, its diagnostics after the transforms' own.
/// `has_ready_provider` false adds Fabro's refusal of a model node no
/// provider can run; `unbound_is_warning` keeps an input nothing binds a
/// warning, for a validation before the run's inputs exist. Blocking:
/// Petri lowers the graph synchronously.
pub(crate) fn validate_prepared_manifest(
prepared: &PreparedManifest,
catalog: Arc<Catalog>,
vars: HashMap<String, String>,
vars: &HashMap<String, String>,
launch: Launch,
runtime: RuntimeSpec,
has_ready_provider: bool,
unbound_is_warning: bool,
) -> Result<Validated, WorkflowError> {
validate_with_catalog(manifest_validate_input(prepared, vars), catalog)
let mut validated = validate(manifest_validate_input(prepared, vars.clone()))?;
let request = petri_check::check_request(
&prepared.workflow_bundle,
&prepared.target_path,
&prepared.settings,
vars,
launch,
runtime,
unbound_is_warning,
)?;
let checked = petri_check::check(&request, has_ready_provider)?;
validated.extend_diagnostics(checked.diagnostics);
Ok(validated)
}
pub(crate) fn validate_prepared_manifest_for_preflight(
prepared: &PreparedManifest,
catalog: Arc<Catalog>,
vars: HashMap<String, String>,
/// The model and provider the run's LLM nodes default to: what the settings
/// or the graph name, resolved against the ready providers first and the
/// whole catalog after, as the run's launch binds them.
fn preflight_model(
catalog: &Catalog,
graph: &Graph,
settings: &WorkflowSettings,
ready_providers: &[ProviderId],
) -> Result<Validated, WorkflowError> {
validate_with_ready_providers(
manifest_validate_input(prepared, vars),
) -> Result<(String, ProviderId)> {
let graph_attr = |name: &str| {
graph
.attrs
.get(name)
.and_then(|value| value.as_str())
.map(str::to_string)
};
let model = settings
.run
.model
.name
.clone()
.or_else(|| graph_attr("default_model"));
let provider = settings
.run
.model
.provider
.clone()
.or_else(|| graph_attr("default_provider"))
.filter(|provider| !provider.is_empty())
.map(ProviderId::new);
let eligible = ready_providers.iter().cloned().collect();
let selected = selection::resolve_selection_with_catalog_fallback(
catalog,
ready_providers,
)
model.as_deref(),
provider.as_ref(),
&eligible,
)?;
Ok((selected.model, selected.provider))
}
fn manifest_validate_input(
@ -420,19 +464,15 @@ async fn build_preflight_report(
.as_ref()
.map(FabroClient::provider_ids)
.unwrap_or_default();
let materialized = materialize_run_with_ready_providers(
prepared.settings.clone(),
graph,
let (run_model, run_provider) = preflight_model(
catalog.as_ref(),
graph,
&prepared.settings,
&ready_providers,
)?;
let resolved_run = materialized.run;
let (Some(run_model), Some(run_provider)) = (
resolved_run.model.name.as_deref(),
resolved_run.model.provider.as_deref(),
) else {
bail!("materialized run is missing a resolved model or provider");
};
let run_provider = run_provider.into_string();
let (run_model, run_provider) = (run_model.as_str(), run_provider.as_str());
let resolved_run = prepared.settings.run.clone();
let server_settings = state.server_settings();
let github_integration = &server_settings.server.integrations.github;
let sandbox_provider = effective_sandbox_provider(&resolved_run);
@ -999,6 +1039,16 @@ async fn run_llm_check(
) -> bool {
let mut model_providers = std::collections::BTreeSet::new();
let mut has_llm_nodes = false;
// Each node's selector resolves against the ready providers first and
// the whole catalog after, as the run's launch binds it: an alias
// becomes the catalog model on the provider that offers it. A selector
// the catalog cannot place stays as written; the checks below say why.
let eligible = llm_result
.as_ref()
.map(FabroClient::provider_ids)
.unwrap_or_default()
.into_iter()
.collect::<HashSet<_>>();
for node in graph.nodes.values() {
if !is_llm_handler_type(node.handler_type()) {
@ -1007,7 +1057,23 @@ async fn run_llm_check(
has_llm_nodes = true;
let node_model = node.model().unwrap_or(model);
let node_provider = node.provider().unwrap_or(default_provider);
model_providers.insert((node_model.to_string(), node_provider.to_string()));
// Only a provider the node names itself constrains the selection:
// an unqualified alias goes to whichever eligible provider offers it.
let provider = node
.provider()
.filter(|provider| !provider.is_empty())
.map(ProviderId::new);
let resolved = selection::resolve_selection_with_catalog_fallback(
catalog,
Some(node_model),
provider.as_ref(),
&eligible,
)
.map_or_else(
|_| (node_model.to_string(), node_provider.to_string()),
|selected| (selected.model, selected.provider.into_string()),
);
model_providers.insert(resolved);
}
if !has_llm_nodes {
@ -1508,9 +1574,7 @@ pub(crate) fn workflow_summary(
}
}
fn diagnostics_to_api(
diagnostics: &[fabro_validate::Diagnostic],
) -> Vec<types::WorkflowDiagnostic> {
fn diagnostics_to_api(diagnostics: &[Diagnostic]) -> Vec<types::WorkflowDiagnostic> {
diagnostics
.iter()
.map(|diagnostic| types::WorkflowDiagnostic {
@ -1589,11 +1653,44 @@ fn report_to_api(report: &CheckReport) -> types::PreflightCheckReport {
#[cfg(test)]
mod tests {
use fabro_workflow::run_materialization::materialize_run;
use lithos_llm::catalog::ProviderId;
use super::*;
/// Validate as the endpoints do: the structural pass, then Petri's check
/// with the state's model client over `ready_providers`.
fn validate_for_test(
state: &AppState,
prepared: &PreparedManifest,
ready_providers: &[ProviderId],
) -> Result<Validated, WorkflowError> {
let launch =
petri_check::launch(&state.catalog(), &prepared.settings, ready_providers, None);
let runtime = crate::server::petri_runs::runtime_spec(state, ready_providers, false);
validate_prepared_manifest(
prepared,
&HashMap::new(),
launch,
runtime,
!ready_providers.is_empty(),
false,
)
}
/// Validate with every provider of the state's catalog eligible, as the
/// legacy catalog validation judged a manifest.
fn validate_with_catalog(
state: &AppState,
prepared: &PreparedManifest,
) -> Result<Validated, WorkflowError> {
let providers = state
.catalog()
.enabled_provider_ids()
.into_iter()
.collect::<Vec<_>>();
validate_for_test(state, prepared, &providers)
}
#[tokio::test]
async fn ls_remote_capture_keeps_diagnostic_precedence_and_unlimited_output() {
for (script, expected) in [
@ -1706,10 +1803,6 @@ mod tests {
)
}
fn test_catalog() -> Arc<Catalog> {
Arc::new(fabro_llm::test_support::test_catalog())
}
fn openai_compatible_completion(model: &str) -> serde_json::Value {
serde_json::json!({
"id": "chatcmpl_preflight",
@ -1780,13 +1873,42 @@ digraph Demo {{
&manifest,
)
.unwrap();
let validated = validate_prepared_manifest_for_preflight(
&prepared,
state.catalog(),
HashMap::new(),
&ready_providers,
let validated = validate_for_test(state, &prepared, &ready_providers).unwrap();
assert!(!validated.has_errors(), "{:?}", validated.diagnostics());
run_preflight(state.as_ref(), &prepared, &validated, llm_result)
.await
.unwrap()
}
/// Preflight for a workflow naming `model`, which Petri refuses.
async fn preflight_for_refused_model(
state: &Arc<crate::server::AppState>,
model: &str,
) -> (types::PreflightResponse, bool) {
let llm_result = state.resolve_llm_client().await;
let ready_providers = llm_result
.as_ref()
.map(FabroClient::provider_ids)
.unwrap_or_default();
let mut manifest = minimal_manifest();
manifest.workflows.get_mut("workflow.fabro").unwrap().source = format!(
r#"
digraph Demo {{
start [shape=Mdiamond]
exit [shape=Msquare]
work [prompt="Do work", model="{model}"]
start -> work -> exit
}}
"#
);
let prepared = prepare_manifest(
&manifest_run_defaults(Some(&default_settings_fixture())),
&manifest,
)
.unwrap();
let validated = validate_for_test(state, &prepared, &ready_providers).unwrap();
assert!(validated.has_errors(), "{:?}", validated.diagnostics());
run_preflight(state.as_ref(), &prepared, &validated, llm_result)
.await
@ -1866,15 +1988,7 @@ enabled = {clone_enabled}
&manifest,
)
.unwrap();
let validated = validate_prepared_manifest(&prepared, test_catalog()).unwrap();
let resolved = materialize_run(
prepared.settings.clone(),
validated.graph(),
test_catalog().as_ref(),
&[lithos_llm::catalog::builtin::anthropic()],
)
.unwrap()
.run;
let resolved = prepared.settings.run.clone();
(prepared, resolved)
}
@ -2415,7 +2529,7 @@ name = "Control Plane"
&invalid_manifest(),
)
.unwrap();
let validated = validate_prepared_manifest(&prepared, test_catalog()).unwrap();
let validated = validate_with_catalog(&state, &prepared).unwrap();
assert!(validated.has_errors());
@ -2446,6 +2560,9 @@ name = "Control Plane"
path: "workflow.toml".to_string(),
source: r#"_version = 1
[environments.local]
provider = "local"
[run.environment]
id = "local"
@ -2460,8 +2577,8 @@ issues = "read"
&manifest,
)
.unwrap();
let validated = validate_prepared_manifest(&prepared, test_catalog()).unwrap();
assert!(!validated.has_errors());
let validated = validate_with_catalog(&state, &prepared).unwrap();
assert!(!validated.has_errors(), "{:?}", validated.diagnostics());
let (response, _ok) = resolve_and_run_preflight(state.as_ref(), &prepared, &validated)
.await
@ -2491,6 +2608,9 @@ issues = "read"
path: "workflow.toml".to_string(),
source: r#"_version = 1
[environments.local]
provider = "local"
[run.environment]
id = "local"
@ -2505,8 +2625,8 @@ issues = "{{ env.GITHUB_ISSUES_PERMISSION }}"
&manifest,
)
.unwrap();
let validated = validate_prepared_manifest(&prepared, test_catalog()).unwrap();
assert!(!validated.has_errors());
let validated = validate_with_catalog(&state, &prepared).unwrap();
assert!(!validated.has_errors(), "{:?}", validated.diagnostics());
let (response, ok) = resolve_and_run_preflight(state.as_ref(), &prepared, &validated)
.await
@ -2559,9 +2679,9 @@ id = "local"
&manifest,
)
.unwrap();
let validated = validate_prepared_manifest(&prepared, test_catalog()).unwrap();
let validated = validate_with_catalog(&state, &prepared).unwrap();
assert!(!validated.has_errors());
assert!(!validated.has_errors(), "{:?}", validated.diagnostics());
let (response, ok) = resolve_and_run_preflight(state.as_ref(), &prepared, &validated)
.await
@ -2668,7 +2788,7 @@ id = "daytona"
&manifest,
)
.unwrap();
let validated = validate_prepared_manifest(&prepared, test_catalog()).unwrap();
let validated = validate_with_catalog(&state, &prepared).unwrap();
let (response, _ok) = resolve_and_run_preflight(state.as_ref(), &prepared, &validated)
.await
@ -2736,7 +2856,7 @@ digraph Demo {
&manifest,
)
.unwrap();
let validated = validate_prepared_manifest(&prepared, test_catalog()).unwrap();
let validated = validate_with_catalog(&state, &prepared).unwrap();
let (response, ok) = resolve_and_run_preflight(state.as_ref(), &prepared, &validated)
.await
@ -2781,7 +2901,12 @@ digraph Demo {
.checks
.iter()
.find(|check| check.name == "LLM" && check.summary == "claude-fable-5")
.expect("preflight should include Claude Fable");
.unwrap_or_else(|| {
panic!(
"preflight should include Claude Fable: {:?}",
response.checks.sections
)
});
assert_eq!(
llm_check
.details
@ -2810,31 +2935,28 @@ digraph Demo {
.await;
let state = ready_moonshot_and_openrouter_state(&server);
let (response, _ok) = preflight_for_model(&state, "provider-private-preview").await;
// Petri admits no model its catalog lacks, so the workflow is refused
// before any probe runs: no passthrough of an unknown model.
let (response, ok) = preflight_for_refused_model(&state, "provider-private-preview").await;
assert!(!ok);
assert!(response.workflow.diagnostics.iter().any(|diagnostic| {
diagnostic.rule == "node_model_known"
diagnostic.rule == "attractor.model.unknown"
&& diagnostic.message.contains("provider-private-preview")
}));
let llm_check = response.checks.sections[0]
.checks
.iter()
.find(|check| check.name == "LLM" && check.summary == "provider-private-preview")
.expect("preflight should include the unknown passthrough model");
assert_eq!(
llm_check
.details
assert!(
response.checks.sections[0]
.checks
.iter()
.map(|detail| detail.text.as_str())
.find(|detail| detail.starts_with("Provider: ")),
Some("Provider: moonshot")
.all(|check| check.name != "LLM"),
"no LLM check runs on a refused workflow"
);
assert_eq!(llm_check.status, types::PreflightCheckResultStatus::Pass);
moonshot_probe.assert_async().await;
assert_eq!(moonshot_probe.calls_async().await, 0);
}
#[test]
fn static_validation_rejects_unknown_llm_provider() {
let state = crate::test_support::test_app_state();
let mut manifest = minimal_manifest();
manifest.workflows.get_mut("workflow.fabro").unwrap().source = r#"
digraph Demo {
@ -2850,16 +2972,16 @@ digraph Demo {
&manifest,
)
.unwrap();
let Err(error) = validate_prepared_manifest(&prepared, test_catalog()) else {
panic!("unknown provider should fail static validation");
};
let validated = validate_with_catalog(&state, &prepared).unwrap();
assert!(matches!(
error,
WorkflowError::ModelSelection(fabro_llm::ModelSelectionError::UnknownProvider {
provider
}) if provider.as_str() == "missing-provider"
));
// Petri refuses the model node: the provider is not in the catalog.
let refusal = validated
.diagnostics()
.iter()
.find(|diagnostic| diagnostic.severity == Severity::Error)
.expect("unknown provider should fail static validation");
assert_eq!(refusal.rule, "attractor.model.unknown", "{refusal:?}");
assert!(refusal.message.contains("missing-provider"), "{refusal:?}");
}
#[tokio::test]
@ -2908,34 +3030,33 @@ digraph Demo {
.map(FabroClient::provider_ids)
.unwrap_or_default();
assert!(ready_providers.is_empty());
let validated = validate_prepared_manifest_for_preflight(
&prepared,
state.catalog(),
HashMap::new(),
&ready_providers,
)
.unwrap();
// With no provider ready there is no model client to resolve the
// alias against, so Fabro refuses the model node before any check
// runs: the run could not pick a model at create either.
let validated = validate_for_test(&state, &prepared, &ready_providers).unwrap();
assert!(validated.has_errors());
assert!(
validated
.diagnostics()
.iter()
.any(|diagnostic| diagnostic.rule == petri_check::NO_READY_PROVIDER_RULE),
"{:?}",
validated.diagnostics()
);
let (response, ok) = run_preflight(state.as_ref(), &prepared, &validated, llm_result)
.await
.unwrap();
assert!(!ok);
let llm_check = response.checks.sections[0]
.checks
.iter()
.find(|check| check.name == "LLM" && check.summary == "acme-large")
.expect("preflight should resolve the catalog alias");
assert_eq!(llm_check.status, types::PreflightCheckResultStatus::Warning);
assert_eq!(
llm_check.remediation.as_deref(),
Some("Provider \"acme\" is not configured")
);
assert!(
llm_check
.details
response
.checks
.sections
.iter()
.any(|detail| detail.text == "Provider: acme")
.flat_map(|section| section.checks.iter())
.all(|check| check.name != "LLM"),
"no LLM check runs on a refused workflow"
);
}

View file

@ -160,7 +160,7 @@ use crate::{
mod automation_scheduler;
mod handler;
mod petri_runs;
pub(crate) mod petri_runs;
mod pull_request_supervisor;
pub(crate) mod resource_sampler;
mod session_runtime;

View file

@ -52,7 +52,7 @@ async fn render_graph_from_manifest(
Ok(prepared) => prepared,
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
};
let validated = match run_manifest::validate_prepared_manifest(&prepared, state.catalog()) {
let validated = match run_manifest::validate_prepared_manifest_structural(&prepared) {
Ok(validated) => validated,
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
};

View file

@ -26,6 +26,8 @@ use fabro_static::EnvVars;
use fabro_store::{
RunSummaryListQuery, RunSummarySort, RunSummarySortDirection, RunSummaryVisibility,
};
use fabro_types::diagnostic::Severity;
use fabro_types::settings::run::RunMode;
use fabro_types::{
AutomationRef, ContextWindowStaleness, ManifestPath, Principal, Run, RunClientProvenance,
RunId, RunProvenance, RunServerProvenance, RunStatusKind, RunTarget, SandboxProviderKind,
@ -35,12 +37,13 @@ use fabro_types::{
};
use fabro_util::error as error_util;
use fabro_util::version::FABRO_VERSION;
use fabro_workflow::pipeline::Validated;
use fabro_workflow::run_status::RunStatus;
use fabro_workflow::{Error as WorkflowError, operations};
use lithos_llm::catalog::ProviderId;
use serde::de::IgnoredAny;
use strum::VariantArray as _;
use tokio::fs;
use tokio::{fs, task};
use tracing::info;
use super::super::{
@ -61,11 +64,11 @@ use crate::run_intent::{
EnvironmentSelectionError, PreparedIntentTarget, RunIntentAdmissionError,
lower_workflow_closure, pin_workflow_environment_authority, prepare_intent_target,
};
use crate::run_manifest;
use crate::run_selector::{ResolveRunError, resolve_run_by_selector};
use crate::run_title_generation::{self, GenerateTitleInput, TitlePromptInput, WorkflowSummary};
#[cfg(any(test, feature = "test-support"))]
use crate::test_support as server_test_support;
use crate::{petri_check, run_manifest};
pub(super) fn manifest_routes() -> Router<Arc<AppState>> {
Router::new()
@ -989,7 +992,7 @@ fn compiler_error_detail(error: &run_compiler::RunCompilerError) -> String {
};
let listed = diagnostics
.iter()
.filter(|diagnostic| diagnostic.severity == fabro_validate::Severity::Error)
.filter(|diagnostic| diagnostic.severity == Severity::Error)
.map(|diagnostic| format!("{}: {}", diagnostic.rule, diagnostic.message))
.collect::<Vec<_>>();
if listed.is_empty() {
@ -1228,18 +1231,14 @@ async fn run_preflight(
.into_response();
}
let (llm_result, ready_providers) = state.resolve_llm_client_with_ready_ids().await;
let mut validated = match run_manifest::validate_prepared_manifest_for_preflight(
&prepared,
state.catalog(),
vars,
&ready_providers,
) {
Ok(validated) => validated,
Err(WorkflowError::Parse(_)) => {
return ApiError::bad_request("Validation failed").into_response();
}
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
};
let mut validated =
match validate_manifest_on_petri(&state, &prepared, vars, &ready_providers).await {
Ok(validated) => validated,
Err(WorkflowError::Parse(_)) => {
return ApiError::bad_request("Validation failed").into_response();
}
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
};
validated.promote_template_undefined_variables_to_errors();
let response =
match run_manifest::run_preflight(&state, &prepared, &validated, llm_result).await {
@ -1280,17 +1279,15 @@ async fn validate_run_manifest(
return ApiError::bad_request(format!("Run config variable interpolation failed: {err}"))
.into_response();
}
let validated = match run_manifest::validate_prepared_manifest_with_vars(
&prepared,
state.catalog(),
vars,
) {
Ok(validated) => validated,
Err(WorkflowError::Parse(_)) => {
return ApiError::bad_request("Validation failed").into_response();
}
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
};
let (_, ready_providers) = state.resolve_llm_client_with_ready_ids().await;
let validated =
match validate_manifest_on_petri(&state, &prepared, vars, &ready_providers).await {
Ok(validated) => validated,
Err(WorkflowError::Parse(_)) => {
return ApiError::bad_request("Validation failed").into_response();
}
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
};
(
StatusCode::OK,
Json(run_manifest::validate_response(&prepared, &validated)),
@ -1298,6 +1295,34 @@ async fn validate_run_manifest(
.into_response()
}
/// Validate a prepared manifest as a run would be admitted: Fabro's
/// structural pass, then Petri's check with the model client over the ready
/// providers, on the blocking pool.
async fn validate_manifest_on_petri(
state: &Arc<AppState>,
prepared: &run_manifest::PreparedManifest,
vars: HashMap<String, String>,
ready_providers: &[ProviderId],
) -> Result<Validated, WorkflowError> {
let launch = petri_check::launch(&state.catalog(), &prepared.settings, ready_providers, None);
let dry_run = prepared.settings.run.execution.mode == RunMode::DryRun;
let runtime = petri_runs::runtime_spec(state, ready_providers, dry_run);
let has_ready_provider = !ready_providers.is_empty();
let prepared = prepared.clone();
task::spawn_blocking(move || {
run_manifest::validate_prepared_manifest(
&prepared,
&vars,
launch,
runtime,
has_ready_provider,
false,
)
})
.await
.map_err(|source| WorkflowError::engine_with_source("manifest check task failed", source))?
}
async fn snapshot_run_variables(
state: &AppState,
) -> Result<HashMap<String, String>, VariableError> {

View file

@ -28,14 +28,11 @@
//! workspace to the snapshot its durable state names, or reports the run
//! failed when it cannot.
use std::collections::{BTreeMap, HashSet};
use std::sync::Arc;
use std::time::Instant;
use fabro_config::{Home, SettingsLayer, Storage};
use fabro_interview::ControlInterviewer;
use fabro_llm::selection;
use fabro_petri::check::{self, Bundle, CheckError, CheckRequest, Diagnostic, Launch};
use fabro_petri::controls::RunControls;
use fabro_petri::engine::{self, Conclusion, Execution, RunRequest};
use fabro_petri::hooks::HooksSpec;
@ -49,7 +46,6 @@ use fabro_petri::{SqliteRunStore, admission};
use fabro_types::settings::run::{ApprovalMode, RunMode};
use fabro_types::{PetriAdmission, RunId, RunRunnableSource, RunTarget, RunTiming, StageOutcome};
use fabro_util::error as error_util;
use fabro_validate::{Diagnostic as FabroDiagnostic, Severity};
use fabro_workflow::Error as WorkflowError;
use fabro_workflow::run_status::{FailureReason, RunStatus, SuccessReason};
use lithos_llm::catalog::ProviderId;
@ -58,6 +54,7 @@ use tokio_util::sync::CancellationToken;
use tracing::{error, info, warn};
use super::{AppState, RunAnswerTransport, RunExecutionMode, clear_live_run_state, workflow_event};
use crate::petri_check;
use crate::petri_runs::PetriRuns;
use crate::run_compiler::{PreparedRun, RunCompilerError};
@ -114,74 +111,31 @@ fn settings_layer_toml(state: &AppState) -> Option<String> {
/// Petri compiles the run: check the bundle, map the diagnostics, and
/// persist the admitted graphs. A refusal is the same validation error the
/// legacy compiler raises, carrying Petri's diagnostics.
/// legacy compiler raised, carrying Petri's diagnostics.
pub(crate) async fn admit(
state: &AppState,
prepared: &PreparedRun,
eligible: &[ProviderId],
) -> Result<PetriAdmission, RunCompilerError> {
let settings = prepared.settings();
let mut files = BTreeMap::new();
for workflow in prepared.workflow_bundle().workflows().values() {
for (path, text) in &workflow.files {
files.insert(path.to_string(), text.clone());
}
files.insert(workflow.path.to_string(), workflow.source.clone());
if let Some(config) = &workflow.config {
files.insert(config.path.to_string(), config.source.clone());
}
}
let mut inputs = BTreeMap::new();
for (name, value) in &settings.run.inputs {
let value = serde_json::to_value(value).map_err(|err| {
RunCompilerError::Workflow(WorkflowError::engine_with_source(
format!("run input `{name}` does not encode as JSON"),
err,
))
})?;
inputs.insert(name.clone(), value);
}
// The launch: Fabro's resolved model and provider. When the settings
// name neither, the default offering of the eligible providers, as the
// legacy compiler picked it, is bound as the launch model alone: a node
// that names no model runs on it, and a node that names a model the
// catalog lacks stays unqualified, so Petri's admission refuses it.
let catalog = state.catalog();
let model = settings.run.model.name.clone().or_else(|| {
if settings.run.model.provider.is_some() {
return None;
}
let eligible = eligible.iter().cloned().collect::<HashSet<_>>();
selection::select_default(&catalog, &eligible)
.ok()
.map(|offering| offering.model.id().to_string())
});
let provider = settings.run.model.provider.clone();
let repository = match prepared.target() {
Some(RunTarget::Folder { path }) => Some(path.into()),
Some(RunTarget::Git(_) | RunTarget::None {}) | None => None,
};
let launch = petri_check::launch(&state.catalog(), settings, eligible, repository);
let dry_run = settings.run.execution.mode == RunMode::DryRun;
let request = CheckRequest {
bundle: Bundle {
files,
entrypoint: prepared.entrypoint().to_string(),
project_toml: None,
},
inputs,
vars: prepared
.vars()
.iter()
.map(|(name, value)| (name.clone(), value.clone()))
.collect(),
launch: Launch {
model,
provider,
repository,
},
runtime: runtime_spec(state, eligible, dry_run),
};
let admitted = task::spawn_blocking(move || check::check(&request))
let request = petri_check::check_request(
prepared.workflow_bundle(),
prepared.entrypoint(),
settings,
prepared.vars(),
launch,
runtime_spec(state, eligible, dry_run),
false,
)
.map_err(RunCompilerError::Workflow)?;
let has_ready_provider = !eligible.is_empty();
let checked = task::spawn_blocking(move || petri_check::check(&request, has_ready_provider))
.await
.map_err(|source| {
RunCompilerError::Workflow(WorkflowError::engine_with_source(
@ -189,42 +143,22 @@ pub(crate) async fn admit(
source,
))
})?
.map_err(|err| match err {
CheckError::Rejected(diagnostics) => {
RunCompilerError::Workflow(WorkflowError::ValidationFailed {
diagnostics: diagnostics.iter().map(fabro_diagnostic).collect(),
})
}
other => RunCompilerError::Workflow(WorkflowError::engine_with_source(
"Petri could not check the workflow",
other,
)),
})?;
for warning in &admitted.warnings {
info!(code = %warning.code, message = %warning.message, "Petri warned at admission");
}
// Without a ready provider there is no model client, so Petri admitted
// the model nodes unchecked: refuse a run they would fail at once, as
// the legacy compiler refused every run without a default model.
if eligible.is_empty() && admitted.needs_model() {
.map_err(RunCompilerError::Workflow)?;
if checked.has_errors() {
return Err(RunCompilerError::Workflow(
WorkflowError::ValidationFailed {
diagnostics: vec![FabroDiagnostic {
rule: "fabro.model.no_ready_provider".to_string(),
severity: Severity::Error,
message: "no default model is available: no LLM provider is ready, and the \
workflow has a node that runs a model"
.to_string(),
fix: Some(
"configure a provider credential (for example `OPENAI_API_KEY`) or a \
`[run.model]`"
.to_string(),
),
..FabroDiagnostic::default()
}],
diagnostics: checked.diagnostics,
},
));
}
for warning in &checked.diagnostics {
info!(code = %warning.rule, message = %warning.message, "Petri warned at admission");
}
let admitted = checked.admitted.ok_or_else(|| {
RunCompilerError::Workflow(WorkflowError::engine(
"Petri's check admitted no graph and raised no error",
))
})?;
admission::persist(&state.store_ref().blobs(), &admitted)
.await
.map_err(|err| {
@ -235,25 +169,6 @@ pub(crate) async fn admit(
})
}
/// Petri's diagnostic in Fabro's shape: the code is the rule, the hint is
/// the fix, the bundle-relative file and position are the source location.
fn fabro_diagnostic(diagnostic: &Diagnostic) -> FabroDiagnostic {
FabroDiagnostic {
rule: diagnostic.code.clone(),
severity: if diagnostic.is_error() {
Severity::Error
} else {
Severity::Warning
},
message: diagnostic.message.clone(),
fix: diagnostic.hint.clone(),
source_path: Some(diagnostic.file.clone()),
line: diagnostic.line,
column: diagnostic.column,
..FabroDiagnostic::default()
}
}
/// Execute a Petri run in the server process, under the test override:
/// runnable → starting → running → succeeded or failed, with the lifecycle
/// events Fabro's read side needs. Outside tests a Petri run executes in

View file

@ -1,45 +0,0 @@
[package]
name = "fabro-acp"
edition.workspace = true
version.workspace = true
publish = false
license.workspace = true
description = "Agent Client Protocol backend support for Fabro"
[features]
default = ["runtime"]
runtime = [
"dep:fabro-sandbox",
"dep:fabro-types",
"dep:futures",
"dep:pebble-coding-agent",
"dep:tokio",
"dep:tokio-util",
"dep:tracing",
]
test-support = []
[lib]
doctest = false
[lints]
workspace = true
[dependencies]
agent-client-protocol.workspace = true
agent-client-protocol-tokio.workspace = true
fabro-sandbox = { path = "../fabro-sandbox", optional = true }
fabro-types = { path = "../../foundation/fabro-types", optional = true }
fabro-util = { path = "../../foundation/fabro-util" }
pebble-coding-agent = { workspace = true, optional = true }
serde_json.workspace = true
shlex = "1"
thiserror.workspace = true
tokio = { workspace = true, optional = true }
tokio-util = { workspace = true, features = ["compat", "io"], optional = true }
futures = { workspace = true, optional = true }
tracing = { workspace = true, optional = true }
[dev-dependencies]
fabro-sandbox = { path = "../fabro-sandbox", features = ["test-support"] }
tempfile = "3"

View file

@ -1,272 +0,0 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use agent_client_protocol::schema::{McpServer, McpServerStdio};
use agent_client_protocol_tokio::AcpAgent;
use fabro_util::shell::shell_join;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AcpProcessSpec {
name: Option<String>,
program: PathBuf,
args: Vec<String>,
env: HashMap<String, String>,
}
impl AcpProcessSpec {
pub fn from_attrs(
legacy_command: Option<&str>,
command: Option<&str>,
config: Option<&str>,
) -> Result<Self, AcpCommandError> {
if legacy_command.is_some() {
return Err(AcpCommandError::LegacyCommandAttribute);
}
match (command, config) {
(Some(command), None) => Self::from_command_attr(command),
(None, Some(config)) => Self::from_config_attr(config),
(None, None) | (Some(_), Some(_)) => Err(AcpCommandError::MissingOverride),
}
}
pub fn from_command_attr(raw: &str) -> Result<Self, AcpCommandError> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(AcpCommandError::EmptyOverride);
}
let parts = shlex::split(trimmed).ok_or(AcpCommandError::InvalidCommandString)?;
let agent =
AcpAgent::from_args(parts).map_err(|_| AcpCommandError::InvalidCommandString)?;
let mut spec = Self::from_server(agent.into_server())?;
spec.name = None;
Ok(spec)
}
pub fn from_config_attr(raw: &str) -> Result<Self, AcpCommandError> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(AcpCommandError::EmptyOverride);
}
let server = parse_config_server(trimmed)?;
Self::from_server(server)
}
fn from_server(server: McpServer) -> Result<Self, AcpCommandError> {
match server {
McpServer::Stdio(stdio) => Self::from_stdio_config(stdio),
_ => Err(AcpCommandError::UnsupportedTransport),
}
}
fn from_stdio_config(stdio: McpServerStdio) -> Result<Self, AcpCommandError> {
if stdio.command.as_os_str().is_empty() {
return Err(AcpCommandError::InvalidConfigShape("missing command"));
}
let env = stdio
.env
.into_iter()
.map(|env| (env.name, env.value))
.collect();
Ok(Self::from_stdio_parts(
Some(stdio.name),
stdio.command,
stdio.args,
env,
))
}
fn from_stdio_parts(
name: Option<String>,
program: PathBuf,
args: Vec<String>,
env: HashMap<String, String>,
) -> Self {
Self {
name,
program,
args,
env,
}
}
#[must_use]
pub fn name(&self) -> Option<&str> {
self.name.as_deref()
}
#[must_use]
pub fn program(&self) -> &Path {
&self.program
}
#[must_use]
pub fn args(&self) -> &[String] {
&self.args
}
#[must_use]
pub fn env(&self) -> &HashMap<String, String> {
&self.env
}
#[must_use]
pub fn to_shell_command(&self) -> String {
render_command(&self.program, &self.args)
}
}
impl std::fmt::Display for AcpProcessSpec {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.to_shell_command())
}
}
#[derive(Debug, thiserror::Error)]
pub enum AcpCommandError {
#[error("acp_command is no longer supported; use acp.command or acp.config")]
LegacyCommandAttribute,
#[error("ACP process attribute must not be empty")]
EmptyOverride,
#[error("backend=\"acp\" requires exactly one of acp.command or acp.config")]
MissingOverride,
#[error("only stdio ACP commands are supported")]
UnsupportedTransport,
#[error("failed to parse acp.command as a shell command")]
InvalidCommandString,
#[error("failed to parse acp.config as JSON")]
InvalidConfigJson(#[source] serde_json::Error),
#[error("invalid acp.config shape: {0}")]
InvalidConfigShape(&'static str),
}
fn render_command(program: &Path, args: &[String]) -> String {
shell_join(std::iter::once(program.to_string_lossy().into_owned()).chain(args.iter().cloned()))
}
fn parse_config_server(raw: &str) -> Result<McpServer, AcpCommandError> {
let mut value: serde_json::Value =
serde_json::from_str(raw).map_err(AcpCommandError::InvalidConfigJson)?;
match value.get("type").and_then(serde_json::Value::as_str) {
Some("stdio") | None => {}
Some(_) => return Err(AcpCommandError::UnsupportedTransport),
}
if let Some(object) = value.as_object_mut() {
object
.entry("args".to_string())
.or_insert_with(|| serde_json::Value::Array(Vec::new()));
object
.entry("env".to_string())
.or_insert_with(|| serde_json::Value::Array(Vec::new()));
}
serde_json::from_value(value).map_err(AcpCommandError::InvalidConfigJson)
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::*;
#[test]
fn command_attr_parses_shell_command() {
let command = AcpProcessSpec::from_command_attr("python fake_agent.py").unwrap();
assert_eq!(command.to_string(), "python fake_agent.py");
assert_eq!(command.name(), None);
assert_eq!(command.program(), Path::new("python"));
assert_eq!(command.args(), &["fake_agent.py".to_string()]);
}
#[test]
fn command_attr_parses_leading_env_assignments() {
let command = AcpProcessSpec::from_command_attr(
"RUST_LOG=debug TOKEN='secret value' python fake_agent.py",
)
.unwrap();
assert_eq!(command.to_string(), "python fake_agent.py");
assert_eq!(command.program(), Path::new("python"));
assert_eq!(
command.env().get("RUST_LOG").map(String::as_str),
Some("debug")
);
assert_eq!(
command.env().get("TOKEN").map(String::as_str),
Some("secret value")
);
}
#[test]
fn blank_acp_process_attr_is_rejected() {
let err = AcpProcessSpec::from_command_attr(" ").unwrap_err();
assert!(err.to_string().contains("must not be empty"));
}
#[test]
fn json_stdio_acp_config_is_supported() {
let raw = r#"{"type":"stdio","name":"fake","command":"python","args":["fake agent.py"],"env":[{"name":"MODE","value":"test"}]}"#;
let command = AcpProcessSpec::from_config_attr(raw).unwrap();
assert_eq!(command.name(), Some("fake"));
assert_eq!(command.program(), Path::new("python"));
assert_eq!(command.args(), &["fake agent.py".to_string()]);
assert_eq!(command.env().get("MODE").map(String::as_str), Some("test"));
}
#[test]
fn json_stdio_acp_config_display_omits_env_contents() {
let raw = r#"{"type":"stdio","name":"fake","command":"agent","args":["--flag","two words"],"env":[{"name":"OPENAI_API_KEY","value":"secret-key"}]}"#;
let command = AcpProcessSpec::from_config_attr(raw).unwrap();
assert_eq!(
command.env().get("OPENAI_API_KEY").map(String::as_str),
Some("secret-key")
);
assert_eq!(command.to_string(), "agent --flag 'two words'");
assert!(!command.to_string().contains("secret-key"));
assert!(!command.to_string().contains("OPENAI_API_KEY"));
}
#[test]
fn non_stdio_acp_config_is_rejected() {
let raw = r#"{"type":"http","name":"remote","url":"https://example.test/acp"}"#;
let err = AcpProcessSpec::from_config_attr(raw).unwrap_err();
assert!(
err.to_string()
.contains("only stdio ACP commands are supported")
);
}
#[test]
fn command_attr_is_always_shell_command_even_when_json_shaped() {
let command = AcpProcessSpec::from_command_attr(r#"{"type":"stdio"}"#).unwrap();
assert_ne!(command.program(), Path::new("stdio"));
assert!(command.args().is_empty());
}
#[test]
fn config_attr_requires_json_stdio_config() {
let command = AcpProcessSpec::from_config_attr(
r#"{"type":"stdio","name":"fake","command":"python3","args":["agent.py"]}"#,
)
.unwrap();
assert_eq!(command.name(), Some("fake"));
assert_eq!(command.program(), Path::new("python3"));
assert_eq!(command.args(), &["agent.py".to_string()]);
assert!(AcpProcessSpec::from_config_attr("python3 agent.py").is_err());
assert!(
AcpProcessSpec::from_config_attr(
r#"{"type":"http","name":"remote","url":"https://example.test/acp"}"#
)
.is_err()
);
}
}

View file

@ -1,70 +0,0 @@
use fabro_types::{CommandTermination, ExecOutputTail};
use crate::command::AcpCommandError;
#[derive(Debug)]
pub struct AcpProcessExit {
pub termination: CommandTermination,
pub exit_code: Option<i32>,
pub exec_output_tail: Option<ExecOutputTail>,
}
impl std::fmt::Display for AcpProcessExit {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let exit_code = self
.exit_code
.map_or_else(|| "unknown".to_string(), |code| code.to_string());
write!(
f,
"ACP process exited before protocol completed: termination={}, exit_code={exit_code}",
self.termination
)
}
}
#[derive(Debug, thiserror::Error)]
pub enum AcpError {
#[error(transparent)]
Command(#[from] AcpCommandError),
#[error(transparent)]
Sandbox(#[from] fabro_sandbox::Error),
#[error("ACP protocol error")]
Protocol(#[source] agent_client_protocol::Error),
#[error("ACP turn was cancelled")]
Cancelled,
#[error("ACP turn timed out")]
TimedOut {
exec_output_tail: Option<ExecOutputTail>,
},
#[error("{0}")]
ProcessExited(AcpProcessExit),
#[error("ACP prompt stopped with {stop_reason}: {text}")]
StopReason {
stop_reason: String,
text: String,
},
}
impl AcpError {
#[must_use]
pub fn exec_output_tail(&self) -> Option<ExecOutputTail> {
match self {
Self::TimedOut { exec_output_tail } => exec_output_tail.clone(),
Self::ProcessExited(exit) => exit.exec_output_tail.clone(),
Self::Sandbox(source) => source.default_redacted_output_tail(),
_ => None,
}
}
}
impl From<agent_client_protocol::Error> for AcpError {
fn from(error: agent_client_protocol::Error) -> Self {
Self::Protocol(error)
}
}

View file

@ -1,20 +0,0 @@
pub mod command;
#[cfg(feature = "runtime")]
pub mod error;
#[cfg(feature = "runtime")]
pub mod session;
#[cfg(any(test, feature = "test-support"))]
pub mod test_support;
#[cfg(feature = "runtime")]
mod transport;
pub use command::{AcpCommandError, AcpProcessSpec};
#[cfg(feature = "runtime")]
pub use error::{AcpError, AcpProcessExit};
#[cfg(feature = "runtime")]
pub use session::{
AcpControlHandle, AcpLiveControl, AcpRunRequest, AcpRunResult, render_stop_reason, run_acp_turn,
};

View file

@ -1,480 +0,0 @@
use std::collections::{HashMap, VecDeque};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use agent_client_protocol::schema::{
CancelNotification, ContentBlock, ContentChunk, InitializeRequest, PermissionOptionKind,
ProtocolVersion, RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse,
SelectedPermissionOutcome, SessionNotification, SessionUpdate, StopReason,
};
use agent_client_protocol::util::MatchDispatch;
use agent_client_protocol::{ActiveSession, Agent, Client, Error as ProtocolError, SessionMessage};
use fabro_sandbox::RunSandbox;
use fabro_util::time::elapsed_ms;
use pebble_coding_agent::SteeringMessage;
use pebble_coding_agent::events::Actor;
use tokio::sync::Notify;
use tokio::sync::futures::Notified;
use tokio::time::{sleep, timeout};
use tokio_util::sync::CancellationToken;
use crate::command::AcpProcessSpec;
use crate::error::AcpError;
use crate::transport::{SandboxAcpTransport, TransportState};
pub type AcpNaturalCompletionCallback = Arc<dyn Fn() -> bool + Send + Sync>;
pub type AcpSteerPromptCallback = Arc<dyn Fn(String, Option<Actor>) + Send + Sync>;
const CANCEL_GRACE_PERIOD: Duration = Duration::from_millis(500);
#[derive(Default)]
struct AcpControlState {
queue: VecDeque<SteeringMessage>,
waiting_for_steer: bool,
interrupt_requested: bool,
}
#[derive(Clone, Default)]
pub struct AcpControlHandle {
state: Arc<Mutex<AcpControlState>>,
notify: Arc<Notify>,
}
impl AcpControlHandle {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn enqueue_bounded(&self, item: SteeringMessage, cap: usize) -> Option<SteeringMessage> {
self.push_bounded(item, cap, false)
}
pub fn interrupt(&self) {
{
let mut state = self.state.lock().expect("ACP control lock poisoned");
if state.queue.is_empty() {
state.waiting_for_steer = true;
}
state.interrupt_requested = true;
}
self.notify.notify_one();
}
pub fn interrupt_then_enqueue_bounded(
&self,
item: SteeringMessage,
cap: usize,
) -> Option<SteeringMessage> {
self.push_bounded(item, cap, true)
}
fn push_bounded(
&self,
item: SteeringMessage,
cap: usize,
request_interrupt: bool,
) -> Option<SteeringMessage> {
let evicted = {
let mut state = self.state.lock().expect("ACP control lock poisoned");
let evicted = if state.queue.len() >= cap {
state.queue.pop_front()
} else {
None
};
state.waiting_for_steer = false;
if request_interrupt {
state.interrupt_requested = true;
}
state.queue.push_back(item);
evicted
};
self.notify.notify_one();
evicted
}
#[must_use]
pub fn has_pending_control_work(&self) -> bool {
let state = self.state.lock().expect("ACP control lock poisoned");
!state.queue.is_empty() || state.waiting_for_steer || state.interrupt_requested
}
#[cfg(test)]
#[must_use]
pub fn queue_len(&self) -> usize {
self.state
.lock()
.expect("ACP control lock poisoned")
.queue
.len()
}
fn pop_steer(&self) -> Option<SteeringMessage> {
let item = {
let mut state = self.state.lock().expect("ACP control lock poisoned");
let item = state.queue.pop_front();
if item.is_some() {
state.waiting_for_steer = false;
}
item
};
if item.is_some() {
self.notify.notify_one();
}
item
}
fn take_interrupt_requested(&self) -> bool {
let mut state = self.state.lock().expect("ACP control lock poisoned");
let requested = state.interrupt_requested;
state.interrupt_requested = false;
requested
}
fn should_wait_for_steer(&self) -> bool {
let state = self.state.lock().expect("ACP control lock poisoned");
state.waiting_for_steer && state.queue.is_empty()
}
fn notified(&self) -> Notified<'_> {
self.notify.notified()
}
}
#[derive(Default)]
pub struct AcpLiveControl {
pub handle: AcpControlHandle,
pub on_natural_completion: Option<AcpNaturalCompletionCallback>,
pub on_steer_prompt: Option<AcpSteerPromptCallback>,
}
impl AcpLiveControl {
#[must_use]
pub fn new(handle: AcpControlHandle) -> Self {
Self {
handle,
on_natural_completion: None,
on_steer_prompt: None,
}
}
}
pub struct AcpRunRequest {
pub command: AcpProcessSpec,
pub prompt: String,
pub cwd: String,
pub timeout_ms: Option<u64>,
pub env: HashMap<String, String>,
pub sandbox: Arc<RunSandbox>,
pub cancel_token: CancellationToken,
pub on_activity: Option<Arc<dyn Fn() + Send + Sync>>,
pub live_control: Option<AcpLiveControl>,
}
#[derive(Debug)]
pub struct AcpRunResult {
pub text: String,
pub stop_reason: StopReason,
pub stderr: String,
pub duration_ms: u64,
}
pub async fn run_acp_turn(request: AcpRunRequest) -> Result<AcpRunResult, AcpError> {
let AcpRunRequest {
command,
prompt,
cwd,
timeout_ms,
env,
sandbox,
cancel_token,
on_activity,
live_control,
} = request;
let live_control = live_control.unwrap_or_default();
let start = std::time::Instant::now();
let state = TransportState::new();
let read_cancel_token = cancel_token.clone();
let run_cancel_token = cancel_token.clone();
let permission_cancel_token = cancel_token.clone();
let transport = SandboxAcpTransport::new(command, cwd.clone(), env, sandbox, state.clone());
let run = Client
.builder()
.name("fabro")
.on_receive_request(
async move |request: RequestPermissionRequest, responder, _connection| {
let outcome = if permission_cancel_token.is_cancelled() {
RequestPermissionOutcome::Cancelled
} else {
select_permission_outcome(&request)
};
responder.respond(RequestPermissionResponse::new(outcome))
},
agent_client_protocol::on_receive_request!(),
)
.connect_with(transport, async move |cx| {
cx.send_request(InitializeRequest::new(ProtocolVersion::V1))
.block_task()
.await?;
cx.build_session(&cwd)
.block_task()
.run_until(async |mut session| {
session.send_prompt(prompt)?;
read_live_session(
&mut session,
&read_cancel_token,
&live_control.handle,
live_control.on_natural_completion.as_ref(),
live_control.on_steer_prompt.as_ref(),
on_activity.as_ref(),
)
.await
})
.await
});
let cancel_deadline_token = cancel_token.clone();
let run_outcome = async {
match timeout_ms {
Some(timeout_ms) => {
if let Ok(result) = timeout(Duration::from_millis(timeout_ms), run).await {
Ok(result)
} else {
state.terminate().await?;
if run_cancel_token.is_cancelled() {
return Err(AcpError::Cancelled);
}
Err(AcpError::TimedOut {
exec_output_tail: state.exec_output_tail().await,
})
}
}
None => Ok(run.await),
}
};
let outcome = tokio::select! {
result = run_outcome => result?,
() = async {
cancel_deadline_token.cancelled().await;
sleep(Duration::from_millis(500)).await;
} => {
state.terminate().await?;
return Err(AcpError::Cancelled);
}
};
let (text, stop_reason) = match outcome {
Ok(result) => result,
Err(_) if run_cancel_token.is_cancelled() => {
state.terminate().await?;
return Err(AcpError::Cancelled);
}
Err(error) => {
state.terminate().await?;
if let Some(startup_error) = state.take_startup_error().await {
return Err(AcpError::Sandbox(startup_error));
}
if let Some(process_exit) = state.take_process_exit().await {
return Err(AcpError::ProcessExited(process_exit));
}
return Err(map_protocol_error(error));
}
};
match stop_reason {
StopReason::EndTurn | StopReason::Refusal => {}
StopReason::Cancelled => {
state.terminate().await?;
return Err(AcpError::Cancelled);
}
_ => {
state.terminate().await?;
return Err(AcpError::StopReason {
stop_reason: render_stop_reason(&stop_reason),
text,
});
}
}
state.terminate().await?;
let stderr = state.stderr_tail().await;
Ok(AcpRunResult {
text,
stop_reason,
stderr,
duration_ms: elapsed_ms(start),
})
}
fn map_protocol_error(error: ProtocolError) -> AcpError {
AcpError::Protocol(error)
}
fn select_permission_outcome(request: &RequestPermissionRequest) -> RequestPermissionOutcome {
let selected = request
.options
.iter()
.find(|option| option.kind == PermissionOptionKind::AllowAlways)
.or_else(|| {
request
.options
.iter()
.find(|option| option.kind == PermissionOptionKind::AllowOnce)
})
.or_else(|| {
request.options.iter().find(|option| {
!matches!(
option.kind,
PermissionOptionKind::RejectOnce | PermissionOptionKind::RejectAlways
)
})
});
selected.map_or(RequestPermissionOutcome::Cancelled, |option| {
RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(option.option_id.clone()))
})
}
async fn read_live_session(
session: &mut ActiveSession<'_, Agent>,
cancel_token: &CancellationToken,
control_handle: &AcpControlHandle,
on_natural_completion: Option<&AcpNaturalCompletionCallback>,
on_steer_prompt: Option<&AcpSteerPromptCallback>,
on_activity: Option<&Arc<dyn Fn() + Send + Sync>>,
) -> Result<(String, StopReason), ProtocolError> {
let mut text = String::new();
let mut prompt_active = true;
let mut cancel_sent = false;
let mut last_stop_reason: Option<StopReason> = None;
loop {
if !prompt_active {
if let Some(message) = control_handle.pop_steer() {
if let Some(on_steer_prompt) = on_steer_prompt {
on_steer_prompt(message.text().to_string(), message.actor().cloned());
}
session.send_prompt(message.text().to_string())?;
prompt_active = true;
cancel_sent = false;
continue;
}
if control_handle.take_interrupt_requested() {
continue;
}
if control_handle.should_wait_for_steer() {
let notified = control_handle.notified();
tokio::select! {
() = cancel_token.cancelled() => {
return Ok((text, StopReason::Cancelled));
}
() = notified => {}
}
continue;
}
let stop_reason = last_stop_reason.unwrap_or(StopReason::EndTurn);
if matches!(stop_reason, StopReason::EndTurn | StopReason::Refusal)
&& on_natural_completion.is_some_and(|callback| !callback())
{
// The lease reports pending control work but our flags didn't
// observe it yet. Wait on a notify so we don't spin.
let notified = control_handle.notified();
tokio::select! {
() = cancel_token.cancelled() => {
return Ok((text, StopReason::Cancelled));
}
() = notified => {}
}
continue;
}
return Ok((text, stop_reason));
}
if control_handle.take_interrupt_requested() && !cancel_sent {
cancel_sent = true;
send_cancel_notification(session)?;
}
let control_notified = control_handle.notified();
tokio::select! {
update = session.read_update() => {
if let Some(on_activity) = on_activity {
on_activity();
}
match update? {
SessionMessage::SessionMessage(dispatch) => {
MatchDispatch::new(dispatch)
.if_notification(async |notification: SessionNotification| {
if let SessionUpdate::AgentMessageChunk(ContentChunk {
content: ContentBlock::Text(text_chunk),
..
}) = notification.update {
text.push_str(&text_chunk.text);
}
Ok(())
})
.await
.otherwise_ignore()?;
}
SessionMessage::StopReason(stop_reason) => {
prompt_active = false;
cancel_sent = false;
last_stop_reason = Some(stop_reason);
}
_ => {}
}
}
() = control_notified => {
if control_handle.take_interrupt_requested() && !cancel_sent {
cancel_sent = true;
send_cancel_notification(session)?;
}
}
() = cancel_token.cancelled(), if !cancel_sent => {
cancel_sent = true;
send_cancel_notification(session)?;
}
() = sleep(CANCEL_GRACE_PERIOD), if cancel_sent => {
return Ok((text, StopReason::Cancelled));
}
}
}
}
fn send_cancel_notification(session: &ActiveSession<'_, Agent>) -> Result<(), ProtocolError> {
session
.connection()
.send_notification_to(Agent, CancelNotification::new(session.session_id().clone()))
}
#[must_use]
pub fn render_stop_reason(stop_reason: &StopReason) -> String {
serde_json::to_value(stop_reason)
.ok()
.and_then(|value| value.as_str().map(str::to_string))
.unwrap_or_else(|| format!("{stop_reason:?}"))
}
#[cfg(test)]
mod tests {
use agent_client_protocol::schema::SessionNotification;
#[test]
fn codex_usage_update_session_notification_deserializes() {
let notification = serde_json::json!({
"sessionId": "session-1",
"update": {
"sessionUpdate": "usage_update",
"used": 26128,
"size": 258_400
}
});
serde_json::from_value::<SessionNotification>(notification)
.expect("Codex ACP usage_update notifications should be ignored, not fatal");
}
}

View file

@ -1,274 +0,0 @@
use agent_client_protocol::schema::{
ContentBlock, ContentChunk, SessionNotification, SessionUpdate,
};
use serde_json::json;
pub const SESSION_ID: &str = "sess-1";
pub fn agent_message_chunk(session_id: &str, text: &str) -> SessionNotification {
SessionNotification::new(
session_id.to_string(),
SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::from(text.to_string()))),
)
}
pub fn agent_message_chunk_json(session_id: &str, text: &str) -> serde_json::Value {
json!({
"jsonrpc": "2.0",
"method": "session/update",
"params": agent_message_chunk(session_id, text),
})
}
pub fn fake_acp_agent_script() -> &'static str {
r#"
import json
import os
import signal
import sys
import time
methods = []
session_id = "sess-1"
prompt_count = 0
if os.environ.get("ACP_PID_RECORD"):
with open(os.environ["ACP_PID_RECORD"], "w", encoding="utf-8") as record:
record.write(str(os.getpid()))
if os.environ.get("ACP_ENV_RECORD"):
keys = [
key.strip()
for key in os.environ.get(
"ACP_ENV_RECORD_KEYS",
"ANTHROPIC_API_KEY,OPENAI_API_KEY,GEMINI_API_KEY",
).split(",")
if key.strip()
]
snapshot = {key: os.environ[key] for key in keys if key in os.environ}
with open(os.environ["ACP_ENV_RECORD"], "w", encoding="utf-8") as record:
record.write(json.dumps(snapshot, sort_keys=True))
def handle_sigterm(signum, frame):
if os.environ.get("ACP_LINGER_TERMINATED"):
with open(os.environ["ACP_LINGER_TERMINATED"], "w", encoding="utf-8") as record:
record.write("terminated\n")
sys.exit(0)
signal.signal(signal.SIGTERM, handle_sigterm)
def send(message):
print(json.dumps(message), flush=True)
def respond(message, result):
send({"jsonrpc": "2.0", "id": message["id"], "result": result})
def record_methods():
if os.environ.get("ACP_RECORD"):
with open(os.environ["ACP_RECORD"], "w", encoding="utf-8") as record:
record.write("\n".join(methods) + "\n")
def first_prompt_text(message):
prompt = message.get("params", {}).get("prompt", [])
if not prompt:
return ""
first = prompt[0]
if isinstance(first, str):
return first
return first.get("text", "")
for line in sys.stdin:
message = json.loads(line)
method = message.get("method")
methods.append(method)
if method == "initialize":
if os.environ.get("ACP_MODE") == "slow_initialize":
time.sleep(60)
respond(message, {"protocolVersion": 1, "agentCapabilities": {}})
elif method == "session/new":
if os.environ.get("ACP_SESSION_NEW_PARAMS"):
with open(os.environ["ACP_SESSION_NEW_PARAMS"], "w", encoding="utf-8") as record:
record.write(json.dumps(message.get("params", {}), separators=(",", ":")))
respond(message, {"sessionId": session_id})
elif method == "session/prompt":
prompt_count += 1
if os.environ.get("ACP_PROMPT_RECORD"):
with open(os.environ["ACP_PROMPT_RECORD"], "w", encoding="utf-8") as record:
record.write(json.dumps(message.get("params", {})))
mode = os.environ.get("ACP_MODE", "normal")
if mode == "timeout":
time.sleep(60)
if mode == "malformed":
print("malformed json", file=sys.stderr, flush=True)
print("{not-json", flush=True)
break
if mode == "early_exit":
print("early boom", file=sys.stderr, flush=True)
sys.exit(2)
if mode == "write_file":
path = os.environ.get("ACP_WRITE_PATH", "hello.txt")
parent = os.path.dirname(path)
if parent:
os.makedirs(parent, exist_ok=True)
with open(path, "w", encoding="utf-8") as file:
file.write(os.environ.get("ACP_WRITE_CONTENT", "hello from sandbox\n"))
if os.environ.get("ACP_WRITE_MTIME_EPOCH"):
mtime = float(os.environ["ACP_WRITE_MTIME_EPOCH"])
os.utime(path, (mtime, mtime))
if mode == "cancel":
send({
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": session_id,
"update": {
"sessionUpdate": "agent_message_chunk",
"content": {"type": "text", "text": "waiting for cancellation"}
}
}
})
for cancel_line in sys.stdin:
cancel_message = json.loads(cancel_line)
if cancel_message.get("method") == "session/cancel":
with open(os.environ["ACP_CANCEL_RECORD"], "w", encoding="utf-8") as record:
record.write("session/cancel\n")
respond(message, {"stopReason": "cancelled"})
sys.exit(0)
if mode == "ignore_cancel":
send({
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": session_id,
"update": {
"sessionUpdate": "agent_message_chunk",
"content": {"type": "text", "text": "waiting for ignored cancellation"}
}
}
})
for control_line in sys.stdin:
control_message = json.loads(control_line)
methods.append(control_message.get("method"))
if control_message.get("method") == "session/cancel":
if os.environ.get("ACP_CANCEL_RECORD"):
with open(os.environ["ACP_CANCEL_RECORD"], "w", encoding="utf-8") as record:
record.write("session/cancel\n")
record_methods()
time.sleep(60)
if mode == "permission":
send({
"jsonrpc": "2.0",
"id": "permission-1",
"method": "session/request_permission",
"params": {
"sessionId": session_id,
"toolCall": {"toolCallId": "tool-1"},
"options": [
{"optionId": "reject", "name": "Reject", "kind": "reject_once"},
{"optionId": "once", "name": "Allow once", "kind": "allow_once"},
{"optionId": "always", "name": "Allow always", "kind": "allow_always"}
]
}
})
permission_response = json.loads(sys.stdin.readline())
with open(os.environ["ACP_PERMISSION"], "w", encoding="utf-8") as permission:
permission.write(json.dumps(permission_response.get("result", {}), separators=(",", ":")))
if mode == "interrupt_steer":
if prompt_count == 1:
send({
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": session_id,
"update": {
"sessionUpdate": "agent_message_chunk",
"content": {"type": "text", "text": "interrupted "}
}
}
})
for control_line in sys.stdin:
control_message = json.loads(control_line)
methods.append(control_message.get("method"))
if control_message.get("method") == "session/cancel":
if os.environ.get("ACP_CANCEL_RECORD"):
with open(os.environ["ACP_CANCEL_RECORD"], "w", encoding="utf-8") as record:
record.write("session/cancel\n")
respond(message, {"stopReason": "cancelled"})
break
continue
if os.environ.get("ACP_STEER_PROMPT_RECORD"):
with open(os.environ["ACP_STEER_PROMPT_RECORD"], "w", encoding="utf-8") as record:
record.write(json.dumps(message.get("params", {}), separators=(",", ":")))
send({
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": session_id,
"update": {
"sessionUpdate": "agent_message_chunk",
"content": {"type": "text", "text": "steered:" + first_prompt_text(message)}
}
}
})
record_methods()
respond(message, {"stopReason": "end_turn"})
break
if mode == "steer":
if prompt_count == 1:
send({
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": session_id,
"update": {
"sessionUpdate": "agent_message_chunk",
"content": {"type": "text", "text": "initial "}
}
}
})
respond(message, {"stopReason": "end_turn"})
continue
if os.environ.get("ACP_STEER_PROMPT_RECORD"):
with open(os.environ["ACP_STEER_PROMPT_RECORD"], "w", encoding="utf-8") as record:
record.write(json.dumps(message.get("params", {}), separators=(",", ":")))
send({
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": session_id,
"update": {
"sessionUpdate": "agent_message_chunk",
"content": {"type": "text", "text": "steered:" + first_prompt_text(message)}
}
}
})
record_methods()
respond(message, {"stopReason": "end_turn"})
break
for text in ["hello ", "from acp"]:
send({
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": session_id,
"update": {
"sessionUpdate": "agent_message_chunk",
"content": {"type": "text", "text": text}
}
}
})
record_methods()
respond(message, {"stopReason": os.environ.get("ACP_STOP_REASON", "end_turn")})
if mode == "linger_after_response":
while True:
time.sleep(1)
break
else:
send({
"jsonrpc": "2.0",
"id": message.get("id"),
"error": {"code": -32601, "message": "method not found"}
})
"#
}

View file

@ -1,193 +0,0 @@
use std::collections::HashMap;
use std::io::Result as IoResult;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use agent_client_protocol::util::internal_error;
use agent_client_protocol::{
Agent, Client, ConnectTo, Error as ProtocolError, Lines, Result as AcpProtocolResult,
};
use fabro_sandbox::{
DEFAULT_EXEC_OUTPUT_TAIL_BYTES, Error as SandboxError, Result as SandboxResult, RunSandbox,
StderrTail, StdioProcessHandle, Termination, command_termination, program_exit_code,
};
use fabro_types::ExecOutputTail;
use futures::io::BufReader;
use futures::sink::unfold;
use futures::{AsyncBufReadExt, AsyncWriteExt, Stream};
use tokio::sync::Mutex as TokioMutex;
use tokio::time::timeout;
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
use crate::command::AcpProcessSpec;
use crate::error::AcpProcessExit;
const CLEAN_EXIT_PROTOCOL_GRACE: Duration = Duration::from_millis(500);
#[derive(Clone)]
pub(crate) struct TransportState {
handle: Arc<TokioMutex<Option<Arc<dyn StdioProcessHandle>>>>,
stderr: Arc<TokioMutex<Option<StderrTail>>>,
startup_error: Arc<TokioMutex<Option<SandboxError>>>,
process_exit: Arc<TokioMutex<Option<AcpProcessExit>>>,
}
impl TransportState {
pub(crate) fn new() -> Self {
Self {
handle: Arc::new(TokioMutex::new(None)),
stderr: Arc::new(TokioMutex::new(None)),
startup_error: Arc::new(TokioMutex::new(None)),
process_exit: Arc::new(TokioMutex::new(None)),
}
}
async fn set_process(&self, handle: Arc<dyn StdioProcessHandle>, stderr: StderrTail) {
*self.handle.lock().await = Some(handle);
*self.stderr.lock().await = Some(stderr);
}
async fn set_startup_error(&self, error: SandboxError) {
*self.startup_error.lock().await = Some(error);
}
async fn set_process_exit(
&self,
termination: Termination,
exit_code: Option<i32>,
stderr: &str,
) {
*self.process_exit.lock().await = Some(AcpProcessExit {
termination: command_termination(termination),
exit_code: program_exit_code(termination, exit_code),
exec_output_tail: redacted_stderr_tail(stderr),
});
}
pub(crate) async fn take_startup_error(&self) -> Option<SandboxError> {
self.startup_error.lock().await.take()
}
pub(crate) async fn take_process_exit(&self) -> Option<AcpProcessExit> {
self.process_exit.lock().await.take()
}
pub(crate) async fn terminate(&self) -> SandboxResult<()> {
if let Some(handle) = self.handle.lock().await.as_ref().cloned() {
handle.terminate().await;
}
Ok(())
}
pub(crate) async fn stderr_tail(&self) -> String {
if let Some(stderr) = self.stderr.lock().await.as_ref().cloned() {
return stderr.to_string_lossy();
}
String::new()
}
pub(crate) async fn exec_output_tail(&self) -> Option<ExecOutputTail> {
let stderr = self.stderr_tail().await;
redacted_stderr_tail(&stderr)
}
}
pub(crate) struct SandboxAcpTransport {
command: AcpProcessSpec,
cwd: String,
env: HashMap<String, String>,
sandbox: Arc<RunSandbox>,
state: TransportState,
}
impl SandboxAcpTransport {
pub(crate) fn new(
command: AcpProcessSpec,
cwd: String,
env: HashMap<String, String>,
sandbox: Arc<RunSandbox>,
state: TransportState,
) -> Self {
Self {
command,
cwd,
env,
sandbox,
state,
}
}
}
impl ConnectTo<Client> for SandboxAcpTransport {
async fn connect_to(self, client: impl ConnectTo<Agent>) -> AcpProtocolResult<()> {
let mut env = self.command.env().clone();
env.extend(self.env);
let process = match self
.sandbox
.spawn_stdio_process(
&self.command.to_shell_command(),
Some(&self.cwd),
Some(&env),
)
.await
{
Ok(process) => process,
Err(error) => {
self.state.set_startup_error(error).await;
return Err(internal_error("ACP process failed to start"));
}
};
let handle: Arc<dyn StdioProcessHandle> = Arc::from(process.handle);
let stderr = process.stderr_tail.clone();
self.state
.set_process(Arc::clone(&handle), stderr.clone())
.await;
let incoming_lines = Box::pin(BufReader::new(process.stdout.compat()).lines())
as Pin<Box<dyn Stream<Item = IoResult<String>> + Send>>;
let outgoing_sink = Box::pin(unfold(
process.stdin.compat_write(),
async move |mut writer, line: String| {
let mut bytes = line.into_bytes();
bytes.push(b'\n');
writer.write_all(&bytes).await?;
Ok::<_, std::io::Error>(writer)
},
));
let mut protocol = Box::pin(agent_client_protocol::ConnectTo::<Client>::connect_to(
Lines::new(outgoing_sink, incoming_lines),
client,
));
tokio::select! {
result = &mut protocol => {
handle.terminate().await;
let _ = timeout(Duration::from_millis(500), handle.wait()).await;
result
}
(termination, exit_code) = handle.wait() => {
let stderr = stderr.to_string_lossy();
if termination == Termination::Exited && exit_code == Some(0) {
// Stdio agents commonly exit immediately after writing their final response.
// Process wait can observe that exit before the line reader drains stdout.
if let Ok(result) = timeout(CLEAN_EXIT_PROTOCOL_GRACE, &mut protocol).await {
return result;
}
}
self.state.set_process_exit(termination, exit_code, &stderr).await;
Err(process_exited_before_protocol_completed())
}
}
}
}
fn redacted_stderr_tail(stderr: &str) -> Option<ExecOutputTail> {
fabro_sandbox::redacted_output_tail("", stderr, DEFAULT_EXEC_OUTPUT_TAIL_BYTES)
}
fn process_exited_before_protocol_completed() -> ProtocolError {
internal_error("ACP process exited before protocol completed")
}

View file

@ -1,841 +0,0 @@
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use agent_client_protocol::schema::StopReason;
use fabro_acp::{
AcpControlHandle, AcpError, AcpLiveControl, AcpProcessSpec, AcpRunRequest, AcpRunResult,
run_acp_turn,
};
use fabro_sandbox::test_support::{MockSandbox, MockStdioProcess};
use fabro_sandbox::{RunSandbox, local_sandbox};
use fabro_util::error::collect_chain;
use fabro_util::shell;
use pebble_coding_agent::SteeringMessage;
use tokio::fs::{read_to_string, write};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, DuplexStream};
use tokio::process::Command;
use tokio::sync::Notify;
use tokio::time::{sleep, timeout};
use tokio_util::sync::CancellationToken;
const ACP_TEST_TIMEOUT_MS: u64 = 30_000;
#[allow(
unused,
unreachable_pub,
reason = "integration test imports the shared test fixture source as a private module"
)]
#[path = "../src/test_support.rs"]
mod test_support;
use test_support::fake_acp_agent_script;
#[tokio::test]
async fn stdio_spawn_failure_returns_sandbox_error() {
const SANDBOX_FAILURE: &str = "ACP backend requires bidirectional stdio; the Daytona sandbox provider does not support it yet";
let command = AcpProcessSpec::from_command_attr("fake-acp-agent").expect("parse ACP command");
let mut sandbox = MockSandbox::linux();
sandbox.stdio_process_error = Some(SANDBOX_FAILURE.to_string());
let sandbox = sandbox.sandbox();
let result = run_acp_turn(AcpRunRequest {
command,
prompt: "hello".to_string(),
cwd: "/workspace".to_string(),
timeout_ms: Some(ACP_TEST_TIMEOUT_MS),
env: HashMap::new(),
sandbox,
cancel_token: CancellationToken::new(),
on_activity: None,
live_control: None,
})
.await;
let Err(error) = result else {
panic!("stdio spawn failure should fail");
};
assert!(
matches!(error, AcpError::Sandbox(_)),
"expected sandbox error, got {error:?}"
);
let chain = collect_chain(&error);
assert!(
chain.iter().any(|cause| cause == SANDBOX_FAILURE),
"cause chain should contain sandbox failure, got: {chain:?}"
);
}
#[tokio::test]
async fn clean_stdio_exit_after_final_response_completes_turn() {
let sandbox = MockSandbox {
stdio_process: Some(mock_acp_stdio_process("end_turn")),
..MockSandbox::linux()
}
.sandbox();
let command = AcpProcessSpec::from_command_attr("mock-acp-agent").expect("parse ACP command");
let result = run_acp_turn(AcpRunRequest {
command,
prompt: "hello".to_string(),
cwd: "/workspace".to_string(),
timeout_ms: Some(ACP_TEST_TIMEOUT_MS),
env: HashMap::new(),
sandbox,
cancel_token: CancellationToken::new(),
on_activity: None,
live_control: None,
})
.await
.expect("clean ACP process exit should not preempt final protocol response");
assert_eq!(result.text, "hello from acp");
assert_eq!(result.stop_reason, StopReason::EndTurn);
}
#[tokio::test]
async fn session_lifecycle_initializes_sends_prompt_and_aggregates_text() {
let tempdir = tempfile::tempdir().expect("create tempdir");
let script_path = tempdir.path().join("fake_acp_agent.py");
let record_path = tempdir.path().join("methods.txt");
write(&script_path, fake_acp_agent_script())
.await
.expect("write fake ACP agent");
let raw_command = format!(
"python3 {}",
shell::shell_quote(&script_path.to_string_lossy())
);
let command = AcpProcessSpec::from_command_attr(&raw_command).expect("parse ACP command");
let sandbox: Arc<RunSandbox> = Arc::new(
local_sandbox(tempdir.path().to_path_buf())
.await
.expect("local sandbox should be created"),
);
let result = run_acp_turn(AcpRunRequest {
command,
prompt: "hello".to_string(),
cwd: tempdir.path().to_string_lossy().into_owned(),
timeout_ms: Some(ACP_TEST_TIMEOUT_MS),
env: HashMap::from([(
"ACP_RECORD".to_string(),
record_path.to_string_lossy().into_owned(),
)]),
sandbox,
cancel_token: CancellationToken::new(),
on_activity: None,
live_control: None,
})
.await
.expect("run ACP turn");
assert_eq!(result.text, "hello from acp");
assert_eq!(result.stop_reason, StopReason::EndTurn);
assert_eq!(
read_to_string(record_path)
.await
.expect("read method record"),
"initialize\nsession/new\nsession/prompt\n"
);
}
#[tokio::test]
async fn steering_sends_followup_session_prompt_over_acp() {
let tempdir = tempfile::tempdir().expect("create tempdir");
let script_path = tempdir.path().join("fake_acp_agent.py");
let record_path = tempdir.path().join("methods.txt");
let steer_prompt_path = tempdir.path().join("steer_prompt.json");
write(&script_path, fake_acp_agent_script())
.await
.expect("write fake ACP agent");
let raw_command = format!(
"python3 {}",
shell::shell_quote(&script_path.to_string_lossy())
);
let command = AcpProcessSpec::from_command_attr(&raw_command).expect("parse ACP command");
let sandbox: Arc<RunSandbox> = Arc::new(
local_sandbox(tempdir.path().to_path_buf())
.await
.expect("local sandbox should be created"),
);
let control_handle = AcpControlHandle::new();
let handle_for_activity = control_handle.clone();
let queued = Arc::new(AtomicBool::new(false));
let queued_for_activity = Arc::clone(&queued);
let result = run_acp_turn(AcpRunRequest {
command,
prompt: "hello".to_string(),
cwd: tempdir.path().to_string_lossy().into_owned(),
timeout_ms: Some(ACP_TEST_TIMEOUT_MS),
env: HashMap::from([
("ACP_MODE".to_string(), "steer".to_string()),
(
"ACP_RECORD".to_string(),
record_path.to_string_lossy().into_owned(),
),
(
"ACP_STEER_PROMPT_RECORD".to_string(),
steer_prompt_path.to_string_lossy().into_owned(),
),
]),
sandbox,
cancel_token: CancellationToken::new(),
on_activity: Some(Arc::new(move || {
if !queued_for_activity.swap(true, Ordering::AcqRel) {
handle_for_activity.enqueue_bounded(SteeringMessage::new("please revise"), 32);
}
})),
live_control: Some(AcpLiveControl::new(control_handle)),
})
.await
.expect("run ACP turn with steering");
assert_eq!(result.text, "initial steered:please revise");
assert_eq!(
read_to_string(record_path)
.await
.expect("read method record"),
"initialize\nsession/new\nsession/prompt\nsession/prompt\n"
);
assert!(
read_to_string(steer_prompt_path)
.await
.expect("read steer prompt")
.contains("please revise")
);
}
#[tokio::test]
async fn interrupt_then_steer_sends_cancel_then_followup_session_prompt_over_acp() {
let tempdir = tempfile::tempdir().expect("create tempdir");
let script_path = tempdir.path().join("fake_acp_agent.py");
let record_path = tempdir.path().join("methods.txt");
let cancel_path = tempdir.path().join("cancel.txt");
let steer_prompt_path = tempdir.path().join("steer_prompt.json");
write(&script_path, fake_acp_agent_script())
.await
.expect("write fake ACP agent");
let raw_command = format!(
"python3 {}",
shell::shell_quote(&script_path.to_string_lossy())
);
let command = AcpProcessSpec::from_command_attr(&raw_command).expect("parse ACP command");
let sandbox: Arc<RunSandbox> = Arc::new(
local_sandbox(tempdir.path().to_path_buf())
.await
.expect("local sandbox should be created"),
);
let control_handle = AcpControlHandle::new();
let handle_for_activity = control_handle.clone();
let queued = Arc::new(AtomicBool::new(false));
let queued_for_activity = Arc::clone(&queued);
let result = run_acp_turn(AcpRunRequest {
command,
prompt: "hello".to_string(),
cwd: tempdir.path().to_string_lossy().into_owned(),
timeout_ms: Some(ACP_TEST_TIMEOUT_MS),
env: HashMap::from([
("ACP_MODE".to_string(), "interrupt_steer".to_string()),
("LC_ALL".to_string(), "C".to_string()),
(
"ACP_RECORD".to_string(),
record_path.to_string_lossy().into_owned(),
),
(
"ACP_CANCEL_RECORD".to_string(),
cancel_path.to_string_lossy().into_owned(),
),
(
"ACP_STEER_PROMPT_RECORD".to_string(),
steer_prompt_path.to_string_lossy().into_owned(),
),
]),
sandbox,
cancel_token: CancellationToken::new(),
on_activity: Some(Arc::new(move || {
if !queued_for_activity.swap(true, Ordering::AcqRel) {
handle_for_activity
.interrupt_then_enqueue_bounded(SteeringMessage::new("please revise"), 32);
}
})),
live_control: Some(AcpLiveControl::new(control_handle)),
})
.await
.expect("run ACP turn with interrupt and steering");
assert_eq!(result.text, "interrupted steered:please revise");
assert_eq!(
read_to_string(record_path)
.await
.expect("read method record"),
"initialize\nsession/new\nsession/prompt\nsession/cancel\nsession/prompt\n"
);
assert_eq!(
read_to_string(cancel_path)
.await
.expect("read cancel record"),
"session/cancel\n"
);
assert!(
read_to_string(steer_prompt_path)
.await
.expect("read steer prompt")
.contains("please revise")
);
}
#[tokio::test]
async fn inline_interrupt_terminates_agent_that_ignores_cancel() {
let tempdir = tempfile::tempdir().expect("create tempdir");
let script_path = tempdir.path().join("fake_acp_agent.py");
let record_path = tempdir.path().join("methods.txt");
let cancel_path = tempdir.path().join("cancel.txt");
write(&script_path, fake_acp_agent_script())
.await
.expect("write fake ACP agent");
let raw_command = format!(
"python3 {}",
shell::shell_quote(&script_path.to_string_lossy())
);
let command = AcpProcessSpec::from_command_attr(&raw_command).expect("parse ACP command");
let sandbox: Arc<RunSandbox> = Arc::new(
local_sandbox(tempdir.path().to_path_buf())
.await
.expect("local sandbox should be created"),
);
let control_handle = AcpControlHandle::new();
let handle_for_activity = control_handle.clone();
let interrupted = Arc::new(AtomicBool::new(false));
let interrupted_for_activity = Arc::clone(&interrupted);
let err = run_acp_turn(AcpRunRequest {
command,
prompt: "hello".to_string(),
cwd: tempdir.path().to_string_lossy().into_owned(),
timeout_ms: Some(ACP_TEST_TIMEOUT_MS),
env: HashMap::from([
("ACP_MODE".to_string(), "ignore_cancel".to_string()),
("LC_ALL".to_string(), "C".to_string()),
(
"ACP_RECORD".to_string(),
record_path.to_string_lossy().into_owned(),
),
(
"ACP_CANCEL_RECORD".to_string(),
cancel_path.to_string_lossy().into_owned(),
),
]),
sandbox,
cancel_token: CancellationToken::new(),
on_activity: Some(Arc::new(move || {
if !interrupted_for_activity.swap(true, Ordering::AcqRel) {
handle_for_activity.interrupt();
}
})),
live_control: Some(AcpLiveControl::new(control_handle)),
})
.await
.expect_err("ignored inline interrupt should terminate as cancelled");
assert!(matches!(err, AcpError::Cancelled), "{err:?}");
assert_eq!(
read_to_string(record_path)
.await
.expect("read method record"),
"initialize\nsession/new\nsession/prompt\nsession/cancel\n"
);
assert_eq!(
read_to_string(cancel_path)
.await
.expect("read cancel record"),
"session/cancel\n"
);
}
#[tokio::test]
async fn permission_request_selects_allow_always() {
let tempdir = tempfile::tempdir().expect("create tempdir");
let permission_path = tempdir.path().join("permission.json");
let result = run_fake_agent(
tempdir.path(),
HashMap::from([
("ACP_MODE".to_string(), "permission".to_string()),
(
"ACP_PERMISSION".to_string(),
permission_path.to_string_lossy().into_owned(),
),
]),
Some(ACP_TEST_TIMEOUT_MS),
CancellationToken::new(),
)
.await
.expect("run ACP turn");
assert_eq!(result.text, "hello from acp");
let permission = read_to_string(permission_path)
.await
.expect("read permission record");
assert!(permission.contains(r#""outcome":"selected""#));
assert!(permission.contains(r#""optionId":"always""#));
}
#[tokio::test]
async fn runs_inside_sandbox_and_uses_requested_cwd() {
let tempdir = tempfile::tempdir().expect("create tempdir");
let cwd_path = tempdir.path().join("session_new.json");
let result = run_fake_agent(
tempdir.path(),
HashMap::from([
("ACP_MODE".to_string(), "write_file".to_string()),
(
"ACP_SESSION_NEW_PARAMS".to_string(),
cwd_path.to_string_lossy().into_owned(),
),
]),
Some(ACP_TEST_TIMEOUT_MS),
CancellationToken::new(),
)
.await
.expect("run ACP turn");
assert_eq!(result.text, "hello from acp");
assert_eq!(
read_to_string(tempdir.path().join("hello.txt"))
.await
.expect("read sandbox output file"),
"hello from sandbox\n"
);
assert!(
read_to_string(cwd_path)
.await
.expect("read session/new params")
.contains(&tempdir.path().to_string_lossy().into_owned())
);
}
#[tokio::test]
async fn cancellation_sends_session_cancel_and_returns_cancelled() {
let tempdir = tempfile::tempdir().expect("create tempdir");
let cancel_path = tempdir.path().join("cancel.txt");
let tempdir_path = tempdir.path().to_path_buf();
let cancel_path_for_task = cancel_path.clone();
let cancel_token = CancellationToken::new();
let cancel_for_task = cancel_token.clone();
let prompt_started = Arc::new(Notify::new());
let prompt_started_for_task = prompt_started.clone();
let task = tokio::spawn(async move {
run_fake_agent_with_activity(
&tempdir_path,
HashMap::from([
("ACP_MODE".to_string(), "cancel".to_string()),
(
"ACP_CANCEL_RECORD".to_string(),
cancel_path_for_task.to_string_lossy().into_owned(),
),
]),
Some(ACP_TEST_TIMEOUT_MS),
cancel_for_task,
Some(Arc::new(move || prompt_started_for_task.notify_one())),
)
.await
});
timeout(
Duration::from_millis(ACP_TEST_TIMEOUT_MS),
prompt_started.notified(),
)
.await
.expect("fake ACP agent should acknowledge session/prompt before cancellation");
cancel_token.cancel();
let err = task
.await
.expect("join cancellation task")
.expect_err("cancelled turn should error");
assert!(matches!(err, AcpError::Cancelled));
assert_eq!(
read_to_string(cancel_path)
.await
.expect("read cancel record"),
"session/cancel\n"
);
}
#[tokio::test]
async fn pre_session_cancellation_returns_cancelled() {
let tempdir = tempfile::tempdir().expect("create tempdir");
let cancel_token = CancellationToken::new();
cancel_token.cancel();
let err = run_fake_agent(
tempdir.path(),
HashMap::from([("ACP_MODE".to_string(), "slow_initialize".to_string())]),
Some(1_000),
cancel_token,
)
.await
.expect_err("pre-session cancellation should error");
assert!(matches!(err, AcpError::Cancelled));
}
#[tokio::test]
async fn successful_turn_terminates_lingering_agent_process() {
let tempdir = tempfile::tempdir().expect("create tempdir");
let pid_path = tempdir.path().join("agent.pid");
let result = run_fake_agent(
tempdir.path(),
HashMap::from([
("ACP_MODE".to_string(), "linger_after_response".to_string()),
(
"ACP_PID_RECORD".to_string(),
pid_path.to_string_lossy().into_owned(),
),
]),
Some(ACP_TEST_TIMEOUT_MS),
CancellationToken::new(),
)
.await
.expect("run ACP turn");
sleep(Duration::from_millis(100)).await;
let pid = read_to_string(&pid_path).await.expect("read agent pid");
let still_running = process_is_running(pid.trim()).await;
if still_running {
let _ = Command::new("kill")
.arg("-TERM")
.arg(pid.trim())
.status()
.await;
}
assert_eq!(result.text, "hello from acp");
assert!(
!still_running,
"successful ACP turn should not leave lingering agent process"
);
}
#[tokio::test]
async fn refusal_stop_reason_returns_text() {
let tempdir = tempfile::tempdir().expect("create tempdir");
let result = run_fake_agent(
tempdir.path(),
HashMap::from([("ACP_STOP_REASON".to_string(), "refusal".to_string())]),
Some(ACP_TEST_TIMEOUT_MS),
CancellationToken::new(),
)
.await
.expect("run ACP turn");
assert_eq!(result.text, "hello from acp");
assert_eq!(result.stop_reason, StopReason::Refusal);
}
#[tokio::test]
async fn max_tokens_stop_reason_returns_partial_text_error() {
let tempdir = tempfile::tempdir().expect("create tempdir");
let err = run_fake_agent(
tempdir.path(),
HashMap::from([("ACP_STOP_REASON".to_string(), "max_tokens".to_string())]),
Some(ACP_TEST_TIMEOUT_MS),
CancellationToken::new(),
)
.await
.expect_err("max_tokens should return stop reason error");
let AcpError::StopReason { stop_reason, text } = err else {
panic!("expected stop reason error");
};
assert_eq!(stop_reason, "max_tokens");
assert_eq!(text, "hello from acp");
}
#[tokio::test]
async fn max_turn_requests_stop_reason_returns_partial_text_error() {
let tempdir = tempfile::tempdir().expect("create tempdir");
let err = run_fake_agent(
tempdir.path(),
HashMap::from([(
"ACP_STOP_REASON".to_string(),
"max_turn_requests".to_string(),
)]),
Some(ACP_TEST_TIMEOUT_MS),
CancellationToken::new(),
)
.await
.expect_err("max_turn_requests should return stop reason error");
let AcpError::StopReason { stop_reason, text } = err else {
panic!("expected stop reason error");
};
assert_eq!(stop_reason, "max_turn_requests");
assert_eq!(text, "hello from acp");
}
#[tokio::test]
async fn timeout_terminates_process_and_returns_timeout() {
let tempdir = tempfile::tempdir().expect("create tempdir");
let err = run_fake_agent(
tempdir.path(),
HashMap::from([("ACP_MODE".to_string(), "timeout".to_string())]),
Some(100),
CancellationToken::new(),
)
.await
.expect_err("timeout should error");
assert!(matches!(err, AcpError::TimedOut { .. }));
}
#[tokio::test]
async fn malformed_json_returns_diagnostic_without_raw_stderr_in_message() {
let tempdir = tempfile::tempdir().expect("create tempdir");
let err = run_fake_agent(
tempdir.path(),
HashMap::from([("ACP_MODE".to_string(), "malformed".to_string())]),
Some(ACP_TEST_TIMEOUT_MS),
CancellationToken::new(),
)
.await
.expect_err("malformed JSON should error");
match err {
AcpError::Protocol(error) => {
let message = error.to_string();
assert!(
!message.contains("malformed json"),
"protocol error display should not include raw stderr: {message}"
);
}
AcpError::ProcessExited(exit) => {
let message = exit.to_string();
assert!(
!message.contains("malformed json"),
"process exit display should not include raw stderr: {message}"
);
assert_eq!(
exit.exec_output_tail
.as_ref()
.and_then(|tail| tail.stderr.as_deref()),
Some("malformed json\n")
);
}
other => panic!("expected protocol or process exit error, got {other:?}"),
}
}
#[tokio::test]
async fn early_exit_returns_process_exit_with_redacted_stderr_tail() {
let tempdir = tempfile::tempdir().expect("create tempdir");
let err = run_fake_agent(
tempdir.path(),
HashMap::from([("ACP_MODE".to_string(), "early_exit".to_string())]),
Some(ACP_TEST_TIMEOUT_MS),
CancellationToken::new(),
)
.await
.expect_err("early exit should error");
let AcpError::ProcessExited(exit) = err else {
panic!("expected process exit error");
};
let message = exit.to_string();
assert!(
message.contains("exit_code=2"),
"early exit should include exit code in diagnostic: {message}"
);
assert!(
!message.contains("early boom"),
"early exit display should not include raw stderr: {message}"
);
assert_eq!(
exit.exec_output_tail
.as_ref()
.and_then(|tail| tail.stderr.as_deref()),
Some("early boom\n")
);
}
async fn run_fake_agent(
tempdir: &Path,
env: HashMap<String, String>,
timeout_ms: Option<u64>,
cancel_token: CancellationToken,
) -> Result<AcpRunResult, AcpError> {
run_fake_agent_with_activity(tempdir, env, timeout_ms, cancel_token, None).await
}
async fn run_fake_agent_with_activity(
tempdir: &Path,
mut env: HashMap<String, String>,
timeout_ms: Option<u64>,
cancel_token: CancellationToken,
on_activity: Option<Arc<dyn Fn() + Send + Sync>>,
) -> Result<AcpRunResult, AcpError> {
let script_path = tempdir.join("fake_acp_agent.py");
write(&script_path, fake_acp_agent_script())
.await
.expect("write fake ACP agent");
let raw_command = format!(
"python3 {}",
shell::shell_quote(&script_path.to_string_lossy())
);
let command = AcpProcessSpec::from_command_attr(&raw_command).expect("parse ACP command");
let sandbox: Arc<RunSandbox> = Arc::new(
local_sandbox(tempdir.to_path_buf())
.await
.expect("local sandbox should be created"),
);
env.entry("LC_ALL".to_string())
.or_insert_with(|| "C".to_string());
run_acp_turn(AcpRunRequest {
command,
prompt: "hello".to_string(),
cwd: tempdir.to_string_lossy().into_owned(),
timeout_ms,
env,
sandbox,
cancel_token,
on_activity,
live_control: None,
})
.await
}
async fn process_is_running(pid: &str) -> bool {
let Ok(status) = Command::new("kill").arg("-0").arg(pid).status().await else {
return false;
};
if !status.success() {
return false;
}
let Ok(output) = Command::new("ps")
.args(["-ww", "-o", "stat=", "-p", pid])
.output()
.await
else {
return true;
};
if !output.status.success() {
return false;
}
String::from_utf8_lossy(&output.stdout)
.chars()
.find(|ch| !ch.is_whitespace())
.is_none_or(|state| !matches!(state, 'Z' | 'z'))
}
fn mock_acp_stdio_process(stop_reason: &'static str) -> MockStdioProcess {
MockStdioProcess::new(move |stdin, mut stdout, _stderr| {
tokio::spawn(async move {
let mut lines = BufReader::new(stdin).lines();
while let Some(line) = lines.next_line().await.expect("read mock ACP stdin") {
let message: serde_json::Value =
serde_json::from_str(&line).expect("parse mock ACP request");
let method = message
.get("method")
.and_then(serde_json::Value::as_str)
.expect("mock ACP request method");
let id = message
.get("id")
.cloned()
.unwrap_or(serde_json::Value::Null);
match method {
"initialize" => {
write_acp_response(
&mut stdout,
id,
serde_json::json!({
"protocolVersion": 1,
"agentCapabilities": {},
}),
)
.await;
}
"session/new" => {
write_acp_response(
&mut stdout,
id,
serde_json::json!({ "sessionId": "sess-1" }),
)
.await;
}
"session/prompt" => {
write_acp_message(
&mut stdout,
serde_json::json!({
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": "sess-1",
"update": {
"sessionUpdate": "agent_message_chunk",
"content": { "type": "text", "text": "hello from acp" }
}
}
}),
)
.await;
write_acp_response(
&mut stdout,
id,
serde_json::json!({ "stopReason": stop_reason }),
)
.await;
return;
}
_ => {}
}
}
});
})
}
async fn write_acp_response(
stdout: &mut DuplexStream,
id: serde_json::Value,
result: serde_json::Value,
) {
write_acp_message(
stdout,
serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"result": result,
}),
)
.await;
}
async fn write_acp_message(stdout: &mut DuplexStream, message: serde_json::Value) {
let mut line = serde_json::to_vec(&message).expect("serialize mock ACP message");
line.push(b'\n');
stdout
.write_all(&line)
.await
.expect("write mock ACP stdout");
stdout.flush().await.expect("flush mock ACP stdout");
}

View file

@ -19,7 +19,6 @@ fabro-types = { path = "../../foundation/fabro-types" }
nom = "7"
regex = { workspace = true }
serde = { workspace = true }
strum.workspace = true
thiserror = { workspace = true }
[dev-dependencies]

View file

@ -1,571 +0,0 @@
/// Condition expression parser for edge guards (spec Section 10).
///
/// Grammar:
/// ```text
/// Expr ::= OrExpr
/// OrExpr ::= AndExpr ('||' AndExpr)*
/// AndExpr ::= UnaryExpr ('&&' UnaryExpr)*
/// UnaryExpr ::= '!' UnaryExpr | Clause
/// Clause ::= Key Op Literal | Key (bare key = truthy)
/// Op ::= '=' | '!=' | '>' | '<' | '>=' | '<='
/// | 'contains' | 'matches'
/// Literal ::= String | Integer | Boolean | BareLiteral
/// BareLiteral ::= [A-Za-z_][A-Za-z0-9_.:-]*
/// ```
use crate::error::Error;
// ---------------------------------------------------------------------------
// AST
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq)]
pub enum ConditionExpr {
Clause(Clause),
Not(Box<Self>),
And(Vec<Self>),
Or(Vec<Self>),
}
#[derive(Debug, Clone, PartialEq)]
pub struct Clause {
pub key: String,
pub op: Op,
pub value: String,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Op {
Eq,
NotEq,
Gt,
Lt,
Gte,
Lte,
Contains,
Matches,
Truthy,
}
// ---------------------------------------------------------------------------
// Tokenizer
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq)]
enum Token {
Word(String),
OpEq, // =
OpNotEq, // !=
OpGt, // >
OpLt, // <
OpGte, // >=
OpLte, // <=
And, // &&
Or, // ||
Not, // !
Contains, // contains
Matches, // matches
}
fn tokenize(input: &str) -> Result<Vec<Token>, Error> {
let input = input.trim();
if input.is_empty() {
return Ok(Vec::new());
}
let chars: Vec<char> = input.chars().collect();
let len = chars.len();
let mut i = 0;
let mut tokens = Vec::new();
while i < len {
// Skip whitespace
if chars[i].is_whitespace() {
i += 1;
continue;
}
// Two-char operators (longest match first)
if i + 1 < len {
let two = format!("{}{}", chars[i], chars[i + 1]);
match two.as_str() {
"&&" => {
tokens.push(Token::And);
i += 2;
continue;
}
"||" => {
tokens.push(Token::Or);
i += 2;
continue;
}
"!=" => {
tokens.push(Token::OpNotEq);
i += 2;
continue;
}
">=" => {
tokens.push(Token::OpGte);
i += 2;
continue;
}
"<=" => {
tokens.push(Token::OpLte);
i += 2;
continue;
}
_ => {}
}
}
// Single-char operators
match chars[i] {
'=' => {
tokens.push(Token::OpEq);
i += 1;
continue;
}
'>' => {
tokens.push(Token::OpGt);
i += 1;
continue;
}
'<' => {
tokens.push(Token::OpLt);
i += 1;
continue;
}
'!' => {
tokens.push(Token::Not);
i += 1;
continue;
}
_ => {}
}
// Quoted string: consume `"..."` as a single word token
if chars[i] == '"' {
let start = i;
i += 1; // skip opening quote
while i < len && chars[i] != '"' {
if chars[i] == '\\' && i + 1 < len {
i += 1; // skip escaped char
}
i += 1;
}
if i < len {
i += 1; // skip closing quote
}
let word: String = chars[start..i].iter().collect();
tokens.push(Token::Word(word));
continue;
}
// Word: everything up to whitespace or operator char
let start = i;
while i < len && !chars[i].is_whitespace() && !is_op_char(chars[i]) && chars[i] != '"' {
i += 1;
}
if i == start {
return Err(Error::Parse(format!(
"unexpected character '{}' in condition expression",
chars[i]
)));
}
let word: String = chars[start..i].iter().collect();
// Recognize keyword operators only when they appear between words
// (not as the first or last token, and not adjacent to another operator)
match word.as_str() {
"contains" if is_word_operator_context(&tokens) => {
tokens.push(Token::Contains);
}
"matches" if is_word_operator_context(&tokens) => {
tokens.push(Token::Matches);
}
_ => {
tokens.push(Token::Word(word));
}
}
}
Ok(tokens)
}
fn is_op_char(c: char) -> bool {
matches!(c, '=' | '!' | '>' | '<' | '&' | '|')
}
/// Word operators (`contains`, `matches`) are recognized when preceded by a
/// Word token.
fn is_word_operator_context(tokens: &[Token]) -> bool {
matches!(tokens.last(), Some(Token::Word(_)))
}
// ---------------------------------------------------------------------------
// Parser (recursive descent)
// ---------------------------------------------------------------------------
struct Parser {
tokens: Vec<Token>,
pos: usize,
}
impl Parser {
fn new(tokens: Vec<Token>) -> Self {
Self { tokens, pos: 0 }
}
fn peek(&self) -> Option<&Token> {
self.tokens.get(self.pos)
}
fn advance(&mut self) -> Option<Token> {
let tok = self.tokens.get(self.pos).cloned();
if tok.is_some() {
self.pos += 1;
}
tok
}
fn parse_expr(&mut self) -> Result<ConditionExpr, Error> {
self.parse_or()
}
fn parse_or(&mut self) -> Result<ConditionExpr, Error> {
let mut children = vec![self.parse_and()?];
while self.peek() == Some(&Token::Or) {
self.advance();
children.push(self.parse_and()?);
}
if children.len() == 1 {
Ok(children.pop().expect("just checked length"))
} else {
Ok(ConditionExpr::Or(children))
}
}
fn parse_and(&mut self) -> Result<ConditionExpr, Error> {
let mut children = vec![self.parse_unary()?];
while self.peek() == Some(&Token::And) {
self.advance();
children.push(self.parse_unary()?);
}
if children.len() == 1 {
Ok(children.pop().expect("just checked length"))
} else {
Ok(ConditionExpr::And(children))
}
}
fn parse_unary(&mut self) -> Result<ConditionExpr, Error> {
if self.peek() == Some(&Token::Not) {
self.advance();
let inner = self.parse_unary()?;
return Ok(ConditionExpr::Not(Box::new(inner)));
}
self.parse_clause()
}
fn parse_clause(&mut self) -> Result<ConditionExpr, Error> {
let key = match self.advance() {
Some(Token::Word(w)) => w,
Some(other) => {
return Err(Error::Parse(format!(
"expected key, got {other:?} in condition expression"
)));
}
None => {
return Err(Error::Parse(
"unexpected end of condition expression".to_string(),
));
}
};
// Check for operator
let op = match self.peek() {
Some(Token::OpEq) => Some(Op::Eq),
Some(Token::OpNotEq) => Some(Op::NotEq),
Some(Token::OpGt) => Some(Op::Gt),
Some(Token::OpLt) => Some(Op::Lt),
Some(Token::OpGte) => Some(Op::Gte),
Some(Token::OpLte) => Some(Op::Lte),
Some(Token::Contains) => Some(Op::Contains),
Some(Token::Matches) => Some(Op::Matches),
_ => None,
};
let Some(op) = op else {
// Bare key -> truthy
return Ok(ConditionExpr::Clause(Clause {
key,
op: Op::Truthy,
value: String::new(),
}));
};
self.advance(); // consume the operator
// Value: must be a Word
let value = match self.advance() {
Some(Token::Word(w)) => parse_literal(&w),
Some(other) => {
return Err(Error::Parse(format!(
"expected value after operator, got {other:?}"
)));
}
None => {
// Allow empty value for `=` and `!=` (backward compat: `missing_key=`)
if op == Op::Eq || op == Op::NotEq {
String::new()
} else {
return Err(Error::Parse("expected value after operator".to_string()));
}
}
};
// Validate regex at parse time
if op == Op::Matches {
regex::Regex::new(&value)
.map_err(|e| Error::Parse(format!("invalid regex pattern '{value}': {e}")))?;
}
Ok(ConditionExpr::Clause(Clause { key, op, value }))
}
}
/// Strip surrounding double-quotes from a literal value.
/// Bare values pass through unchanged.
fn parse_literal(raw: &str) -> String {
let trimmed = raw.trim();
if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') {
trimmed[1..trimmed.len() - 1]
.replace("\\\"", "\"")
.replace("\\\\", "\\")
} else {
trimmed.to_string()
}
}
fn parse_expression(expr: &str) -> Result<ConditionExpr, Error> {
let tokens = tokenize(expr)?;
if tokens.is_empty() {
return Ok(ConditionExpr::And(Vec::new()));
}
let mut parser = Parser::new(tokens);
let result = parser.parse_expr()?;
if parser.pos < parser.tokens.len() {
return Err(Error::Parse(format!(
"unexpected token {:?} in condition expression",
parser.tokens[parser.pos]
)));
}
Ok(result)
}
/// Parse and validate a condition expression.
///
/// # Errors
///
/// Returns an error if the expression contains invalid syntax.
pub fn parse_condition(expr: &str) -> Result<(), Error> {
parse_expression(expr)?;
Ok(())
}
/// Parse a condition expression and return the AST.
///
/// # Errors
///
/// Returns an error if the expression contains invalid syntax.
pub fn parse_condition_expr(expr: &str) -> Result<ConditionExpr, Error> {
parse_expression(expr)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_condition_validates() {
assert!(parse_condition("outcome=succeeded").is_ok());
assert!(parse_condition("outcome=succeeded && context.x=y").is_ok());
assert!(parse_condition("").is_ok());
}
#[test]
fn parse_condition_accepts_bare_key() {
assert!(parse_condition("some_flag").is_ok());
}
#[test]
fn parse_eq_into_clause() {
let expr = parse_expression("outcome=succeeded").unwrap();
assert_eq!(
expr,
ConditionExpr::Clause(Clause {
key: "outcome".to_string(),
op: Op::Eq,
value: "succeeded".to_string(),
})
);
}
#[test]
fn parse_and_into_and_node() {
let expr = parse_expression("a=1 && b=2").unwrap();
assert_eq!(
expr,
ConditionExpr::And(vec![
ConditionExpr::Clause(Clause {
key: "a".to_string(),
op: Op::Eq,
value: "1".to_string(),
}),
ConditionExpr::Clause(Clause {
key: "b".to_string(),
op: Op::Eq,
value: "2".to_string(),
}),
])
);
}
#[test]
fn parse_bare_key_into_truthy() {
let expr = parse_expression("some_flag").unwrap();
assert_eq!(
expr,
ConditionExpr::Clause(Clause {
key: "some_flag".to_string(),
op: Op::Truthy,
value: String::new(),
})
);
}
#[test]
fn parse_not_eq_into_clause() {
let expr = parse_expression("outcome!=failed").unwrap();
assert_eq!(
expr,
ConditionExpr::Clause(Clause {
key: "outcome".to_string(),
op: Op::NotEq,
value: "failed".to_string(),
})
);
}
#[test]
fn parse_numeric_comparisons() {
assert!(parse_condition("x > 5").is_ok());
assert!(parse_condition("x >= 5").is_ok());
assert!(parse_condition("x < 5").is_ok());
assert!(parse_condition("x <= 5").is_ok());
}
#[test]
fn parse_contains() {
assert!(parse_condition("x contains y").is_ok());
}
#[test]
fn parse_matches() {
assert!(parse_condition("x matches ^ok$").is_ok());
}
#[test]
fn matches_invalid_regex_fails_parse() {
assert!(parse_condition("x matches [bad").is_err());
}
#[test]
fn parse_or() {
assert!(parse_condition("a=1 || b=2").is_ok());
}
#[test]
fn parse_not() {
assert!(parse_condition("!x=y").is_ok());
}
// -----------------------------------------------------------------------
// parse_literal: quote stripping
// -----------------------------------------------------------------------
#[test]
fn parse_literal_bare_value() {
assert_eq!(parse_literal("success"), "success");
}
#[test]
fn parse_literal_strips_quotes() {
assert_eq!(parse_literal("\"success\""), "success");
}
#[test]
fn parse_literal_unescapes_inner_quotes() {
assert_eq!(parse_literal(r#""say \"hello\"""#), r#"say "hello""#);
}
#[test]
fn parse_literal_single_quote_not_stripped() {
// Only double-quotes are stripped
assert_eq!(parse_literal("\"partial"), "\"partial");
}
#[test]
fn parse_literal_empty_quoted_string() {
assert_eq!(parse_literal("\"\""), "");
}
// -----------------------------------------------------------------------
// Quoted string values in conditions
// -----------------------------------------------------------------------
#[test]
fn quoted_value_stripped_in_clause() {
let expr = parse_expression(r#"outcome="succeeded""#).unwrap();
assert_eq!(
expr,
ConditionExpr::Clause(Clause {
key: "outcome".to_string(),
op: Op::Eq,
value: "succeeded".to_string(),
})
);
}
#[test]
fn bare_and_quoted_values_produce_same_clause() {
let bare = parse_expression("outcome=succeeded").unwrap();
let quoted = parse_expression(r#"outcome="succeeded""#).unwrap();
assert_eq!(bare, quoted);
}
#[test]
fn quoted_value_with_not_eq() {
let expr = parse_expression(r#"outcome!="failed""#).unwrap();
assert_eq!(
expr,
ConditionExpr::Clause(Clause {
key: "outcome".to_string(),
op: Op::NotEq,
value: "failed".to_string(),
})
);
}
#[test]
fn quoted_value_with_spaces() {
let expr = parse_expression(r#"context.msg="hello world""#).unwrap();
assert_eq!(
expr,
ConditionExpr::Clause(Clause {
key: "context.msg".to_string(),
op: Op::Eq,
value: "hello world".to_string(),
})
);
}
}

View file

@ -1,104 +0,0 @@
use serde::{Deserialize, Serialize};
use strum::{Display, EnumString, VariantArray};
/// Fidelity mode controlling how much prior context is provided to LLM
/// sessions.
#[derive(
Debug,
Clone,
Copy,
Default,
PartialEq,
Eq,
Hash,
Display,
EnumString,
VariantArray,
Serialize,
Deserialize,
)]
#[strum(serialize_all = "lowercase")]
#[serde(rename_all = "lowercase")]
pub enum Fidelity {
/// Complete context, no summarization — sessions share a thread.
Full,
/// Minimal: only graph goal and run ID.
Truncate,
/// Structured nested-bullet summary (default).
#[default]
Compact,
/// Brief textual summary (~600 token target).
#[strum(serialize = "summary:low")]
#[serde(rename = "summary:low")]
SummaryLow,
/// Moderate textual summary (~1500 token target).
#[strum(serialize = "summary:medium")]
#[serde(rename = "summary:medium")]
SummaryMedium,
/// Detailed per-stage Markdown report.
#[strum(serialize = "summary:high")]
#[serde(rename = "summary:high")]
SummaryHigh,
}
impl Fidelity {
/// All supported fidelity modes in display order.
#[must_use]
pub fn variants() -> &'static [Self] {
Self::VARIANTS
}
/// Degrade full fidelity to summary:high (used on checkpoint resume).
#[must_use]
pub fn degraded(self) -> Self {
match self {
Self::Full => Self::SummaryHigh,
other => other,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fidelity_display_roundtrips() {
for mode in Fidelity::variants() {
let s = mode.to_string();
let parsed: Fidelity = s.parse().unwrap();
assert_eq!(parsed, *mode);
}
}
#[test]
fn fidelity_default_is_compact() {
assert_eq!(Fidelity::default(), Fidelity::Compact);
}
#[test]
fn fidelity_degraded_full_becomes_summary_high() {
assert_eq!(Fidelity::Full.degraded(), Fidelity::SummaryHigh);
}
#[test]
fn fidelity_degraded_non_full_unchanged() {
assert_eq!(Fidelity::Compact.degraded(), Fidelity::Compact);
assert_eq!(Fidelity::SummaryHigh.degraded(), Fidelity::SummaryHigh);
}
#[test]
fn fidelity_unknown_mode_errors() {
assert!("bogus".parse::<Fidelity>().is_err());
}
#[test]
fn fidelity_serde_matches_strum_display() {
for mode in Fidelity::variants() {
let json = serde_json::to_value(mode).unwrap();
assert_eq!(json, serde_json::Value::String(mode.to_string()));
let parsed: Fidelity = serde_json::from_value(json).unwrap();
assert_eq!(parsed, *mode);
}
}
}

View file

@ -1,10 +1,7 @@
pub mod condition;
pub mod error;
pub mod fidelity;
pub mod graph;
pub mod parser;
pub mod render;
pub mod stylesheet;
pub use error::{Error, Result};
pub use fidelity::Fidelity;

View file

@ -434,9 +434,9 @@ impl<'a> WorkflowBundler<'a> {
for image in layer.environment_images() {
self.collect_environment_dockerfile(files, base_dir, config_path, image)?;
}
if self.workflow_version_projection {
self.collect_config_goal_files(files, base_dir, config_path, entrypoint, &layer)?;
}
// The goal file rides with the manifest: Petri reads `[run.goal]
// file` from the bundle at check, as it does for a version.
self.collect_config_goal_files(files, base_dir, config_path, entrypoint, &layer)?;
Ok(())
}

View file

@ -75,13 +75,17 @@ pub struct Launch {
/// the runtime.
#[derive(Clone, Default)]
pub struct CheckRequest {
pub bundle: Bundle,
pub bundle: Bundle,
/// The intent's inputs, under which `[run.inputs]` defaults fill in.
pub inputs: BTreeMap<String, Value>,
pub inputs: BTreeMap<String, Value>,
/// The server's run variables, read by `{{ vars.NAME }}`.
pub vars: BTreeMap<String, String>,
pub launch: Launch,
pub runtime: RuntimeSpec,
pub vars: BTreeMap<String, String>,
pub launch: Launch,
pub runtime: RuntimeSpec,
/// Whether a template that reads an input nothing binds is a warning
/// that leaves the text unrendered, instead of an error: a validation
/// before the run's inputs exist sets it; a run never does.
pub unbound_is_warning: bool,
}
/// Petri's diagnostic, in the shape Fabro's create handler maps onto its
@ -158,7 +162,8 @@ pub fn check(request: &CheckRequest) -> Result<Admitted, CheckError> {
entrypoint: bundle.entrypoint.clone(),
})?;
let runtime = request.runtime.runtime(false);
let inputs = compile_inputs(&request.inputs, &request.vars, &request.launch);
let mut inputs = compile_inputs(&request.inputs, &request.vars, &request.launch);
inputs.unbound_is_warning = request.unbound_is_warning;
let lowered = runtime
.check_source(&bundle.entrypoint, text, &bundle.files(), None, &inputs)
.map_err(CheckError::Load)?;

View file

@ -71,6 +71,7 @@ fn request(bundle: Bundle, runtime: RuntimeSpec) -> CheckRequest {
vars: BTreeMap::new(),
launch: Launch::default(),
runtime,
unbound_is_warning: false,
}
}
@ -131,18 +132,19 @@ async fn the_hello_bundle_is_admitted_and_round_trips_through_the_blob_store() {
async fn a_launch_binds_the_repository_and_the_model_default() {
let repository = tempfile::tempdir().expect("a temp dir");
let request = CheckRequest {
bundle: bundle(&[
bundle: bundle(&[
("workflow.fabro", COMMAND_WORKFLOW),
("workflow.toml", SETTINGS),
]),
inputs: BTreeMap::new(),
vars: BTreeMap::new(),
launch: Launch {
inputs: BTreeMap::new(),
vars: BTreeMap::new(),
launch: Launch {
model: Some("gpt-5.4".to_string()),
provider: None,
repository: Some(repository.path().to_path_buf()),
},
runtime: RuntimeSpec::default(),
runtime: RuntimeSpec::default(),
unbound_is_warning: false,
};
let admitted = check::check(&request).expect("the command bundle is admitted");

View file

@ -83,7 +83,7 @@ const SETTINGS: &str = "_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n
/// The bundle admitted the way the create handler admits it.
fn admit(workflow: &str, settings: &str) -> AdmittedGraphs {
let request = CheckRequest {
bundle: Bundle {
bundle: Bundle {
files: BTreeMap::from([
("workflow.fabro".to_string(), workflow.to_string()),
("workflow.toml".to_string(), settings.to_string()),
@ -91,10 +91,11 @@ fn admit(workflow: &str, settings: &str) -> AdmittedGraphs {
entrypoint: "workflow.fabro".to_string(),
project_toml: None,
},
inputs: BTreeMap::new(),
vars: BTreeMap::new(),
launch: Launch::default(),
runtime: RuntimeSpec::default(),
inputs: BTreeMap::new(),
vars: BTreeMap::new(),
launch: Launch::default(),
runtime: RuntimeSpec::default(),
unbound_is_warning: false,
};
let admitted = check::check(&request).expect("the bundle is admitted");
AdmittedGraphs {

View file

@ -88,6 +88,7 @@ pub(crate) fn admit(
vars: BTreeMap::new(),
launch,
runtime: runtime.clone(),
unbound_is_warning: false,
};
let admitted = check::check(&request)
.unwrap_or_else(|error| panic!("the workflow is admitted: {error:?}"));

View file

@ -1,25 +0,0 @@
[package]
name = "fabro-validate"
edition.workspace = true
version.workspace = true
publish = false
license.workspace = true
description = "Validation and lint rules for Fabro workflow graphs"
[lib]
doctest = false
[lints]
workspace = true
[dependencies]
fabro-acp = { path = "../fabro-acp", default-features = false }
fabro-graphviz = { path = "../fabro-graphviz" }
fabro-llm = { path = "../fabro-llm" }
fabro-types = { path = "../../foundation/fabro-types" }
serde = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
fabro-llm = { path = "../fabro-llm", features = ["test-support"] }
toml = { workspace = true }

View file

@ -1,461 +0,0 @@
pub mod rules;
use fabro_graphviz::graph::Graph;
use fabro_llm::lithos_catalog::Catalog;
use serde::{Deserialize, Serialize};
/// Severity level for validation diagnostics.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Severity {
Error,
Warning,
Info,
}
/// A validation diagnostic produced by a lint rule.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Diagnostic {
pub rule: String,
pub severity: Severity,
pub message: String,
pub node_id: Option<String>,
pub edge: Option<(String, String)>,
pub fix: Option<String>,
pub source_path: Option<String>,
pub line: Option<u32>,
pub column: Option<u32>,
pub span_start: Option<usize>,
pub span_len: Option<usize>,
#[serde(default)]
pub related: Vec<RelatedDiagnostic>,
}
impl Default for Diagnostic {
fn default() -> Self {
Self {
rule: String::new(),
severity: Severity::Info,
message: String::new(),
node_id: None,
edge: None,
fix: None,
source_path: None,
line: None,
column: None,
span_start: None,
span_len: None,
related: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelatedDiagnostic {
pub message: String,
pub source_path: Option<String>,
pub line: Option<u32>,
pub column: Option<u32>,
}
/// A lint rule that validates a graph.
pub trait LintRule {
fn name(&self) -> &'static str;
fn apply(&self, graph: &Graph) -> Vec<Diagnostic>;
}
/// Validation error returned when error-severity diagnostics are present.
#[derive(Debug, thiserror::Error)]
#[error("Validation error: {0}")]
pub struct ValidationError(pub String);
/// Run all built-in lint rules (and any extra rules) against the graph.
#[must_use]
pub fn validate(graph: &Graph, extra_rules: &[&dyn LintRule]) -> Vec<Diagnostic> {
let built_in = rules::built_in_rules();
let mut diagnostics = Vec::new();
for rule in &built_in {
diagnostics.extend(rule.apply(graph));
}
for rule in extra_rules {
diagnostics.extend(rule.apply(graph));
}
diagnostics
}
/// Run all built-in catalog-free lint rules, caller-supplied model catalog
/// rules, and any extra rules against the graph.
#[must_use]
pub fn validate_with_catalog(
graph: &Graph,
catalog: &Catalog,
extra_rules: &[&dyn LintRule],
) -> Vec<Diagnostic> {
let mut diagnostics = validate(graph, &[]);
let catalog_rules = rules::catalog_rules(catalog);
for rule in &catalog_rules {
diagnostics.extend(rule.apply(graph));
}
for rule in extra_rules {
diagnostics.extend(rule.apply(graph));
}
diagnostics
}
/// If any Error-severity diagnostics are present, return `ValidationError`.
///
/// # Errors
/// Returns `ValidationError` with joined error messages.
pub fn raise_on_errors(diagnostics: &[Diagnostic]) -> Result<(), ValidationError> {
let mut errors = diagnostics
.iter()
.filter(|d| d.severity == Severity::Error)
.peekable();
if errors.peek().is_some() {
let message = errors
.map(|d| d.message.as_str())
.collect::<Vec<_>>()
.join("; ");
return Err(ValidationError(message));
}
Ok(())
}
/// Run all built-in lint rules (and any extra rules). Returns Err if any
/// Error-severity diagnostics are found.
///
/// # Errors
/// Returns `ValidationError` if any Error-severity diagnostics are found.
pub fn validate_or_raise(
graph: &Graph,
extra_rules: &[&dyn LintRule],
) -> Result<Vec<Diagnostic>, ValidationError> {
let diagnostics = validate(graph, extra_rules);
raise_on_errors(&diagnostics)?;
Ok(diagnostics)
}
/// Run catalog-aware validation and return `ValidationError` if any
/// Error-severity diagnostics are found.
///
/// # Errors
/// Returns `ValidationError` if any Error-severity diagnostics are found.
pub fn validate_with_catalog_or_raise(
graph: &Graph,
catalog: &Catalog,
extra_rules: &[&dyn LintRule],
) -> Result<Vec<Diagnostic>, ValidationError> {
let diagnostics = validate_with_catalog(graph, catalog, extra_rules);
raise_on_errors(&diagnostics)?;
Ok(diagnostics)
}
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};
use fabro_graphviz::parser;
use fabro_llm::lithos_catalog::Catalog;
use fabro_llm::test_support::test_catalog_with_overlay;
use super::*;
fn minimal_valid_graph() -> Graph {
let mut g = Graph::new("test");
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
g.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
g.nodes.insert("exit".to_string(), exit);
g.edges.push(Edge::new("start", "exit"));
g
}
fn graph_with_model_and_provider(model: &str, provider: &str) -> Graph {
let mut g = Graph::new("test");
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
g.nodes.insert("start".to_string(), start);
let mut work = Node::new("work");
work.attrs
.insert("model".to_string(), AttrValue::String(model.to_string()));
work.attrs.insert(
"provider".to_string(),
AttrValue::String(provider.to_string()),
);
g.nodes.insert("work".to_string(), work);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
g.nodes.insert("exit".to_string(), exit);
g.edges.push(Edge::new("start", "work"));
g.edges.push(Edge::new("work", "exit"));
g
}
/// An operator-defined provider layered over the built-ins, the shape an
/// `[llm]` overlay produces.
fn custom_catalog() -> Catalog {
test_catalog_with_overlay(
r#"
[providers.acme-venice]
display_name = "Acme Venice"
adapter = "openai-compatible"
codec = "openai-chat"
base_url = "https://api.venice.ai/api/v1"
auth = { type = "bearer" }
default_model = "venice-large"
[providers.acme-venice.metadata.agent]
profile = "openai"
[providers.acme-venice.models.venice-large]
display_name = "Venice Large"
api_model = "venice-large"
limits = { context_tokens = 128000, max_output_tokens = 8192 }
capabilities = { text = true, tools = true }
"#,
)
}
#[test]
fn validate_minimal_valid_graph_has_no_errors() {
let g = minimal_valid_graph();
let diagnostics = validate(&g, &[]);
let errors: Vec<_> = diagnostics
.iter()
.filter(|d| d.severity == Severity::Error)
.collect();
assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
}
#[test]
fn validate_or_raise_passes_for_valid_graph() {
let g = minimal_valid_graph();
let result = validate_or_raise(&g, &[]);
assert!(result.is_ok());
}
#[test]
fn validate_rejects_join_policy_on_any_node() {
let mut g = minimal_valid_graph();
let mut fork = Node::new("fork");
fork.attrs.insert(
"shape".to_string(),
AttrValue::String("component".to_string()),
);
fork.attrs.insert(
"join_policy".to_string(),
AttrValue::String("wait_all".to_string()),
);
g.nodes.insert("fork".to_string(), fork);
let mut custom = Node::new("custom");
custom.attrs.insert(
"type".to_string(),
AttrValue::String("custom.handler".to_string()),
);
custom.attrs.insert(
"join_policy".to_string(),
AttrValue::String("first_success".to_string()),
);
g.nodes.insert("custom".to_string(), custom);
let diagnostics = validate(&g, &[]);
let removed = diagnostics
.iter()
.filter(|d| d.rule == "join_policy_removed")
.collect::<Vec<_>>();
assert_eq!(removed.len(), 2, "diagnostics: {diagnostics:?}");
assert!(removed.iter().all(|d| d.severity == Severity::Error));
assert!(
removed
.iter()
.all(|d| d.message.contains("Remove 'join_policy'"))
);
assert_eq!(
removed
.iter()
.filter_map(|d| d.node_id.as_deref())
.collect::<std::collections::BTreeSet<_>>(),
std::collections::BTreeSet::from(["custom", "fork"]),
);
}
#[test]
fn validate_or_raise_fails_for_missing_start() {
let mut g = Graph::new("test");
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
g.nodes.insert("exit".to_string(), exit);
let result = validate_or_raise(&g, &[]);
assert!(result.is_err());
}
#[test]
fn validate_or_raise_fails_for_missing_exit() {
let mut g = Graph::new("test");
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
g.nodes.insert("start".to_string(), start);
let result = validate_or_raise(&g, &[]);
assert!(result.is_err());
}
#[test]
fn validate_runs_extra_rules() {
struct AlwaysWarnRule;
impl LintRule for AlwaysWarnRule {
fn name(&self) -> &'static str {
"always_warn"
}
fn apply(&self, _graph: &Graph) -> Vec<Diagnostic> {
vec![Diagnostic {
rule: "always_warn".to_string(),
severity: Severity::Warning,
message: "custom warning".to_string(),
node_id: None,
edge: None,
fix: None,
..Diagnostic::default()
}]
}
}
let g = minimal_valid_graph();
let extra = AlwaysWarnRule;
let diagnostics = validate(&g, &[&extra]);
let custom: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "always_warn")
.collect();
assert_eq!(custom.len(), 1);
}
#[test]
fn validate_does_not_run_catalog_aware_model_rules() {
let g = graph_with_model_and_provider("not-in-any-catalog", "not-a-provider");
let diagnostics = validate(&g, &[]);
assert!(
diagnostics
.iter()
.all(|d| d.rule != "node_model_known" && d.rule != "stylesheet_model_known"),
"catalog-free validation should not emit model/provider diagnostics: {diagnostics:?}"
);
}
#[test]
fn validate_with_catalog_accepts_custom_catalog_entries() {
let g = graph_with_model_and_provider("venice-large", "acme-venice");
let catalog = custom_catalog();
let diagnostics = validate_with_catalog(&g, &catalog, &[]);
assert!(
diagnostics
.iter()
.all(|d| d.rule != "node_model_known" && d.rule != "stylesheet_model_known"),
"custom catalog entries should validate cleanly: {diagnostics:?}"
);
}
#[test]
fn validate_with_catalog_warns_for_unknown_model_and_provider() {
let g = graph_with_model_and_provider("missing-model", "missing-provider");
let catalog = custom_catalog();
let diagnostics = validate_with_catalog(&g, &catalog, &[]);
assert!(
diagnostics
.iter()
.any(|d| d.rule == "node_model_known" && d.message.contains("missing-model")),
"missing model diagnostic not found: {diagnostics:?}"
);
assert!(
diagnostics.iter().any(|d| d.rule == "node_model_known"
&& d.message.contains("missing-provider")
&& d.message.contains("acme-venice")),
"missing provider diagnostic not found: {diagnostics:?}"
);
}
#[test]
fn diagnostic_severity_eq() {
assert_eq!(Severity::Error, Severity::Error);
assert_ne!(Severity::Error, Severity::Warning);
assert_ne!(Severity::Warning, Severity::Info);
}
#[expect(
clippy::disallowed_methods,
reason = "unit test reads checked-in DOT compatibility fixtures synchronously"
)]
#[test]
fn dot_compatibility_corpus_validates() {
let fixtures = dot_compatibility_fixtures();
assert_eq!(
fixtures.len(),
3,
"dot compatibility corpus should stay intentionally small"
);
for fixture in fixtures {
let source = std::fs::read_to_string(&fixture)
.unwrap_or_else(|err| panic!("failed to read {}: {err}", fixture.display()));
let graph = parser::parse(&source)
.unwrap_or_else(|err| panic!("failed to parse {}: {err}", fixture.display()));
validate_or_raise(&graph, &[])
.unwrap_or_else(|err| panic!("failed to validate {}: {err}", fixture.display()));
}
}
#[expect(
clippy::disallowed_methods,
reason = "unit test helper enumerates checked-in DOT compatibility fixtures synchronously"
)]
fn dot_compatibility_fixtures() -> Vec<std::path::PathBuf> {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../../test/dot-compatibility");
let mut fixtures = std::fs::read_dir(&dir)
.unwrap_or_else(|err| panic!("failed to read {}: {err}", dir.display()))
.map(|entry| {
entry
.unwrap_or_else(|err| {
panic!("failed to read entry in {}: {err}", dir.display())
})
.path()
})
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("fabro"))
.collect::<Vec<_>>();
fixtures.sort();
fixtures
}
}

View file

@ -1,116 +0,0 @@
use fabro_graphviz::graph::Graph;
use crate::{Diagnostic, LintRule, Severity};
pub(super) fn rule() -> Box<dyn LintRule> {
Box::new(Rule)
}
struct Rule;
impl LintRule for Rule {
fn name(&self) -> &'static str {
"all_conditional_edges"
}
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
for node in graph.nodes.values() {
let outgoing = graph.outgoing_edges(&node.id);
if outgoing.is_empty() {
continue;
}
let all_conditional = outgoing
.iter()
.all(|e| e.condition().is_some_and(|c| !c.is_empty()));
if all_conditional {
diagnostics.push(Diagnostic {
rule: self.name().to_string(),
severity: Severity::Error,
message: format!(
"Node '{}' has all conditional outgoing edges with no unconditional fallback",
node.id
),
node_id: Some(node.id.clone()),
edge: None,
fix: Some(
"Add at least one unconditional edge as a fallback".to_string(),
),
..Diagnostic::default()});
}
}
diagnostics
}
}
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::{AttrValue, Edge, Node};
use super::Rule;
use crate::rules::test_support::minimal_graph;
use crate::{LintRule, Severity};
#[test]
fn all_conditional_edges_rule_all_conditional() {
let mut g = minimal_graph();
g.nodes.insert("work".to_string(), Node::new("work"));
g.edges.push({
let mut e = Edge::new("work", "exit");
e.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=succeeded".to_string()),
);
e
});
g.edges.push({
let mut e = Edge::new("work", "start");
e.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=failed".to_string()),
);
e
});
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Error);
assert_eq!(d[0].node_id.as_deref(), Some("work"));
}
#[test]
fn all_conditional_edges_rule_mix_conditional_unconditional() {
let mut g = minimal_graph();
g.nodes.insert("work".to_string(), Node::new("work"));
g.edges.push({
let mut e = Edge::new("work", "exit");
e.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=succeeded".to_string()),
);
e
});
g.edges.push(Edge::new("work", "start")); // unconditional fallback
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn all_conditional_edges_rule_only_unconditional() {
let g = minimal_graph(); // start -> exit is unconditional
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn all_conditional_edges_rule_exit_node_no_outgoing() {
let g = minimal_graph(); // exit has no outgoing edges
let rule = Rule;
let d = rule.apply(&g);
// exit node has no outgoing edges, so rule doesn't fire
assert!(d.is_empty());
}
}

View file

@ -1,163 +0,0 @@
use fabro_graphviz::graph::Graph;
use crate::{Diagnostic, LintRule, Severity};
pub(super) fn rule() -> Box<dyn LintRule> {
Box::new(Rule)
}
/// `auto_status=true` is the deprecated spelling of `on_failure="succeed"`.
/// The runtime still honors it as an alias; this rule points workflows at the
/// explicit policy.
struct Rule;
impl LintRule for Rule {
fn name(&self) -> &'static str {
"auto_status_deprecated"
}
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
let mut nodes: Vec<_> = graph
.nodes
.values()
.filter(|node| node.attrs.contains_key("auto_status"))
.collect();
nodes.sort_unstable_by(|a, b| a.id.cmp(&b.id));
nodes
.into_iter()
.map(|node| {
let (message, fix) = if node.attrs.contains_key("on_failure") {
(
format!(
"Node '{}' sets deprecated 'auto_status', which is ignored because \
'on_failure' is set",
node.id
),
"Remove 'auto_status'",
)
} else if node.auto_status() {
(
format!("Node '{}' sets deprecated 'auto_status=true'", node.id),
"Use on_failure=\"succeed\" instead",
)
} else {
(
format!(
"Node '{}' sets deprecated 'auto_status', which has no effect \
unless it is true",
node.id
),
"Remove 'auto_status'",
)
};
Diagnostic {
rule: self.name().to_string(),
severity: Severity::Warning,
message,
node_id: Some(node.id.clone()),
fix: Some(fix.to_string()),
..Diagnostic::default()
}
})
.collect()
}
}
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::{AttrValue, Graph};
use super::Rule;
use crate::rules::test_support::{minimal_graph, node_with_attrs};
use crate::{LintRule, Severity};
fn graph_with_auto_status(value: AttrValue, on_failure: Option<&str>) -> Graph {
let mut graph = minimal_graph();
let mut node = match on_failure {
Some(policy) => node_with_attrs("work", &[("on_failure", policy)]),
None => node_with_attrs("work", &[]),
};
node.attrs.insert("auto_status".to_string(), value);
graph.nodes.insert("work".to_string(), node);
graph
}
#[test]
fn accepts_graphs_without_auto_status() {
let mut graph = minimal_graph();
graph.nodes.insert(
"work".to_string(),
node_with_attrs("work", &[("on_failure", "succeed")]),
);
assert!(Rule.apply(&graph).is_empty());
}
#[test]
fn warns_for_auto_status_true_and_suggests_succeed_policy() {
let graph = graph_with_auto_status(AttrValue::Boolean(true), None);
let diagnostics = Rule.apply(&graph);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].severity, Severity::Warning);
assert_eq!(
diagnostics[0].message,
"Node 'work' sets deprecated 'auto_status=true'"
);
assert_eq!(diagnostics[0].node_id.as_deref(), Some("work"));
assert_eq!(
diagnostics[0].fix.as_deref(),
Some("Use on_failure=\"succeed\" instead")
);
}
#[test]
fn warns_that_auto_status_is_ignored_when_on_failure_is_set() {
let graph = graph_with_auto_status(AttrValue::Boolean(true), Some("exit"));
let diagnostics = Rule.apply(&graph);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].severity, Severity::Warning);
assert_eq!(
diagnostics[0].message,
"Node 'work' sets deprecated 'auto_status', which is ignored because 'on_failure' is set"
);
assert_eq!(diagnostics[0].fix.as_deref(), Some("Remove 'auto_status'"));
}
#[test]
fn warns_for_auto_status_false() {
let graph = graph_with_auto_status(AttrValue::Boolean(false), None);
let diagnostics = Rule.apply(&graph);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].severity, Severity::Warning);
assert_eq!(
diagnostics[0].message,
"Node 'work' sets deprecated 'auto_status', which has no effect unless it is true"
);
assert_eq!(diagnostics[0].fix.as_deref(), Some("Remove 'auto_status'"));
}
#[test]
fn reports_nodes_in_id_order() {
let mut graph = minimal_graph();
for id in ["zeta", "alpha"] {
let mut node = node_with_attrs(id, &[]);
node.attrs
.insert("auto_status".to_string(), AttrValue::Boolean(true));
graph.nodes.insert(id.to_string(), node);
}
let ids: Vec<_> = Rule
.apply(&graph)
.into_iter()
.map(|diagnostic| diagnostic.node_id.unwrap())
.collect();
assert_eq!(ids, ["alpha", "zeta"]);
}
}

View file

@ -1,447 +0,0 @@
use fabro_acp::{AcpCommandError, AcpProcessSpec};
use fabro_graphviz::graph::{Graph, Node};
use fabro_types::AgentBackend;
use crate::{Diagnostic, LintRule, Severity};
pub(super) fn rule() -> Box<dyn LintRule> {
Box::new(Rule)
}
struct Rule;
impl LintRule for Rule {
fn name(&self) -> &'static str {
"backend_valid"
}
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
for node in graph.nodes.values() {
if let Some(backend) = node.backend() {
match node.agent_backend() {
Some(Err(_)) => {
diagnostics.push(unsupported_backend_diagnostic(
self.name(),
node,
backend,
));
}
Some(Ok(AgentBackend::Acp)) => {
diagnostics.extend(validate_acp_node(self.name(), node));
}
Some(Ok(_)) | None => {}
}
}
}
diagnostics
}
}
fn unsupported_backend_diagnostic(rule: &str, node: &Node, backend: &str) -> Diagnostic {
if backend == "cli" {
return Diagnostic {
rule: rule.to_string(),
severity: Severity::Error,
message: "backend=\"cli\" is no longer supported; external agents must be launched \
through backend=\"acp\" with acp.command or acp.config"
.to_string(),
node_id: Some(node.id.clone()),
edge: None,
fix: Some(
"Use backend=\"api\" for Fabro-owned provider execution, or backend=\"acp\" with \
acp.command/acp.config for a user-supplied ACP process"
.to_string(),
),
..Diagnostic::default()
};
}
let expected = AgentBackend::expected_values();
Diagnostic {
rule: rule.to_string(),
severity: Severity::Error,
message: format!("unsupported agent backend \"{backend}\"; expected one of: {expected}"),
node_id: Some(node.id.clone()),
edge: None,
fix: Some(format!("Use one of: {expected}")),
..Diagnostic::default()
}
}
fn validate_acp_node(rule: &str, node: &Node) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
if node.handler_type() != Some("agent") {
diagnostics.push(Diagnostic {
rule: rule.to_string(),
severity: Severity::Error,
message: "backend=\"acp\" is only valid on agent nodes; prompt nodes are API-only"
.to_string(),
node_id: Some(node.id.clone()),
edge: None,
fix: Some("Use backend=\"api\" on prompt nodes".to_string()),
..Diagnostic::default()
});
}
if let Err(error) = AcpProcessSpec::from_attrs(
node.legacy_acp_command_attr(),
node.acp_command_attr(),
node.acp_config_attr(),
) {
diagnostics.push(acp_process_diagnostic(rule, node, &error));
}
let api_only_attrs = api_only_attrs_present(node);
if !api_only_attrs.is_empty() {
diagnostics.push(Diagnostic {
rule: rule.to_string(),
severity: Severity::Error,
message: format!(
"backend=\"acp\" does not support API-only attributes: {}",
api_only_attrs.join(", ")
),
node_id: Some(node.id.clone()),
edge: None,
fix: Some("Remove API model/provider/control attributes from ACP nodes".to_string()),
..Diagnostic::default()
});
}
diagnostics
}
fn acp_process_diagnostic(rule: &str, node: &Node, error: &AcpCommandError) -> Diagnostic {
match error {
AcpCommandError::LegacyCommandAttribute => Diagnostic {
rule: rule.to_string(),
severity: Severity::Error,
message: "acp_command is no longer supported; use acp.command for shell commands or \
acp.config for JSON stdio ACP configs"
.to_string(),
node_id: Some(node.id.clone()),
edge: None,
fix: Some("Rename acp_command to acp.command".to_string()),
..Diagnostic::default()
},
AcpCommandError::EmptyOverride
| AcpCommandError::MissingOverride
| AcpCommandError::InvalidCommandString => Diagnostic {
rule: rule.to_string(),
severity: Severity::Error,
message: render_acp_process_error(error),
node_id: Some(node.id.clone()),
edge: None,
fix: Some(
"Set acp.command to a shell command, or acp.config to a JSON stdio ACP config"
.to_string(),
),
..Diagnostic::default()
},
AcpCommandError::InvalidConfigJson(_)
| AcpCommandError::InvalidConfigShape(_)
| AcpCommandError::UnsupportedTransport => Diagnostic {
rule: rule.to_string(),
severity: Severity::Error,
message: format!(
"acp.config must be a JSON stdio ACP config: {}",
render_acp_process_error(error)
),
node_id: Some(node.id.clone()),
edge: None,
fix: Some(
"Provide a JSON config with type=\"stdio\", command, and optional args".to_string(),
),
..Diagnostic::default()
},
}
}
fn render_acp_process_error(error: &AcpCommandError) -> String {
error.to_string()
}
fn api_only_attrs_present(node: &Node) -> Vec<&'static str> {
const API_ONLY_ATTRS: &[&str] = &[
"model",
"provider",
"reasoning_effort",
"max_tokens",
"speed",
];
API_ONLY_ATTRS
.iter()
.copied()
.filter(|attr| node.attrs.contains_key(*attr))
.collect()
}
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::{AttrValue, Node};
use super::Rule;
use crate::rules::test_support::minimal_graph;
use crate::{LintRule, Severity};
#[test]
fn backend_valid_accepts_absent_and_api() {
for backend in [None, Some("api")] {
let mut graph = minimal_graph();
let mut node = Node::new("work");
if let Some(backend) = backend {
node.attrs.insert(
"backend".to_string(),
AttrValue::String(backend.to_string()),
);
}
graph.nodes.insert("work".to_string(), node);
assert!(Rule.apply(&graph).is_empty(), "backend: {backend:?}");
}
}
#[test]
fn backend_valid_rejects_unknown_backend() {
let mut graph = minimal_graph();
let mut node = Node::new("work");
node.attrs.insert(
"backend".to_string(),
AttrValue::String("codex".to_string()),
);
graph.nodes.insert("work".to_string(), node);
let diagnostics = Rule.apply(&graph);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].severity, Severity::Error);
assert!(
diagnostics[0]
.message
.contains("unsupported agent backend \"codex\"; expected one of: api, acp")
);
}
#[test]
fn backend_valid_requires_acp_process_attr_for_acp_backend() {
let mut graph = minimal_graph();
let mut node = Node::new("work");
node.attrs
.insert("backend".to_string(), AttrValue::String("acp".to_string()));
graph.nodes.insert("work".to_string(), node);
let diagnostics = Rule.apply(&graph);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].severity, Severity::Error);
assert!(
diagnostics[0]
.message
.contains("requires exactly one of acp.command or acp.config")
);
}
#[test]
fn backend_valid_accepts_acp_backend_with_acp_command_attr() {
let mut graph = minimal_graph();
let mut node = Node::new("work");
node.attrs
.insert("backend".to_string(), AttrValue::String("acp".to_string()));
node.attrs.insert(
"acp.command".to_string(),
AttrValue::String("agent-acp".to_string()),
);
graph.nodes.insert("work".to_string(), node);
assert!(Rule.apply(&graph).is_empty());
}
#[test]
fn backend_valid_rejects_cli_backend_with_migration_guidance() {
let mut graph = minimal_graph();
let mut node = Node::new("work");
node.attrs
.insert("backend".to_string(), AttrValue::String("cli".to_string()));
graph.nodes.insert("work".to_string(), node);
let diagnostics = Rule.apply(&graph);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].severity, Severity::Error);
assert!(
diagnostics[0]
.message
.contains("backend=\"cli\" is no longer supported")
);
assert!(
diagnostics[0]
.fix
.as_deref()
.unwrap()
.contains("backend=\"acp\"")
);
}
#[test]
fn backend_valid_requires_exactly_one_acp_process_attr() {
let mut missing = minimal_graph();
let mut missing_node = Node::new("missing");
missing_node
.attrs
.insert("backend".to_string(), AttrValue::String("acp".to_string()));
missing.nodes.insert("missing".to_string(), missing_node);
let diagnostics = Rule.apply(&missing);
assert_eq!(diagnostics.len(), 1);
assert!(
diagnostics[0]
.message
.contains("requires exactly one of acp.command or acp.config")
);
let mut both = minimal_graph();
let mut both_node = Node::new("both");
both_node
.attrs
.insert("backend".to_string(), AttrValue::String("acp".to_string()));
both_node.attrs.insert(
"acp.command".to_string(),
AttrValue::String("python3 agent.py".to_string()),
);
both_node.attrs.insert(
"acp.config".to_string(),
AttrValue::String(
r#"{"type":"stdio","name":"agent","command":"python3","args":["agent.py"]}"#
.to_string(),
),
);
both.nodes.insert("both".to_string(), both_node);
let diagnostics = Rule.apply(&both);
assert_eq!(diagnostics.len(), 1);
assert!(
diagnostics[0]
.message
.contains("requires exactly one of acp.command or acp.config")
);
}
#[test]
fn backend_valid_rejects_legacy_acp_command_attr() {
let mut graph = minimal_graph();
let mut node = Node::new("work");
node.attrs
.insert("backend".to_string(), AttrValue::String("acp".to_string()));
node.attrs.insert(
"acp_command".to_string(),
AttrValue::String("python3 agent.py".to_string()),
);
graph.nodes.insert("work".to_string(), node);
let diagnostics = Rule.apply(&graph);
assert_eq!(diagnostics.len(), 1);
assert!(
diagnostics[0]
.message
.contains("acp_command is no longer supported")
);
}
#[test]
fn backend_valid_rejects_acp_on_prompt_nodes_and_api_only_attrs() {
let mut graph = minimal_graph();
let mut node = Node::new("prompt");
node.attrs
.insert("type".to_string(), AttrValue::String("prompt".to_string()));
node.attrs
.insert("backend".to_string(), AttrValue::String("acp".to_string()));
node.attrs.insert(
"acp.command".to_string(),
AttrValue::String("python3 agent.py".to_string()),
);
node.attrs.insert(
"model".to_string(),
AttrValue::String("gpt-5.4".to_string()),
);
node.attrs.insert(
"reasoning_effort".to_string(),
AttrValue::String("high".to_string()),
);
graph.nodes.insert("prompt".to_string(), node);
let diagnostics = Rule.apply(&graph);
assert_eq!(diagnostics.len(), 2);
assert!(diagnostics.iter().any(|diagnostic| {
diagnostic
.message
.contains("backend=\"acp\" is only valid on agent nodes")
}));
assert!(diagnostics.iter().any(|diagnostic| {
diagnostic
.message
.contains("backend=\"acp\" does not support API-only attributes")
}));
}
#[test]
fn backend_valid_rejects_invalid_acp_config_but_accepts_json_shaped_command() {
let mut command_graph = minimal_graph();
let mut command_node = Node::new("command");
command_node
.attrs
.insert("backend".to_string(), AttrValue::String("acp".to_string()));
command_node.attrs.insert(
"acp.command".to_string(),
AttrValue::String(r#"{"type":"stdio"}"#.to_string()),
);
command_graph
.nodes
.insert("command".to_string(), command_node);
assert!(Rule.apply(&command_graph).is_empty());
let mut config_graph = minimal_graph();
let mut config_node = Node::new("config");
config_node
.attrs
.insert("backend".to_string(), AttrValue::String("acp".to_string()));
config_node.attrs.insert(
"acp.config".to_string(),
AttrValue::String("python3 agent.py".to_string()),
);
config_graph.nodes.insert("config".to_string(), config_node);
let diagnostics = Rule.apply(&config_graph);
assert_eq!(diagnostics.len(), 1);
assert!(
diagnostics[0]
.message
.contains("acp.config must be a JSON stdio ACP config")
);
}
#[test]
fn backend_valid_rejects_invalid_acp_command() {
let mut graph = minimal_graph();
let mut node = Node::new("command");
node.attrs
.insert("backend".to_string(), AttrValue::String("acp".to_string()));
node.attrs.insert(
"acp.command".to_string(),
AttrValue::String("python 'unterminated".to_string()),
);
graph.nodes.insert("command".to_string(), node);
let diagnostics = Rule.apply(&graph);
assert_eq!(diagnostics.len(), 1);
assert!(
diagnostics[0]
.message
.contains("failed to parse acp.command as a shell command")
);
}
}

View file

@ -1,146 +0,0 @@
use fabro_graphviz::graph::Graph;
use crate::{Diagnostic, LintRule, Severity};
pub(super) fn rule() -> Box<dyn LintRule> {
Box::new(Rule)
}
struct Rule;
impl LintRule for Rule {
fn name(&self) -> &'static str {
"command_requires_script"
}
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
for node in graph.nodes.values() {
if node.handler_type() != Some("command") {
continue;
}
if node.script().is_some_and(|s| !s.trim().is_empty()) {
continue;
}
diagnostics.push(Diagnostic {
rule: self.name().to_string(),
severity: Severity::Error,
message: format!("Command node '{}' has no 'script' to run", node.id),
node_id: Some(node.id.clone()),
edge: None,
fix: Some(
"Add a 'script' attribute, or remove the command shape or type if this was \
meant to be an agent node"
.to_string(),
),
..Diagnostic::default()
});
}
diagnostics
}
}
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::{AttrValue, Node};
use super::Rule;
use crate::rules::test_support;
use crate::{LintRule, Severity};
#[test]
fn errors_on_command_shape_without_script() {
let mut g = test_support::minimal_graph();
g.nodes.insert(
"run".to_string(),
test_support::node_with_attrs("run", &[("shape", "parallelogram"), ("label", "Build")]),
);
let d = Rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Error);
assert!(d[0].message.contains("no 'script'"));
assert_eq!(d[0].node_id.as_deref(), Some("run"));
}
#[test]
fn errors_on_explicit_command_type_without_script() {
let mut g = test_support::minimal_graph();
g.nodes.insert(
"run".to_string(),
test_support::node_with_attrs("run", &[("type", "command")]),
);
let d = Rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Error);
}
#[test]
fn errors_on_legacy_tool_type_without_script() {
let mut g = test_support::minimal_graph();
g.nodes.insert(
"run".to_string(),
test_support::node_with_attrs("run", &[("type", "tool")]),
);
assert_eq!(Rule.apply(&g).len(), 1);
}
#[test]
fn errors_on_blank_script() {
let mut g = test_support::minimal_graph();
g.nodes.insert(
"run".to_string(),
test_support::node_with_attrs("run", &[("shape", "parallelogram"), ("script", " ")]),
);
assert_eq!(Rule.apply(&g).len(), 1);
}
#[test]
fn errors_on_non_string_script() {
let mut g = test_support::minimal_graph();
let mut node = Node::new("run");
node.attrs
.insert("script".to_string(), AttrValue::Integer(123));
g.nodes.insert("run".to_string(), node);
assert_eq!(Rule.apply(&g).len(), 1);
}
#[test]
fn accepts_command_node_with_script() {
let mut g = test_support::minimal_graph();
g.nodes.insert(
"run".to_string(),
test_support::node_with_attrs("run", &[
("shape", "parallelogram"),
("script", "cargo build"),
]),
);
g.nodes.insert(
"build".to_string(),
test_support::node_with_attrs("build", &[("script", "cargo build")]),
);
assert!(Rule.apply(&g).is_empty());
}
#[test]
fn ignores_non_command_nodes() {
let mut g = test_support::minimal_graph();
g.nodes.insert(
"plan".to_string(),
test_support::node_with_attrs("plan", &[("prompt", "do it")]),
);
g.nodes.insert(
"gate".to_string(),
test_support::node_with_attrs("gate", &[("shape", "hexagon")]),
);
assert!(Rule.apply(&g).is_empty());
}
}

View file

@ -1,276 +0,0 @@
use fabro_graphviz::condition::parse_condition;
use fabro_graphviz::graph::Graph;
use crate::{Diagnostic, LintRule, Severity};
pub(super) fn rule() -> Box<dyn LintRule> {
Box::new(Rule)
}
struct Rule;
impl LintRule for Rule {
fn name(&self) -> &'static str {
"condition_syntax"
}
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
for edge in &graph.edges {
let Some(condition) = edge.condition() else {
continue;
};
if condition.is_empty() {
continue;
}
if let Err(e) = parse_condition(condition) {
diagnostics.push(Diagnostic {
rule: self.name().to_string(),
severity: Severity::Error,
message: format!(
"Condition '{condition}' on edge {} -> {} failed parse: {e}",
edge.from, edge.to
),
node_id: None,
edge: Some((edge.from.clone(), edge.to.clone())),
fix: Some(
"Use key=value, key!=value, key>value, key contains value, \
key matches pattern, or bare key syntax"
.to_string(),
),
..Diagnostic::default()
});
}
}
diagnostics
}
}
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::{AttrValue, Edge};
use super::Rule;
use crate::rules::test_support::minimal_graph;
use crate::{LintRule, Severity};
#[test]
fn condition_syntax_rule_valid_condition() {
let mut g = minimal_graph();
let mut edge = Edge::new("start", "exit");
edge.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=succeeded".to_string()),
);
g.edges = vec![edge];
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn condition_syntax_rule_invalid_clause() {
let mut g = minimal_graph();
let mut edge = Edge::new("start", "exit");
edge.attrs.insert(
"condition".to_string(),
AttrValue::String("bad clause here".to_string()),
);
g.edges = vec![edge];
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Error);
}
#[test]
fn condition_syntax_rule_not_equals() {
let mut g = minimal_graph();
let mut edge = Edge::new("start", "exit");
edge.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome!=failed".to_string()),
);
g.edges = vec![edge];
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn condition_syntax_rule_empty_condition() {
let mut g = minimal_graph();
let mut edge = Edge::new("start", "exit");
edge.attrs
.insert("condition".to_string(), AttrValue::String(String::new()));
g.edges = vec![edge];
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn condition_syntax_rule_compound_and() {
let mut g = minimal_graph();
let mut edge = Edge::new("start", "exit");
edge.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=succeeded && retries=0".to_string()),
);
g.edges = vec![edge];
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
// --- Additional coverage: terminal_node two terminals ---
#[test]
fn condition_syntax_rule_bare_key_truthy() {
let mut g = minimal_graph();
let mut edge = Edge::new("start", "exit");
edge.attrs.insert(
"condition".to_string(),
AttrValue::String("context.passed".to_string()),
);
g.edges = vec![edge];
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
// --- condition_syntax: context-prefixed clause with spaces is rejected ---
#[test]
fn condition_syntax_rule_context_prefix_with_space() {
let mut g = minimal_graph();
let mut edge = Edge::new("start", "exit");
edge.attrs.insert(
"condition".to_string(),
AttrValue::String("context.foo bar".to_string()),
);
g.edges = vec![edge];
let rule = Rule;
let d = rule.apply(&g);
// "context.foo bar" has an unexpected trailing word — parse error
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Error);
}
// --- terminal_node: by "Exit" capitalized id ---
#[test]
fn condition_syntax_rule_no_condition_attr() {
let g = minimal_graph();
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
// --- freeform_edge_count: freeform=false does not count ---
#[test]
fn condition_syntax_rule_empty_key_fails_parse() {
let mut g = minimal_graph();
let mut edge = Edge::new("start", "exit");
edge.attrs.insert(
"condition".to_string(),
AttrValue::String("=value".to_string()),
);
g.edges = vec![edge];
let rule = Rule;
let d = rule.apply(&g);
// parse_condition catches empty key
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Error);
assert!(d[0].message.contains("failed parse"));
}
#[test]
fn condition_syntax_rule_valid_passes_both_checks() {
let mut g = minimal_graph();
let mut edge = Edge::new("start", "exit");
edge.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=succeeded".to_string()),
);
g.edges = vec![edge];
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
// --- condition_syntax: new operators accepted ---
#[test]
fn condition_syntax_rule_accepts_or() {
let mut g = minimal_graph();
let mut edge = Edge::new("start", "exit");
edge.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=succeeded || outcome=failed".to_string()),
);
g.edges = vec![edge];
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn condition_syntax_rule_accepts_not() {
let mut g = minimal_graph();
let mut edge = Edge::new("start", "exit");
edge.attrs.insert(
"condition".to_string(),
AttrValue::String("!outcome=failed".to_string()),
);
g.edges = vec![edge];
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn condition_syntax_rule_accepts_contains() {
let mut g = minimal_graph();
let mut edge = Edge::new("start", "exit");
edge.attrs.insert(
"condition".to_string(),
AttrValue::String("context.x contains y".to_string()),
);
g.edges = vec![edge];
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn condition_syntax_rule_accepts_numeric() {
let mut g = minimal_graph();
let mut edge = Edge::new("start", "exit");
edge.attrs.insert(
"condition".to_string(),
AttrValue::String("context.score > 80".to_string()),
);
g.edges = vec![edge];
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn condition_syntax_rule_rejects_invalid_regex() {
let mut g = minimal_graph();
let mut edge = Edge::new("start", "exit");
edge.attrs.insert(
"condition".to_string(),
AttrValue::String("context.x matches [bad".to_string()),
);
g.edges = vec![edge];
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Error);
}
}

View file

@ -1,85 +0,0 @@
use fabro_graphviz::graph::{AttrValue, Graph};
use crate::{Diagnostic, LintRule, Severity};
pub(super) fn rule() -> Box<dyn LintRule> {
Box::new(Rule)
}
struct Rule;
const VALID_DIRECTIONS: &[&str] = &["TB", "LR", "BT", "RL"];
impl LintRule for Rule {
fn name(&self) -> &'static str {
"direction_valid"
}
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
let Some(rankdir) = graph.attrs.get("rankdir").and_then(AttrValue::as_str) else {
return Vec::new();
};
if VALID_DIRECTIONS.contains(&rankdir) {
return Vec::new();
}
vec![Diagnostic {
rule: self.name().to_string(),
severity: Severity::Warning,
message: format!("Graph has invalid rankdir '{rankdir}'"),
node_id: None,
edge: None,
fix: Some(format!("Use one of: {}", VALID_DIRECTIONS.join(", "))),
..Diagnostic::default()
}]
}
}
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::AttrValue;
use super::Rule;
use crate::rules::test_support::minimal_graph;
use crate::{LintRule, Severity};
#[test]
fn direction_valid_rule_no_rankdir() {
let g = minimal_graph();
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn direction_valid_rule_valid_directions() {
let rule = Rule;
let mut g = minimal_graph();
g.attrs
.insert("rankdir".to_string(), AttrValue::String("LR".to_string()));
assert!(rule.apply(&g).is_empty());
g.attrs
.insert("rankdir".to_string(), AttrValue::String("TB".to_string()));
assert!(rule.apply(&g).is_empty());
g.attrs
.insert("rankdir".to_string(), AttrValue::String("BT".to_string()));
assert!(rule.apply(&g).is_empty());
g.attrs
.insert("rankdir".to_string(), AttrValue::String("RL".to_string()));
assert!(rule.apply(&g).is_empty());
}
#[test]
fn direction_valid_rule_invalid_direction() {
let mut g = minimal_graph();
g.attrs
.insert("rankdir".to_string(), AttrValue::String("XY".to_string()));
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Warning);
}
}

View file

@ -1,216 +0,0 @@
use std::collections::HashSet;
use fabro_graphviz::graph::Graph;
use crate::{Diagnostic, LintRule, Severity};
pub(super) fn rule() -> Box<dyn LintRule> {
Box::new(Rule)
}
struct Rule;
impl Rule {
fn diagnostic(&self, node_id: &str, from: &str, to: &str) -> Diagnostic {
Diagnostic {
rule: self.name().to_string(),
severity: Severity::Error,
message: format!(
"Node '{node_id}' is referenced by edge '{from} -> {to}' but has no node \
declaration"
),
node_id: Some(node_id.to_string()),
edge: Some((from.to_string(), to.to_string())),
fix: Some(format!(
"Declare node '{node_id}' or correct the edge endpoint"
)),
..Diagnostic::default()
}
}
}
impl LintRule for Rule {
fn name(&self) -> &'static str {
"edge_target_exists"
}
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
let mut reported = HashSet::new();
for edge in &graph.edges {
for endpoint in [&edge.to, &edge.from] {
if !graph.nodes.contains_key(endpoint) && reported.insert(endpoint) {
diagnostics.push(self.diagnostic(endpoint, &edge.from, &edge.to));
}
}
}
diagnostics
}
}
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::{Edge, Graph};
use fabro_graphviz::parser;
use super::Rule;
use crate::rules::test_support::minimal_graph;
use crate::{Diagnostic, LintRule, Severity};
fn parse(dot: &str) -> Graph {
parser::parse(dot).expect("fixture should parse")
}
fn undeclared_nodes(graph: &Graph) -> Vec<String> {
Rule.apply(graph)
.into_iter()
.map(|d| d.node_id.expect("diagnostic should name a node"))
.collect()
}
#[test]
fn edge_only_node_is_rejected() {
let graph = parse(
r"digraph EdgeOnly {
start [shape=Mdiamond]
exit [shape=Msquare]
start -> misspelled_node
misspelled_node -> exit
}",
);
let diagnostics = Rule.apply(&graph);
assert_eq!(diagnostics.len(), 1, "diagnostics: {diagnostics:?}");
let Diagnostic {
severity,
node_id,
edge,
..
} = &diagnostics[0];
assert_eq!(*severity, Severity::Error);
assert_eq!(node_id.as_deref(), Some("misspelled_node"));
assert_eq!(
edge.clone(),
Some(("start".to_string(), "misspelled_node".to_string()))
);
}
#[test]
fn declaration_after_the_edge_is_accepted() {
let graph = parse(
r#"digraph DeclaredLater {
start -> work
work [prompt="Do the work"]
work -> exit
start [shape=Mdiamond]
exit [shape=Msquare]
}"#,
);
assert!(Rule.apply(&graph).is_empty());
}
#[test]
fn chained_edges_report_every_undeclared_endpoint() {
let graph = parse(
r"digraph Chained {
start [shape=Mdiamond]
exit [shape=Msquare]
start -> first -> second -> exit
}",
);
assert_eq!(undeclared_nodes(&graph), vec!["first", "second"]);
}
#[test]
fn a_node_is_reported_once_no_matter_how_many_edges_use_it() {
let graph = parse(
r"digraph Repeated {
start [shape=Mdiamond]
exit [shape=Msquare]
start -> typo
typo -> exit
typo -> start
}",
);
assert_eq!(undeclared_nodes(&graph), vec!["typo"]);
}
#[test]
fn subgraph_declaration_is_accepted() {
let graph = parse(
r#"digraph Subgraphed {
start [shape=Mdiamond]
exit [shape=Msquare]
subgraph cluster_loop {
label = "Loop A"
plan [prompt="Plan the work"]
}
start -> plan -> exit
}"#,
);
assert!(Rule.apply(&graph).is_empty());
}
#[test]
fn edge_target_exists_rule_missing_target() {
let mut g = minimal_graph();
g.edges.push(Edge::new("start", "nonexistent"));
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Error);
}
#[test]
fn edge_target_exists_rule_valid() {
let g = minimal_graph();
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn edge_target_exists_rule_missing_source() {
let mut g = minimal_graph();
g.edges.push(Edge::new("nonexistent_source", "exit"));
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Error);
assert!(d[0].message.contains("nonexistent_source"));
}
// --- Additional coverage: reachability no start node ---
#[test]
fn edge_target_exists_rule_both_missing() {
let mut g = minimal_graph();
g.edges
.push(Edge::new("nonexistent_source", "nonexistent_target"));
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 2);
assert_eq!(d[0].severity, Severity::Error);
assert_eq!(d[1].severity, Severity::Error);
}
// --- reachability: multiple unreachable nodes ---
#[test]
fn edge_target_exists_rule_no_edges() {
let mut g = minimal_graph();
g.edges.clear();
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
// --- goal_gate_has_retry: goal_gate=false explicitly ---
}

View file

@ -1,139 +0,0 @@
use fabro_graphviz::graph::Graph;
use crate::{Diagnostic, LintRule, Severity};
pub(super) fn rule() -> Box<dyn LintRule> {
Box::new(Rule)
}
struct Rule;
impl LintRule for Rule {
fn name(&self) -> &'static str {
"exit_no_outgoing"
}
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
for (id, node) in &graph.nodes {
let is_terminal = node.shape() == "Msquare"
|| *id == "exit"
|| *id == "Exit"
|| *id == "end"
|| *id == "End";
if is_terminal {
let outgoing = graph.outgoing_edges(&node.id);
if !outgoing.is_empty() {
diagnostics.push(Diagnostic {
rule: self.name().to_string(),
severity: Severity::Error,
message: format!(
"Exit node '{}' has {} outgoing edge(s) but must have none",
node.id,
outgoing.len()
),
node_id: Some(node.id.clone()),
edge: None,
fix: Some("Remove outgoing edges from the exit node".to_string()),
..Diagnostic::default()
});
}
}
}
diagnostics
}
}
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};
use super::Rule;
use crate::rules::test_support::minimal_graph;
use crate::{LintRule, Severity};
#[test]
fn exit_no_outgoing_rule_with_outgoing() {
let mut g = minimal_graph();
g.edges.push(Edge::new("exit", "start"));
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Error);
}
#[test]
fn exit_no_outgoing_rule_clean() {
let g = minimal_graph();
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn exit_no_outgoing_rule_end_id_with_outgoing() {
let mut g = Graph::new("test");
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
g.nodes.insert("start".to_string(), start);
let end_node = Node::new("end");
g.nodes.insert("end".to_string(), end_node);
g.edges.push(Edge::new("start", "end"));
g.edges.push(Edge::new("end", "start"));
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Error);
assert_eq!(d[0].node_id, Some("end".to_string()));
}
// --- condition_syntax: bare key (truthy check) is valid ---
#[test]
fn exit_no_outgoing_rule_exit_capitalized_with_outgoing() {
let mut g = Graph::new("test");
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
g.nodes.insert("start".to_string(), start);
let exit_node = Node::new("Exit");
g.nodes.insert("Exit".to_string(), exit_node);
g.edges.push(Edge::new("start", "Exit"));
g.edges.push(Edge::new("Exit", "start"));
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Error);
assert_eq!(d[0].node_id, Some("Exit".to_string()));
}
// --- exit_no_outgoing: by "End" capitalized id ---
#[test]
fn exit_no_outgoing_rule_end_capitalized_with_outgoing() {
let mut g = Graph::new("test");
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
g.nodes.insert("start".to_string(), start);
let end_node = Node::new("End");
g.nodes.insert("End".to_string(), end_node);
g.edges.push(Edge::new("start", "End"));
g.edges.push(Edge::new("End", "start"));
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Error);
assert_eq!(d[0].node_id, Some("End".to_string()));
}
// --- stylesheet_syntax: valid multi-rule stylesheet ---
}

View file

@ -1,244 +0,0 @@
use fabro_graphviz::graph::Graph;
use crate::{Diagnostic, LintRule, Severity};
pub(super) fn rule() -> Box<dyn LintRule> {
Box::new(Rule)
}
struct Rule;
impl Rule {
fn fix_message() -> String {
use fabro_graphviz::Fidelity;
let modes: Vec<_> = Fidelity::variants()
.iter()
.map(ToString::to_string)
.collect();
format!("Use one of: {}", modes.join(", "))
}
}
impl LintRule for Rule {
fn name(&self) -> &'static str {
"fidelity_valid"
}
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
use fabro_graphviz::Fidelity;
let mut diagnostics = Vec::new();
for node in graph.nodes.values() {
if let Some(fidelity) = node.fidelity() {
if fidelity.parse::<Fidelity>().is_err() {
diagnostics.push(Diagnostic {
rule: self.name().to_string(),
severity: Severity::Warning,
message: format!(
"Node '{}' has invalid fidelity mode '{fidelity}'",
node.id
),
node_id: Some(node.id.clone()),
edge: None,
fix: Some(Self::fix_message()),
..Diagnostic::default()
});
}
}
}
for edge in &graph.edges {
if let Some(fidelity) = edge.fidelity() {
if fidelity.parse::<Fidelity>().is_err() {
diagnostics.push(Diagnostic {
rule: self.name().to_string(),
severity: Severity::Warning,
message: format!(
"Edge {} -> {} has invalid fidelity mode '{fidelity}'",
edge.from, edge.to
),
node_id: None,
edge: Some((edge.from.clone(), edge.to.clone())),
fix: Some(Self::fix_message()),
..Diagnostic::default()
});
}
}
}
if let Some(fidelity) = graph.default_fidelity() {
if fidelity.parse::<Fidelity>().is_err() {
diagnostics.push(Diagnostic {
rule: self.name().to_string(),
severity: Severity::Warning,
message: format!("Graph has invalid default_fidelity '{fidelity}'"),
node_id: None,
edge: None,
fix: Some(Self::fix_message()),
..Diagnostic::default()
});
}
}
diagnostics
}
}
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::{AttrValue, Edge, Node};
use super::Rule;
use crate::rules::test_support::minimal_graph;
use crate::{LintRule, Severity};
#[test]
fn fidelity_valid_rule_invalid_mode() {
let mut g = minimal_graph();
let mut node = Node::new("work");
node.attrs.insert(
"fidelity".to_string(),
AttrValue::String("invalid_mode".to_string()),
);
g.nodes.insert("work".to_string(), node);
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Warning);
}
#[test]
fn fidelity_valid_rule_valid_mode() {
let mut g = minimal_graph();
let mut node = Node::new("work");
node.attrs.insert(
"fidelity".to_string(),
AttrValue::String("full".to_string()),
);
g.nodes.insert("work".to_string(), node);
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn fidelity_valid_rule_invalid_edge_fidelity() {
let mut g = minimal_graph();
let mut edge = Edge::new("start", "exit");
edge.attrs.insert(
"fidelity".to_string(),
AttrValue::String("bogus".to_string()),
);
g.edges = vec![edge];
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Warning);
assert!(d[0].edge.is_some());
}
#[test]
fn fidelity_valid_rule_valid_edge_fidelity() {
let mut g = minimal_graph();
let mut edge = Edge::new("start", "exit");
edge.attrs.insert(
"fidelity".to_string(),
AttrValue::String("compact".to_string()),
);
g.edges = vec![edge];
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn fidelity_valid_rule_invalid_graph_default() {
let mut g = minimal_graph();
g.attrs.insert(
"default_fidelity".to_string(),
AttrValue::String("wrong".to_string()),
);
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Warning);
assert!(d[0].message.contains("default_fidelity"));
}
#[test]
fn fidelity_valid_rule_valid_graph_default() {
let mut g = minimal_graph();
g.attrs.insert(
"default_fidelity".to_string(),
AttrValue::String("summary:high".to_string()),
);
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn fidelity_valid_rule_all_summary_modes() {
let rule = Rule;
let mut g = minimal_graph();
let mut node = Node::new("w1");
node.attrs.insert(
"fidelity".to_string(),
AttrValue::String("summary:low".to_string()),
);
g.nodes.insert("w1".to_string(), node);
assert!(rule.apply(&g).is_empty());
let mut g = minimal_graph();
let mut node = Node::new("w2");
node.attrs.insert(
"fidelity".to_string(),
AttrValue::String("summary:medium".to_string()),
);
g.nodes.insert("w2".to_string(), node);
assert!(rule.apply(&g).is_empty());
let mut g = minimal_graph();
let mut node = Node::new("w3");
node.attrs.insert(
"fidelity".to_string(),
AttrValue::String("truncate".to_string()),
);
g.nodes.insert("w3".to_string(), node);
assert!(rule.apply(&g).is_empty());
}
// --- Additional coverage: freeform_edge_count non-wait.human ---
#[test]
fn fidelity_valid_rule_node_and_edge_and_graph_all_invalid() {
let mut g = minimal_graph();
let mut node = Node::new("work");
node.attrs.insert(
"fidelity".to_string(),
AttrValue::String("invalid_node".to_string()),
);
g.nodes.insert("work".to_string(), node);
let mut edge = Edge::new("start", "exit");
edge.attrs.insert(
"fidelity".to_string(),
AttrValue::String("invalid_edge".to_string()),
);
g.edges = vec![edge];
g.attrs.insert(
"default_fidelity".to_string(),
AttrValue::String("invalid_graph".to_string()),
);
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 3);
}
// --- retry_target_exists: both retry_target and fallback on same node,
// both invalid ---
}

View file

@ -1,231 +0,0 @@
use fabro_graphviz::graph::{Graph, is_llm_handler_type};
use crate::{Diagnostic, LintRule, Severity};
pub(super) fn rule() -> Box<dyn LintRule> {
Box::new(Rule)
}
struct Rule;
fn diagnostic(node_id: &str, message: String, fix: impl Into<String>) -> Diagnostic {
Diagnostic {
rule: "for_each_contract".to_string(),
severity: Severity::Error,
message,
node_id: Some(node_id.to_string()),
edge: None,
fix: Some(fix.into()),
..Diagnostic::default()
}
}
impl LintRule for Rule {
fn name(&self) -> &'static str {
"for_each_contract"
}
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
for node in graph.nodes.values() {
if !node.attrs.contains_key("for_each") {
continue;
}
// Reported as an error rather than through `inert_attribute`: an
// ignored tuning knob is a warning, but a fan-out that silently
// never happens is not.
if node.handler_type() != Some("parallel") {
diagnostics.push(diagnostic(
&node.id,
format!(
"Node '{}' sets 'for_each', but only parallel nodes can fan out over runtime items",
node.id
),
"Remove 'for_each' or change the node to type=\"parallel\"",
));
continue;
}
if node
.for_each()
.is_none_or(|source| source.trim().is_empty())
{
diagnostics.push(diagnostic(
&node.id,
format!(
"Node '{}' has an empty or non-string 'for_each' source",
node.id
),
"Set 'for_each' to a context key such as \"context.candidates\"",
));
}
let outgoing = graph.outgoing_edges(&node.id);
if outgoing.len() != 1 {
diagnostics.push(diagnostic(
&node.id,
format!(
"Parallel node '{}' sets 'for_each' and must have exactly one outgoing template edge, but has {}",
node.id,
outgoing.len()
),
"Keep one outgoing edge whose target is the agent or prompt to run for each item",
));
continue;
}
let target_id = &outgoing[0].to;
let Some(target) = graph.nodes.get(target_id) else {
continue;
};
if target.attrs.contains_key("for_each") {
diagnostics.push(diagnostic(
&node.id,
format!(
"Parallel node '{}' targets '{}', which also sets 'for_each'; nested for_each is not supported",
node.id, target.id
),
"Remove the nested 'for_each' and use a single runtime fan-out",
));
}
if !is_llm_handler_type(target.handler_type()) {
diagnostics.push(diagnostic(
&node.id,
format!(
"Parallel node '{}' sets 'for_each', but template target '{}' is not an agent or prompt node",
node.id, target.id
),
"Target one agent or prompt node from the for_each parallel node",
));
}
}
diagnostics
}
}
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::{AttrValue, Edge, Node};
use super::Rule;
use crate::rules::test_support::minimal_graph;
use crate::{LintRule, Severity};
fn for_each_node(id: &str) -> Node {
let mut node = Node::new(id);
node.attrs.insert(
"type".to_string(),
AttrValue::String("parallel".to_string()),
);
node.attrs.insert(
"for_each".to_string(),
AttrValue::String("context.items".to_string()),
);
node
}
#[test]
fn accepts_one_agent_template_target() {
let mut graph = minimal_graph();
graph
.nodes
.insert("fanout".to_string(), for_each_node("fanout"));
graph
.nodes
.insert("worker".to_string(), Node::new("worker"));
graph.edges.push(Edge::new("fanout", "worker"));
assert!(Rule.apply(&graph).is_empty());
}
#[test]
fn rejects_for_each_on_non_parallel_node() {
let mut graph = minimal_graph();
let mut worker = Node::new("worker");
worker.attrs.insert(
"for_each".to_string(),
AttrValue::String("items".to_string()),
);
graph.nodes.insert("worker".to_string(), worker);
let diagnostics = Rule.apply(&graph);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].severity, Severity::Error);
assert!(diagnostics[0].message.contains("only parallel"));
}
#[test]
fn rejects_multiple_template_edges() {
let mut graph = minimal_graph();
graph
.nodes
.insert("fanout".to_string(), for_each_node("fanout"));
graph.nodes.insert("one".to_string(), Node::new("one"));
graph.nodes.insert("two".to_string(), Node::new("two"));
graph.edges.push(Edge::new("fanout", "one"));
graph.edges.push(Edge::new("fanout", "two"));
let diagnostics = Rule.apply(&graph);
assert_eq!(diagnostics.len(), 1);
assert!(diagnostics[0].message.contains("exactly one"));
}
#[test]
fn rejects_non_llm_and_nested_template_targets() {
let mut graph = minimal_graph();
graph
.nodes
.insert("outer".to_string(), for_each_node("outer"));
graph
.nodes
.insert("inner".to_string(), for_each_node("inner"));
graph.edges.push(Edge::new("outer", "inner"));
let diagnostics = Rule.apply(&graph);
let outer_diagnostics = diagnostics
.iter()
.filter(|diagnostic| diagnostic.node_id.as_deref() == Some("outer"))
.collect::<Vec<_>>();
assert_eq!(outer_diagnostics.len(), 2);
assert!(diagnostics.iter().all(|d| d.severity == Severity::Error));
assert!(
outer_diagnostics
.iter()
.any(|d| d.message.contains("nested"))
);
assert!(
outer_diagnostics
.iter()
.any(|d| d.message.contains("not an agent or prompt"))
);
}
#[test]
fn rejects_empty_or_non_string_source() {
for value in [
AttrValue::String(String::new()),
AttrValue::String(" ".to_string()),
AttrValue::Integer(3),
] {
let mut graph = minimal_graph();
let mut fanout = for_each_node("fanout");
fanout.attrs.insert("for_each".to_string(), value);
graph.nodes.insert("fanout".to_string(), fanout);
graph
.nodes
.insert("worker".to_string(), Node::new("worker"));
graph.edges.push(Edge::new("fanout", "worker"));
let diagnostics = Rule.apply(&graph);
assert_eq!(diagnostics.len(), 1);
assert!(diagnostics[0].message.contains("empty or non-string"));
}
}
}

View file

@ -1,197 +0,0 @@
use fabro_graphviz::graph::Graph;
use crate::{Diagnostic, LintRule, Severity};
pub(super) fn rule() -> Box<dyn LintRule> {
Box::new(Rule)
}
struct Rule;
impl LintRule for Rule {
fn name(&self) -> &'static str {
"freeform_edge_count"
}
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
for node in graph.nodes.values() {
if node.handler_type() == Some("human") {
let freeform_count = graph
.outgoing_edges(&node.id)
.iter()
.filter(|e| e.freeform())
.count();
if freeform_count > 1 {
diagnostics.push(Diagnostic {
rule: self.name().to_string(),
severity: Severity::Error,
message: format!(
"wait.human node '{}' has {freeform_count} freeform edges but at most one is allowed",
node.id
),
node_id: Some(node.id.clone()),
edge: None,
fix: Some(
"Remove extra freeform=true edges so at most one remains".to_string(),
),
..Diagnostic::default()});
}
}
}
diagnostics
}
}
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::{AttrValue, Edge, Node};
use super::Rule;
use crate::rules::test_support::minimal_graph;
use crate::{LintRule, Severity};
#[test]
fn freeform_edge_count_rule_two_freeform() {
let mut g = minimal_graph();
let mut gate = Node::new("gate");
gate.attrs.insert(
"shape".to_string(),
AttrValue::String("hexagon".to_string()),
);
g.nodes.insert("gate".to_string(), gate);
g.nodes.insert("a".to_string(), Node::new("a"));
g.nodes.insert("b".to_string(), Node::new("b"));
let mut e1 = Edge::new("gate", "a");
e1.attrs
.insert("freeform".to_string(), AttrValue::Boolean(true));
let mut e2 = Edge::new("gate", "b");
e2.attrs
.insert("freeform".to_string(), AttrValue::Boolean(true));
g.edges.push(e1);
g.edges.push(e2);
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Error);
}
#[test]
fn freeform_edge_count_rule_one_freeform() {
let mut g = minimal_graph();
let mut gate = Node::new("gate");
gate.attrs.insert(
"shape".to_string(),
AttrValue::String("hexagon".to_string()),
);
g.nodes.insert("gate".to_string(), gate);
g.nodes.insert("a".to_string(), Node::new("a"));
let mut e1 = Edge::new("gate", "a");
e1.attrs
.insert("freeform".to_string(), AttrValue::Boolean(true));
g.edges.push(e1);
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn freeform_edge_count_rule_non_wait_human_ignored() {
let mut g = minimal_graph();
// Regular codergen node (box shape) with multiple freeform edges should not
// trigger
g.nodes.insert("a".to_string(), Node::new("a"));
g.nodes.insert("b".to_string(), Node::new("b"));
g.nodes.insert("work".to_string(), Node::new("work"));
let mut e1 = Edge::new("work", "a");
e1.attrs
.insert("freeform".to_string(), AttrValue::Boolean(true));
let mut e2 = Edge::new("work", "b");
e2.attrs
.insert("freeform".to_string(), AttrValue::Boolean(true));
g.edges.push(e1);
g.edges.push(e2);
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn freeform_edge_count_rule_zero_freeform() {
let mut g = minimal_graph();
let mut gate = Node::new("gate");
gate.attrs
.insert("type".to_string(), AttrValue::String("human".to_string()));
g.nodes.insert("gate".to_string(), gate);
g.nodes.insert("a".to_string(), Node::new("a"));
g.edges.push(Edge::new("gate", "a"));
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
// --- Additional coverage: stylesheet_syntax no stylesheet ---
#[test]
fn freeform_edge_count_rule_explicit_type_two_freeform() {
let mut g = minimal_graph();
let mut gate = Node::new("gate");
gate.attrs
.insert("type".to_string(), AttrValue::String("human".to_string()));
g.nodes.insert("gate".to_string(), gate);
g.nodes.insert("a".to_string(), Node::new("a"));
g.nodes.insert("b".to_string(), Node::new("b"));
let mut e1 = Edge::new("gate", "a");
e1.attrs
.insert("freeform".to_string(), AttrValue::Boolean(true));
let mut e2 = Edge::new("gate", "b");
e2.attrs
.insert("freeform".to_string(), AttrValue::Boolean(true));
g.edges.push(e1);
g.edges.push(e2);
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Error);
}
// --- exit_no_outgoing: by "Exit" capitalized id ---
#[test]
fn freeform_edge_count_rule_freeform_false_ignored() {
let mut g = minimal_graph();
let mut gate = Node::new("gate");
gate.attrs.insert(
"shape".to_string(),
AttrValue::String("hexagon".to_string()),
);
g.nodes.insert("gate".to_string(), gate);
g.nodes.insert("a".to_string(), Node::new("a"));
g.nodes.insert("b".to_string(), Node::new("b"));
let mut e1 = Edge::new("gate", "a");
e1.attrs
.insert("freeform".to_string(), AttrValue::Boolean(false));
let mut e2 = Edge::new("gate", "b");
e2.attrs
.insert("freeform".to_string(), AttrValue::Boolean(false));
g.edges.push(e1);
g.edges.push(e2);
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
// --- reachability: chain of reachable nodes ---
}

View file

@ -1,158 +0,0 @@
use fabro_graphviz::graph::Graph;
use crate::{Diagnostic, LintRule, Severity};
pub(super) fn rule() -> Box<dyn LintRule> {
Box::new(Rule)
}
struct Rule;
impl LintRule for Rule {
fn name(&self) -> &'static str {
"goal_gate_has_retry"
}
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
for node in graph.nodes.values() {
if node.goal_gate() {
let has_node_retry =
node.retry_target().is_some() || node.fallback_retry_target().is_some();
let has_graph_retry =
graph.retry_target().is_some() || graph.fallback_retry_target().is_some();
if !has_node_retry && !has_graph_retry {
diagnostics.push(Diagnostic {
rule: self.name().to_string(),
severity: Severity::Warning,
message: format!(
"Node '{}' has goal_gate=true but no retry_target or fallback_retry_target",
node.id
),
node_id: Some(node.id.clone()),
edge: None,
fix: Some(
"Add retry_target or fallback_retry_target attribute".to_string(),
),
..Diagnostic::default()});
}
}
}
diagnostics
}
}
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::{AttrValue, Node};
use super::Rule;
use crate::rules::test_support::minimal_graph;
use crate::{LintRule, Severity};
#[test]
fn goal_gate_has_retry_rule_no_retry() {
let mut g = minimal_graph();
let mut node = Node::new("work");
node.attrs
.insert("goal_gate".to_string(), AttrValue::Boolean(true));
g.nodes.insert("work".to_string(), node);
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Warning);
}
#[test]
fn goal_gate_has_retry_rule_with_retry() {
let mut g = minimal_graph();
let mut node = Node::new("work");
node.attrs
.insert("goal_gate".to_string(), AttrValue::Boolean(true));
node.attrs.insert(
"retry_target".to_string(),
AttrValue::String("start".to_string()),
);
g.nodes.insert("work".to_string(), node);
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn goal_gate_has_retry_rule_with_graph_retry_target() {
let mut g = minimal_graph();
let mut node = Node::new("work");
node.attrs
.insert("goal_gate".to_string(), AttrValue::Boolean(true));
g.nodes.insert("work".to_string(), node);
g.attrs.insert(
"retry_target".to_string(),
AttrValue::String("start".to_string()),
);
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn goal_gate_has_retry_rule_with_fallback_retry_target() {
let mut g = minimal_graph();
let mut node = Node::new("work");
node.attrs
.insert("goal_gate".to_string(), AttrValue::Boolean(true));
node.attrs.insert(
"fallback_retry_target".to_string(),
AttrValue::String("start".to_string()),
);
g.nodes.insert("work".to_string(), node);
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn goal_gate_has_retry_rule_not_goal_gate() {
let mut g = minimal_graph();
let node = Node::new("work");
g.nodes.insert("work".to_string(), node);
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
// --- Additional coverage: prompt_on_llm_nodes with label ---
#[test]
fn goal_gate_has_retry_rule_with_graph_fallback_retry_target() {
let mut g = minimal_graph();
let mut node = Node::new("work");
node.attrs
.insert("goal_gate".to_string(), AttrValue::Boolean(true));
g.nodes.insert("work".to_string(), node);
g.attrs.insert(
"fallback_retry_target".to_string(),
AttrValue::String("start".to_string()),
);
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
// --- freeform_edge_count: with explicit type=human ---
#[test]
fn goal_gate_has_retry_rule_explicit_false() {
let mut g = minimal_graph();
let mut node = Node::new("work");
node.attrs
.insert("goal_gate".to_string(), AttrValue::Boolean(false));
g.nodes.insert("work".to_string(), node);
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
// --- stylesheet_syntax: empty string stylesheet ---
}

View file

@ -1,108 +0,0 @@
use fabro_graphviz::graph::{AttrValue, Graph};
use crate::{Diagnostic, LintRule, Severity};
pub(super) fn rule() -> Box<dyn LintRule> {
Box::new(Rule)
}
struct Rule;
impl LintRule for Rule {
fn name(&self) -> &'static str {
"import_error"
}
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
for node in graph.nodes.values() {
if let Some(AttrValue::String(message)) = node.attrs.get("import_error") {
diagnostics.push(Diagnostic {
rule: self.name().to_string(),
severity: Severity::Error,
message: message.clone(),
node_id: Some(node.id.clone()),
edge: None,
fix: Some("Fix the imported workflow or import path".to_string()),
..Diagnostic::default()
});
}
if node.attrs.contains_key("import") {
diagnostics.push(Diagnostic {
rule: self.name().to_string(),
severity: Severity::Error,
message: "unresolved import (no base directory available)".to_string(),
node_id: Some(node.id.clone()),
edge: None,
fix: Some(
"Load the workflow from a file so imports can resolve relative to it"
.to_string(),
),
..Diagnostic::default()
});
}
}
diagnostics
}
}
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::{AttrValue, Node};
use super::Rule;
use crate::rules::test_support::minimal_graph;
use crate::{LintRule, Severity};
#[test]
fn import_error_rule_fires_on_import_error_attr() {
let mut g = minimal_graph();
let mut node = Node::new("work");
node.attrs.insert(
"import_error".to_string(),
AttrValue::String("file not found: ./missing.fabro".to_string()),
);
g.nodes.insert("work".to_string(), node);
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Error);
assert_eq!(d[0].message, "file not found: ./missing.fabro");
assert_eq!(d[0].node_id.as_deref(), Some("work"));
}
#[test]
fn import_error_rule_fires_on_unresolved_import_attr() {
let mut g = minimal_graph();
let mut node = Node::new("work");
node.attrs.insert(
"import".to_string(),
AttrValue::String("./validate.fabro".to_string()),
);
g.nodes.insert("work".to_string(), node);
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Error);
assert_eq!(
d[0].message,
"unresolved import (no base directory available)"
);
assert_eq!(d[0].node_id.as_deref(), Some("work"));
}
#[test]
fn import_error_rule_silent_for_clean_nodes() {
let g = minimal_graph();
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
}

View file

@ -1,394 +0,0 @@
use fabro_graphviz::graph::{self, Graph, Node};
use fabro_types::StageHandler;
use crate::{Diagnostic, LintRule, Severity};
pub(super) fn rule() -> Box<dyn LintRule> {
Box::new(Rule)
}
/// Attributes that only specific handlers read, paired with the handlers that
/// consume them. On every other node type the attribute is inert: accepted by
/// the parser and read by nothing at runtime.
///
/// Node types are compared after canonicalization through
/// [`StageHandler::from_handler_type`], so alias types (`tool` runs the
/// command handler) accept the same attributes as their canonical form.
///
/// Attributes read by several handlers (`timeout`), resolved for every node
/// (`fidelity`, `retry_policy`, `max_visits`, `goal_gate`), or injectable via
/// model stylesheets (`model`, `provider`, `reasoning_effort`, `speed`,
/// `backend`) are deliberately not listed.
const HANDLER_SPECIFIC_ATTRS: &[(&str, &[StageHandler])] = &[
("script", &[StageHandler::Command]),
("language", &[StageHandler::Command]),
("stdin_source", &[StageHandler::Command]),
("duration", &[StageHandler::Wait]),
("max_parallel", &[StageHandler::Parallel]),
("output_retries", &[
StageHandler::Agent,
StageHandler::Prompt,
]),
("output_schema", &[
StageHandler::Agent,
StageHandler::Prompt,
StageHandler::Command,
]),
("prompt", &[
StageHandler::Agent,
StageHandler::Prompt,
StageHandler::ParallelFanIn,
]),
("review_target", &[StageHandler::Human]),
];
const SCRIPT_PROMPT_CONFLICT_RULE: &str = "script_prompt_conflict";
struct Rule;
fn script_prompt_conflict(node: &Node) -> Diagnostic {
Diagnostic {
rule: SCRIPT_PROMPT_CONFLICT_RULE.to_string(),
severity: Severity::Error,
message: format!(
"Node '{}' sets both 'script' and 'prompt'. No built-in handler reads both: command \
handlers consume 'script', while LLM handlers consume 'prompt'",
node.id
),
node_id: Some(node.id.clone()),
edge: None,
fix: Some(
"Remove whichever attribute is wrong, or split the node into a command node and an \
agent node"
.to_string(),
),
..Diagnostic::default()
}
}
impl LintRule for Rule {
fn name(&self) -> &'static str {
"inert_attribute"
}
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
for node in graph.nodes.values() {
let has_script_prompt_conflict =
node.attrs.contains_key("script") && node.attrs.contains_key("prompt");
if has_script_prompt_conflict {
diagnostics.push(script_prompt_conflict(node));
}
// An unknown shape or type is covered by the type_known rule; a
// node this rule cannot classify is skipped rather than guessed at.
let Some(raw_type) = node.handler_type() else {
continue;
};
if !graph::is_known_handler_type(raw_type) {
continue;
}
let handler = StageHandler::from_handler_type(Some(raw_type));
for (attr, consumers) in HANDLER_SPECIFIC_ATTRS {
if !node.attrs.contains_key(*attr) {
continue;
}
if has_script_prompt_conflict && matches!(*attr, "script" | "prompt") {
continue;
}
if consumers.contains(&handler) {
continue;
}
let consumer_names = consumers
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ");
diagnostics.push(Diagnostic {
rule: self.name().to_string(),
severity: Severity::Warning,
message: format!(
"Node '{}' (type '{raw_type}') sets '{attr}', which is only read by {consumer_names} nodes and has no effect here",
node.id,
),
node_id: Some(node.id.clone()),
edge: None,
fix: Some(format!(
"Remove '{attr}' or change the node to a type that reads it ({consumer_names})",
)),
..Diagnostic::default()
});
}
}
diagnostics
}
}
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::{AttrValue, Edge, Node};
use super::Rule;
use crate::rules::test_support;
use crate::{LintRule, Severity};
fn node_with_attr(id: &str, shape: &str, attr: &str, value: &str) -> Node {
test_support::node_with_attrs(id, &[("shape", shape), (attr, value)])
}
#[test]
fn warns_on_script_on_agent_node() {
let mut g = test_support::minimal_graph();
g.nodes.insert(
"work".to_string(),
node_with_attr("work", "box", "script", "echo hi"),
);
let d = Rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Warning);
assert!(d[0].message.contains("'script'"));
assert!(d[0].message.contains("command"));
assert_eq!(d[0].node_id.as_deref(), Some("work"));
}
#[test]
fn built_in_rules_report_script_prompt_conflict_once() {
let mut g = test_support::minimal_graph();
g.nodes.insert(
"work".to_string(),
test_support::node_with_attrs("work", &[
("script", "cargo build"),
("prompt", "do it"),
]),
);
g.edges = vec![Edge::new("start", "work"), Edge::new("work", "exit")];
let diagnostics = crate::validate(&g, &[]);
let work_diagnostics = diagnostics
.iter()
.filter(|diagnostic| diagnostic.node_id.as_deref() == Some("work"))
.collect::<Vec<_>>();
assert_eq!(work_diagnostics.len(), 1, "diagnostics: {diagnostics:?}");
assert_eq!(work_diagnostics[0].rule, "script_prompt_conflict");
assert_eq!(work_diagnostics[0].severity, Severity::Error);
}
#[test]
fn conflict_error_replaces_inert_warning_for_explicit_shapes() {
let mut g = test_support::minimal_graph();
g.nodes.insert(
"run".to_string(),
test_support::node_with_attrs("run", &[
("shape", "parallelogram"),
("script", "cargo build"),
("prompt", "do it"),
]),
);
g.nodes.insert(
"plan".to_string(),
test_support::node_with_attrs("plan", &[
("shape", "box"),
("script", "cargo build"),
("prompt", "do it"),
]),
);
let diagnostics = Rule.apply(&g);
assert_eq!(diagnostics.len(), 2);
assert!(
diagnostics
.iter()
.all(|diagnostic| diagnostic.rule == "script_prompt_conflict"
&& diagnostic.severity == Severity::Error)
);
}
#[test]
fn conflict_uses_attribute_presence() {
let mut g = test_support::minimal_graph();
let mut node = test_support::node_with_attrs("work", &[("prompt", "do it")]);
node.attrs
.insert("script".to_string(), AttrValue::Integer(123));
g.nodes.insert("work".to_string(), node);
let diagnostics = Rule.apply(&g);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].rule, "script_prompt_conflict");
}
#[test]
fn warns_on_prompt_on_start_and_command_nodes() {
let mut g = test_support::minimal_graph();
g.nodes
.get_mut("start")
.expect("minimal graph has start")
.attrs
.insert(
"prompt".to_string(),
AttrValue::String("do things".to_string()),
);
g.nodes.insert(
"run".to_string(),
node_with_attr("run", "parallelogram", "prompt", "do things"),
);
let d = Rule.apply(&g);
assert_eq!(d.len(), 2);
assert!(d.iter().all(|d| d.message.contains("'prompt'")));
}
#[test]
fn warns_on_duration_on_command_node() {
let mut g = test_support::minimal_graph();
g.nodes.insert(
"run".to_string(),
node_with_attr("run", "parallelogram", "duration", "30s"),
);
let d = Rule.apply(&g);
assert_eq!(d.len(), 1);
assert!(d[0].message.contains("'duration'"));
assert!(d[0].message.contains("wait"));
}
#[test]
fn warns_on_parallel_attrs_on_agent_node() {
let mut g = test_support::minimal_graph();
let mut node = Node::new("work");
node.attrs
.insert("max_parallel".to_string(), AttrValue::Integer(4));
g.nodes.insert("work".to_string(), node);
let d = Rule.apply(&g);
assert_eq!(d.len(), 1);
}
#[test]
fn warns_on_output_retries_on_command_node() {
let mut g = test_support::minimal_graph();
g.nodes.insert(
"run".to_string(),
node_with_attr("run", "parallelogram", "output_retries", "2"),
);
let d = Rule.apply(&g);
assert_eq!(d.len(), 1);
assert!(d[0].message.contains("'output_retries'"));
assert!(d[0].message.contains("agent, prompt"));
}
#[test]
fn accepts_attrs_on_their_own_handler_types() {
let mut g = test_support::minimal_graph();
let mut run = node_with_attr("run", "parallelogram", "script", "echo hi");
run.attrs.insert(
"stdin_source".to_string(),
AttrValue::String("context.parallel.results".to_string()),
);
g.nodes.insert("run".to_string(), run);
g.nodes.insert(
"audit".to_string(),
node_with_attr("audit", "parallelogram", "output_schema", "routing"),
);
g.nodes.insert(
"pause".to_string(),
node_with_attr("pause", "insulator", "duration", "30s"),
);
g.nodes.insert(
"work".to_string(),
node_with_attr("work", "box", "prompt", "do things"),
);
g.nodes.insert(
"review".to_string(),
node_with_attr("review", "box", "output_retries", "2"),
);
g.nodes.insert(
"fork".to_string(),
node_with_attr("fork", "component", "max_parallel", "4"),
);
g.nodes.insert(
"spec".to_string(),
node_with_attr("spec", "tab", "output_schema", "routing"),
);
g.nodes.insert(
"prompt".to_string(),
node_with_attr("prompt", "tab", "output_retries", "2"),
);
g.nodes.insert(
"human".to_string(),
node_with_attr("human", "hexagon", "review_target", "true"),
);
g.nodes.insert(
"legacy_command".to_string(),
test_support::node_with_attrs("legacy_command", &[
("type", "tool"),
("script", "echo legacy"),
]),
);
assert!(Rule.apply(&g).is_empty());
}
#[test]
fn warns_on_review_target_on_non_human_node() {
let mut g = test_support::minimal_graph();
g.nodes.insert(
"work".to_string(),
node_with_attr("work", "box", "review_target", "true"),
);
let diagnostics = Rule.apply(&g);
assert_eq!(diagnostics.len(), 1);
assert!(diagnostics[0].message.contains("'review_target'"));
assert!(diagnostics[0].message.contains("human"));
}
#[test]
fn accepts_prompt_on_shapeless_node_defaulting_to_agent() {
let mut g = test_support::minimal_graph();
let mut node = Node::new("work");
node.attrs.insert(
"prompt".to_string(),
AttrValue::String("do things".to_string()),
);
g.nodes.insert("work".to_string(), node);
assert!(Rule.apply(&g).is_empty());
}
#[test]
fn accepts_prompt_on_fan_in_judge() {
let mut g = test_support::minimal_graph();
g.nodes.insert(
"merge".to_string(),
node_with_attr("merge", "tripleoctagon", "prompt", "pick the best"),
);
assert!(Rule.apply(&g).is_empty());
}
#[test]
fn ignores_unclassifiable_node_shapes() {
let mut g = test_support::minimal_graph();
g.nodes.insert(
"odd".to_string(),
node_with_attr("odd", "doubleoctagon", "script", "echo hi"),
);
assert!(Rule.apply(&g).is_empty());
}
#[test]
fn ignores_handler_specific_attrs_on_unrecognized_explicit_types() {
let mut g = test_support::minimal_graph();
let mut node = Node::new("custom");
node.attrs.insert(
"type".to_string(),
AttrValue::String("custom.handler".to_string()),
);
node.attrs.insert(
"script".to_string(),
AttrValue::String("echo hi".to_string()),
);
g.nodes.insert("custom".to_string(), node);
assert!(Rule.apply(&g).is_empty());
}
}

View file

@ -1,35 +0,0 @@
use fabro_graphviz::graph::Graph;
use crate::{Diagnostic, LintRule, Severity};
pub(super) fn rule() -> Box<dyn LintRule> {
Box::new(Rule)
}
struct Rule;
impl LintRule for Rule {
fn name(&self) -> &'static str {
"join_policy_removed"
}
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
graph
.nodes
.values()
.filter(|node| node.attrs.contains_key("join_policy"))
.map(|node| Diagnostic {
rule: self.name().to_string(),
severity: Severity::Error,
message: format!(
"Node '{}' sets the removed 'join_policy' attribute. Remove 'join_policy'; parallel nodes always wait for every branch to finish",
node.id,
),
node_id: Some(node.id.clone()),
edge: None,
fix: Some("Remove 'join_policy' from this node".to_string()),
..Diagnostic::default()
})
.collect()
}
}

View file

@ -1,91 +0,0 @@
use fabro_llm::lithos_catalog::Catalog;
mod all_conditional_edges;
mod auto_status_deprecated;
mod backend_valid;
mod command_requires_script;
mod condition_syntax;
mod direction_valid;
mod edge_target_exists;
mod exit_no_outgoing;
mod fidelity_valid;
mod for_each_contract;
mod freeform_edge_count;
mod goal_gate_has_retry;
mod import_error;
mod inert_attribute;
mod join_policy_removed;
mod model_support;
mod node_model_known;
mod on_failure_valid;
mod orphan_custom_outcome;
mod parallel_branch;
mod parallel_branch_inert_attribute;
mod prompt_on_llm_nodes;
mod random_selection_no_conditions;
mod reachability;
mod reserved_keyword_node_id;
mod retry_target_exists;
mod script_absolute_cd;
mod selection_valid;
mod start_no_incoming;
mod start_node;
mod stdin_source_valid;
mod stylesheet_model_known;
mod stylesheet_syntax;
mod terminal_node;
#[cfg(test)]
pub(crate) mod test_support;
mod thread_id_requires_fidelity_full;
mod type_known;
mod unresolved_file_ref;
use crate::LintRule;
/// Returns all built-in lint rules.
#[must_use]
pub fn built_in_rules() -> Vec<Box<dyn LintRule>> {
vec![
start_node::rule(),
terminal_node::rule(),
reachability::rule(),
edge_target_exists::rule(),
start_no_incoming::rule(),
exit_no_outgoing::rule(),
condition_syntax::rule(),
stylesheet_syntax::rule(),
type_known::rule(),
backend_valid::rule(),
fidelity_valid::rule(),
retry_target_exists::rule(),
goal_gate_has_retry::rule(),
prompt_on_llm_nodes::rule(),
freeform_edge_count::rule(),
for_each_contract::rule(),
stdin_source_valid::rule(),
direction_valid::rule(),
reserved_keyword_node_id::rule(),
all_conditional_edges::rule(),
orphan_custom_outcome::rule(),
on_failure_valid::rule(),
auto_status_deprecated::rule(),
script_absolute_cd::rule(),
command_requires_script::rule(),
import_error::rule(),
join_policy_removed::rule(),
unresolved_file_ref::rule(),
thread_id_requires_fidelity_full::rule(),
selection_valid::rule(),
random_selection_no_conditions::rule(),
inert_attribute::rule(),
parallel_branch_inert_attribute::rule(),
]
}
/// Returns lint rules that require the caller's resolved model catalog.
#[must_use]
pub fn catalog_rules(catalog: &Catalog) -> Vec<Box<dyn LintRule + '_>> {
vec![
stylesheet_model_known::rule(catalog),
node_model_known::rule(catalog),
]
}

View file

@ -1,55 +0,0 @@
use fabro_llm::lithos_catalog::Catalog;
use crate::{Diagnostic, Severity};
pub(super) fn check_model_known(
rule_name: &str,
catalog: &Catalog,
model: &str,
context: &str,
node_id: Option<String>,
) -> Option<Diagnostic> {
if catalog.is_model_selector(model) {
return None;
}
Some(Diagnostic {
rule: rule_name.to_string(),
severity: Severity::Warning,
message: format!(
"Unknown model '{model}' {context}. Run `fabro model list` to see available models"
),
node_id,
edge: None,
fix: Some("Use a model ID from `fabro model list`".to_string()),
..Diagnostic::default()
})
}
pub(super) fn check_provider_known(
rule_name: &str,
catalog: &Catalog,
provider: &str,
context: &str,
node_id: Option<String>,
) -> Option<Diagnostic> {
if catalog.enabled_provider(provider).is_some() {
return None;
}
let valid: Vec<String> = catalog
.listed_providers()
.iter()
.map(|provider| provider.id().to_string())
.collect();
let valid_str = valid.join(", ");
Some(Diagnostic {
rule: rule_name.to_string(),
severity: Severity::Warning,
message: format!("Unknown provider '{provider}' {context}. Valid providers: {valid_str}"),
node_id,
edge: None,
fix: Some(format!("Use one of: {valid_str}")),
..Diagnostic::default()
})
}

View file

@ -1,129 +0,0 @@
use fabro_graphviz::graph::Graph;
use fabro_llm::lithos_catalog::Catalog;
use super::model_support::{check_model_known, check_provider_known};
use crate::{Diagnostic, LintRule};
pub(super) fn rule(catalog: &Catalog) -> Box<dyn LintRule + '_> {
Box::new(Rule { catalog })
}
struct Rule<'a> {
catalog: &'a Catalog,
}
impl LintRule for Rule<'_> {
fn name(&self) -> &'static str {
"node_model_known"
}
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
for node in graph.nodes.values() {
let context = format!("on node '{}'", node.id);
let node_id = Some(node.id.clone());
if let Some(model) = node.model() {
if let Some(d) =
check_model_known(self.name(), self.catalog, model, &context, node_id.clone())
{
diagnostics.push(d);
}
}
if let Some(provider) = node.provider() {
if let Some(d) = check_provider_known(
self.name(),
self.catalog,
provider,
&context,
node_id.clone(),
) {
diagnostics.push(d);
}
}
}
diagnostics
}
}
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::{AttrValue, Node};
use fabro_llm::test_support::test_catalog;
use super::Rule;
use crate::rules::test_support::minimal_graph;
use crate::{LintRule, Severity};
#[test]
fn node_model_known_rule_valid_model() {
let mut g = minimal_graph();
let mut node = Node::new("work");
node.attrs.insert(
"model".to_string(),
AttrValue::String("claude-sonnet-4.5".to_string()),
);
g.nodes.insert("work".to_string(), node);
let catalog = test_catalog();
let rule = Rule { catalog: &catalog };
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn node_model_known_rule_unknown_model() {
let mut g = minimal_graph();
let mut node = Node::new("work");
node.attrs.insert(
"model".to_string(),
AttrValue::String("nonexistent-model-xyz".to_string()),
);
g.nodes.insert("work".to_string(), node);
let catalog = test_catalog();
let rule = Rule { catalog: &catalog };
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Warning);
assert!(d[0].message.contains("nonexistent-model-xyz"));
assert_eq!(d[0].node_id.as_deref(), Some("work"));
}
#[test]
fn node_model_known_rule_alias() {
let mut g = minimal_graph();
let mut node = Node::new("work");
node.attrs
.insert("model".to_string(), AttrValue::String("opus".to_string()));
g.nodes.insert("work".to_string(), node);
let catalog = test_catalog();
let rule = Rule { catalog: &catalog };
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn node_model_known_rule_unknown_provider() {
let mut g = minimal_graph();
let mut node = Node::new("work");
node.attrs.insert(
"provider".to_string(),
AttrValue::String("nonexistent-provider".to_string()),
);
g.nodes.insert("work".to_string(), node);
let catalog = test_catalog();
let rule = Rule { catalog: &catalog };
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Warning);
assert!(d[0].message.contains("nonexistent-provider"));
assert_eq!(d[0].node_id.as_deref(), Some("work"));
}
#[test]
fn node_model_known_rule_no_model_no_provider() {
let g = minimal_graph();
let catalog = test_catalog();
let rule = Rule { catalog: &catalog };
let d = rule.apply(&g);
assert!(d.is_empty());
}
}

View file

@ -1,228 +0,0 @@
use fabro_graphviz::graph::{AttrValue, Graph};
use fabro_types::OnFailure;
use crate::{Diagnostic, LintRule, Severity};
pub(super) fn rule() -> Box<dyn LintRule> {
Box::new(Rule)
}
struct Rule;
fn invalid_value_diagnostic(
rule: &str,
node_id: Option<&str>,
value: &AttrValue,
) -> Option<Diagnostic> {
let message = match value {
AttrValue::String(value) if value.parse::<OnFailure>().is_ok() => return None,
AttrValue::String(value) => match node_id {
Some(node_id) => format!("Node '{node_id}' has invalid on_failure value '{value}'"),
None => format!("Graph has invalid on_failure value '{value}'"),
},
_ => match node_id {
Some(node_id) => {
format!("Node '{node_id}' attribute 'on_failure' must be a string")
}
None => "Graph attribute 'on_failure' must be a string".to_string(),
},
};
Some(Diagnostic {
rule: rule.to_string(),
severity: Severity::Error,
message,
node_id: node_id.map(str::to_string),
fix: Some(format!("Use one of: {}", OnFailure::expected_values())),
..Diagnostic::default()
})
}
impl LintRule for Rule {
fn name(&self) -> &'static str {
"on_failure_valid"
}
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
if let Some(value) = graph.attrs.get("on_failure") {
diagnostics.extend(invalid_value_diagnostic(self.name(), None, value));
}
let mut node_values: Vec<_> = graph
.nodes
.iter()
.filter_map(|(node_id, node)| {
node.attrs
.get("on_failure")
.map(|value| (node_id.as_str(), value))
})
.collect();
node_values.sort_unstable_by_key(|(node_id, _)| *node_id);
for (node_id, value) in node_values {
diagnostics.extend(invalid_value_diagnostic(self.name(), Some(node_id), value));
}
for edge in &graph.edges {
if edge.attrs.contains_key("on_failure") {
diagnostics.push(Diagnostic {
rule: self.name().to_string(),
severity: Severity::Warning,
message: format!(
"Edge '{} -> {}' sets 'on_failure', which has no effect on edges",
edge.from, edge.to
),
fix: Some("Set 'on_failure' on the graph or the source node".to_string()),
edge: Some((edge.from.clone(), edge.to.clone())),
..Diagnostic::default()
});
}
}
diagnostics
}
}
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::{AttrValue, Edge};
use super::Rule;
use crate::rules::test_support::{minimal_graph, node_with_attrs};
use crate::{LintRule, Severity};
#[test]
fn accepts_absent_and_supported_graph_values() {
let mut graph = minimal_graph();
assert!(Rule.apply(&graph).is_empty());
for value in ["route", "exit", "succeed"] {
graph.attrs.insert(
"on_failure".to_string(),
AttrValue::String(value.to_string()),
);
assert!(Rule.apply(&graph).is_empty());
}
}
#[test]
fn accepts_supported_node_values() {
let mut graph = minimal_graph();
for value in ["route", "exit", "succeed"] {
graph.nodes.insert(
"work".to_string(),
node_with_attrs("work", &[("on_failure", value)]),
);
assert!(Rule.apply(&graph).is_empty());
}
}
#[test]
fn rejects_unsupported_graph_value() {
let mut graph = minimal_graph();
graph.attrs.insert(
"on_failure".to_string(),
AttrValue::String("stop".to_string()),
);
let diagnostics = Rule.apply(&graph);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].severity, Severity::Error);
assert_eq!(
diagnostics[0].message,
"Graph has invalid on_failure value 'stop'"
);
assert_eq!(
diagnostics[0].fix.as_deref(),
Some("Use one of: route, exit, succeed")
);
}
#[test]
fn rejects_non_string_graph_value() {
let mut graph = minimal_graph();
graph
.attrs
.insert("on_failure".to_string(), AttrValue::Boolean(true));
let diagnostics = Rule.apply(&graph);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].severity, Severity::Error);
assert_eq!(
diagnostics[0].message,
"Graph attribute 'on_failure' must be a string"
);
}
#[test]
fn rejects_unsupported_node_value() {
let mut graph = minimal_graph();
graph.nodes.insert(
"work".to_string(),
node_with_attrs("work", &[("on_failure", "stop")]),
);
let diagnostics = Rule.apply(&graph);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].severity, Severity::Error);
assert_eq!(
diagnostics[0].message,
"Node 'work' has invalid on_failure value 'stop'"
);
assert_eq!(diagnostics[0].node_id.as_deref(), Some("work"));
assert_eq!(
diagnostics[0].fix.as_deref(),
Some("Use one of: route, exit, succeed")
);
}
#[test]
fn rejects_non_string_node_value() {
let mut graph = minimal_graph();
let mut node = node_with_attrs("work", &[]);
node.attrs
.insert("on_failure".to_string(), AttrValue::Boolean(true));
graph.nodes.insert("work".to_string(), node);
let diagnostics = Rule.apply(&graph);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].severity, Severity::Error);
assert_eq!(
diagnostics[0].message,
"Node 'work' attribute 'on_failure' must be a string"
);
}
#[test]
fn warns_for_edge_placement() {
let mut graph = minimal_graph();
let mut edge = Edge::new("start", "work");
edge.attrs.insert(
"on_failure".to_string(),
AttrValue::String("exit".to_string()),
);
graph.edges.push(edge);
let diagnostics = Rule.apply(&graph);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].severity, Severity::Warning);
assert_eq!(
diagnostics[0].message,
"Edge 'start -> work' sets 'on_failure', which has no effect on edges"
);
assert_eq!(
diagnostics[0].fix.as_deref(),
Some("Set 'on_failure' on the graph or the source node")
);
assert_eq!(
diagnostics[0].edge,
Some(("start".to_string(), "work".to_string()))
);
}
}

View file

@ -1,139 +0,0 @@
use fabro_graphviz::graph::Graph;
use crate::{Diagnostic, LintRule, Severity};
pub(super) fn rule() -> Box<dyn LintRule> {
Box::new(Rule)
}
struct Rule;
impl LintRule for Rule {
fn name(&self) -> &'static str {
"orphan_custom_outcome"
}
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
for node in graph.nodes.values() {
let outgoing = graph.outgoing_edges(&node.id);
if outgoing.is_empty() {
continue;
}
// Check if any edge uses outcome=<value> (equality, not !=)
let has_outcome_eq = outgoing.iter().any(|e| {
e.condition().is_some_and(|c| {
c.split("&&")
.any(|clause| clause.trim().starts_with("outcome="))
})
});
if !has_outcome_eq {
continue;
}
// Check if there's at least one unconditional edge
let has_unconditional = outgoing
.iter()
.any(|e| e.condition().is_none_or(str::is_empty));
if !has_unconditional {
diagnostics.push(Diagnostic {
rule: self.name().to_string(),
severity: Severity::Warning,
message: format!(
"Node '{}' uses outcome-based routing but has no unconditional fallback edge",
node.id
),
node_id: Some(node.id.clone()),
edge: None,
fix: Some(
"Add an unconditional edge as a safety net for unmatched outcomes"
.to_string(),
),
..Diagnostic::default()});
}
}
diagnostics
}
}
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::{AttrValue, Edge, Node};
use super::Rule;
use crate::rules::test_support::minimal_graph;
use crate::{LintRule, Severity};
#[test]
fn orphan_custom_outcome_rule_outcome_eq_no_fallback() {
let mut g = minimal_graph();
g.nodes.insert("work".to_string(), Node::new("work"));
g.edges.push({
let mut e = Edge::new("work", "exit");
e.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=succeeded".to_string()),
);
e
});
g.edges.push({
let mut e = Edge::new("work", "start");
e.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=failed".to_string()),
);
e
});
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Warning);
assert_eq!(d[0].node_id.as_deref(), Some("work"));
}
#[test]
fn orphan_custom_outcome_rule_outcome_eq_with_fallback() {
let mut g = minimal_graph();
g.nodes.insert("work".to_string(), Node::new("work"));
g.edges.push({
let mut e = Edge::new("work", "exit");
e.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=succeeded".to_string()),
);
e
});
g.edges.push(Edge::new("work", "start")); // unconditional fallback
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn orphan_custom_outcome_rule_outcome_neq_only() {
let mut g = minimal_graph();
g.nodes.insert("work".to_string(), Node::new("work"));
g.edges.push({
let mut e = Edge::new("work", "exit");
e.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome!=failed".to_string()),
);
e
});
let rule = Rule;
let d = rule.apply(&g);
// outcome!= is not outcome= equality, so rule doesn't fire
assert!(d.is_empty());
}
#[test]
fn orphan_custom_outcome_rule_no_outcome_conditions() {
let g = minimal_graph(); // no outcome conditions at all
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
// --- condition_syntax + parse_condition (condition_eval) tests ---
}

View file

@ -1,59 +0,0 @@
use std::collections::BTreeSet;
use fabro_graphviz::graph::{Edge, Graph};
pub(super) struct ParallelBranches<'a> {
graph: &'a Graph,
fork_ids: BTreeSet<&'a str>,
}
impl<'a> ParallelBranches<'a> {
pub(super) fn new(graph: &'a Graph) -> Self {
let fork_ids = graph
.nodes
.values()
.filter(|node| node.handler_type() == Some("parallel"))
.map(|node| node.id.as_str())
.collect();
Self { graph, fork_ids }
}
pub(super) fn is_empty(&self) -> bool {
self.fork_ids.is_empty()
}
pub(super) fn is_fork_edge(&self, edge: &Edge) -> bool {
self.fork_ids.contains(edge.from.as_str())
}
pub(super) fn branch_targets(&self) -> BTreeSet<&str> {
self.graph
.edges
.iter()
.filter(|edge| self.is_fork_edge(edge))
.map(|edge| edge.to.as_str())
.collect()
}
/// True when every incoming edge of `node_id` comes from a parallel fork
/// (and there is at least one). Such a node only ever runs as a branch.
pub(super) fn is_branch_only_node(&self, node_id: &str) -> bool {
let incoming = self.graph.incoming_edges(node_id);
!incoming.is_empty() && incoming.iter().all(|edge| self.is_fork_edge(edge))
}
/// The sorted, deduplicated fork parents of a branch-only node, or `None`
/// when the node has a non-fork entry path (or no entry at all).
pub(super) fn branch_only_parents(&self, node_id: &str) -> Option<Vec<String>> {
if !self.is_branch_only_node(node_id) {
return None;
}
let parents: BTreeSet<&str> = self
.graph
.incoming_edges(node_id)
.into_iter()
.map(|edge| edge.from.as_str())
.collect();
Some(parents.into_iter().map(String::from).collect())
}
}

View file

@ -1,368 +0,0 @@
use fabro_graphviz::graph::Graph;
use super::parallel_branch::ParallelBranches;
use crate::{Diagnostic, LintRule, Severity};
pub(super) fn rule() -> Box<dyn LintRule> {
Box::new(Rule)
}
/// Attributes that parallel branch execution does not resolve. Only
/// `thread_id` is inert on branches (concurrent branches cannot share an LLM
/// session); per-branch `fidelity` is honored via pre-rendered preambles.
const BRANCH_IGNORED_ATTRS: &[&str] = &["thread_id"];
const FULL_FIDELITY_MESSAGE: &str = "Parallel branches run at most at summary:high; full is degraded at runtime because branches cannot share a session";
const THREAD_ID_FIX: &str = "Remove 'thread_id': parallel branches inherit the thread resolved when the parallel node started";
struct Rule;
/// Renders one or more parallel-node ids as `'a'` or `'a', 'b'`.
fn quoted_list(ids: &[String]) -> String {
ids.iter()
.map(|id| format!("'{id}'"))
.collect::<Vec<_>>()
.join(", ")
}
fn full_fidelity_fix(parallel_ids: &[String]) -> String {
let parent = if parallel_ids.len() == 1 {
format!("parallel node {}", quoted_list(parallel_ids))
} else {
format!("parallel nodes {}", quoted_list(parallel_ids))
};
format!(
"Use fidelity=\"summary:high\" or another lower mode on this branch; to reuse a full session before fan-out, set fidelity=\"full\" on {parent} or its incoming edge"
)
}
fn full_fidelity_diagnostic(
rule_name: &str,
node_id: Option<String>,
edge: Option<(String, String)>,
parallel_ids: &[String],
) -> Diagnostic {
Diagnostic {
rule: rule_name.to_string(),
severity: Severity::Warning,
message: FULL_FIDELITY_MESSAGE.to_string(),
node_id,
edge,
fix: Some(full_fidelity_fix(parallel_ids)),
..Diagnostic::default()
}
}
impl LintRule for Rule {
fn name(&self) -> &'static str {
"parallel_branch_inert_attribute"
}
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
let branches = ParallelBranches::new(graph);
if branches.is_empty() {
return Vec::new();
}
let mut diagnostics = Vec::new();
for edge in &graph.edges {
if !branches.is_fork_edge(edge) {
continue;
}
if edge.fidelity() == Some("full") {
diagnostics.push(full_fidelity_diagnostic(
self.name(),
None,
Some((edge.from.clone(), edge.to.clone())),
std::slice::from_ref(&edge.from),
));
}
for attr in BRANCH_IGNORED_ATTRS {
if !edge.attrs.contains_key(*attr) {
continue;
}
diagnostics.push(Diagnostic {
rule: self.name().to_string(),
severity: Severity::Warning,
message: format!(
"Edge {} -> {} sets '{attr}', which is ignored on parallel branch edges: branches receive the context snapshot taken when '{}' started",
edge.from, edge.to, edge.from,
),
node_id: None,
edge: Some((edge.from.clone(), edge.to.clone())),
fix: Some(THREAD_ID_FIX.to_string()),
..Diagnostic::default()
});
}
}
// A node with any normal incoming path still resolves its attributes on
// that path, so branch-only diagnostics do not apply to it.
for target in branches.branch_targets() {
let Some(parents) = branches.branch_only_parents(target) else {
continue;
};
let Some(node) = graph.nodes.get(target) else {
continue;
};
if node.fidelity() == Some("full") {
diagnostics.push(full_fidelity_diagnostic(
self.name(),
Some(node.id.clone()),
None,
&parents,
));
}
for attr in BRANCH_IGNORED_ATTRS {
if !node.attrs.contains_key(*attr) {
continue;
}
diagnostics.push(Diagnostic {
rule: self.name().to_string(),
severity: Severity::Warning,
message: format!(
"Node '{}' sets '{attr}', but it only runs as a parallel branch (of {}), where '{attr}' is ignored: branches receive the context snapshot taken when the parallel node started",
node.id,
quoted_list(&parents),
),
node_id: Some(node.id.clone()),
edge: None,
fix: Some(THREAD_ID_FIX.to_string()),
..Diagnostic::default()
});
}
}
diagnostics
}
}
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};
use super::Rule;
use crate::rules::test_support::minimal_graph;
use crate::{LintRule, Severity};
fn shaped_node(id: &str, shape: &str) -> Node {
let mut node = Node::new(id);
node.attrs
.insert("shape".to_string(), AttrValue::String(shape.to_string()));
node
}
/// start -> fork -> {branch_a, branch_b} -> merge -> exit
fn parallel_graph() -> Graph {
let mut g = minimal_graph();
g.nodes
.insert("fork".to_string(), shaped_node("fork", "component"));
g.nodes
.insert("branch_a".to_string(), shaped_node("branch_a", "tab"));
g.nodes
.insert("branch_b".to_string(), shaped_node("branch_b", "tab"));
g.nodes
.insert("merge".to_string(), shaped_node("merge", "tripleoctagon"));
g.edges = vec![
Edge::new("start", "fork"),
Edge::new("fork", "branch_a"),
Edge::new("fork", "branch_b"),
Edge::new("branch_a", "merge"),
Edge::new("branch_b", "merge"),
Edge::new("merge", "exit"),
];
g
}
#[test]
fn accepts_non_full_fidelity_on_branch_node() {
let mut g = parallel_graph();
g.nodes
.get_mut("branch_a")
.expect("graph has branch_a")
.attrs
.insert(
"fidelity".to_string(),
AttrValue::String("truncate".to_string()),
);
assert!(Rule.apply(&g).is_empty());
}
#[test]
fn warns_when_full_fidelity_on_branch_node_degrades() {
let mut g = parallel_graph();
g.nodes
.get_mut("branch_a")
.expect("graph has branch_a")
.attrs
.insert(
"fidelity".to_string(),
AttrValue::String("full".to_string()),
);
let d = Rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Warning);
assert_eq!(d[0].node_id.as_deref(), Some("branch_a"));
assert!(d[0].message.contains("full"));
assert!(d[0].message.contains("summary:high"));
assert!(d[0].fix.as_deref().is_some_and(|f| f.contains("'fork'")));
}
#[test]
fn accepts_every_non_full_fidelity_on_branch_edges() {
for fidelity in [
"truncate",
"compact",
"summary:low",
"summary:medium",
"summary:high",
] {
let mut g = parallel_graph();
g.edges[1].attrs.insert(
"fidelity".to_string(),
AttrValue::String(fidelity.to_string()),
);
assert!(
Rule.apply(&g).is_empty(),
"{fidelity} should be accepted on a branch edge"
);
}
}
#[test]
fn warns_when_full_fidelity_on_branch_edge_degrades() {
let mut g = parallel_graph();
g.edges[1].attrs.insert(
"fidelity".to_string(),
AttrValue::String("full".to_string()),
);
let d = Rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(
d[0].edge,
Some(("fork".to_string(), "branch_a".to_string()))
);
assert!(d[0].message.contains("full"));
assert!(d[0].message.contains("summary:high"));
}
#[test]
fn warns_on_thread_id_on_branch_edge() {
let mut g = parallel_graph();
g.edges[1].attrs.insert(
"thread_id".to_string(),
AttrValue::String("impl".to_string()),
);
let d = Rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(
d[0].edge,
Some(("fork".to_string(), "branch_a".to_string()))
);
assert!(d[0].message.contains("'thread_id'"));
assert_eq!(
d[0].fix.as_deref(),
Some(
"Remove 'thread_id': parallel branches inherit the thread resolved when the parallel node started"
)
);
}
#[test]
fn warns_on_thread_id_on_branch_only_node() {
let mut g = parallel_graph();
g.nodes
.get_mut("branch_a")
.expect("graph has branch_a")
.attrs
.insert(
"thread_id".to_string(),
AttrValue::String("impl".to_string()),
);
let d = Rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].node_id.as_deref(), Some("branch_a"));
assert!(d[0].message.contains("'thread_id'"));
assert_eq!(
d[0].fix.as_deref(),
Some(
"Remove 'thread_id': parallel branches inherit the thread resolved when the parallel node started"
)
);
}
#[test]
fn accepts_fidelity_on_the_parallel_node_itself() {
let mut g = parallel_graph();
g.nodes
.get_mut("fork")
.expect("graph has fork")
.attrs
.insert(
"fidelity".to_string(),
AttrValue::String("truncate".to_string()),
);
assert!(Rule.apply(&g).is_empty());
}
#[test]
fn accepts_fidelity_on_branch_node_also_reached_by_normal_edge() {
let mut g = parallel_graph();
// branch_a is also a normal successor of merge, so fidelity resolves
// on that path and is not inert.
g.edges.push(Edge::new("merge", "branch_a"));
g.nodes
.get_mut("branch_a")
.expect("graph has branch_a")
.attrs
.insert(
"fidelity".to_string(),
AttrValue::String("truncate".to_string()),
);
assert!(Rule.apply(&g).is_empty());
}
#[test]
fn names_every_parallel_parent_of_a_shared_branch_node() {
let mut g = parallel_graph();
g.nodes
.insert("fork2".to_string(), shaped_node("fork2", "component"));
g.edges.push(Edge::new("start", "fork2"));
g.edges.push(Edge::new("fork2", "branch_a"));
g.nodes
.get_mut("branch_a")
.expect("graph has branch_a")
.attrs
.insert(
"fidelity".to_string(),
AttrValue::String("full".to_string()),
);
let d = Rule.apply(&g);
assert_eq!(d.len(), 1);
let fix = d[0].fix.as_deref().expect("diagnostic has a fix");
assert!(fix.contains("'fork', 'fork2'"));
assert!(fix.contains("parallel nodes"));
}
#[test]
fn accepts_graph_without_parallel_nodes() {
let mut g = minimal_graph();
let mut node = shaped_node("work", "tab");
node.attrs.insert(
"fidelity".to_string(),
AttrValue::String("truncate".to_string()),
);
g.nodes.insert("work".to_string(), node);
assert!(Rule.apply(&g).is_empty());
}
}

View file

@ -1,156 +0,0 @@
use fabro_graphviz::graph::{AttrValue, Graph, is_llm_handler_type};
use crate::{Diagnostic, LintRule, Severity};
pub(super) fn rule() -> Box<dyn LintRule> {
Box::new(Rule)
}
struct Rule;
impl LintRule for Rule {
fn name(&self) -> &'static str {
"prompt_on_llm_nodes"
}
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
for node in graph.nodes.values() {
if is_llm_handler_type(node.handler_type()) {
let has_prompt = node.prompt().is_some_and(|p| !p.is_empty());
let has_label = node
.attrs
.get("label")
.and_then(AttrValue::as_str)
.is_some_and(|l| !l.is_empty());
if !has_prompt && !has_label {
diagnostics.push(Diagnostic {
rule: self.name().to_string(),
severity: Severity::Warning,
message: format!("LLM node '{}' has no prompt or label attribute", node.id),
node_id: Some(node.id.clone()),
edge: None,
fix: Some("Add a prompt or label attribute".to_string()),
..Diagnostic::default()
});
}
}
}
diagnostics
}
}
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::{AttrValue, Node};
use super::Rule;
use crate::rules::test_support::minimal_graph;
use crate::{LintRule, Severity};
#[test]
fn prompt_on_llm_nodes_rule_no_prompt_no_label() {
let mut g = minimal_graph();
let node = Node::new("work");
g.nodes.insert("work".to_string(), node);
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Warning);
}
#[test]
fn prompt_on_llm_nodes_rule_with_prompt() {
let mut g = minimal_graph();
let mut node = Node::new("work");
node.attrs.insert(
"prompt".to_string(),
AttrValue::String("Do the thing".to_string()),
);
g.nodes.insert("work".to_string(), node);
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn prompt_on_llm_nodes_rule_with_label() {
let mut g = minimal_graph();
let mut node = Node::new("work");
node.attrs.insert(
"label".to_string(),
AttrValue::String("Do something".to_string()),
);
g.nodes.insert("work".to_string(), node);
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn prompt_on_llm_nodes_rule_non_codergen_no_warning() {
let mut g = minimal_graph();
let mut node = Node::new("gate");
node.attrs.insert(
"shape".to_string(),
AttrValue::String("hexagon".to_string()),
);
g.nodes.insert("gate".to_string(), node);
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
// --- Additional coverage: fidelity_valid edge and graph-level ---
#[test]
fn prompt_on_llm_nodes_rule_explicit_agent_type_no_prompt() {
let mut g = minimal_graph();
let mut node = Node::new("work");
node.attrs
.insert("type".to_string(), AttrValue::String("agent".to_string()));
// No shape=box, but explicit type=agent
node.attrs.insert(
"shape".to_string(),
AttrValue::String("diamond".to_string()),
);
g.nodes.insert("work".to_string(), node);
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Warning);
}
// --- goal_gate_has_retry: satisfied by graph-level fallback_retry_target ---
#[test]
fn prompt_on_llm_nodes_rule_empty_prompt_no_label() {
let mut g = minimal_graph();
let mut node = Node::new("work");
node.attrs
.insert("prompt".to_string(), AttrValue::String(String::new()));
g.nodes.insert("work".to_string(), node);
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Warning);
}
// --- prompt_on_llm_nodes: empty label string still triggers ---
#[test]
fn prompt_on_llm_nodes_rule_empty_label_no_prompt() {
let mut g = minimal_graph();
let mut node = Node::new("work");
node.attrs
.insert("label".to_string(), AttrValue::String(String::new()));
g.nodes.insert("work".to_string(), node);
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Warning);
}
// --- condition_syntax: no condition attribute at all ---
}

View file

@ -1,114 +0,0 @@
use fabro_graphviz::graph::Graph;
use crate::{Diagnostic, LintRule, Severity};
pub(super) fn rule() -> Box<dyn LintRule> {
Box::new(Rule)
}
struct Rule;
impl LintRule for Rule {
fn name(&self) -> &'static str {
"random_selection_no_conditions"
}
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
for node in graph.nodes.values() {
if node.selection() != "random" {
continue;
}
let has_conditional = graph
.outgoing_edges(&node.id)
.iter()
.any(|e| e.condition().is_some_and(|c| !c.is_empty()));
if has_conditional {
diagnostics.push(Diagnostic {
rule: self.name().to_string(),
severity: Severity::Error,
message: format!(
"Node '{}' has selection=\"random\" but also has conditional edges; random selection and conditions cannot be combined",
node.id
),
node_id: Some(node.id.clone()),
edge: None,
fix: Some(
"Remove the condition attributes from outgoing edges, or remove selection=\"random\" from the node".to_string(),
),
..Diagnostic::default()});
}
}
diagnostics
}
}
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::{AttrValue, Edge, Node};
use super::Rule;
use crate::rules::test_support::minimal_graph;
use crate::{LintRule, Severity};
#[test]
fn random_selection_no_conditions_clean() {
let mut g = minimal_graph();
let mut node = Node::new("pick");
node.attrs.insert(
"selection".to_string(),
AttrValue::String("random".to_string()),
);
g.nodes.insert("pick".to_string(), node);
g.edges.push(Edge::new("pick", "start"));
g.edges.push(Edge::new("pick", "exit"));
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn random_selection_with_conditions_errors() {
let mut g = minimal_graph();
let mut node = Node::new("pick");
node.attrs.insert(
"selection".to_string(),
AttrValue::String("random".to_string()),
);
g.nodes.insert("pick".to_string(), node);
let mut e = Edge::new("pick", "exit");
e.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=succeeded".to_string()),
);
g.edges.push(e);
g.edges.push(Edge::new("pick", "start"));
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Error);
assert_eq!(d[0].node_id.as_deref(), Some("pick"));
}
#[test]
fn deterministic_selection_with_conditions_ok() {
let mut g = minimal_graph();
let mut node = Node::new("gate");
node.attrs.insert(
"selection".to_string(),
AttrValue::String("deterministic".to_string()),
);
g.nodes.insert("gate".to_string(), node);
let mut e = Edge::new("gate", "exit");
e.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=succeeded".to_string()),
);
g.edges.push(e);
g.edges.push(Edge::new("gate", "start"));
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
}

View file

@ -1,132 +0,0 @@
use std::collections::{HashSet, VecDeque};
use fabro_graphviz::graph::Graph;
use crate::{Diagnostic, LintRule, Severity};
pub(super) fn rule() -> Box<dyn LintRule> {
Box::new(Rule)
}
struct Rule;
impl LintRule for Rule {
fn name(&self) -> &'static str {
"reachability"
}
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
let Some(start) = graph.find_start_node() else {
return Vec::new();
};
let mut visited = HashSet::new();
let mut queue = VecDeque::new();
queue.push_back(start.id.clone());
visited.insert(start.id.clone());
while let Some(node_id) = queue.pop_front() {
for edge in graph.outgoing_edges(&node_id) {
if visited.insert(edge.to.clone()) {
queue.push_back(edge.to.clone());
}
}
}
let mut unreachable: Vec<&str> = graph
.nodes
.keys()
.filter(|id| !visited.contains(id.as_str()))
.map(std::string::String::as_str)
.collect();
unreachable.sort_unstable();
unreachable
.into_iter()
.map(|node_id| Diagnostic {
rule: self.name().to_string(),
severity: Severity::Warning,
message: format!("Node '{node_id}' is not reachable from the start node"),
node_id: Some(node_id.to_string()),
edge: None,
fix: Some(format!(
"Add an edge path from the start node to '{node_id}'"
)),
..Diagnostic::default()
})
.collect()
}
}
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::{Edge, Graph, Node};
use super::Rule;
use crate::rules::test_support::minimal_graph;
use crate::{LintRule, Severity};
#[test]
fn reachability_rule_unreachable_node() {
let mut g = minimal_graph();
g.nodes.insert("orphan".to_string(), Node::new("orphan"));
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].node_id, Some("orphan".to_string()));
}
#[test]
fn reachability_rule_all_reachable() {
let g = minimal_graph();
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn reachability_rule_no_start_node() {
let mut g = Graph::new("test");
g.nodes.insert("orphan".to_string(), Node::new("orphan"));
let rule = Rule;
let d = rule.apply(&g);
// No start node found, rule returns empty
assert!(d.is_empty());
}
// --- Additional coverage: retry_target_exists fallback and graph-level ---
#[test]
fn reachability_rule_multiple_unreachable() {
let mut g = minimal_graph();
g.nodes
.insert("orphan_a".to_string(), Node::new("orphan_a"));
g.nodes
.insert("orphan_b".to_string(), Node::new("orphan_b"));
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 2);
assert_eq!(d[0].severity, Severity::Warning);
assert_eq!(d[1].severity, Severity::Warning);
}
// --- type_known: all known handler types are accepted ---
#[test]
fn reachability_rule_chain_all_reachable() {
let mut g = minimal_graph();
g.nodes.insert("a".to_string(), Node::new("a"));
g.nodes.insert("b".to_string(), Node::new("b"));
g.edges = vec![
Edge::new("start", "a"),
Edge::new("a", "b"),
Edge::new("b", "exit"),
];
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
// --- edge_target_exists: no edges at all ---
}

View file

@ -1,102 +0,0 @@
use fabro_graphviz::graph::Graph;
use crate::{Diagnostic, LintRule, Severity};
pub(super) fn rule() -> Box<dyn LintRule> {
Box::new(Rule)
}
struct Rule;
const DOT_RESERVED_KEYWORDS: &[&str] = &[
"graph", "digraph", "subgraph", "node", "edge", "strict", "if",
];
impl LintRule for Rule {
fn name(&self) -> &'static str {
"reserved_keyword_node_id"
}
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
graph
.nodes
.values()
.filter(|node| DOT_RESERVED_KEYWORDS.contains(&node.id.to_lowercase().as_str()))
.map(|node| Diagnostic {
rule: self.name().to_string(),
severity: Severity::Warning,
message: format!(
"Node ID '{}' is a DOT reserved keyword and may cause parsing failures",
node.id
),
node_id: Some(node.id.clone()),
edge: None,
fix: Some(format!(
"Rename '{}' to '{}_step' or another non-reserved ID",
node.id,
node.id.to_lowercase()
)),
..Diagnostic::default()
})
.collect()
}
}
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::{Edge, Node};
use super::Rule;
use crate::rules::test_support::minimal_graph;
use crate::{LintRule, Severity};
#[test]
fn reserved_keyword_node_id_warns_on_keyword() {
let mut g = minimal_graph();
g.nodes.insert("graph".to_string(), Node::new("graph"));
g.edges.push(Edge::new("start", "graph"));
g.edges.push(Edge::new("graph", "exit"));
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Warning);
assert_eq!(d[0].node_id.as_deref(), Some("graph"));
}
#[test]
fn reserved_keyword_node_id_case_insensitive() {
let mut g = minimal_graph();
g.nodes.insert("Node".to_string(), Node::new("Node"));
g.nodes.insert("EDGE".to_string(), Node::new("EDGE"));
g.edges.push(Edge::new("start", "Node"));
g.edges.push(Edge::new("Node", "EDGE"));
g.edges.push(Edge::new("EDGE", "exit"));
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 2);
}
#[test]
fn reserved_keyword_node_id_normal_id_no_warning() {
let g = minimal_graph();
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn reserved_keyword_node_id_multiple_keywords() {
let mut g = minimal_graph();
g.nodes.insert("strict".to_string(), Node::new("strict"));
g.nodes.insert("digraph".to_string(), Node::new("digraph"));
g.nodes.insert("if".to_string(), Node::new("if"));
g.edges.push(Edge::new("start", "strict"));
g.edges.push(Edge::new("strict", "digraph"));
g.edges.push(Edge::new("digraph", "if"));
g.edges.push(Edge::new("if", "exit"));
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 3);
}
}

View file

@ -1,254 +0,0 @@
use fabro_graphviz::graph::Graph;
use crate::{Diagnostic, LintRule, Severity};
pub(super) fn rule() -> Box<dyn LintRule> {
Box::new(Rule)
}
struct Rule;
impl LintRule for Rule {
fn name(&self) -> &'static str {
"retry_target_exists"
}
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
for node in graph.nodes.values() {
if let Some(target) = node.retry_target() {
if !graph.nodes.contains_key(target) {
diagnostics.push(Diagnostic {
rule: self.name().to_string(),
severity: Severity::Warning,
message: format!(
"Node '{}' has retry_target '{}' that does not exist",
node.id, target
),
node_id: Some(node.id.clone()),
edge: None,
fix: Some(format!("Define node '{target}' or fix retry_target")),
..Diagnostic::default()
});
}
}
if let Some(target) = node.fallback_retry_target() {
if !graph.nodes.contains_key(target) {
diagnostics.push(Diagnostic {
rule: self.name().to_string(),
severity: Severity::Warning,
message: format!(
"Node '{}' has fallback_retry_target '{}' that does not exist",
node.id, target
),
node_id: Some(node.id.clone()),
edge: None,
fix: Some(format!(
"Define node '{target}' or fix fallback_retry_target"
)),
..Diagnostic::default()
});
}
}
}
if let Some(target) = graph.retry_target() {
if !graph.nodes.contains_key(target) {
diagnostics.push(Diagnostic {
rule: self.name().to_string(),
severity: Severity::Warning,
message: format!("Graph has retry_target '{target}' that does not exist"),
node_id: None,
edge: None,
fix: Some(format!("Define node '{target}' or fix graph retry_target")),
..Diagnostic::default()
});
}
}
if let Some(target) = graph.fallback_retry_target() {
if !graph.nodes.contains_key(target) {
diagnostics.push(Diagnostic {
rule: self.name().to_string(),
severity: Severity::Warning,
message: format!(
"Graph has fallback_retry_target '{target}' that does not exist"
),
node_id: None,
edge: None,
fix: Some(format!(
"Define node '{target}' or fix graph fallback_retry_target"
)),
..Diagnostic::default()
});
}
}
diagnostics
}
}
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::{AttrValue, Node};
use super::Rule;
use crate::rules::test_support::minimal_graph;
use crate::{LintRule, Severity};
#[test]
fn retry_target_exists_rule_missing() {
let mut g = minimal_graph();
let mut node = Node::new("work");
node.attrs.insert(
"retry_target".to_string(),
AttrValue::String("nonexistent".to_string()),
);
g.nodes.insert("work".to_string(), node);
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Warning);
}
#[test]
fn retry_target_exists_rule_valid() {
let mut g = minimal_graph();
let mut node = Node::new("work");
node.attrs.insert(
"retry_target".to_string(),
AttrValue::String("start".to_string()),
);
g.nodes.insert("work".to_string(), node);
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn retry_target_exists_rule_fallback_missing() {
let mut g = minimal_graph();
let mut node = Node::new("work");
node.attrs.insert(
"fallback_retry_target".to_string(),
AttrValue::String("nonexistent".to_string()),
);
g.nodes.insert("work".to_string(), node);
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Warning);
assert!(d[0].message.contains("fallback_retry_target"));
}
#[test]
fn retry_target_exists_rule_fallback_valid() {
let mut g = minimal_graph();
let mut node = Node::new("work");
node.attrs.insert(
"fallback_retry_target".to_string(),
AttrValue::String("start".to_string()),
);
g.nodes.insert("work".to_string(), node);
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn retry_target_exists_rule_graph_level_missing() {
let mut g = minimal_graph();
g.attrs.insert(
"retry_target".to_string(),
AttrValue::String("nonexistent".to_string()),
);
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Warning);
assert!(d[0].message.contains("Graph"));
}
#[test]
fn retry_target_exists_rule_graph_level_valid() {
let mut g = minimal_graph();
g.attrs.insert(
"retry_target".to_string(),
AttrValue::String("start".to_string()),
);
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn retry_target_exists_rule_graph_fallback_missing() {
let mut g = minimal_graph();
g.attrs.insert(
"fallback_retry_target".to_string(),
AttrValue::String("nonexistent".to_string()),
);
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Warning);
assert!(d[0].message.contains("fallback_retry_target"));
}
#[test]
fn retry_target_exists_rule_graph_fallback_valid() {
let mut g = minimal_graph();
g.attrs.insert(
"fallback_retry_target".to_string(),
AttrValue::String("exit".to_string()),
);
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
// --- Additional coverage: goal_gate_has_retry with graph-level retry ---
#[test]
fn retry_target_exists_rule_both_node_targets_invalid() {
let mut g = minimal_graph();
let mut node = Node::new("work");
node.attrs.insert(
"retry_target".to_string(),
AttrValue::String("missing_a".to_string()),
);
node.attrs.insert(
"fallback_retry_target".to_string(),
AttrValue::String("missing_b".to_string()),
);
g.nodes.insert("work".to_string(), node);
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 2);
assert_eq!(d[0].severity, Severity::Warning);
assert_eq!(d[1].severity, Severity::Warning);
}
// --- retry_target_exists: both graph-level targets invalid ---
#[test]
fn retry_target_exists_rule_both_graph_targets_invalid() {
let mut g = minimal_graph();
g.attrs.insert(
"retry_target".to_string(),
AttrValue::String("missing_a".to_string()),
);
g.attrs.insert(
"fallback_retry_target".to_string(),
AttrValue::String("missing_b".to_string()),
);
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 2);
assert_eq!(d[0].severity, Severity::Warning);
assert_eq!(d[1].severity, Severity::Warning);
}
// --- start_no_incoming: multiple incoming edges ---
}

View file

@ -1,158 +0,0 @@
use fabro_graphviz::graph::Graph;
use crate::{Diagnostic, LintRule, Severity};
pub(super) fn rule() -> Box<dyn LintRule> {
Box::new(Rule)
}
struct Rule;
/// Returns true if `text` contains `cd` followed by whitespace and then `/`.
fn contains_cd_absolute(text: &str) -> bool {
let bytes = text.as_bytes();
let len = bytes.len();
let mut i = 0;
while i + 3 < len {
if bytes[i] == b'c' && bytes[i + 1] == b'd' && bytes[i + 2].is_ascii_whitespace() {
// found "cd<ws>", scan past remaining whitespace to check for '/'
let mut j = i + 2;
while j < len && bytes[j].is_ascii_whitespace() {
j += 1;
}
if j < len && bytes[j] == b'/' {
return true;
}
}
i += 1;
}
false
}
impl LintRule for Rule {
fn name(&self) -> &'static str {
"script_absolute_cd"
}
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
for node in graph.nodes.values() {
if node.handler_type() != Some("command") {
continue;
}
let script = node.script().unwrap_or("");
if contains_cd_absolute(script) {
diagnostics.push(Diagnostic {
rule: self.name().to_string(),
severity: Severity::Warning,
message: format!(
"Script node '{}' contains `cd /…` with an absolute path",
node.id
),
node_id: Some(node.id.clone()),
edge: None,
fix: Some(
"Use a relative path; the engine sets the working directory to the worktree automatically"
.to_string(),
),
..Diagnostic::default()});
}
}
diagnostics
}
}
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::{AttrValue, Node};
use super::Rule;
use crate::rules::test_support::minimal_graph;
use crate::{LintRule, Severity};
#[test]
fn script_absolute_cd_warns_on_cd_abs_path() {
let mut g = minimal_graph();
let mut node = Node::new("run");
node.attrs.insert(
"shape".to_string(),
AttrValue::String("parallelogram".to_string()),
);
node.attrs.insert(
"script".to_string(),
AttrValue::String("cd /tmp && ls".to_string()),
);
g.nodes.insert("run".to_string(), node);
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Warning);
}
#[test]
fn script_absolute_cd_no_warning_on_relative() {
let mut g = minimal_graph();
let mut node = Node::new("run");
node.attrs.insert(
"shape".to_string(),
AttrValue::String("parallelogram".to_string()),
);
node.attrs.insert(
"script".to_string(),
AttrValue::String("cd src && ls".to_string()),
);
g.nodes.insert("run".to_string(), node);
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn script_absolute_cd_warns_on_shapeless_script_node() {
let mut g = minimal_graph();
let mut node = Node::new("run");
node.attrs.insert(
"script".to_string(),
AttrValue::String("cd /home/user && make".to_string()),
);
g.nodes.insert("run".to_string(), node);
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Warning);
}
#[test]
fn script_absolute_cd_ignores_legacy_tool_command() {
let mut g = minimal_graph();
let mut node = Node::new("run");
node.attrs.insert(
"shape".to_string(),
AttrValue::String("parallelogram".to_string()),
);
node.attrs.insert(
"tool_command".to_string(),
AttrValue::String("cd /home/user && make".to_string()),
);
g.nodes.insert("run".to_string(), node);
let rule = Rule;
assert!(rule.apply(&g).is_empty());
}
#[test]
fn script_absolute_cd_skips_non_script_nodes() {
let mut g = minimal_graph();
let mut node = Node::new("gen");
node.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
node.attrs.insert(
"prompt".to_string(),
AttrValue::String("cd /tmp and do stuff".to_string()),
);
g.nodes.insert("gen".to_string(), node);
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
}

View file

@ -1,85 +0,0 @@
use fabro_graphviz::graph::{AttrValue, Graph};
use crate::{Diagnostic, LintRule, Severity};
pub(super) fn rule() -> Box<dyn LintRule> {
Box::new(Rule)
}
struct Rule;
const VALID_SELECTIONS: &[&str] = &["deterministic", "random"];
impl LintRule for Rule {
fn name(&self) -> &'static str {
"selection_valid"
}
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
for node in graph.nodes.values() {
if let Some(sel) = node.attrs.get("selection").and_then(AttrValue::as_str) {
if !VALID_SELECTIONS.contains(&sel) {
diagnostics.push(Diagnostic {
rule: self.name().to_string(),
severity: Severity::Warning,
message: format!("Node '{}' has invalid selection mode '{sel}'", node.id),
node_id: Some(node.id.clone()),
edge: None,
fix: Some(format!("Use one of: {}", VALID_SELECTIONS.join(", "))),
..Diagnostic::default()
});
}
}
}
diagnostics
}
}
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::{AttrValue, Node};
use super::Rule;
use crate::rules::test_support::minimal_graph;
use crate::{LintRule, Severity};
#[test]
fn selection_valid_known_values() {
let mut g = minimal_graph();
let mut node = Node::new("pick");
node.attrs.insert(
"selection".to_string(),
AttrValue::String("random".to_string()),
);
g.nodes.insert("pick".to_string(), node);
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn selection_valid_unknown_value_warns() {
let mut g = minimal_graph();
let mut node = Node::new("pick");
node.attrs.insert(
"selection".to_string(),
AttrValue::String("randon".to_string()),
);
g.nodes.insert("pick".to_string(), node);
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Warning);
assert_eq!(d[0].node_id.as_deref(), Some("pick"));
}
#[test]
fn selection_valid_no_attr_ok() {
let g = minimal_graph();
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
}

View file

@ -1,91 +0,0 @@
use fabro_graphviz::graph::Graph;
use crate::{Diagnostic, LintRule, Severity};
pub(super) fn rule() -> Box<dyn LintRule> {
Box::new(Rule)
}
struct Rule;
impl LintRule for Rule {
fn name(&self) -> &'static str {
"start_no_incoming"
}
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
let Some(start) = graph.find_start_node() else {
return Vec::new();
};
let incoming = graph.incoming_edges(&start.id);
if !incoming.is_empty() {
return vec![Diagnostic {
rule: self.name().to_string(),
severity: Severity::Error,
message: format!(
"Start node '{}' has {} incoming edge(s) but must have none",
start.id,
incoming.len()
),
node_id: Some(start.id.clone()),
edge: None,
fix: Some("Remove incoming edges to the start node".to_string()),
..Diagnostic::default()
}];
}
Vec::new()
}
}
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::{Edge, Graph, Node};
use super::Rule;
use crate::rules::test_support::minimal_graph;
use crate::{LintRule, Severity};
#[test]
fn start_no_incoming_rule_with_incoming() {
let mut g = minimal_graph();
g.edges.push(Edge::new("exit", "start"));
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Error);
}
#[test]
fn start_no_incoming_rule_clean() {
let g = minimal_graph();
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn start_no_incoming_rule_no_start_node() {
let g = Graph::new("test");
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
// --- Additional coverage: exit_no_outgoing by id variants ---
#[test]
fn start_no_incoming_rule_multiple_incoming() {
let mut g = minimal_graph();
g.nodes.insert("a".to_string(), Node::new("a"));
g.edges.push(Edge::new("exit", "start"));
g.edges.push(Edge::new("a", "start"));
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Error);
assert!(d[0].message.contains('2'));
}
// --- prompt_on_llm_nodes: empty prompt string still triggers ---
}

View file

@ -1,120 +0,0 @@
use fabro_graphviz::graph::Graph;
use crate::{Diagnostic, LintRule, Severity};
pub(super) fn rule() -> Box<dyn LintRule> {
Box::new(Rule)
}
struct Rule;
impl LintRule for Rule {
fn name(&self) -> &'static str {
"start_node"
}
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
let start_count = graph
.nodes
.iter()
.filter(|(id, n)| n.shape() == "Mdiamond" || *id == "start" || *id == "Start")
.count();
if start_count == 0 {
return vec![Diagnostic {
rule: self.name().to_string(),
severity: Severity::Error,
message:
"Pipeline must have exactly one start node (shape=Mdiamond or id start/Start)"
.to_string(),
node_id: None,
edge: None,
fix: Some("Add a node with shape=Mdiamond or id 'start'".to_string()),
..Diagnostic::default()
}];
}
if start_count > 1 {
return vec![Diagnostic {
rule: self.name().to_string(),
severity: Severity::Error,
message: format!(
"Pipeline has {start_count} start nodes but must have exactly one"
),
node_id: None,
edge: None,
fix: Some("Remove extra start nodes".to_string()),
..Diagnostic::default()
}];
}
Vec::new()
}
}
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::{AttrValue, Graph, Node};
use super::Rule;
use crate::rules::test_support::minimal_graph;
use crate::{LintRule, Severity};
#[test]
fn start_node_rule_no_start() {
let g = Graph::new("test");
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Error);
}
#[test]
fn start_node_rule_two_starts() {
let mut g = Graph::new("test");
let mut s1 = Node::new("s1");
s1.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
let mut s2 = Node::new("s2");
s2.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
g.nodes.insert("s1".to_string(), s1);
g.nodes.insert("s2".to_string(), s2);
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Error);
}
#[test]
fn start_node_rule_one_start() {
let g = minimal_graph();
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn start_node_rule_by_id() {
let mut g = Graph::new("test");
// Node with id "start" but no Mdiamond shape
let node = Node::new("start");
g.nodes.insert("start".to_string(), node);
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn start_node_rule_by_capitalized_id() {
let mut g = Graph::new("test");
let node = Node::new("Start");
g.nodes.insert("Start".to_string(), node);
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
}

View file

@ -1,95 +0,0 @@
use fabro_graphviz::graph::{ContextKeyAttr, Graph};
use fabro_types::StageHandler;
use crate::{Diagnostic, LintRule, Severity};
pub(super) fn rule() -> Box<dyn LintRule> {
Box::new(Rule)
}
struct Rule;
impl LintRule for Rule {
fn name(&self) -> &'static str {
"stdin_source_valid"
}
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
graph
.nodes
.values()
.filter(|node| {
StageHandler::from_handler_type(node.handler_type()) == StageHandler::Command
})
.filter(|node| {
matches!(
node.context_key_attr("stdin_source"),
ContextKeyAttr::Invalid
)
})
.map(|node| Diagnostic {
rule: self.name().to_string(),
severity: Severity::Error,
message: format!(
"Node '{}' has an empty or non-string 'stdin_source'",
node.id
),
node_id: Some(node.id.clone()),
edge: None,
fix: Some(
"Set 'stdin_source' to a context key such as \"context.parallel.results\""
.to_string(),
),
..Diagnostic::default()
})
.collect()
}
}
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::{AttrValue, Node};
use super::Rule;
use crate::rules::test_support::minimal_graph;
use crate::{LintRule, Severity};
fn command_node(value: AttrValue) -> Node {
let mut node = Node::new("merge");
node.attrs.insert(
"shape".to_string(),
AttrValue::String("parallelogram".to_string()),
);
node.attrs.insert("stdin_source".to_string(), value);
node
}
#[test]
fn accepts_non_empty_context_source() {
let mut graph = minimal_graph();
graph.nodes.insert(
"merge".to_string(),
command_node(AttrValue::String("context.parallel.results".to_string())),
);
assert!(Rule.apply(&graph).is_empty());
}
#[test]
fn rejects_empty_or_non_string_source() {
for value in [
AttrValue::String(String::new()),
AttrValue::String(" ".to_string()),
AttrValue::Integer(3),
] {
let mut graph = minimal_graph();
graph.nodes.insert("merge".to_string(), command_node(value));
let diagnostics = Rule.apply(&graph);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].severity, Severity::Error);
assert!(diagnostics[0].message.contains("empty or non-string"));
}
}
}

View file

@ -1,151 +0,0 @@
use fabro_graphviz::graph::Graph;
use fabro_graphviz::stylesheet::{Selector, parse_stylesheet};
use fabro_llm::lithos_catalog::Catalog;
use super::model_support::{check_model_known, check_provider_known};
use crate::{Diagnostic, LintRule};
pub(super) fn rule(catalog: &Catalog) -> Box<dyn LintRule + '_> {
Box::new(Rule { catalog })
}
struct Rule<'a> {
catalog: &'a Catalog,
}
impl Rule<'_> {
fn selector_label(selector: &Selector) -> String {
match selector {
Selector::Universal => "*".to_string(),
Selector::Shape(s) => s.clone(),
Selector::Class(c) => format!(".{c}"),
Selector::Id(id) => format!("#{id}"),
}
}
}
impl LintRule for Rule<'_> {
fn name(&self) -> &'static str {
"stylesheet_model_known"
}
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
let stylesheet_str = graph.model_stylesheet();
if stylesheet_str.is_empty() {
return Vec::new();
}
let Ok(stylesheet) = parse_stylesheet(stylesheet_str) else {
return Vec::new(); // syntax errors caught by stylesheet_syntax rule
};
let mut diagnostics = Vec::new();
for rule in &stylesheet.rules {
let label = Self::selector_label(&rule.selector);
for decl in &rule.declarations {
let context = format!("in stylesheet rule '{label}'");
match decl.property.as_str() {
"model" => {
if let Some(d) = check_model_known(
self.name(),
self.catalog,
&decl.value,
&context,
None,
) {
diagnostics.push(d);
}
}
"provider" => {
if let Some(d) = check_provider_known(
self.name(),
self.catalog,
&decl.value,
&context,
None,
) {
diagnostics.push(d);
}
}
_ => {}
}
}
}
diagnostics
}
}
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::AttrValue;
use fabro_llm::test_support::test_catalog;
use super::Rule;
use crate::rules::test_support::minimal_graph;
use crate::{LintRule, Severity};
#[test]
fn stylesheet_model_known_rule_valid() {
let mut g = minimal_graph();
g.attrs.insert(
"model_stylesheet".to_string(),
AttrValue::String("* { model: claude-sonnet-4.5; provider: anthropic; }".to_string()),
);
let catalog = test_catalog();
let rule = Rule { catalog: &catalog };
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn stylesheet_model_known_rule_unknown_model() {
let mut g = minimal_graph();
g.attrs.insert(
"model_stylesheet".to_string(),
AttrValue::String("#opus { model: claude-opus-4-5; }".to_string()),
);
let catalog = test_catalog();
let rule = Rule { catalog: &catalog };
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Warning);
assert!(d[0].message.contains("claude-opus-4-5"));
assert!(d[0].message.contains("#opus"));
}
#[test]
fn stylesheet_model_known_rule_unknown_provider() {
let mut g = minimal_graph();
g.attrs.insert(
"model_stylesheet".to_string(),
AttrValue::String("* { provider: nonexistent-provider; }".to_string()),
);
let catalog = test_catalog();
let rule = Rule { catalog: &catalog };
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Warning);
assert!(d[0].message.contains("nonexistent-provider"));
}
#[test]
fn stylesheet_model_known_rule_alias() {
let mut g = minimal_graph();
g.attrs.insert(
"model_stylesheet".to_string(),
AttrValue::String("* { model: opus; }".to_string()),
);
let catalog = test_catalog();
let rule = Rule { catalog: &catalog };
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn stylesheet_model_known_rule_no_stylesheet() {
let g = minimal_graph();
let catalog = test_catalog();
let rule = Rule { catalog: &catalog };
let d = rule.apply(&g);
assert!(d.is_empty());
}
}

View file

@ -1,125 +0,0 @@
use fabro_graphviz::graph::Graph;
use fabro_graphviz::stylesheet::parse_stylesheet;
use crate::{Diagnostic, LintRule, Severity};
pub(super) fn rule() -> Box<dyn LintRule> {
Box::new(Rule)
}
struct Rule;
impl LintRule for Rule {
fn name(&self) -> &'static str {
"stylesheet_syntax"
}
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
let stylesheet = graph.model_stylesheet();
if stylesheet.is_empty() {
return Vec::new();
}
match parse_stylesheet(stylesheet) {
Ok(_) => Vec::new(),
Err(e) => vec![Diagnostic {
rule: self.name().to_string(),
severity: Severity::Error,
message: format!("Model stylesheet parse error: {e}"),
node_id: None,
edge: None,
fix: Some("Fix the model_stylesheet syntax".to_string()),
..Diagnostic::default()
}],
}
}
}
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::AttrValue;
use super::Rule;
use crate::rules::test_support::minimal_graph;
use crate::{LintRule, Severity};
#[test]
fn stylesheet_syntax_rule_unbalanced() {
let mut g = minimal_graph();
g.attrs.insert(
"model_stylesheet".to_string(),
AttrValue::String("* { model: foo;".to_string()),
);
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Error);
}
#[test]
fn stylesheet_syntax_rule_balanced() {
let mut g = minimal_graph();
g.attrs.insert(
"model_stylesheet".to_string(),
AttrValue::String("* { model: foo; }".to_string()),
);
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn stylesheet_syntax_rule_malformed_selector() {
let mut g = minimal_graph();
g.attrs.insert(
"model_stylesheet".to_string(),
AttrValue::String("* { garbage garbage }".to_string()),
);
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Error);
}
// --- Additional coverage: condition_syntax invalid case ---
#[test]
fn stylesheet_syntax_rule_no_stylesheet() {
let g = minimal_graph();
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
// --- Additional coverage: type_known no type attr ---
#[test]
fn stylesheet_syntax_rule_multi_rule_valid() {
let mut g = minimal_graph();
g.attrs.insert(
"model_stylesheet".to_string(),
AttrValue::String(
"* { model: gpt-4; } .fast { model: gpt-3.5; reasoning_effort: low; }".to_string(),
),
);
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
// --- fidelity_valid: multiple simultaneous violations ---
#[test]
fn stylesheet_syntax_rule_empty_string() {
let mut g = minimal_graph();
g.attrs.insert(
"model_stylesheet".to_string(),
AttrValue::String(String::new()),
);
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
// --- type_known: start and exit types from shape are not flagged ---
}

View file

@ -1,157 +0,0 @@
use fabro_graphviz::graph::Graph;
use crate::{Diagnostic, LintRule, Severity};
pub(super) fn rule() -> Box<dyn LintRule> {
Box::new(Rule)
}
struct Rule;
impl LintRule for Rule {
fn name(&self) -> &'static str {
"terminal_node"
}
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
let terminal_count = graph
.nodes
.iter()
.filter(|(id, n)| {
n.shape() == "Msquare"
|| *id == "exit"
|| *id == "Exit"
|| *id == "end"
|| *id == "End"
})
.count();
if terminal_count == 0 {
return vec![Diagnostic {
rule: self.name().to_string(),
severity: Severity::Error,
message:
"Pipeline must have exactly one terminal node (shape=Msquare or id exit/end)"
.to_string(),
node_id: None,
edge: None,
fix: Some("Add a node with shape=Msquare or id 'exit'/'end'".to_string()),
..Diagnostic::default()
}];
}
if terminal_count > 1 {
return vec![Diagnostic {
rule: self.name().to_string(),
severity: Severity::Error,
message: format!(
"Pipeline must have exactly one terminal node, found {terminal_count}"
),
node_id: None,
edge: None,
fix: Some("Remove extra terminal nodes so exactly one remains".to_string()),
..Diagnostic::default()
}];
}
Vec::new()
}
}
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::{AttrValue, Graph, Node};
use super::Rule;
use crate::rules::test_support::minimal_graph;
use crate::{LintRule, Severity};
#[test]
fn terminal_node_rule_no_terminal() {
let mut g = Graph::new("test");
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
g.nodes.insert("start".to_string(), start);
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Error);
}
#[test]
fn terminal_node_rule_with_terminal() {
let g = minimal_graph();
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn terminal_node_rule_by_exit_id() {
let mut g = Graph::new("test");
// Node with id "exit" but no Msquare shape
let node = Node::new("exit");
g.nodes.insert("exit".to_string(), node);
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn terminal_node_rule_by_end_id() {
let mut g = Graph::new("test");
let node = Node::new("end");
g.nodes.insert("end".to_string(), node);
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn terminal_node_rule_by_capitalized_end_id() {
let mut g = Graph::new("test");
let node = Node::new("End");
g.nodes.insert("End".to_string(), node);
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn terminal_node_rule_two_terminals() {
let mut g = Graph::new("test");
let mut e1 = Node::new("e1");
e1.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
let mut e2 = Node::new("e2");
e2.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
g.nodes.insert("e1".to_string(), e1);
g.nodes.insert("e2".to_string(), e2);
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Error);
assert!(d[0].message.contains("exactly one"));
}
// --- Additional coverage: edge_target_exists missing source ---
#[test]
fn terminal_node_rule_by_exit_capitalized_id() {
let mut g = Graph::new("test");
let node = Node::new("Exit");
g.nodes.insert("Exit".to_string(), node);
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
// --- edge_target_exists: both source and target missing ---
}

View file

@ -1,30 +0,0 @@
use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};
pub(crate) fn node_with_attrs(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
}
pub(crate) fn minimal_graph() -> Graph {
let mut g = Graph::new("test");
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
g.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
g.nodes.insert("exit".to_string(), exit);
g.edges.push(Edge::new("start", "exit"));
g
}

View file

@ -1,311 +0,0 @@
use fabro_graphviz::graph::Graph;
use super::parallel_branch::ParallelBranches;
use crate::{Diagnostic, LintRule, Severity};
pub(super) fn rule() -> Box<dyn LintRule> {
Box::new(Rule)
}
struct Rule;
impl Rule {
const FIX: &str = "Add fidelity=\"full\" to enable session reuse, or remove thread_id";
}
impl LintRule for Rule {
fn name(&self) -> &'static str {
"thread_id_requires_fidelity_full"
}
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
let graph_default_full = graph.default_fidelity() == Some("full");
let branches = ParallelBranches::new(graph);
// thread_id is inert on parallel branches, where
// parallel_branch_inert_attribute already says "remove thread_id" —
// advising fidelity="full" there would contradict it.
for node in graph.nodes.values() {
if node.thread_id().is_some()
&& !branches.is_branch_only_node(&node.id)
&& node.fidelity() != Some("full")
&& !graph_default_full
{
diagnostics.push(Diagnostic {
rule: self.name().to_string(),
severity: Severity::Warning,
message: format!(
"Node '{}' has thread_id but fidelity is not 'full'",
node.id
),
node_id: Some(node.id.clone()),
edge: None,
fix: Some(Self::FIX.to_string()),
..Diagnostic::default()
});
}
}
for edge in &graph.edges {
if edge.thread_id().is_some() && !branches.is_fork_edge(edge) {
let edge_full = edge.fidelity() == Some("full");
let target_full =
graph.nodes.get(&edge.to).and_then(|n| n.fidelity()) == Some("full");
if !edge_full && !target_full && !graph_default_full {
diagnostics.push(Diagnostic {
rule: self.name().to_string(),
severity: Severity::Warning,
message: format!(
"Edge {} -> {} has thread_id but fidelity is not 'full'",
edge.from, edge.to
),
node_id: None,
edge: Some((edge.from.clone(), edge.to.clone())),
fix: Some(Self::FIX.to_string()),
..Diagnostic::default()
});
}
}
}
if graph.default_thread().is_some() && !graph_default_full {
diagnostics.push(Diagnostic {
rule: self.name().to_string(),
severity: Severity::Warning,
message: "Graph has default_thread but default_fidelity is not 'full'".to_string(),
node_id: None,
edge: None,
fix: Some(Self::FIX.to_string()),
..Diagnostic::default()
});
}
diagnostics
}
}
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};
use super::Rule;
use crate::rules::test_support::minimal_graph;
use crate::{LintRule, Severity};
fn parallel_graph() -> Graph {
let mut g = minimal_graph();
let mut fork = Node::new("fork");
fork.attrs.insert(
"shape".to_string(),
AttrValue::String("component".to_string()),
);
g.nodes.insert("fork".to_string(), fork);
g.nodes.insert("branch".to_string(), Node::new("branch"));
g.edges = vec![
Edge::new("start", "fork"),
Edge::new("fork", "branch"),
Edge::new("branch", "exit"),
];
g
}
#[test]
fn thread_id_requires_fidelity_full_node_warns() {
let mut g = minimal_graph();
let mut node = Node::new("work");
node.attrs.insert(
"thread_id".to_string(),
AttrValue::String("session1".to_string()),
);
g.nodes.insert("work".to_string(), node);
g.edges.push(Edge::new("start", "work"));
g.edges.push(Edge::new("work", "exit"));
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Warning);
assert_eq!(d[0].node_id, Some("work".to_string()));
}
#[test]
fn thread_id_requires_fidelity_full_node_ok() {
let mut g = minimal_graph();
let mut node = Node::new("work");
node.attrs.insert(
"thread_id".to_string(),
AttrValue::String("session1".to_string()),
);
node.attrs.insert(
"fidelity".to_string(),
AttrValue::String("full".to_string()),
);
g.nodes.insert("work".to_string(), node);
g.edges.push(Edge::new("start", "work"));
g.edges.push(Edge::new("work", "exit"));
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn thread_id_requires_fidelity_full_node_graph_default_ok() {
let mut g = minimal_graph();
g.attrs.insert(
"default_fidelity".to_string(),
AttrValue::String("full".to_string()),
);
let mut node = Node::new("work");
node.attrs.insert(
"thread_id".to_string(),
AttrValue::String("session1".to_string()),
);
g.nodes.insert("work".to_string(), node);
g.edges.push(Edge::new("start", "work"));
g.edges.push(Edge::new("work", "exit"));
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn thread_id_requires_fidelity_full_edge_warns() {
let mut g = minimal_graph();
let mut edge = Edge::new("start", "exit");
edge.attrs.insert(
"thread_id".to_string(),
AttrValue::String("session1".to_string()),
);
g.edges = vec![edge];
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Warning);
assert_eq!(d[0].edge, Some(("start".to_string(), "exit".to_string())));
}
#[test]
fn thread_id_requires_fidelity_full_edge_ok() {
let mut g = minimal_graph();
let mut edge = Edge::new("start", "exit");
edge.attrs.insert(
"thread_id".to_string(),
AttrValue::String("session1".to_string()),
);
edge.attrs.insert(
"fidelity".to_string(),
AttrValue::String("full".to_string()),
);
g.edges = vec![edge];
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn thread_id_requires_fidelity_full_edge_target_node_ok() {
let mut g = minimal_graph();
if let Some(exit_node) = g.nodes.get_mut("exit") {
exit_node.attrs.insert(
"fidelity".to_string(),
AttrValue::String("full".to_string()),
);
}
let mut edge = Edge::new("start", "exit");
edge.attrs.insert(
"thread_id".to_string(),
AttrValue::String("session1".to_string()),
);
g.edges = vec![edge];
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn skips_thread_id_on_parallel_branch_edge() {
let mut g = parallel_graph();
g.edges[1].attrs.insert(
"thread_id".to_string(),
AttrValue::String("branch-thread".to_string()),
);
assert!(Rule.apply(&g).is_empty());
}
#[test]
fn skips_thread_id_on_branch_only_node() {
let mut g = parallel_graph();
g.nodes
.get_mut("branch")
.expect("graph has branch")
.attrs
.insert(
"thread_id".to_string(),
AttrValue::String("branch-thread".to_string()),
);
assert!(Rule.apply(&g).is_empty());
}
#[test]
fn checks_thread_id_on_branch_node_with_normal_entry() {
let mut g = parallel_graph();
g.edges.push(Edge::new("start", "branch"));
g.nodes
.get_mut("branch")
.expect("graph has branch")
.attrs
.insert(
"thread_id".to_string(),
AttrValue::String("shared-thread".to_string()),
);
let d = Rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].node_id.as_deref(), Some("branch"));
}
#[test]
fn thread_id_requires_fidelity_full_graph_warns() {
let mut g = minimal_graph();
g.attrs.insert(
"default_thread".to_string(),
AttrValue::String("session1".to_string()),
);
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Warning);
assert!(d[0].node_id.is_none());
assert!(d[0].edge.is_none());
}
#[test]
fn thread_id_requires_fidelity_full_graph_ok() {
let mut g = minimal_graph();
g.attrs.insert(
"default_thread".to_string(),
AttrValue::String("session1".to_string()),
);
g.attrs.insert(
"default_fidelity".to_string(),
AttrValue::String("full".to_string()),
);
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
}

View file

@ -1,146 +0,0 @@
use fabro_graphviz::graph::{Graph, KNOWN_HANDLER_TYPES, is_known_handler_type};
use crate::{Diagnostic, LintRule, Severity};
pub(super) fn rule() -> Box<dyn LintRule> {
Box::new(Rule)
}
struct Rule;
impl LintRule for Rule {
fn name(&self) -> &'static str {
"type_known"
}
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
for node in graph.nodes.values() {
if let Some(node_type) = node.node_type() {
if !is_known_handler_type(node_type) {
diagnostics.push(Diagnostic {
rule: self.name().to_string(),
severity: Severity::Warning,
message: format!("Node '{}' has unrecognized type '{node_type}'", node.id),
node_id: Some(node.id.clone()),
edge: None,
fix: Some(format!("Use one of: {}", KNOWN_HANDLER_TYPES.join(", "))),
..Diagnostic::default()
});
}
}
}
diagnostics
}
}
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::{AttrValue, Node};
use super::Rule;
use crate::rules::test_support::minimal_graph;
use crate::{LintRule, Severity};
#[test]
fn type_known_rule_unknown_type() {
let mut g = minimal_graph();
let mut node = Node::new("custom");
node.attrs.insert(
"type".to_string(),
AttrValue::String("unknown_type".to_string()),
);
g.nodes.insert("custom".to_string(), node);
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Warning);
}
#[test]
fn type_known_rule_known_type() {
let mut g = minimal_graph();
let mut node = Node::new("gate");
node.attrs
.insert("type".to_string(), AttrValue::String("human".to_string()));
g.nodes.insert("gate".to_string(), node);
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
#[test]
fn type_known_rule_no_type_attr() {
let g = minimal_graph();
let rule = Rule;
let d = rule.apply(&g);
// Nodes without explicit type attr should not trigger warning
assert!(d.is_empty());
}
// --- Additional coverage: start_no_incoming no start node ---
#[test]
fn type_known_rule_all_known_types_accepted() {
let mut g = minimal_graph();
let mut n1 = Node::new("n1");
n1.attrs
.insert("type".to_string(), AttrValue::String("agent".to_string()));
g.nodes.insert("n1".to_string(), n1);
let mut n2 = Node::new("n2");
n2.attrs.insert(
"type".to_string(),
AttrValue::String("conditional".to_string()),
);
g.nodes.insert("n2".to_string(), n2);
let mut n3 = Node::new("n3");
n3.attrs.insert(
"type".to_string(),
AttrValue::String("parallel".to_string()),
);
g.nodes.insert("n3".to_string(), n3);
let mut n4 = Node::new("n4");
n4.attrs.insert(
"type".to_string(),
AttrValue::String("parallel.fan_in".to_string()),
);
g.nodes.insert("n4".to_string(), n4);
let mut n5 = Node::new("n5");
n5.attrs
.insert("type".to_string(), AttrValue::String("command".to_string()));
g.nodes.insert("n5".to_string(), n5);
let mut n6 = Node::new("n6");
n6.attrs.insert(
"type".to_string(),
AttrValue::String("stack.manager_loop".to_string()),
);
g.nodes.insert("n6".to_string(), n6);
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
// --- prompt_on_llm_nodes: explicit type=agent without prompt/label ---
#[test]
fn type_known_rule_start_exit_shapes_no_warning() {
// The minimal_graph has start (Mdiamond) and exit (Msquare), which resolve
// to known handler types "start" and "exit" via shape mapping, not explicit
// type. Since they have no explicit `type` attr, the rule should not
// flag them.
let g = minimal_graph();
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
// --- reserved_keyword_node_id tests ---
}

View file

@ -1,113 +0,0 @@
use fabro_graphviz::graph::{AttrValue, Graph};
use crate::{Diagnostic, LintRule, Severity};
pub(super) fn rule() -> Box<dyn LintRule> {
Box::new(Rule)
}
struct Rule;
impl LintRule for Rule {
fn name(&self) -> &'static str {
"unresolved_file_ref"
}
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
for node in graph.nodes.values() {
if let Some(AttrValue::String(prompt)) = node.attrs.get("prompt") {
if prompt.starts_with('@') {
diagnostics.push(Diagnostic {
rule: self.name().to_string(),
severity: Severity::Error,
message: format!(
"Node '{}' has unresolved file reference: {prompt}",
node.id
),
node_id: Some(node.id.clone()),
edge: None,
fix: Some("Check that the path is relative to the workflow file's directory and the file exists".to_string()),
..Diagnostic::default()});
}
}
}
if let Some(AttrValue::String(goal)) = graph.attrs.get("goal") {
if goal.starts_with('@') {
diagnostics.push(Diagnostic {
rule: self.name().to_string(),
severity: Severity::Error,
message: format!("Graph goal has unresolved file reference: {goal}"),
node_id: None,
edge: None,
fix: Some("Check that the path is relative to the workflow file's directory and the file exists".to_string()),
..Diagnostic::default()});
}
}
diagnostics
}
}
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::{AttrValue, Edge, Node};
use super::Rule;
use crate::rules::test_support::minimal_graph;
use crate::{LintRule, Severity};
#[test]
fn unresolved_file_ref_rule_prompt() {
let mut g = minimal_graph();
let mut node = Node::new("work");
node.attrs.insert(
"prompt".to_string(),
AttrValue::String("@prompts/simplify.md".to_string()),
);
g.nodes.insert("work".to_string(), node);
g.edges.push(Edge::new("start", "work"));
g.edges.push(Edge::new("work", "exit"));
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Error);
assert!(d[0].message.contains("@prompts/simplify.md"));
assert_eq!(d[0].node_id, Some("work".to_string()));
}
#[test]
fn unresolved_file_ref_rule_goal() {
let mut g = minimal_graph();
g.attrs.insert(
"goal".to_string(),
AttrValue::String("@goal.md".to_string()),
);
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Error);
assert!(d[0].message.contains("@goal.md"));
}
#[test]
fn unresolved_file_ref_rule_resolved_prompt() {
let mut g = minimal_graph();
let mut node = Node::new("work");
node.attrs.insert(
"prompt".to_string(),
AttrValue::String("Do the work".to_string()),
);
g.nodes.insert("work".to_string(), node);
let rule = Rule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
}

View file

@ -24,7 +24,6 @@ anyhow.workspace = true
fabro-auth = { path = "../../foundation/fabro-auth" }
fabro-config = { path = "../../foundation/fabro-config" }
fabro-graphviz = { path = "../fabro-graphviz" }
fabro-validate = { path = "../fabro-validate" }
fabro-sandbox = { path = "../fabro-sandbox" }
sandbox-driver.workspace = true
pebble-agent.workspace = true

View file

@ -4,12 +4,12 @@ use std::sync::{Arc, LazyLock};
use fabro_graphviz::Error as GraphvizError;
use fabro_llm::{ErrorData, ErrorKind, ModelSelectionError, failure_signature_hint};
use fabro_template::TemplateError;
use fabro_types::diagnostic::Diagnostic;
pub use fabro_types::failure_signature::FailureSignature;
pub use fabro_types::outcome::FailureCategory;
use fabro_types::settings::{AmbiguousModelRef, ResolveError};
use fabro_types::{ExecOutputTail, FailureReason, RunFailure};
use fabro_util::error::{SharedError, collect_causes, collect_chain, render_with_causes};
use fabro_validate::Diagnostic;
use regex::Regex;
use thiserror::Error as ThisError;
@ -712,12 +712,6 @@ impl From<fabro_template::TemplateError> for Error {
}
}
impl From<fabro_validate::ValidationError> for Error {
fn from(e: fabro_validate::ValidationError) -> Self {
Self::Validation(e.0)
}
}
impl From<RunEventPersistenceError> for Error {
fn from(err: RunEventPersistenceError) -> Self {
Self::engine_with_source("run event persistence failed", err)
@ -795,7 +789,7 @@ mod tests {
let err = Error::ValidationFailed {
diagnostics: vec![Diagnostic {
rule: "test".to_string(),
severity: fabro_validate::Severity::Error,
severity: fabro_types::diagnostic::Severity::Error,
message: "missing start node".to_string(),
node_id: None,
edge: None,
@ -1982,7 +1976,7 @@ mod tests {
Error::ValidationFailed {
diagnostics: vec![Diagnostic {
rule: "test".into(),
severity: fabro_validate::Severity::Error,
severity: fabro_types::diagnostic::Severity::Error,
message: "bad".into(),
node_id: None,
edge: None,

File diff suppressed because it is too large Load diff

View file

@ -9,12 +9,11 @@ pub use archive::{
unarchive,
};
pub use create::{
CompiledRun, CreateRunCompileInput, CreateRunInput, CreateRunPersistenceInput,
CreateRunPersistenceMetadata, CreatedRun, MaterializedRun,
assemble_create_run_persistence_input, compile_admitted_run, compile_create_run, create,
make_run_dir, materialize_admitted_run, materialize_create_run, persist_create_run,
CompiledRun, CreateRunCompileInput, CreateRunPersistenceInput, CreateRunPersistenceMetadata,
CreatedRun, MaterializedRun, assemble_create_run_persistence_input, compile_admitted_run,
make_run_dir, materialize_admitted_run, persist_create_run,
};
pub use source::WorkflowInput;
pub use validate::{ValidateInput, validate, validate_with_catalog, validate_with_ready_providers};
pub use validate::{ValidateInput, validate};
pub use crate::transforms::RenderMode;

View file

@ -1,17 +1,14 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use fabro_llm::lithos_catalog::Catalog;
use fabro_types::WorkflowSettings;
use lithos_llm::catalog::ProviderId;
use super::create::{configured_default_provider, preprocess_and_validate, template_context};
use super::create::{preprocess_and_validate, template_context};
use super::source::{ResolveWorkflowInput, WorkflowInput, resolve_workflow};
use crate::error::Error;
use crate::operations::RenderMode;
use crate::pipeline::{TransformOptions, Validated};
use crate::transforms::{ModelResolutionTransform, Transform};
use crate::transforms::Transform;
pub struct ValidateInput {
pub workflow: WorkflowInput,
@ -23,51 +20,14 @@ pub struct ValidateInput {
pub custom_transforms: Vec<Box<dyn Transform>>,
}
/// Parse, transform, and structurally validate a DOT source string without a
/// model catalog. Model and provider availability is left to the caller that
/// owns a catalog — typically the server.
/// Parse and transform a DOT source string: the structural validation Fabro
/// does itself. Its diagnostics are the transforms' (an unbound template
/// variable, a missing file); the workflow's rules and its models are
/// Petri's to judge at admission.
///
/// Returns `Validated` even when validation produced errors. Call
/// `validated.raise_on_errors()` if the caller wants to fail fast.
pub fn validate(input: ValidateInput) -> Result<Validated, Error> {
validate_resolving_models(input, None)
}
/// Parse, transform, and validate a DOT source string against `catalog`.
pub fn validate_with_catalog(
input: ValidateInput,
catalog: Arc<Catalog>,
) -> Result<Validated, Error> {
validate_resolving_models(input, Some(ModelResolutionTransform::new(catalog)))
}
/// Parse, transform, and validate, resolving models against the ready
/// providers first and falling back to the full catalog only for
/// provider-readiness selection failures.
pub fn validate_with_ready_providers(
input: ValidateInput,
catalog: Arc<Catalog>,
ready_providers: &[ProviderId],
) -> Result<Validated, Error> {
validate_resolving_models(
input,
Some(
ModelResolutionTransform::for_eligible(
catalog,
ready_providers.iter().cloned().collect(),
)
.with_catalog_fallback(true),
),
)
}
/// The workflow's own default provider is only known once the workflow is
/// resolved, so callers hand in a partially built transform and it is
/// completed here.
fn validate_resolving_models(
input: ValidateInput,
model_resolution: Option<ModelResolutionTransform>,
) -> Result<Validated, Error> {
let ValidateInput {
workflow,
settings,
@ -82,10 +42,6 @@ fn validate_resolving_models(
})
.map_err(|err| Error::Parse(err.to_string()))?;
let model_resolution = model_resolution.map(|resolution| {
resolution.with_default_provider(configured_default_provider(&resolved.settings))
});
preprocess_and_validate(
&resolved.raw_source,
resolved.goal_override.as_deref(),
@ -99,7 +55,6 @@ fn validate_resolving_models(
.map(|path| path.display().to_string()),
render_mode: RenderMode::Structural,
custom_transforms,
model_resolution,
},
)
}

View file

@ -87,10 +87,6 @@ pub fn transform(parsed: Parsed, options: &TransformOptions) -> Result<Transform
.apply_with_diagnostics(graph)?;
diagnostics.extend(transform_diagnostics);
let graph = StylesheetApplicationTransform.apply(graph)?;
let graph = match &options.model_resolution {
Some(model_resolution) => model_resolution.apply(graph)?,
None => graph,
};
// Custom transforms
let graph = options
@ -113,13 +109,11 @@ mod tests {
use std::sync::Arc;
use fabro_graphviz::graph::AttrValue;
use fabro_llm::lithos_catalog::Catalog;
use super::*;
use crate::file_resolver::FilesystemFileResolver;
use crate::pipeline::parse::parse;
use crate::pipeline::types::{GOAL_SELF_REFERENCE_RULE, TEMPLATE_UNDEFINED_VARIABLE_RULE};
use crate::transforms::ModelResolutionTransform;
fn write_file(path: &Path, contents: &str) {
if let Some(parent) = path.parent() {
@ -128,10 +122,6 @@ mod tests {
std::fs::write(path, contents).unwrap();
}
fn test_catalog() -> Arc<Catalog> {
Arc::new(fabro_llm::test_support::test_catalog())
}
fn transform_options() -> TransformOptions {
TransformOptions {
current_dir: None,
@ -140,7 +130,6 @@ mod tests {
source_name: None,
render_mode: crate::operations::RenderMode::Strict,
custom_transforms: vec![],
model_resolution: Some(ModelResolutionTransform::new(test_catalog())),
}
}
@ -176,7 +165,7 @@ mod tests {
let transformed = transform(parsed, &transform_options()).unwrap();
assert_eq!(
transformed.graph.nodes["work"].attrs.get("model"),
Some(&AttrValue::String("claude-sonnet-5".into()))
Some(&AttrValue::String("sonnet".into()))
);
}
@ -231,7 +220,7 @@ mod tests {
.attrs
.get("model")
.and_then(AttrValue::as_str),
Some("claude-sonnet-5")
Some("sonnet")
);
assert_eq!(
transformed.graph.nodes["explicit"]
@ -279,7 +268,7 @@ mod tests {
.attrs
.get("model")
.and_then(AttrValue::as_str),
Some("claude-sonnet-5")
Some("sonnet")
);
}
@ -295,7 +284,6 @@ mod tests {
let parsed = parse(dot).unwrap();
let transformed = transform(parsed, &TransformOptions {
render_mode: crate::operations::RenderMode::Structural,
model_resolution: None,
..transform_options()
})
.unwrap();
@ -395,7 +383,7 @@ mod tests {
);
assert_eq!(
lint.attrs.get("model"),
Some(&AttrValue::String("claude-sonnet-5".into()))
Some(&AttrValue::String("sonnet".into()))
);
}
@ -589,7 +577,6 @@ mod tests {
}"#;
let parsed = parse(dot).unwrap();
let transformed = transform(parsed, &TransformOptions {
model_resolution: None,
..transform_options()
})
.unwrap();

View file

@ -3,12 +3,12 @@ use std::sync::Arc;
use fabro_graphviz::graph::Graph;
use fabro_template::TemplateContext;
use fabro_validate::{Diagnostic, Severity};
use fabro_types::diagnostic::{Diagnostic, Severity};
use crate::error::Error;
use crate::file_resolver::FileResolver;
use crate::records::RunSpec;
use crate::transforms::{ModelResolutionTransform, RenderMode, Transform};
use crate::transforms::{RenderMode, Transform};
/// Output of the PARSE phase.
#[non_exhaustive]
@ -81,6 +81,12 @@ impl Validated {
self.promote_rule_to_error(TEMPLATE_UNDEFINED_VARIABLE_RULE);
}
/// Add diagnostics from another judge of the workflow (Petri's check),
/// after the transforms' own.
pub fn extend_diagnostics(&mut self, diagnostics: impl IntoIterator<Item = Diagnostic>) {
self.diagnostics.extend(diagnostics);
}
/// True if any diagnostic has Error severity.
#[must_use]
pub fn has_errors(&self) -> bool {
@ -212,7 +218,4 @@ pub struct TransformOptions {
pub source_name: Option<String>,
pub render_mode: RenderMode,
pub custom_transforms: Vec<Box<dyn Transform>>,
/// Catalog-backed model resolution to perform. `None` preserves authored
/// model and provider selectors for catalog-free structural validation.
pub model_resolution: Option<ModelResolutionTransform>,
}

View file

@ -1,30 +1,18 @@
use fabro_llm::lithos_catalog::Catalog;
use fabro_validate::LintRule;
use super::types::{Transformed, Validated};
/// VALIDATE phase: run lint rules against the transformed graph.
///
/// Catalog-backed rules (model and provider availability) run only when
/// `catalog` is `Some`. Offline callers pass `None` so a workflow naming a
/// server-owned model is left for the server to judge.
/// VALIDATE phase: the transformed graph with the transforms' diagnostics.
/// Fabro's lint rules went with the legacy executor; the workflow's rules
/// and its models are Petri's to judge at admission.
///
/// **Infallible.** Always returns `Validated` with diagnostics. Caller decides
/// whether to fail via `validated.raise_on_errors()`.
pub fn validate(
transformed: Transformed,
catalog: Option<&Catalog>,
extra_rules: &[&dyn LintRule],
) -> Validated {
#[must_use]
pub fn validate(transformed: Transformed) -> Validated {
let Transformed {
graph,
source,
mut diagnostics,
diagnostics,
} = transformed;
diagnostics.extend(match catalog {
Some(catalog) => fabro_validate::validate_with_catalog(&graph, catalog, extra_rules),
None => fabro_validate::validate(&graph, extra_rules),
});
Validated::new(graph, source, diagnostics)
}
@ -32,6 +20,8 @@ pub fn validate(
mod tests {
use std::collections::HashMap;
use fabro_types::diagnostic::Severity;
use super::*;
use crate::pipeline::parse::parse;
use crate::pipeline::transform;
@ -45,14 +35,13 @@ mod tests {
source_name: None,
render_mode: crate::operations::RenderMode::Strict,
custom_transforms: vec![],
model_resolution: None,
}
}
fn run_pipeline(dot: &str) -> Validated {
let parsed = parse(dot).unwrap();
let transformed = transform::transform(parsed, &transform_options()).unwrap();
validate(transformed, None, &[])
validate(transformed)
}
#[test]
@ -68,17 +57,6 @@ mod tests {
assert!(validated.raise_on_errors().is_ok());
}
#[test]
fn validate_missing_start_node() {
let dot = r#"digraph Test {
graph [goal="Test"]
work [label="Work"]
}"#;
let validated = run_pipeline(dot);
assert!(validated.has_errors());
assert!(validated.raise_on_errors().is_err());
}
#[test]
fn validate_into_parts() {
let dot = r#"digraph Test {
@ -91,61 +69,11 @@ mod tests {
let (graph, source, diagnostics) = validated.into_parts();
assert_eq!(graph.name, "Test");
assert_eq!(source, dot);
assert!(
diagnostics
.iter()
.all(|d| d.severity != fabro_validate::Severity::Error)
);
assert!(diagnostics.iter().all(|d| d.severity != Severity::Error));
}
#[test]
fn validate_diagnostics_accessible_before_raise() {
let dot = r#"digraph Test {
graph [goal="Test"]
work [label="Work"]
}"#;
let validated = run_pipeline(dot);
// Can read diagnostics before raising
let diags = validated.diagnostics();
assert!(!diags.is_empty());
// Then raise
assert!(validated.raise_on_errors().is_err());
}
#[test]
fn validates_fully_rendered_model_stylesheet_syntax() {
let dot = r#"digraph Test {
graph [model_stylesheet="* { {{ inputs.declaration }} }"]
start [shape=Mdiamond]
exit [shape=Msquare]
start -> exit
}"#;
let transformed = transform::transform(parse(dot).unwrap(), &TransformOptions {
template_context: fabro_template::TemplateContext::new().with_inputs(HashMap::from([
(
"declaration".to_string(),
toml::Value::String("garbage garbage".to_string()),
),
])),
source_name: Some("workflow.fabro".to_string()),
render_mode: crate::operations::RenderMode::Structural,
..transform_options()
})
.unwrap();
let validated = validate(transformed, None, &[]);
assert!(
validated
.diagnostics()
.iter()
.any(|diagnostic| diagnostic.rule == "stylesheet_syntax"),
"{:?}",
validated.diagnostics()
);
}
#[test]
fn unresolved_model_stylesheet_skips_stylesheet_rules() {
fn unresolved_template_variables_are_the_transforms_diagnostics() {
let dot = r#"digraph Test {
graph [model_stylesheet="* { model: {{ vars.MODEL }}; }"]
start [shape=Mdiamond]
@ -153,19 +81,17 @@ mod tests {
start -> exit
}"#;
let transformed = transform::transform(parse(dot).unwrap(), &TransformOptions {
template_context: fabro_template::TemplateContext::new().with_inputs(HashMap::new()),
source_name: Some("workflow.fabro".to_string()),
render_mode: crate::operations::RenderMode::Structural,
..transform_options()
})
.unwrap();
let validated = validate(transformed, None, &[]);
let validated = validate(transformed);
assert!(validated.diagnostics().iter().any(|diagnostic| {
diagnostic.rule == "template_undefined_variable"
&& diagnostic.message.contains("vars.MODEL")
}));
assert!(validated.diagnostics().iter().all(|diagnostic| {
diagnostic.rule != "stylesheet_syntax" && diagnostic.rule != "stylesheet_model_known"
}));
}
}

View file

@ -1,72 +1,7 @@
use std::collections::HashSet;
use fabro_graphviz::graph::Graph;
use fabro_llm::lithos_catalog::Catalog;
use fabro_llm::{ModelSelectionError, selection};
use fabro_types::WorkflowSettings;
use fabro_types::settings::InterpString;
use fabro_types::settings::run::RunGoal;
use lithos_llm::catalog::ProviderId;
use crate::error::Error;
pub fn materialize_run(
settings: WorkflowSettings,
graph: &Graph,
catalog: &Catalog,
configured_providers: &[ProviderId],
) -> Result<WorkflowSettings, Error> {
materialize_run_with_eligible_providers(settings, graph, catalog, configured_providers, false)
}
/// Materialize while resolving the run model against the ready providers
/// first, falling back to the full catalog only for provider-readiness
/// selection failures.
pub fn materialize_run_with_ready_providers(
settings: WorkflowSettings,
graph: &Graph,
catalog: &Catalog,
ready_providers: &[ProviderId],
) -> Result<WorkflowSettings, Error> {
materialize_run_with_eligible_providers(settings, graph, catalog, ready_providers, true)
}
fn materialize_run_with_eligible_providers(
mut settings: WorkflowSettings,
graph: &Graph,
catalog: &Catalog,
eligible_providers: &[ProviderId],
catalog_fallback: bool,
) -> Result<WorkflowSettings, Error> {
let configured_model = settings.run.model.name.take();
let configured_provider = settings.run.model.provider.take();
let graph_provider = graph
.attrs
.get("default_provider")
.and_then(|value| value.as_str())
.map(str::to_string);
let graph_model = graph
.attrs
.get("default_model")
.and_then(|value| value.as_str())
.map(str::to_string);
let provider = configured_provider.or(graph_provider);
let model = configured_model.or(graph_model);
let eligible = eligible_providers.iter().cloned().collect::<HashSet<_>>();
let (resolved_model, resolved_provider) = resolve_run_model(
catalog,
&eligible,
model.as_deref(),
provider.as_deref(),
catalog_fallback,
)?;
settings.run.model.name = Some(resolved_model);
settings.run.model.provider = Some(resolved_provider.into_string());
materialize_goal_and_pull_request(&mut settings, graph);
Ok(settings)
}
/// The graph's goal becomes the run's inline goal (none when the graph has
/// none), and a pull request block the settings disable is dropped.
@ -87,26 +22,3 @@ pub fn materialize_goal_and_pull_request(settings: &mut WorkflowSettings, graph:
settings.run.pull_request = None;
}
}
pub(crate) fn resolve_run_model(
catalog: &Catalog,
eligible: &HashSet<ProviderId>,
model: Option<&str>,
provider: Option<&str>,
catalog_fallback: bool,
) -> Result<(String, ProviderId), ModelSelectionError> {
let provider = provider
.filter(|provider| !provider.is_empty())
.map(ProviderId::new);
let selected = if catalog_fallback {
selection::resolve_selection_with_catalog_fallback(
catalog,
model,
provider.as_ref(),
eligible,
)?
} else {
selection::resolve_selection(catalog, model, provider.as_ref(), eligible)?
};
Ok((selected.model, selected.provider))
}

View file

@ -4,7 +4,7 @@ use std::sync::Arc;
use fabro_graphviz::graph::{AttrValue, Graph};
use fabro_template::{TemplateContext, TemplateSource, TemplateStore};
use fabro_types::ManifestPath;
use fabro_validate::Diagnostic;
use fabro_types::diagnostic::Diagnostic;
use super::Transform;
use super::importable_field::ImportableField;

View file

@ -5,8 +5,8 @@ use std::sync::Arc;
use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};
use fabro_graphviz::parser;
use fabro_template::{TemplateContext, validate_static_reference};
use fabro_types::diagnostic::{Diagnostic, Severity};
use fabro_types::graph::ReferenceKind;
use fabro_validate::{Diagnostic, Severity};
use super::file_inlining::template_render_store;
use super::{FileInliningTransform, Transform};

View file

@ -11,7 +11,6 @@ pub trait Transform {
mod file_inlining;
mod import;
mod importable_field;
mod model_resolution;
mod model_stylesheet_template;
pub mod stylesheet;
mod stylesheet_application;
@ -19,7 +18,6 @@ pub mod variable_expansion;
pub use file_inlining::FileInliningTransform;
pub use import::ImportTransform;
pub use model_resolution::ModelResolutionTransform;
pub(crate) use model_stylesheet_template::ModelStylesheetTemplateTransform;
pub use stylesheet_application::StylesheetApplicationTransform;
pub use variable_expansion::{RenderMode, ScriptInterpolationTransform, TemplateTransform};

View file

@ -1,417 +0,0 @@
use std::collections::HashSet;
use std::sync::Arc;
use fabro_graphviz::graph::{AttrValue, Graph};
use fabro_llm::lithos_catalog::Catalog;
use fabro_llm::selection;
use lithos_llm::catalog::ProviderId;
use super::Transform;
use crate::error::Error;
/// Resolves model aliases to canonical IDs and infers the provider from the
/// model catalog.
pub struct ModelResolutionTransform {
catalog: Arc<Catalog>,
default_provider: Option<ProviderId>,
eligible_providers: HashSet<ProviderId>,
catalog_fallback: bool,
}
impl ModelResolutionTransform {
#[must_use]
pub fn new(catalog: Arc<Catalog>) -> Self {
let eligible_providers = catalog.enabled_provider_ids().into_iter().collect();
Self {
catalog,
default_provider: None,
eligible_providers,
catalog_fallback: false,
}
}
#[must_use]
pub fn for_eligible(catalog: Arc<Catalog>, eligible_providers: HashSet<ProviderId>) -> Self {
Self {
catalog,
default_provider: None,
eligible_providers,
catalog_fallback: false,
}
}
#[must_use]
pub fn with_default_provider(mut self, provider: Option<ProviderId>) -> Self {
self.default_provider = provider;
self
}
/// When enabled, provider-readiness selection failures fall back to the
/// full catalog instead of erroring.
#[must_use]
pub fn with_catalog_fallback(mut self, catalog_fallback: bool) -> Self {
self.catalog_fallback = catalog_fallback;
self
}
/// The catalog this transform resolves against, so callers can run the
/// matching catalog-backed lint rules.
#[must_use]
pub fn catalog(&self) -> &Catalog {
&self.catalog
}
fn resolve_model(
&self,
model: &str,
explicit_provider: Option<&ProviderId>,
) -> Result<(String, ProviderId), Error> {
let selected = if self.catalog_fallback {
selection::resolve_selection_with_catalog_fallback(
&self.catalog,
Some(model),
explicit_provider,
&self.eligible_providers,
)
} else {
selection::resolve_selection(
&self.catalog,
Some(model),
explicit_provider,
&self.eligible_providers,
)
}?;
Ok((selected.model, selected.provider))
}
}
impl Transform for ModelResolutionTransform {
fn apply(&self, graph: Graph) -> Result<Graph, Error> {
let mut graph = graph;
let graph_default_provider = graph
.attrs
.get("default_provider")
.and_then(AttrValue::as_str)
.filter(|provider| !provider.is_empty())
.map(ProviderId::new);
let requested_default_provider = self
.default_provider
.as_ref()
.or(graph_default_provider.as_ref());
if let Some(default_model) = graph
.attrs
.get("default_model")
.and_then(AttrValue::as_str)
.map(str::to_string)
{
let (model, provider) =
self.resolve_model(&default_model, requested_default_provider)?;
graph
.attrs
.insert("default_model".to_string(), AttrValue::String(model));
graph.attrs.insert(
"default_provider".to_string(),
AttrValue::String(provider.to_string()),
);
}
let default_provider = self.default_provider.clone().or_else(|| {
graph
.attrs
.get("default_provider")
.and_then(AttrValue::as_str)
.filter(|provider| !provider.is_empty())
.map(ProviderId::new)
});
for node in graph.nodes.values_mut() {
let model = node
.attrs
.get("model")
.and_then(AttrValue::as_str)
.map(String::from);
if let Some(model) = model {
let explicit_provider = node
.attrs
.get("provider")
.and_then(AttrValue::as_str)
.filter(|provider| !provider.is_empty())
.map(ProviderId::new)
.or_else(|| default_provider.clone());
let (model, provider) = self.resolve_model(&model, explicit_provider.as_ref())?;
node.attrs
.insert("model".to_string(), AttrValue::String(model));
node.attrs.insert(
"provider".to_string(),
AttrValue::String(provider.to_string()),
);
}
}
Ok(graph)
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use fabro_graphviz::graph::{AttrValue, Graph, Node};
use fabro_llm::test_support::{test_catalog, test_catalog_with_overlay};
use super::*;
/// An operator-defined provider with one aliased model, the shape an
/// `[llm]` overlay produces. It joins the built-ins rather than replacing
/// them: lithos catalogs are layered, never standalone.
fn custom_catalog() -> Arc<Catalog> {
Arc::new(test_catalog_with_overlay(
r#"
[providers.acme-venice]
display_name = "Acme Venice"
adapter = "openai-compatible"
codec = "openai-chat"
base_url = "https://api.venice.ai/api/v1"
auth = { type = "bearer" }
priority = 200
default_model = "venice-large"
[providers.acme-venice.metadata.agent]
profile = "openai"
[providers.acme-venice.models.venice-large]
display_name = "Venice Large"
aliases = ["vl"]
api_model = "venice-large"
limits = { context_tokens = 128000, max_output_tokens = 8192 }
capabilities = { text = true, tools = true }
"#,
))
}
fn builtin_transform() -> ModelResolutionTransform {
ModelResolutionTransform::new(Arc::new(test_catalog()))
}
#[test]
fn provider_inference_sets_provider_from_catalog() {
let mut graph = Graph::new("test");
let mut node = Node::new("a");
node.attrs.insert(
"model".to_string(),
AttrValue::String("claude-sonnet-4.5".to_string()),
);
graph.nodes.insert("a".to_string(), node);
let graph = builtin_transform().apply(graph).unwrap();
assert_eq!(
graph.nodes["a"]
.attrs
.get("provider")
.and_then(AttrValue::as_str),
Some("anthropic")
);
}
#[test]
fn explicit_provider_allows_unknown_model_passthrough() {
let mut graph = Graph::new("test");
let mut node = Node::new("a");
node.attrs.insert(
"model".to_string(),
AttrValue::String("claude-sonnet-4.5".to_string()),
);
node.attrs.insert(
"provider".to_string(),
AttrValue::String("openai".to_string()),
);
graph.nodes.insert("a".to_string(), node);
let graph = builtin_transform().apply(graph).unwrap();
assert_eq!(
graph.nodes["a"]
.attrs
.get("provider")
.and_then(AttrValue::as_str),
Some("openai")
);
assert_eq!(
graph.nodes["a"]
.attrs
.get("model")
.and_then(AttrValue::as_str),
Some("claude-sonnet-4.5")
);
}
#[test]
fn provider_inference_unknown_model_pins_default_eligible_provider() {
let mut graph = Graph::new("test");
let mut node = Node::new("a");
node.attrs.insert(
"model".to_string(),
AttrValue::String("unknown-model-xyz".to_string()),
);
graph.nodes.insert("a".to_string(), node);
let graph = builtin_transform().apply(graph).unwrap();
assert_eq!(
graph.nodes["a"]
.attrs
.get("provider")
.and_then(AttrValue::as_str),
Some("anthropic")
);
}
#[test]
fn provider_inference_no_model_no_change() {
let mut graph = Graph::new("test");
let node = Node::new("a");
graph.nodes.insert("a".to_string(), node);
let graph = builtin_transform().apply(graph).unwrap();
assert_eq!(graph.nodes["a"].attrs.get("provider"), None);
}
#[test]
fn model_resolution_resolves_alias_to_canonical_id() {
let mut graph = Graph::new("test");
let mut node = Node::new("a");
node.attrs
.insert("model".to_string(), AttrValue::String("gpt-54".to_string()));
graph.nodes.insert("a".to_string(), node);
let graph = builtin_transform().apply(graph).unwrap();
assert_eq!(
graph.nodes["a"]
.attrs
.get("model")
.and_then(AttrValue::as_str),
Some("gpt-5.4")
);
assert_eq!(
graph.nodes["a"]
.attrs
.get("provider")
.and_then(AttrValue::as_str),
Some("openai")
);
}
#[test]
fn model_resolution_keeps_canonical_id_unchanged() {
let mut graph = Graph::new("test");
let mut node = Node::new("a");
node.attrs.insert(
"model".to_string(),
AttrValue::String("gpt-5.4".to_string()),
);
graph.nodes.insert("a".to_string(), node);
let graph = builtin_transform().apply(graph).unwrap();
assert_eq!(
graph.nodes["a"]
.attrs
.get("model")
.and_then(AttrValue::as_str),
Some("gpt-5.4")
);
}
#[test]
fn model_resolution_uses_injected_catalog_for_alias_and_provider() {
let mut graph = Graph::new("test");
let mut node = Node::new("a");
node.attrs
.insert("model".to_string(), AttrValue::String("vl".to_string()));
graph.nodes.insert("a".to_string(), node);
let graph = ModelResolutionTransform::new(custom_catalog())
.apply(graph)
.unwrap();
assert_eq!(
graph.nodes["a"]
.attrs
.get("model")
.and_then(AttrValue::as_str),
Some("venice-large")
);
assert_eq!(
graph.nodes["a"]
.attrs
.get("provider")
.and_then(AttrValue::as_str),
Some("acme-venice")
);
}
#[test]
fn fallback_resolution_keeps_ready_preference_for_unpinned_nodes() {
let catalog = Arc::new(test_catalog_with_overlay(
"[providers.openrouter]\nenabled = true\n",
));
let mut graph = Graph::new("test");
let mut portable = Node::new("portable");
portable.attrs.insert(
"model".to_string(),
AttrValue::String("claude-fable".to_string()),
);
graph.nodes.insert("portable".to_string(), portable);
let mut pinned = Node::new("pinned");
pinned.attrs.insert(
"model".to_string(),
AttrValue::String("claude-fable".to_string()),
);
pinned.attrs.insert(
"provider".to_string(),
AttrValue::String("anthropic".to_string()),
);
graph.nodes.insert("pinned".to_string(), pinned);
let graph = ModelResolutionTransform::for_eligible(
Arc::clone(&catalog),
HashSet::from([ProviderId::new("openrouter")]),
)
.with_catalog_fallback(true)
.apply(graph)
.unwrap();
assert_eq!(
graph.nodes["portable"].provider(),
Some("openrouter"),
"the unrelated unavailable pin must not force catalog-wide routing"
);
assert_eq!(graph.nodes["pinned"].provider(), Some("anthropic"));
}
#[test]
fn graph_default_alias_materializes_to_canonical_offering() {
let mut graph = Graph::new("test");
graph.attrs.insert(
"default_model".to_string(),
AttrValue::String("vl".to_string()),
);
let graph = ModelResolutionTransform::new(custom_catalog())
.apply(graph)
.unwrap();
assert_eq!(
graph.attrs.get("default_model").and_then(AttrValue::as_str),
Some("venice-large")
);
assert_eq!(
graph
.attrs
.get("default_provider")
.and_then(AttrValue::as_str),
Some("acme-venice")
);
}
}

View file

@ -3,7 +3,7 @@ use std::sync::Arc;
use fabro_graphviz::graph::{AttrValue, Graph};
use fabro_template::TemplateContext;
use fabro_validate::Diagnostic;
use fabro_types::diagnostic::Diagnostic;
use super::file_inlining::template_render_store;
use super::variable_expansion::{

View file

@ -8,12 +8,12 @@ use fabro_template::{
TemplateContext, TemplateError, TemplateRenderMode, TemplateSource, TemplateSourceOrigin,
TemplateStore, validate_static_reference,
};
use fabro_types::diagnostic::{Diagnostic, Severity};
use fabro_types::graph::{AttributeScope, ReferenceKind, reference_kind_for_attribute};
use fabro_types::settings::interp::Namespace;
use fabro_types::settings::{InterpString, ResolveCtx, ResolveError, ResolveErrorKind};
use fabro_util::error::collect_chain;
use fabro_util::shell;
use fabro_validate::{Diagnostic, Severity};
use super::Transform;
use crate::error::Error;

View file

@ -1,72 +0,0 @@
use fabro_graphviz::graph::Graph;
use fabro_graphviz::parser;
use fabro_llm::test_support::test_catalog;
use fabro_types::WorkflowSettings;
use fabro_types::settings::InterpString;
use fabro_types::settings::run::{PullRequestSettings, RunGoal, RunModelSettings, RunNamespace};
use fabro_workflow::run_materialization::materialize_run;
use lithos_llm::catalog::builtin;
fn graph(source: &str) -> Graph {
parser::parse(source).expect("graph should parse")
}
#[test]
fn materialize_run_applies_graph_and_catalog_defaults() {
let source = r#"digraph Test {
graph [goal="Build feature"]
start [shape=Mdiamond]
exit [shape=Msquare]
start -> exit
}"#;
let settings = WorkflowSettings {
run: RunNamespace {
model: RunModelSettings {
name: Some("sonnet".to_string()),
..RunModelSettings::default()
},
pull_request: Some(PullRequestSettings {
enabled: false,
..PullRequestSettings::default()
}),
..RunNamespace::default()
},
..WorkflowSettings::default()
};
let materialized = materialize_run(settings, &graph(source), &test_catalog(), &[
builtin::anthropic(),
])
.unwrap();
let resolved = &materialized.run;
assert_eq!(resolved.model.name.as_deref(), Some("claude-sonnet-5"));
assert_eq!(resolved.model.provider.as_deref(), Some("anthropic"));
assert_eq!(
materialized.run.goal.as_ref(),
Some(&RunGoal::Inline(InterpString::parse("Build feature")))
);
assert!(resolved.pull_request.is_none());
}
#[test]
fn materialize_run_uses_configured_provider_defaults() {
let source = r#"digraph Test {
graph [goal="Build feature"]
start [shape=Mdiamond]
exit [shape=Msquare]
start -> exit
}"#;
let materialized = materialize_run(
WorkflowSettings::default(),
&graph(source),
&test_catalog(),
&[builtin::openai()],
)
.unwrap();
let resolved = &materialized.run;
assert_eq!(resolved.model.provider.as_deref(), Some("openai"));
}

View file

@ -0,0 +1,60 @@
//! A workflow diagnostic: what validation, a transform or Petri's check says
//! about a workflow, with the source position when known.
use serde::{Deserialize, Serialize};
/// Severity level for workflow diagnostics.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Severity {
Error,
Warning,
Info,
}
/// One diagnostic about a workflow.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Diagnostic {
/// The rule or code that raised it (`template_undefined_variable`,
/// `attractor.model.unknown`).
pub rule: String,
pub severity: Severity,
pub message: String,
pub node_id: Option<String>,
pub edge: Option<(String, String)>,
pub fix: Option<String>,
pub source_path: Option<String>,
pub line: Option<u32>,
pub column: Option<u32>,
pub span_start: Option<usize>,
pub span_len: Option<usize>,
#[serde(default)]
pub related: Vec<RelatedDiagnostic>,
}
impl Default for Diagnostic {
fn default() -> Self {
Self {
rule: String::new(),
severity: Severity::Info,
message: String::new(),
node_id: None,
edge: None,
fix: None,
source_path: None,
line: None,
column: None,
span_start: None,
span_len: None,
related: Vec::new(),
}
}
}
/// Another position a diagnostic points at.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelatedDiagnostic {
pub message: String,
pub source_path: Option<String>,
pub line: Option<u32>,
pub column: Option<u32>,
}

View file

@ -9,6 +9,7 @@ pub mod checkpoint;
pub mod command_output;
pub mod conclusion;
pub mod dense;
pub mod diagnostic;
pub mod diff;
pub mod engine;
pub mod event_envelope;