mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-11 22:53:00 +00:00
fabro-model's ids and billing rollup now live in fabro-types, and its pricing, catalog, provider TOMLs, and legacy index are replaced by the lithos built-in catalog plus the Fabro policy layer. Regenerate the configuration reference for the `[llm]` overlay and `metadata.fabro`, and rewrite the SDK, models, and integration docs for the lithos provider and model shapes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
181 lines
6 KiB
Rust
181 lines
6 KiB
Rust
//! Workspace policy tests.
|
|
//!
|
|
//! These tests scan the source tree for references that violate
|
|
//! product-level invariants. They run as part of `cargo nextest` and are
|
|
//! cheap (text scans only).
|
|
|
|
use walkdir::WalkDir;
|
|
|
|
use crate::workspace_root;
|
|
|
|
const TEMPLATE_RENDER_ALLOWED_PATH_FRAGMENTS: &[&str] = &[
|
|
// The template crate owns the rendering API and its tests.
|
|
"lib/foundation/fabro-template/src/lib.rs",
|
|
// Workflow-definition rendering must stay centralized here.
|
|
"lib/components/fabro-workflow/src/transforms/variable_expansion.rs",
|
|
// Hook header/env interpolation is a separate system.
|
|
"lib/components/fabro-hooks/src/executor.rs",
|
|
// This policy test names the forbidden patterns.
|
|
"/tests/it/policy.rs",
|
|
];
|
|
|
|
const TEMPLATE_RENDER_FORBIDDEN_PATTERNS: &[&str] = &[
|
|
"render_template(",
|
|
"render_lenient(",
|
|
"render_scan_template",
|
|
"render as render_template",
|
|
"render_lenient as",
|
|
"fabro_template::{",
|
|
];
|
|
|
|
#[test]
|
|
fn workflow_template_rendering_call_sites_stay_in_allowlist() {
|
|
let mut violations = Vec::new();
|
|
for pattern in TEMPLATE_RENDER_FORBIDDEN_PATTERNS {
|
|
violations.extend(source_symbol_violations(
|
|
pattern,
|
|
TEMPLATE_RENDER_ALLOWED_PATH_FRAGMENTS,
|
|
));
|
|
}
|
|
|
|
assert!(
|
|
violations.is_empty(),
|
|
"Workflow template rendering must go through TemplateTransform. Add an allowlist entry only for non-workflow interpolation with a reason:\n{}",
|
|
format_violations(violations),
|
|
);
|
|
}
|
|
|
|
#[expect(
|
|
clippy::disallowed_methods,
|
|
reason = "policy test reads source files synchronously with std::fs"
|
|
)]
|
|
fn source_symbol_violations(
|
|
symbol: &str,
|
|
allowed_path_fragments: &[&str],
|
|
) -> Vec<(String, usize, String)> {
|
|
let root = workspace_root();
|
|
let lib_root = root.join("lib");
|
|
let mut violations: Vec<(String, usize, String)> = Vec::new();
|
|
|
|
let walker = WalkDir::new(&lib_root).into_iter().filter_entry(|entry| {
|
|
// Skip generated/output directories at any depth.
|
|
let name = entry.file_name().to_string_lossy();
|
|
!matches!(
|
|
name.as_ref(),
|
|
"target" | ".git" | "node_modules" | "dist" | "build"
|
|
)
|
|
});
|
|
|
|
for entry in walker.flatten() {
|
|
let path = entry.path();
|
|
if !path.is_file() || path.extension().is_none_or(|ext| ext != "rs") {
|
|
continue;
|
|
}
|
|
let Ok(contents) = std::fs::read_to_string(path) else {
|
|
continue;
|
|
};
|
|
// Cheap early-out: avoids per-line work for the ~99% of files with no
|
|
// reference to the symbol.
|
|
if !contents.contains(symbol) {
|
|
continue;
|
|
}
|
|
let rel = path.strip_prefix(&root).unwrap_or(path);
|
|
let rel_str = rel.to_string_lossy().replace('\\', "/");
|
|
let path_allowed = allowed_path_fragments
|
|
.iter()
|
|
.any(|frag| rel_str.contains(frag));
|
|
if path_allowed {
|
|
continue;
|
|
}
|
|
let mut pending_cfg_test = false;
|
|
let mut cfg_test_depth = None;
|
|
let mut brace_depth = 0usize;
|
|
for (idx, line) in contents.lines().enumerate() {
|
|
let trimmed = line.trim_start();
|
|
let starts_cfg_test_module =
|
|
pending_cfg_test && trimmed.contains("mod tests") && trimmed.contains('{');
|
|
let in_cfg_test_module = cfg_test_depth.is_some() || starts_cfg_test_module;
|
|
|
|
if !line.contains(symbol) {
|
|
update_test_module_state(
|
|
trimmed,
|
|
&mut pending_cfg_test,
|
|
&mut cfg_test_depth,
|
|
&mut brace_depth,
|
|
starts_cfg_test_module,
|
|
);
|
|
continue;
|
|
}
|
|
// Skip comments referencing the symbol in prose.
|
|
if trimmed.starts_with("//") || trimmed.starts_with("/*") || trimmed.starts_with('*') {
|
|
update_test_module_state(
|
|
trimmed,
|
|
&mut pending_cfg_test,
|
|
&mut cfg_test_depth,
|
|
&mut brace_depth,
|
|
starts_cfg_test_module,
|
|
);
|
|
continue;
|
|
}
|
|
if in_cfg_test_module {
|
|
update_test_module_state(
|
|
trimmed,
|
|
&mut pending_cfg_test,
|
|
&mut cfg_test_depth,
|
|
&mut brace_depth,
|
|
starts_cfg_test_module,
|
|
);
|
|
continue;
|
|
}
|
|
violations.push((rel_str.clone(), idx + 1, line.to_string()));
|
|
update_test_module_state(
|
|
trimmed,
|
|
&mut pending_cfg_test,
|
|
&mut cfg_test_depth,
|
|
&mut brace_depth,
|
|
starts_cfg_test_module,
|
|
);
|
|
}
|
|
}
|
|
|
|
violations
|
|
}
|
|
|
|
fn update_test_module_state(
|
|
trimmed: &str,
|
|
pending_cfg_test: &mut bool,
|
|
cfg_test_depth: &mut Option<usize>,
|
|
brace_depth: &mut usize,
|
|
starts_cfg_test_module: bool,
|
|
) {
|
|
let depth_before = *brace_depth;
|
|
let open_count = trimmed.chars().filter(|c| *c == '{').count();
|
|
let close_count = trimmed.chars().filter(|c| *c == '}').count();
|
|
*brace_depth = brace_depth.saturating_add(open_count);
|
|
*brace_depth = brace_depth.saturating_sub(close_count);
|
|
|
|
if starts_cfg_test_module {
|
|
*cfg_test_depth = Some(
|
|
depth_before
|
|
.saturating_add(open_count)
|
|
.saturating_sub(close_count),
|
|
);
|
|
}
|
|
if cfg_test_depth.is_some_and(|depth| *brace_depth < depth) {
|
|
*cfg_test_depth = None;
|
|
}
|
|
|
|
if trimmed.starts_with("#[cfg(test)]") {
|
|
*pending_cfg_test = true;
|
|
} else if !trimmed.is_empty() && !trimmed.starts_with("#[") {
|
|
*pending_cfg_test = false;
|
|
}
|
|
}
|
|
|
|
fn format_violations(violations: Vec<(String, usize, String)>) -> String {
|
|
violations
|
|
.into_iter()
|
|
.map(|(p, l, s)| format!(" {p}:{l}: {}", s.trim()))
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
}
|