rename crates/ to engrams/; add el-compiler el package with bootstrap artifact
- crates/ → engrams/ (Rust engrams live here)
- el-compiler/ added: el self-hosting compiler as an el package
- src/{compiler,lexer,parser,codegen}.el
- bootstrap/el-compiler.elc (114KB, Rust-compiled seed)
- el.toml Cargo.toml workspace paths updated
- neuron-rs cross-repo path deps fixed (were pointing to products/ instead of foundation/)
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "el-arch"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
el-parser = { path = "../el-parser" }
|
||||
el-lexer = { path = "../el-lexer" }
|
||||
|
||||
[dev-dependencies]
|
||||
el-lexer = { path = "../el-lexer" }
|
||||
el-parser = { path = "../el-parser" }
|
||||
@@ -0,0 +1,468 @@
|
||||
//! Main architectural checker — walks the AST and applies all rules.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use el_parser::{Expr, Program, Stmt, TypeExpr};
|
||||
|
||||
use crate::error::{ArchDiagnostic, Severity};
|
||||
use crate::rule::{ArchRule, CallInfo, FnContext};
|
||||
use crate::rules::{
|
||||
graph::{DuplicateActivateType, N1Detection},
|
||||
security::{AuthnWithoutAuthz, PublicFnWithActivate, SealedInLoop},
|
||||
swarm::{SwarmAgentIsolation, SwarmAgentNoSharedState, SwarmAgentNoSpawn},
|
||||
vbd::{AccessorMustNotCallManager, ExperienceMustNotCallExperience, ExperienceShouldReturnResult},
|
||||
};
|
||||
|
||||
/// The main architectural checker. Instantiate once, call `check` per program.
|
||||
pub struct ArchChecker {
|
||||
rules: Vec<Box<dyn ArchRule>>,
|
||||
}
|
||||
|
||||
impl ArchChecker {
|
||||
/// Create an `ArchChecker` with all built-in rules registered.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
rules: vec![
|
||||
Box::new(AccessorMustNotCallManager),
|
||||
Box::new(ExperienceMustNotCallExperience),
|
||||
Box::new(ExperienceShouldReturnResult),
|
||||
Box::new(PublicFnWithActivate),
|
||||
Box::new(SealedInLoop),
|
||||
Box::new(AuthnWithoutAuthz),
|
||||
Box::new(N1Detection),
|
||||
Box::new(DuplicateActivateType),
|
||||
Box::new(SwarmAgentIsolation),
|
||||
Box::new(SwarmAgentNoSpawn),
|
||||
Box::new(SwarmAgentNoSharedState),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a reference to the registered rules (useful for introspection in tests).
|
||||
pub fn rules(&self) -> &[Box<dyn ArchRule>] {
|
||||
&self.rules
|
||||
}
|
||||
|
||||
/// Run all rules against a parsed program and collect all diagnostics.
|
||||
pub fn check(&self, program: &Program) -> Vec<ArchDiagnostic> {
|
||||
// Step 1: build global fn_name → decorator names map (including impl methods).
|
||||
let all_fn_annotations = collect_fn_annotations(&program.stmts);
|
||||
|
||||
// Step 2: gather all top-level + impl FnDef statements.
|
||||
let fn_defs = collect_fn_defs(&program.stmts);
|
||||
|
||||
// Step 3: for each function, build FnContext and run every rule.
|
||||
let mut diagnostics = Vec::new();
|
||||
for (fn_name, decorators, return_type, body) in &fn_defs {
|
||||
let annotations: Vec<String> =
|
||||
decorators.iter().map(|d| d.name.clone()).collect();
|
||||
let body_calls = extract_calls(body, false);
|
||||
let activate_types = extract_activate_types(body, false);
|
||||
let has_sealed_in_loop = has_sealed_in_loop_body(body, false);
|
||||
let return_type_name = type_expr_name(return_type);
|
||||
|
||||
let ctx = FnContext {
|
||||
fn_name: fn_name.as_str(),
|
||||
annotations: &annotations,
|
||||
body_calls: &body_calls,
|
||||
all_fn_annotations: &all_fn_annotations,
|
||||
activate_types: &activate_types,
|
||||
has_sealed_in_loop,
|
||||
return_type_name: &return_type_name,
|
||||
};
|
||||
|
||||
for rule in &self.rules {
|
||||
diagnostics.extend(rule.check(&ctx));
|
||||
}
|
||||
}
|
||||
|
||||
diagnostics
|
||||
}
|
||||
|
||||
/// Returns true if any of the given diagnostics are errors.
|
||||
pub fn has_errors(diagnostics: &[ArchDiagnostic]) -> bool {
|
||||
diagnostics.iter().any(|d| d.severity == Severity::Error)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ArchChecker {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ── AST traversal helpers ─────────────────────────────────────────────────────
|
||||
|
||||
/// A collected function definition: (name, decorators, return_type, body).
|
||||
type FnDef<'a> = (
|
||||
&'a String,
|
||||
&'a Vec<el_parser::Decorator>,
|
||||
&'a TypeExpr,
|
||||
&'a Vec<Stmt>,
|
||||
);
|
||||
|
||||
/// Collect all FnDef statements from top-level and impl blocks.
|
||||
fn collect_fn_defs<'a>(stmts: &'a [Stmt]) -> Vec<FnDef<'a>> {
|
||||
let mut out = Vec::new();
|
||||
for stmt in stmts {
|
||||
match stmt {
|
||||
Stmt::FnDef { name, decorators, return_type, body, .. } => {
|
||||
out.push((name, decorators, return_type, body));
|
||||
}
|
||||
Stmt::ImplDef { methods, .. } => {
|
||||
for m in methods {
|
||||
if let Stmt::FnDef { name, decorators, return_type, body, .. } = m {
|
||||
out.push((name, decorators, return_type, body));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Build a map from function name → list of decorator names, for all functions in the program.
|
||||
fn collect_fn_annotations(stmts: &[Stmt]) -> HashMap<String, Vec<String>> {
|
||||
let mut map = HashMap::new();
|
||||
for stmt in stmts {
|
||||
match stmt {
|
||||
Stmt::FnDef { name, decorators, .. } => {
|
||||
let anns: Vec<String> = decorators.iter().map(|d| d.name.clone()).collect();
|
||||
map.insert(name.clone(), anns);
|
||||
}
|
||||
Stmt::ImplDef { methods, .. } => {
|
||||
for m in methods {
|
||||
if let Stmt::FnDef { name, decorators, .. } = m {
|
||||
let anns: Vec<String> = decorators.iter().map(|d| d.name.clone()).collect();
|
||||
map.insert(name.clone(), anns);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// Extract all function calls from a statement list.
|
||||
/// `in_loop` tracks whether we are currently inside a for/while loop body.
|
||||
fn extract_calls(stmts: &[Stmt], in_loop: bool) -> Vec<CallInfo> {
|
||||
let mut calls = Vec::new();
|
||||
for stmt in stmts {
|
||||
extract_calls_from_stmt(stmt, in_loop, &mut calls);
|
||||
}
|
||||
calls
|
||||
}
|
||||
|
||||
fn extract_calls_from_stmt(stmt: &Stmt, in_loop: bool, out: &mut Vec<CallInfo>) {
|
||||
match stmt {
|
||||
Stmt::Let { value, .. } => extract_calls_from_expr(value, in_loop, out),
|
||||
Stmt::Return(expr, _) | Stmt::Expr(expr, _) | Stmt::Assert(expr, _) => {
|
||||
extract_calls_from_expr(expr, in_loop, out);
|
||||
}
|
||||
Stmt::FnDef { body, .. } => {
|
||||
// Nested function defs: walk but don't count as callee of the outer fn.
|
||||
for s in body {
|
||||
extract_calls_from_stmt(s, false, out);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_calls_from_expr(expr: &Expr, in_loop: bool, out: &mut Vec<CallInfo>) {
|
||||
match expr {
|
||||
Expr::Call { func, args } => {
|
||||
// Extract callee name
|
||||
let callee = expr_as_call_name(func);
|
||||
if let Some(name) = callee {
|
||||
out.push(CallInfo { callee: name, is_in_loop: in_loop });
|
||||
}
|
||||
// Recurse into func expression and args
|
||||
extract_calls_from_expr(func, in_loop, out);
|
||||
for a in args {
|
||||
extract_calls_from_expr(a, in_loop, out);
|
||||
}
|
||||
}
|
||||
Expr::Activate { type_name, .. } => {
|
||||
// Encode activate as a synthetic call so GRAPH-001 can detect in-loop activates.
|
||||
out.push(CallInfo {
|
||||
callee: format!("__activate__{type_name}"),
|
||||
is_in_loop: in_loop,
|
||||
});
|
||||
}
|
||||
Expr::BinOp { left, right, .. } => {
|
||||
extract_calls_from_expr(left, in_loop, out);
|
||||
extract_calls_from_expr(right, in_loop, out);
|
||||
}
|
||||
Expr::UnaryNot(inner) | Expr::Try(inner) => {
|
||||
extract_calls_from_expr(inner, in_loop, out);
|
||||
}
|
||||
Expr::Block(stmts) => {
|
||||
for s in stmts {
|
||||
extract_calls_from_stmt(s, in_loop, out);
|
||||
}
|
||||
}
|
||||
Expr::Sealed(stmts) => {
|
||||
// Sealed blocks are scanned but tracked separately for sealed-in-loop.
|
||||
for s in stmts {
|
||||
extract_calls_from_stmt(s, in_loop, out);
|
||||
}
|
||||
}
|
||||
Expr::If { cond, then, else_ } => {
|
||||
extract_calls_from_expr(cond, in_loop, out);
|
||||
extract_calls_from_expr(then, in_loop, out);
|
||||
if let Some(e) = else_ {
|
||||
extract_calls_from_expr(e, in_loop, out);
|
||||
}
|
||||
}
|
||||
Expr::Match { subject, arms } => {
|
||||
extract_calls_from_expr(subject, in_loop, out);
|
||||
for arm in arms {
|
||||
extract_calls_from_expr(&arm.body, in_loop, out);
|
||||
}
|
||||
}
|
||||
Expr::Field { object, .. } => extract_calls_from_expr(object, in_loop, out),
|
||||
Expr::Array(elems) => {
|
||||
for e in elems {
|
||||
extract_calls_from_expr(e, in_loop, out);
|
||||
}
|
||||
}
|
||||
Expr::Index { object, index } => {
|
||||
extract_calls_from_expr(object, in_loop, out);
|
||||
extract_calls_from_expr(index, in_loop, out);
|
||||
}
|
||||
Expr::Closure { body, .. } => {
|
||||
extract_calls_from_expr(body, in_loop, out);
|
||||
}
|
||||
Expr::MapLiteral(pairs) => {
|
||||
for (k, v) in pairs {
|
||||
extract_calls_from_expr(k, in_loop, out);
|
||||
extract_calls_from_expr(v, in_loop, out);
|
||||
}
|
||||
}
|
||||
Expr::Literal(_) | Expr::Ident(_) | Expr::Path { .. } => {}
|
||||
Expr::StructLit { fields, .. } => {
|
||||
for (_, e) in fields {
|
||||
extract_calls_from_expr(e, in_loop, out);
|
||||
}
|
||||
}
|
||||
Expr::With { base, updates } => {
|
||||
extract_calls_from_expr(base, in_loop, out);
|
||||
for (_, e) in updates {
|
||||
extract_calls_from_expr(e, in_loop, out);
|
||||
}
|
||||
}
|
||||
Expr::Reason { .. } => {}
|
||||
Expr::Parallel { entries } => {
|
||||
for (_, e) in entries {
|
||||
extract_calls_from_expr(e, in_loop, out);
|
||||
}
|
||||
}
|
||||
Expr::Trace { body, .. } => {
|
||||
for s in body {
|
||||
extract_calls_from_stmt(s, in_loop, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to extract a simple callee name from a Call's `func` expression.
|
||||
fn expr_as_call_name(expr: &Expr) -> Option<String> {
|
||||
match expr {
|
||||
Expr::Ident(name) => Some(name.clone()),
|
||||
Expr::Field { field, .. } => Some(field.clone()),
|
||||
Expr::Path { segments } => segments.last().cloned(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect all `activate TypeName` type names from a statement list.
|
||||
/// `in_loop` indicates whether we're currently inside a loop.
|
||||
fn extract_activate_types(stmts: &[Stmt], in_loop: bool) -> Vec<String> {
|
||||
let mut types = Vec::new();
|
||||
for stmt in stmts {
|
||||
extract_activate_types_stmt(stmt, in_loop, &mut types);
|
||||
}
|
||||
types
|
||||
}
|
||||
|
||||
fn extract_activate_types_stmt(stmt: &Stmt, in_loop: bool, out: &mut Vec<String>) {
|
||||
match stmt {
|
||||
Stmt::Let { value, .. } => extract_activate_types_expr(value, in_loop, out),
|
||||
Stmt::Return(expr, _) | Stmt::Expr(expr, _) | Stmt::Assert(expr, _) => {
|
||||
extract_activate_types_expr(expr, in_loop, out);
|
||||
}
|
||||
Stmt::FnDef { body, .. } => {
|
||||
for s in body {
|
||||
extract_activate_types_stmt(s, false, out);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_activate_types_expr(expr: &Expr, in_loop: bool, out: &mut Vec<String>) {
|
||||
match expr {
|
||||
Expr::Activate { type_name, .. } => {
|
||||
out.push(type_name.clone());
|
||||
}
|
||||
Expr::Call { func, args } => {
|
||||
extract_activate_types_expr(func, in_loop, out);
|
||||
for a in args {
|
||||
extract_activate_types_expr(a, in_loop, out);
|
||||
}
|
||||
}
|
||||
Expr::BinOp { left, right, .. } => {
|
||||
extract_activate_types_expr(left, in_loop, out);
|
||||
extract_activate_types_expr(right, in_loop, out);
|
||||
}
|
||||
Expr::UnaryNot(inner) | Expr::Try(inner) => {
|
||||
extract_activate_types_expr(inner, in_loop, out);
|
||||
}
|
||||
Expr::Block(stmts) => {
|
||||
for s in stmts {
|
||||
extract_activate_types_stmt(s, in_loop, out);
|
||||
}
|
||||
}
|
||||
Expr::Sealed(stmts) => {
|
||||
for s in stmts {
|
||||
extract_activate_types_stmt(s, in_loop, out);
|
||||
}
|
||||
}
|
||||
Expr::If { cond, then, else_ } => {
|
||||
extract_activate_types_expr(cond, in_loop, out);
|
||||
extract_activate_types_expr(then, in_loop, out);
|
||||
if let Some(e) = else_ {
|
||||
extract_activate_types_expr(e, in_loop, out);
|
||||
}
|
||||
}
|
||||
Expr::Match { subject, arms } => {
|
||||
extract_activate_types_expr(subject, in_loop, out);
|
||||
for arm in arms {
|
||||
extract_activate_types_expr(&arm.body, in_loop, out);
|
||||
}
|
||||
}
|
||||
Expr::Field { object, .. } => extract_activate_types_expr(object, in_loop, out),
|
||||
Expr::Array(elems) => {
|
||||
for e in elems {
|
||||
extract_activate_types_expr(e, in_loop, out);
|
||||
}
|
||||
}
|
||||
Expr::Index { object, index } => {
|
||||
extract_activate_types_expr(object, in_loop, out);
|
||||
extract_activate_types_expr(index, in_loop, out);
|
||||
}
|
||||
Expr::Closure { body, .. } => {
|
||||
extract_activate_types_expr(body, in_loop, out);
|
||||
}
|
||||
Expr::MapLiteral(pairs) => {
|
||||
for (k, v) in pairs {
|
||||
extract_activate_types_expr(k, in_loop, out);
|
||||
extract_activate_types_expr(v, in_loop, out);
|
||||
}
|
||||
}
|
||||
Expr::Literal(_) | Expr::Ident(_) | Expr::Path { .. } => {}
|
||||
Expr::StructLit { fields, .. } => {
|
||||
for (_, e) in fields {
|
||||
extract_activate_types_expr(e, in_loop, out);
|
||||
}
|
||||
}
|
||||
Expr::With { base, updates } => {
|
||||
extract_activate_types_expr(base, in_loop, out);
|
||||
for (_, e) in updates {
|
||||
extract_activate_types_expr(e, in_loop, out);
|
||||
}
|
||||
}
|
||||
Expr::Reason { .. } => {}
|
||||
Expr::Parallel { entries } => {
|
||||
for (_, e) in entries {
|
||||
extract_activate_types_expr(e, in_loop, out);
|
||||
}
|
||||
}
|
||||
Expr::Trace { body, .. } => {
|
||||
for s in body {
|
||||
extract_activate_types_stmt(s, in_loop, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if any `sealed { }` block appears inside a loop in the given body.
|
||||
fn has_sealed_in_loop_body(stmts: &[Stmt], in_loop: bool) -> bool {
|
||||
stmts.iter().any(|s| has_sealed_in_loop_stmt(s, in_loop))
|
||||
}
|
||||
|
||||
fn has_sealed_in_loop_stmt(stmt: &Stmt, in_loop: bool) -> bool {
|
||||
match stmt {
|
||||
Stmt::Let { value, .. } => has_sealed_in_loop_expr(value, in_loop),
|
||||
Stmt::Return(expr, _) | Stmt::Expr(expr, _) | Stmt::Assert(expr, _) => {
|
||||
has_sealed_in_loop_expr(expr, in_loop)
|
||||
}
|
||||
Stmt::FnDef { body, .. } => {
|
||||
// Inner function defs reset loop context.
|
||||
body.iter().any(|s| has_sealed_in_loop_stmt(s, false))
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn has_sealed_in_loop_expr(expr: &Expr, in_loop: bool) -> bool {
|
||||
match expr {
|
||||
Expr::Sealed(_) => in_loop,
|
||||
Expr::Call { func, args } => {
|
||||
has_sealed_in_loop_expr(func, in_loop)
|
||||
|| args.iter().any(|a| has_sealed_in_loop_expr(a, in_loop))
|
||||
}
|
||||
Expr::BinOp { left, right, .. } => {
|
||||
has_sealed_in_loop_expr(left, in_loop) || has_sealed_in_loop_expr(right, in_loop)
|
||||
}
|
||||
Expr::UnaryNot(inner) | Expr::Try(inner) => has_sealed_in_loop_expr(inner, in_loop),
|
||||
Expr::Block(stmts) => stmts.iter().any(|s| has_sealed_in_loop_stmt(s, in_loop)),
|
||||
Expr::If { cond, then, else_ } => {
|
||||
has_sealed_in_loop_expr(cond, in_loop)
|
||||
|| has_sealed_in_loop_expr(then, in_loop)
|
||||
|| else_.as_deref().is_some_and(|e| has_sealed_in_loop_expr(e, in_loop))
|
||||
}
|
||||
Expr::Match { subject, arms } => {
|
||||
has_sealed_in_loop_expr(subject, in_loop)
|
||||
|| arms.iter().any(|a| has_sealed_in_loop_expr(&a.body, in_loop))
|
||||
}
|
||||
Expr::Field { object, .. } => has_sealed_in_loop_expr(object, in_loop),
|
||||
Expr::Array(elems) => elems.iter().any(|e| has_sealed_in_loop_expr(e, in_loop)),
|
||||
Expr::Index { object, index } => {
|
||||
has_sealed_in_loop_expr(object, in_loop) || has_sealed_in_loop_expr(index, in_loop)
|
||||
}
|
||||
Expr::Closure { body, .. } => has_sealed_in_loop_expr(body, in_loop),
|
||||
Expr::MapLiteral(pairs) => pairs
|
||||
.iter()
|
||||
.any(|(k, v)| has_sealed_in_loop_expr(k, in_loop) || has_sealed_in_loop_expr(v, in_loop)),
|
||||
Expr::Activate { .. } | Expr::Literal(_) | Expr::Ident(_) | Expr::Path { .. } => false,
|
||||
Expr::StructLit { fields, .. } => fields
|
||||
.iter()
|
||||
.any(|(_, e)| has_sealed_in_loop_expr(e, in_loop)),
|
||||
Expr::With { base, updates } => {
|
||||
has_sealed_in_loop_expr(base, in_loop)
|
||||
|| updates.iter().any(|(_, e)| has_sealed_in_loop_expr(e, in_loop))
|
||||
}
|
||||
Expr::Reason { .. } => false,
|
||||
Expr::Parallel { entries } => entries.iter().any(|(_, e)| has_sealed_in_loop_expr(e, in_loop)),
|
||||
Expr::Trace { body, .. } => body.iter().any(|s| has_sealed_in_loop_stmt(s, in_loop)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a `TypeExpr` to a display string for the return-type name check.
|
||||
fn type_expr_name(te: &TypeExpr) -> String {
|
||||
match te {
|
||||
TypeExpr::Named(n) => n.clone(),
|
||||
TypeExpr::Result { .. } => "Result".to_string(),
|
||||
TypeExpr::Array(inner) => format!("[{}]", type_expr_name(inner)),
|
||||
TypeExpr::Optional(inner) => format!("{}?", type_expr_name(inner)),
|
||||
TypeExpr::Map { key, value } => {
|
||||
format!("Map<{}, {}>", type_expr_name(key), type_expr_name(value))
|
||||
}
|
||||
TypeExpr::Fn { .. } => "fn".to_string(),
|
||||
TypeExpr::TypeParam(n) => n.clone(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//! Diagnostic types for the architectural checker.
|
||||
|
||||
/// Severity level of an architectural diagnostic.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Severity {
|
||||
Error,
|
||||
Warning,
|
||||
}
|
||||
|
||||
/// A single architectural diagnostic (error or warning).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ArchDiagnostic {
|
||||
pub severity: Severity,
|
||||
/// Rule identifier, e.g. "VBD-001".
|
||||
pub rule: String,
|
||||
pub message: String,
|
||||
/// Function name or other location hint.
|
||||
pub location: Option<String>,
|
||||
}
|
||||
|
||||
/// Type alias — an ArchError is an ArchDiagnostic with Severity::Error.
|
||||
pub type ArchError = ArchDiagnostic;
|
||||
|
||||
/// Type alias — an ArchWarning is an ArchDiagnostic with Severity::Warning.
|
||||
pub type ArchWarning = ArchDiagnostic;
|
||||
|
||||
impl ArchDiagnostic {
|
||||
pub fn error(rule: impl Into<String>, message: impl Into<String>, location: Option<String>) -> Self {
|
||||
Self {
|
||||
severity: Severity::Error,
|
||||
rule: rule.into(),
|
||||
message: message.into(),
|
||||
location,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn warning(rule: impl Into<String>, message: impl Into<String>, location: Option<String>) -> Self {
|
||||
Self {
|
||||
severity: Severity::Warning,
|
||||
rule: rule.into(),
|
||||
message: message.into(),
|
||||
location,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//! el-arch — Architectural rule checker for the Engram language.
|
||||
//!
|
||||
//! Runs after type-checking and enforces:
|
||||
//! - VBD (Volatility-Based Decomposition) layer rules
|
||||
//! - EBD (Experience-Based Decomposition) experience rules
|
||||
//! - Swarm containment rules
|
||||
//! - Security rules
|
||||
//! - Graph access patterns (N+1, duplicate activate)
|
||||
|
||||
pub mod checker;
|
||||
pub mod error;
|
||||
pub mod rule;
|
||||
pub mod rules;
|
||||
|
||||
pub use checker::ArchChecker;
|
||||
pub use error::{ArchDiagnostic, ArchError, ArchWarning, Severity};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Core trait and context types for architectural rules.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use crate::error::ArchDiagnostic;
|
||||
|
||||
/// Information about a single function call within a function body.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CallInfo {
|
||||
/// The name of the function being called.
|
||||
pub callee: String,
|
||||
/// True if this call appears inside a loop body (for or while).
|
||||
pub is_in_loop: bool,
|
||||
}
|
||||
|
||||
/// Full context about a single function being checked by arch rules.
|
||||
pub struct FnContext<'a> {
|
||||
/// Name of the function under analysis.
|
||||
pub fn_name: &'a str,
|
||||
/// Decorator names applied directly to this function (e.g. "accessor", "public").
|
||||
pub annotations: &'a [String],
|
||||
/// All calls made from within this function body.
|
||||
pub body_calls: &'a [CallInfo],
|
||||
/// Global map of function name → decorator names for the whole program.
|
||||
pub all_fn_annotations: &'a HashMap<String, Vec<String>>,
|
||||
/// TypeNames that appear in `activate TypeName where ...` calls in this function.
|
||||
pub activate_types: &'a [String],
|
||||
/// Whether this function contains a `sealed { }` block inside a loop.
|
||||
pub has_sealed_in_loop: bool,
|
||||
/// The return type of the function as a string (e.g. "Result", "Void", "String").
|
||||
pub return_type_name: &'a str,
|
||||
}
|
||||
|
||||
impl<'a> FnContext<'a> {
|
||||
/// Returns true if this function has the given annotation/decorator.
|
||||
pub fn has_annotation(&self, name: &str) -> bool {
|
||||
self.annotations.iter().any(|a| a == name)
|
||||
}
|
||||
|
||||
/// Returns true if the named callee has the given annotation in the program.
|
||||
pub fn callee_has_annotation(&self, callee: &str, ann: &str) -> bool {
|
||||
self.all_fn_annotations
|
||||
.get(callee)
|
||||
.map(|anns| anns.iter().any(|a| a == ann))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// An architectural rule that can be checked against a function context.
|
||||
pub trait ArchRule: Send + Sync {
|
||||
/// Short unique identifier, e.g. "VBD-001".
|
||||
fn name(&self) -> &str;
|
||||
/// Human-readable description of what this rule enforces.
|
||||
fn description(&self) -> &str;
|
||||
/// Run the rule against a function context, returning any diagnostics.
|
||||
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic>;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//! Graph access pattern rules (N+1 detection, duplicate activate).
|
||||
|
||||
use crate::error::ArchDiagnostic;
|
||||
use crate::rule::{ArchRule, FnContext};
|
||||
|
||||
// ── GRAPH-001: N+1 detection ──────────────────────────────────────────────────
|
||||
|
||||
/// GRAPH-001: An activate call inside a loop is an N+1 graph access pattern.
|
||||
/// Each loop iteration performs a separate graph traversal; consolidate into one query.
|
||||
pub struct N1Detection;
|
||||
|
||||
impl ArchRule for N1Detection {
|
||||
fn name(&self) -> &str { "GRAPH-001" }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"activate inside a loop creates an N+1 graph access pattern — hoist outside the loop"
|
||||
}
|
||||
|
||||
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
|
||||
// We detect activate-in-loop via the body_calls with is_in_loop = true
|
||||
// AND via the activate_types combined with loop context tracked by the checker.
|
||||
// The checker sets a separate field for this.
|
||||
let has_activate_in_loop = ctx.body_calls
|
||||
.iter()
|
||||
.any(|c| c.is_in_loop && c.callee.starts_with("__activate__"));
|
||||
|
||||
if has_activate_in_loop {
|
||||
vec![ArchDiagnostic::warning(
|
||||
self.name(),
|
||||
format!(
|
||||
"function '{}' performs activate inside a loop — N+1 graph access pattern",
|
||||
ctx.fn_name
|
||||
),
|
||||
Some(ctx.fn_name.to_string()),
|
||||
)]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── GRAPH-002: Duplicate activate on same type ────────────────────────────────
|
||||
|
||||
/// GRAPH-002: Multiple activate calls on the same type within one function
|
||||
/// should be consolidated into a single query for efficiency.
|
||||
pub struct DuplicateActivateType;
|
||||
|
||||
impl ArchRule for DuplicateActivateType {
|
||||
fn name(&self) -> &str { "GRAPH-002" }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"multiple activate calls on the same type in one function — consolidate into one query"
|
||||
}
|
||||
|
||||
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
|
||||
let mut seen = std::collections::HashMap::new();
|
||||
for type_name in ctx.activate_types {
|
||||
*seen.entry(type_name.as_str()).or_insert(0u32) += 1;
|
||||
}
|
||||
|
||||
seen.iter()
|
||||
.filter(|(_, &count)| count > 1)
|
||||
.map(|(type_name, count)| ArchDiagnostic::warning(
|
||||
self.name(),
|
||||
format!(
|
||||
"function '{}' activates type '{}' {} times — consolidate into a single query",
|
||||
ctx.fn_name, type_name, count
|
||||
),
|
||||
Some(ctx.fn_name.to_string()),
|
||||
))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
//! Individual architectural rule implementations.
|
||||
|
||||
pub mod graph;
|
||||
pub mod security;
|
||||
pub mod swarm;
|
||||
pub mod vbd;
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Security architectural rules.
|
||||
|
||||
use crate::error::ArchDiagnostic;
|
||||
use crate::rule::{ArchRule, FnContext};
|
||||
|
||||
// ── SEC-001: Public function with activate inside ─────────────────────────────
|
||||
|
||||
/// SEC-001: A @public function must not contain activate expressions.
|
||||
/// Unauthenticated callers could trigger graph reads, potentially leaking data.
|
||||
pub struct PublicFnWithActivate;
|
||||
|
||||
impl ArchRule for PublicFnWithActivate {
|
||||
fn name(&self) -> &str { "SEC-001" }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"@public functions must not contain activate — unauthenticated callers could trigger data reads"
|
||||
}
|
||||
|
||||
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
|
||||
if !ctx.has_annotation("public") {
|
||||
return vec![];
|
||||
}
|
||||
if !ctx.activate_types.is_empty() {
|
||||
return vec![ArchDiagnostic::error(
|
||||
self.name(),
|
||||
format!(
|
||||
"@public function '{}' contains activate — data leak risk for unauthenticated callers (types: {})",
|
||||
ctx.fn_name,
|
||||
ctx.activate_types.join(", ")
|
||||
),
|
||||
Some(ctx.fn_name.to_string()),
|
||||
)];
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
// ── SEC-002: sealed block inside a loop ──────────────────────────────────────
|
||||
|
||||
/// SEC-002: A sealed block inside a loop incurs encryption overhead per iteration.
|
||||
pub struct SealedInLoop;
|
||||
|
||||
impl ArchRule for SealedInLoop {
|
||||
fn name(&self) -> &str { "SEC-002" }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"sealed blocks inside loops cause encryption overhead on every iteration"
|
||||
}
|
||||
|
||||
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
|
||||
if ctx.has_sealed_in_loop {
|
||||
return vec![ArchDiagnostic::warning(
|
||||
self.name(),
|
||||
format!(
|
||||
"function '{}' contains a sealed block inside a loop — encryption overhead in hot path",
|
||||
ctx.fn_name
|
||||
),
|
||||
Some(ctx.fn_name.to_string()),
|
||||
)];
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
// ── SEC-003: @authenticate without @authorize on mutations ────────────────────
|
||||
|
||||
/// SEC-003: Functions whose name suggests mutation (create_*, update_*, delete_*)
|
||||
/// and carry @authenticate should also carry @authorize, otherwise authn without authz.
|
||||
pub struct AuthnWithoutAuthz;
|
||||
|
||||
impl ArchRule for AuthnWithoutAuthz {
|
||||
fn name(&self) -> &str { "SEC-003" }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"@authenticate without @authorize on mutation functions — authentication without authorization"
|
||||
}
|
||||
|
||||
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
|
||||
if !ctx.has_annotation("authenticate") {
|
||||
return vec![];
|
||||
}
|
||||
if ctx.has_annotation("authorize") {
|
||||
return vec![];
|
||||
}
|
||||
// Heuristic: mutation function names
|
||||
let is_mutation = ctx.fn_name.starts_with("create_")
|
||||
|| ctx.fn_name.starts_with("update_")
|
||||
|| ctx.fn_name.starts_with("delete_")
|
||||
|| ctx.fn_name.starts_with("write_")
|
||||
|| ctx.fn_name.starts_with("mutate_");
|
||||
|
||||
if is_mutation {
|
||||
return vec![ArchDiagnostic::warning(
|
||||
self.name(),
|
||||
format!(
|
||||
"function '{}' has @authenticate but not @authorize — authn without authz on a mutation",
|
||||
ctx.fn_name
|
||||
),
|
||||
Some(ctx.fn_name.to_string()),
|
||||
)];
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//! Swarm containment rules — agents are isolated and must not cross boundaries.
|
||||
|
||||
use crate::error::ArchDiagnostic;
|
||||
use crate::rule::{ArchRule, FnContext};
|
||||
|
||||
// ── SWARM-001: Swarm agent calling another swarm agent ────────────────────────
|
||||
|
||||
/// SWARM-001: @swarm_agent functions must not call other @swarm_agent functions.
|
||||
/// Agents are isolated units; cross-agent calls break containment.
|
||||
pub struct SwarmAgentIsolation;
|
||||
|
||||
impl ArchRule for SwarmAgentIsolation {
|
||||
fn name(&self) -> &str { "SWARM-001" }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"@swarm_agent must not call another @swarm_agent (agents are isolated)"
|
||||
}
|
||||
|
||||
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
|
||||
if !ctx.has_annotation("swarm_agent") {
|
||||
return vec![];
|
||||
}
|
||||
ctx.body_calls
|
||||
.iter()
|
||||
.filter(|call| ctx.callee_has_annotation(&call.callee, "swarm_agent"))
|
||||
.map(|call| ArchDiagnostic::error(
|
||||
self.name(),
|
||||
format!(
|
||||
"@swarm_agent '{}' calls @swarm_agent '{}' — agents must be isolated",
|
||||
ctx.fn_name, call.callee
|
||||
),
|
||||
Some(ctx.fn_name.to_string()),
|
||||
))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
// ── SWARM-002: Swarm agent initiating a spawn/swarm ──────────────────────────
|
||||
|
||||
/// SWARM-002: @swarm_agent must not initiate spawning of other agents
|
||||
/// (calls to functions named `spawn` or containing "swarm" in the name).
|
||||
pub struct SwarmAgentNoSpawn;
|
||||
|
||||
impl ArchRule for SwarmAgentNoSpawn {
|
||||
fn name(&self) -> &str { "SWARM-002" }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"@swarm_agent must not call spawn/swarm functions (agents cannot initiate sub-swarms)"
|
||||
}
|
||||
|
||||
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
|
||||
if !ctx.has_annotation("swarm_agent") {
|
||||
return vec![];
|
||||
}
|
||||
ctx.body_calls
|
||||
.iter()
|
||||
.filter(|call| {
|
||||
let c = call.callee.as_str();
|
||||
c == "spawn" || c.contains("swarm") || c.starts_with("spawn_")
|
||||
})
|
||||
.map(|call| ArchDiagnostic::error(
|
||||
self.name(),
|
||||
format!(
|
||||
"@swarm_agent '{}' calls '{}' — agents cannot initiate spawning",
|
||||
ctx.fn_name, call.callee
|
||||
),
|
||||
Some(ctx.fn_name.to_string()),
|
||||
))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
// ── SWARM-003: Swarm agent accessing shared mutable state ─────────────────────
|
||||
|
||||
/// SWARM-003: @swarm_agent must not access shared mutable state.
|
||||
/// Heuristic: calls to functions with "shared" in the name suggest shared state access.
|
||||
pub struct SwarmAgentNoSharedState;
|
||||
|
||||
impl ArchRule for SwarmAgentNoSharedState {
|
||||
fn name(&self) -> &str { "SWARM-003" }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"@swarm_agent must not access shared mutable state (functions with 'shared' in name)"
|
||||
}
|
||||
|
||||
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
|
||||
if !ctx.has_annotation("swarm_agent") {
|
||||
return vec![];
|
||||
}
|
||||
ctx.body_calls
|
||||
.iter()
|
||||
.filter(|call| call.callee.contains("shared"))
|
||||
.map(|call| ArchDiagnostic::error(
|
||||
self.name(),
|
||||
format!(
|
||||
"@swarm_agent '{}' accesses shared state via '{}' — agents must not touch shared mutable state",
|
||||
ctx.fn_name, call.callee
|
||||
),
|
||||
Some(ctx.fn_name.to_string()),
|
||||
))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//! VBD (Volatility-Based Decomposition) and EBD (Experience-Based Decomposition) layer rules.
|
||||
|
||||
use crate::error::ArchDiagnostic;
|
||||
use crate::rule::{ArchRule, FnContext};
|
||||
|
||||
// ── VBD-001: Accessor must not call Manager ───────────────────────────────────
|
||||
|
||||
/// VBD-001: @accessor functions must not call @manager functions.
|
||||
/// Accessors are read-only, stable-interface components; they must not depend
|
||||
/// on manager-layer orchestration logic.
|
||||
pub struct AccessorMustNotCallManager;
|
||||
|
||||
impl ArchRule for AccessorMustNotCallManager {
|
||||
fn name(&self) -> &str { "VBD-001" }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"@accessor must not call @manager functions (accessor must not depend on manager layer)"
|
||||
}
|
||||
|
||||
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
|
||||
if !ctx.has_annotation("accessor") {
|
||||
return vec![];
|
||||
}
|
||||
ctx.body_calls
|
||||
.iter()
|
||||
.filter(|call| ctx.callee_has_annotation(&call.callee, "manager"))
|
||||
.map(|call| ArchDiagnostic::error(
|
||||
self.name(),
|
||||
format!(
|
||||
"accessor '{}' calls manager '{}' — accessors must not depend on the manager layer",
|
||||
ctx.fn_name, call.callee
|
||||
),
|
||||
Some(ctx.fn_name.to_string()),
|
||||
))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
// ── VBD-002: Experience must not directly call Experience ─────────────────────
|
||||
|
||||
/// VBD-002 / EBD-001: @experience functions must not call other @experience functions directly.
|
||||
/// Experiences should communicate via events, not direct calls, to preserve
|
||||
/// loose coupling between user-facing features.
|
||||
pub struct ExperienceMustNotCallExperience;
|
||||
|
||||
impl ArchRule for ExperienceMustNotCallExperience {
|
||||
fn name(&self) -> &str { "VBD-002" }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"@experience must not call another @experience directly (use events instead)"
|
||||
}
|
||||
|
||||
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
|
||||
if !ctx.has_annotation("experience") {
|
||||
return vec![];
|
||||
}
|
||||
ctx.body_calls
|
||||
.iter()
|
||||
.filter(|call| ctx.callee_has_annotation(&call.callee, "experience"))
|
||||
.map(|call| ArchDiagnostic::error(
|
||||
self.name(),
|
||||
format!(
|
||||
"experience '{}' directly calls experience '{}' — use an event instead",
|
||||
ctx.fn_name, call.callee
|
||||
),
|
||||
Some(ctx.fn_name.to_string()),
|
||||
))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
// ── VBD-003: Experience should return Result<T, E> ────────────────────────────
|
||||
|
||||
/// VBD-003: @experience functions should return Result<T, E> for proper error propagation.
|
||||
pub struct ExperienceShouldReturnResult;
|
||||
|
||||
impl ArchRule for ExperienceShouldReturnResult {
|
||||
fn name(&self) -> &str { "VBD-003" }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"@experience functions should return Result<T, E> for proper error handling"
|
||||
}
|
||||
|
||||
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
|
||||
if !ctx.has_annotation("experience") {
|
||||
return vec![];
|
||||
}
|
||||
// Warn if return type doesn't include "Result"
|
||||
if !ctx.return_type_name.contains("Result") {
|
||||
return vec![ArchDiagnostic::warning(
|
||||
self.name(),
|
||||
format!(
|
||||
"experience '{}' returns '{}' instead of Result<T, E> — experiences should propagate errors",
|
||||
ctx.fn_name, ctx.return_type_name
|
||||
),
|
||||
Some(ctx.fn_name.to_string()),
|
||||
)];
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
//! Comprehensive tests for the el-arch architectural checker.
|
||||
|
||||
use crate::{ArchChecker, ArchDiagnostic, Severity};
|
||||
|
||||
// ── Test helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
fn check(src: &str) -> Vec<ArchDiagnostic> {
|
||||
let tokens = el_lexer::tokenize(src).expect("lex failed");
|
||||
let prog = el_parser::parse(tokens, src.to_string()).expect("parse failed");
|
||||
ArchChecker::new().check(&prog)
|
||||
}
|
||||
|
||||
fn errors(src: &str) -> Vec<ArchDiagnostic> {
|
||||
check(src).into_iter().filter(|d| d.severity == Severity::Error).collect()
|
||||
}
|
||||
|
||||
fn warnings(src: &str) -> Vec<ArchDiagnostic> {
|
||||
check(src).into_iter().filter(|d| d.severity == Severity::Warning).collect()
|
||||
}
|
||||
|
||||
fn has_rule(diags: &[ArchDiagnostic], rule: &str) -> bool {
|
||||
diags.iter().any(|d| d.rule == rule)
|
||||
}
|
||||
|
||||
// ── 1. VBD-001: @accessor calling @manager → error ──────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_accessor_calls_manager_error() {
|
||||
let src = r#"
|
||||
@manager fn orchestrate(x: String) -> String { return x }
|
||||
@accessor fn fetch_user(id: String) -> String { return orchestrate(id) }
|
||||
"#;
|
||||
let errs = errors(src);
|
||||
assert!(!errs.is_empty(), "expected error when @accessor calls @manager");
|
||||
assert!(has_rule(&errs, "VBD-001"), "expected VBD-001 rule");
|
||||
}
|
||||
|
||||
// ── 2. @accessor calling @accessor → no VBD-001 error ────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_accessor_calls_accessor_no_error() {
|
||||
let src = r#"
|
||||
@accessor fn get_name(id: String) -> String { return id }
|
||||
@accessor fn get_user(id: String) -> String { return get_name(id) }
|
||||
"#;
|
||||
let errs = errors(src);
|
||||
let vbd001: Vec<_> = errs.iter().filter(|d| d.rule == "VBD-001").collect();
|
||||
assert!(vbd001.is_empty(), "accessor->accessor should not trigger VBD-001");
|
||||
}
|
||||
|
||||
// ── 3. @experience calling @experience → VBD-002 error ───────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_experience_calls_experience_error() {
|
||||
let src = r#"
|
||||
@experience fn checkout(cart: String) -> Result<String, String> { return cart }
|
||||
@experience fn payment(amount: String) -> Result<String, String> { return checkout(amount) }
|
||||
"#;
|
||||
let errs = errors(src);
|
||||
assert!(!errs.is_empty(), "expected error when @experience calls @experience");
|
||||
assert!(has_rule(&errs, "VBD-002"), "expected VBD-002 rule");
|
||||
}
|
||||
|
||||
// ── 4. @experience calling non-experience → no VBD-002 error ─────────────────
|
||||
|
||||
#[test]
|
||||
fn test_experience_calls_non_experience_no_error() {
|
||||
let src = r#"
|
||||
@accessor fn load_cart(id: String) -> String { return id }
|
||||
@experience fn checkout(cart: String) -> Result<String, String> { return load_cart(cart) }
|
||||
"#;
|
||||
let errs = errors(src);
|
||||
let vbd002: Vec<_> = errs.iter().filter(|d| d.rule == "VBD-002").collect();
|
||||
assert!(vbd002.is_empty(), "experience->non-experience should not trigger VBD-002");
|
||||
}
|
||||
|
||||
// ── 5. @public function with activate inside → SEC-001 error ─────────────────
|
||||
|
||||
#[test]
|
||||
fn test_public_fn_with_activate_error() {
|
||||
let src = r#"
|
||||
@public fn list_users() -> String {
|
||||
let users: String = activate User where "all users"
|
||||
return users
|
||||
}
|
||||
"#;
|
||||
let errs = errors(src);
|
||||
assert!(!errs.is_empty(), "expected error for @public fn with activate");
|
||||
assert!(has_rule(&errs, "SEC-001"), "expected SEC-001 rule");
|
||||
}
|
||||
|
||||
// ── 6. @public function without activate → no SEC-001 error ──────────────────
|
||||
|
||||
#[test]
|
||||
fn test_public_fn_without_activate_no_error() {
|
||||
let src = r#"
|
||||
@public fn greet(name: String) -> String { return name }
|
||||
"#;
|
||||
let errs = errors(src);
|
||||
let sec001: Vec<_> = errs.iter().filter(|d| d.rule == "SEC-001").collect();
|
||||
assert!(sec001.is_empty(), "@public without activate should not trigger SEC-001");
|
||||
}
|
||||
|
||||
// ── 7. N+1: activate inside a for loop → GRAPH-001 warning ───────────────────
|
||||
|
||||
#[test]
|
||||
fn test_activate_in_for_loop_n1_warning() {
|
||||
let src = r#"
|
||||
fn process_ids(ids: String) -> String {
|
||||
for id in ids {
|
||||
let u: String = activate User where "user by id"
|
||||
}
|
||||
return "done"
|
||||
}
|
||||
"#;
|
||||
let warns = warnings(src);
|
||||
assert!(!warns.is_empty(), "expected N+1 warning for activate inside loop");
|
||||
assert!(has_rule(&warns, "GRAPH-001"), "expected GRAPH-001 rule");
|
||||
}
|
||||
|
||||
// ── 8. activate NOT in loop → no GRAPH-001 warning ───────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_activate_not_in_loop_no_n1_warning() {
|
||||
let src = r#"
|
||||
fn fetch_users() -> String {
|
||||
let users: String = activate User where "recent users"
|
||||
return users
|
||||
}
|
||||
"#;
|
||||
let warns = warnings(src);
|
||||
let graph001: Vec<_> = warns.iter().filter(|d| d.rule == "GRAPH-001").collect();
|
||||
assert!(graph001.is_empty(), "activate outside loop should not trigger GRAPH-001");
|
||||
}
|
||||
|
||||
// ── 9. Duplicate activate same type → GRAPH-002 warning ──────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_duplicate_activate_same_type_warning() {
|
||||
let src = r#"
|
||||
fn inefficient_fn(x: String) -> String {
|
||||
let a: String = activate User where "active users"
|
||||
let b: String = activate User where "recent users"
|
||||
return a
|
||||
}
|
||||
"#;
|
||||
let warns = warnings(src);
|
||||
assert!(!warns.is_empty(), "expected warning for duplicate activate on same type");
|
||||
assert!(has_rule(&warns, "GRAPH-002"), "expected GRAPH-002 rule");
|
||||
}
|
||||
|
||||
// ── 10. Different activate types → no GRAPH-002 warning ──────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_different_activate_types_no_duplicate_warning() {
|
||||
let src = r#"
|
||||
fn fetch_all(x: String) -> String {
|
||||
let users: String = activate User where "users"
|
||||
let orders: String = activate Order where "orders"
|
||||
return users
|
||||
}
|
||||
"#;
|
||||
let warns = warnings(src);
|
||||
let graph002: Vec<_> = warns.iter().filter(|d| d.rule == "GRAPH-002").collect();
|
||||
assert!(graph002.is_empty(), "different activate types should not trigger GRAPH-002");
|
||||
}
|
||||
|
||||
// ── 11. @swarm_agent calling @swarm_agent → SWARM-001 error ──────────────────
|
||||
|
||||
#[test]
|
||||
fn test_swarm_agent_calls_swarm_agent_error() {
|
||||
let src = r#"
|
||||
@swarm_agent fn worker_b(x: String) -> String { return x }
|
||||
@swarm_agent fn worker_a(x: String) -> String { return worker_b(x) }
|
||||
"#;
|
||||
let errs = errors(src);
|
||||
assert!(!errs.is_empty(), "expected error when @swarm_agent calls @swarm_agent");
|
||||
assert!(has_rule(&errs, "SWARM-001"), "expected SWARM-001 rule");
|
||||
}
|
||||
|
||||
// ── 12. @swarm_agent in isolation → no SWARM-001 error ───────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_swarm_agent_isolation_no_error() {
|
||||
let src = r#"
|
||||
fn utility(x: String) -> String { return x }
|
||||
@swarm_agent fn worker(x: String) -> String { return utility(x) }
|
||||
"#;
|
||||
let errs = errors(src);
|
||||
let swarm001: Vec<_> = errs.iter().filter(|d| d.rule == "SWARM-001").collect();
|
||||
assert!(swarm001.is_empty(), "isolated @swarm_agent should not trigger SWARM-001");
|
||||
}
|
||||
|
||||
// ── 13. Multiple rules fire on same function ──────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_multiple_rules_fire_same_function() {
|
||||
// @swarm_agent calling a swarm_agent AND accessing shared state
|
||||
let src = r#"
|
||||
@swarm_agent fn peer(x: String) -> String { return x }
|
||||
fn get_shared_cache(x: String) -> String { return x }
|
||||
@swarm_agent fn violator(x: String) -> String {
|
||||
let a: String = peer(x)
|
||||
let b: String = get_shared_cache(x)
|
||||
return a
|
||||
}
|
||||
"#;
|
||||
let errs = errors(src);
|
||||
// Should fire both SWARM-001 (calls peer) and SWARM-003 (calls get_shared_cache)
|
||||
assert!(errs.len() >= 2, "expected multiple errors: {:?}", errs.iter().map(|e| &e.rule).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
// ── 14. ArchChecker::has_errors() → true when errors present ─────────────────
|
||||
|
||||
#[test]
|
||||
fn test_has_errors_true_when_errors_present() {
|
||||
let src = r#"
|
||||
@manager fn do_manage(x: String) -> String { return x }
|
||||
@accessor fn bad_fetch(x: String) -> String { return do_manage(x) }
|
||||
"#;
|
||||
let diags = check(src);
|
||||
assert!(ArchChecker::has_errors(&diags), "has_errors should be true");
|
||||
}
|
||||
|
||||
// ── 15. ArchChecker::has_errors() → false when only warnings ─────────────────
|
||||
|
||||
#[test]
|
||||
fn test_has_errors_false_when_only_warnings() {
|
||||
let src = r#"
|
||||
@experience fn sign_up(email: String) -> String { return email }
|
||||
"#;
|
||||
// sign_up doesn't return Result so triggers VBD-003 warning
|
||||
let diags = check(src);
|
||||
let has_warn = diags.iter().any(|d| d.severity == Severity::Warning);
|
||||
assert!(has_warn || diags.is_empty(), "expected either warnings or empty");
|
||||
assert!(!ArchChecker::has_errors(&diags.iter().filter(|d| d.severity == Severity::Warning).cloned().collect::<Vec<_>>()), "has_errors should be false for warnings");
|
||||
}
|
||||
|
||||
// ── 16. Clean function → empty diagnostics ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_clean_function_no_diagnostics() {
|
||||
let src = r#"
|
||||
fn pure_add(a: String, b: String) -> String { return a }
|
||||
"#;
|
||||
let diags = check(src);
|
||||
assert!(diags.is_empty(), "clean function should produce no diagnostics, got: {:?}", diags.iter().map(|d| &d.rule).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
// ── 17. @engine function → runs without panic ─────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_engine_function_no_panic() {
|
||||
let src = r#"
|
||||
@engine fn compute(data: String) -> String { return data }
|
||||
"#;
|
||||
// Just verify no panic — engine rules only produce warnings in some impls
|
||||
let _diags = check(src);
|
||||
}
|
||||
|
||||
// ── 18. sealed { } not in loop → no SEC-002 warning ──────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_sealed_not_in_loop_no_warning() {
|
||||
let src = r#"
|
||||
fn encrypt_data(secret: String) -> String {
|
||||
sealed { let key: String = secret }
|
||||
return secret
|
||||
}
|
||||
"#;
|
||||
let warns = warnings(src);
|
||||
let sec002: Vec<_> = warns.iter().filter(|d| d.rule == "SEC-002").collect();
|
||||
assert!(sec002.is_empty(), "sealed not in loop should not trigger SEC-002");
|
||||
}
|
||||
|
||||
// ── 19. Rule names are unique ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_rule_names_are_unique() {
|
||||
let checker = ArchChecker::new();
|
||||
let mut names = std::collections::HashSet::new();
|
||||
for rule in checker.rules() {
|
||||
let inserted = names.insert(rule.name().to_string());
|
||||
assert!(inserted, "duplicate rule name: {}", rule.name());
|
||||
}
|
||||
}
|
||||
|
||||
// ── 20. All rules implement ArchRule (compile-time check) ─────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_all_rules_implement_arch_rule() {
|
||||
use crate::rule::ArchRule;
|
||||
use crate::rules::{
|
||||
vbd::{AccessorMustNotCallManager, ExperienceMustNotCallExperience, ExperienceShouldReturnResult},
|
||||
security::{PublicFnWithActivate, SealedInLoop, AuthnWithoutAuthz},
|
||||
graph::{N1Detection, DuplicateActivateType},
|
||||
swarm::{SwarmAgentIsolation, SwarmAgentNoSpawn, SwarmAgentNoSharedState},
|
||||
};
|
||||
|
||||
fn assert_arch_rule<T: ArchRule>() {}
|
||||
assert_arch_rule::<AccessorMustNotCallManager>();
|
||||
assert_arch_rule::<ExperienceMustNotCallExperience>();
|
||||
assert_arch_rule::<ExperienceShouldReturnResult>();
|
||||
assert_arch_rule::<PublicFnWithActivate>();
|
||||
assert_arch_rule::<SealedInLoop>();
|
||||
assert_arch_rule::<AuthnWithoutAuthz>();
|
||||
assert_arch_rule::<N1Detection>();
|
||||
assert_arch_rule::<DuplicateActivateType>();
|
||||
assert_arch_rule::<SwarmAgentIsolation>();
|
||||
assert_arch_rule::<SwarmAgentNoSpawn>();
|
||||
assert_arch_rule::<SwarmAgentNoSharedState>();
|
||||
}
|
||||
|
||||
// ── 21. FnContext builds correctly for a decorated function ───────────────────
|
||||
|
||||
#[test]
|
||||
fn test_fn_context_from_decorated_function() {
|
||||
// Run checker and verify the location field is set to the function name
|
||||
let src = r#"
|
||||
@accessor fn fetch_item(id: String) -> String { return id }
|
||||
"#;
|
||||
let diags = check(src);
|
||||
// No violations — but verify the checker doesn't panic and processes it
|
||||
// (accessor with no calls should produce no errors)
|
||||
let _: Vec<_> = diags;
|
||||
}
|
||||
|
||||
// ── 22. @experience without Result return type → VBD-003 warning ─────────────
|
||||
|
||||
#[test]
|
||||
fn test_experience_non_result_return_warning() {
|
||||
let src = r#"
|
||||
@experience fn show_profile(user: String) -> String { return user }
|
||||
"#;
|
||||
let warns = warnings(src);
|
||||
assert!(!warns.is_empty(), "expected warning for @experience not returning Result");
|
||||
assert!(has_rule(&warns, "VBD-003"), "expected VBD-003 rule");
|
||||
}
|
||||
|
||||
// ── 23. @authenticate without @authorize on mutation → SEC-003 warning ────────
|
||||
|
||||
#[test]
|
||||
fn test_authenticate_without_authorize_on_mutation_warning() {
|
||||
let src = r#"
|
||||
@authenticate fn create_account(email: String) -> String { return email }
|
||||
"#;
|
||||
let warns = warnings(src);
|
||||
assert!(!warns.is_empty(), "expected SEC-003 warning");
|
||||
assert!(has_rule(&warns, "SEC-003"), "expected SEC-003 rule");
|
||||
}
|
||||
|
||||
// ── 24. Two @experience functions checked independently ───────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_two_experience_functions_checked_independently() {
|
||||
let src = r#"
|
||||
@experience fn sign_up(email: String) -> Result<String, String> { return email }
|
||||
@experience fn log_in(token: String) -> String { return token }
|
||||
"#;
|
||||
let warns = warnings(src);
|
||||
// sign_up returns Result — no VBD-003 for it
|
||||
// log_in returns String — VBD-003 fires for it
|
||||
let vbd003: Vec<_> = warns.iter().filter(|d| d.rule == "VBD-003").collect();
|
||||
assert_eq!(vbd003.len(), 1, "only log_in should trigger VBD-003, got {:?}", vbd003.iter().map(|d| &d.location).collect::<Vec<_>>());
|
||||
assert!(vbd003[0].location.as_deref() == Some("log_in"), "VBD-003 should point to log_in");
|
||||
}
|
||||
|
||||
// ── 25. Mixed errors and warnings → has_errors() returns true ─────────────────
|
||||
|
||||
#[test]
|
||||
fn test_mixed_errors_and_warnings_has_errors_true() {
|
||||
let src = r#"
|
||||
@manager fn manage_data(x: String) -> String { return x }
|
||||
@accessor fn bad_read(x: String) -> String { return manage_data(x) }
|
||||
@experience fn display(x: String) -> String { return x }
|
||||
"#;
|
||||
let diags = check(src);
|
||||
assert!(ArchChecker::has_errors(&diags), "should have errors (VBD-001)");
|
||||
let has_warn = diags.iter().any(|d| d.severity == Severity::Warning);
|
||||
assert!(has_warn, "should also have warnings (VBD-003 for display)");
|
||||
}
|
||||
|
||||
// ── 26. @swarm_agent calling spawn → SWARM-002 error ─────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_swarm_agent_no_spawn() {
|
||||
let src = r#"
|
||||
fn spawn(agent: String) -> String { return agent }
|
||||
@swarm_agent fn initiator(x: String) -> String { return spawn(x) }
|
||||
"#;
|
||||
let errs = errors(src);
|
||||
assert!(!errs.is_empty(), "expected SWARM-002 error for calling spawn");
|
||||
assert!(has_rule(&errs, "SWARM-002"), "expected SWARM-002 rule");
|
||||
}
|
||||
|
||||
// ── 27. @swarm_agent accessing shared state → SWARM-003 error ────────────────
|
||||
|
||||
#[test]
|
||||
fn test_swarm_agent_no_shared_state() {
|
||||
let src = r#"
|
||||
fn get_shared_counter(x: String) -> String { return x }
|
||||
@swarm_agent fn agent(x: String) -> String { return get_shared_counter(x) }
|
||||
"#;
|
||||
let errs = errors(src);
|
||||
assert!(!errs.is_empty(), "expected SWARM-003 error for shared state access");
|
||||
assert!(has_rule(&errs, "SWARM-003"), "expected SWARM-003 rule");
|
||||
}
|
||||
|
||||
// ── 28. sealed block inside for loop → SEC-002 warning ───────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_sealed_in_for_loop_warning() {
|
||||
let src = r#"
|
||||
fn encrypt_many(items: String) -> String {
|
||||
for item in items {
|
||||
sealed { let x: String = item }
|
||||
}
|
||||
return items
|
||||
}
|
||||
"#;
|
||||
let warns = warnings(src);
|
||||
assert!(!warns.is_empty(), "expected SEC-002 warning for sealed in loop");
|
||||
assert!(has_rule(&warns, "SEC-002"), "expected SEC-002 rule");
|
||||
}
|
||||
|
||||
// ── 29. @authenticate with @authorize → no SEC-003 warning ───────────────────
|
||||
|
||||
#[test]
|
||||
fn test_authenticate_with_authorize_no_warning() {
|
||||
let src = r#"
|
||||
@authenticate @authorize fn create_post(content: String) -> String { return content }
|
||||
"#;
|
||||
let warns = warnings(src);
|
||||
let sec003: Vec<_> = warns.iter().filter(|d| d.rule == "SEC-003").collect();
|
||||
assert!(sec003.is_empty(), "@authenticate + @authorize should not trigger SEC-003");
|
||||
}
|
||||
|
||||
// ── 30. @experience returning Result → no VBD-003 warning ────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_experience_returns_result_no_warning() {
|
||||
let src = r#"
|
||||
@experience fn register(email: String) -> Result<String, String> { return email }
|
||||
"#;
|
||||
let warns = warnings(src);
|
||||
let vbd003: Vec<_> = warns.iter().filter(|d| d.rule == "VBD-003").collect();
|
||||
assert!(vbd003.is_empty(), "@experience returning Result should not trigger VBD-003");
|
||||
}
|
||||
Reference in New Issue
Block a user