mod action_expand;
mod canonical;
pub use canonical::{canonical_declarations, canonical_program_hash, DeclCanon};
pub mod body;
mod body_print;
mod format;
mod lowering;
use format::*;
pub use format::{format_program, format_program_preserving_comments, FormatOutput};
use lowering::*;
mod syntax;
use syntax::*;
pub use syntax::{lex_comments, parse_program, parser_stage, string_and_comment_spans};
mod then_expand;
use std::{
collections::{BTreeMap, BTreeSet, VecDeque},
fmt,
};
use whipplescript_core::{
ContractRegistry, EffectContract, LibraryRegistration, TypedOutputValidation,
};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SourceSpan {
pub start: usize,
pub end: usize,
}
impl SourceSpan {
fn join(self, other: Self) -> Self {
Self {
start: self.start,
end: other.end,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Diagnostic {
pub span: SourceSpan,
pub message: String,
pub suggestion: Option<String>,
pub related: Vec<RelatedInfo>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RelatedInfo {
pub span: SourceSpan,
pub message: String,
}
impl Diagnostic {
pub fn with_related(mut self, span: SourceSpan, message: impl Into<String>) -> Self {
self.related.push(RelatedInfo {
span,
message: message.into(),
});
self
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CommentMarker {
Hash,
Slash,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Comment {
pub marker: CommentMarker,
pub text: String,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Ident {
pub name: String,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct StringLiteral {
pub value: String,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Program {
pub workflow: Option<Ident>,
pub workflow_tags: Vec<TagDecl>,
pub workflow_description: Option<StringLiteral>,
pub explicit_workflow_body: bool,
pub workflows: Vec<WorkflowDecl>,
pub patterns: Vec<PatternDecl>,
pub items: Vec<Item>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WorkflowDecl {
pub name: Ident,
pub tags: Vec<TagDecl>,
pub description: Option<StringLiteral>,
pub items: Vec<Item>,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Item {
Include(IncludeDecl),
Use(UseDecl),
Pattern(PatternDecl),
Apply(ApplyDecl),
WorkflowContract(WorkflowContractDecl),
Harness(HarnessDecl),
Tracker(TrackerDecl),
Channel(ChannelDecl),
Credential(CredentialDecl),
Stream(StreamDecl),
Gauge(GaugeDecl),
Mark(MarkDecl),
Campaign(CampaignDecl),
FileStore(FileStoreDecl),
MemoryPool(MemoryPoolDecl),
Action(ActionDecl),
Agent(AgentDecl),
Enum(EnumDecl),
Event(EventDecl),
Source(Box<SourceDecl>),
Test(TestDecl),
Lease(LeaseDecl),
Ledger(LedgerDecl),
Counter(CounterDecl),
Class(ClassDecl),
Table(TableDecl),
Coerce(CoerceDecl),
Assert(AssertDecl),
Rule(RuleDecl),
}
impl Item {
fn span(&self) -> SourceSpan {
match self {
Self::Include(decl) => decl.path.span,
Self::Use(decl) => decl.name.span,
Self::Pattern(decl) => decl.span,
Self::Apply(decl) => decl.span,
Self::WorkflowContract(decl) => decl.span,
Self::Harness(decl) => decl.span,
Self::Tracker(decl) => decl.span,
Self::Channel(decl) => decl.span,
Self::Credential(decl) => decl.span,
Self::Stream(decl) => decl.span,
Self::Gauge(decl) => decl.span,
Self::Mark(decl) => decl.span,
Self::Campaign(decl) => decl.span,
Self::FileStore(decl) => decl.span,
Self::MemoryPool(decl) => decl.span,
Self::Action(decl) => decl.span,
Self::Agent(decl) => decl.span,
Self::Enum(decl) => decl.span,
Self::Event(decl) => decl.span,
Self::Source(decl) => decl.span,
Self::Test(decl) => decl.span,
Self::Lease(decl) => decl.span,
Self::Ledger(decl) => decl.span,
Self::Counter(decl) => decl.span,
Self::Class(decl) => decl.span,
Self::Table(decl) => decl.span,
Self::Coerce(decl) => decl.span,
Self::Assert(decl) => decl.span,
Self::Rule(decl) => decl.span,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PatternDecl {
pub name: Ident,
pub type_params: Vec<Ident>,
pub items: Vec<Item>,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ApplyDecl {
pub pattern: Ident,
pub type_args: Vec<TypeSyntax>,
pub alias: Ident,
pub body: BlockSource,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IncludeDecl {
pub path: StringLiteral,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WorkflowContractDecl {
pub kind: WorkflowContractKind,
pub name: Ident,
pub ty: TypeSyntax,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum WorkflowContractKind {
Input,
Output,
Failure,
}
impl WorkflowContractKind {
fn as_str(&self) -> &'static str {
match self {
Self::Input => "input",
Self::Output => "output",
Self::Failure => "failure",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AssertDecl {
pub tags: Vec<TagDecl>,
pub description: Option<StringLiteral>,
pub expr: String,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TagDecl {
pub name: String,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct UseDecl {
pub name: StringLiteral,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HarnessDecl {
pub name: Ident,
pub kind: Ident,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TrackerDecl {
pub name: Ident,
pub provider: Ident,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ChannelDecl {
pub name: Ident,
pub provider: Ident,
pub workspace: Option<Ident>,
pub destination: Option<StringLiteral>,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CredentialDecl {
pub name: Ident,
pub kind: Ident,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct StreamDecl {
pub name: Ident,
pub members: Vec<Ident>,
pub staleness_seconds: Option<u64>,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MarkDecl {
pub name: StringLiteral,
pub site: String,
pub site_span: SourceSpan,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GaugeDecl {
pub name: Ident,
pub site: Option<String>,
pub site_span: Option<SourceSpan>,
pub judge: GaugeJudge,
pub expect: Option<GaugeBar>,
pub inputs: Vec<GaugeRef>,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum GaugeJudge {
Coerce(Ident, Vec<String>),
Prompt(StringLiteral),
Exec(StringLiteral),
Labels(StringLiteral),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GaugeBar {
pub subject: GaugeBarSubject,
pub at_least: bool,
pub threshold: String,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum GaugeBarSubject {
Chance { field: Ident },
Stat { stat: Ident },
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GaugeRef {
pub name: String,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CampaignDecl {
pub name: Ident,
pub ascend: Vec<GaugeRef>,
pub reach: Vec<CampaignReach>,
pub guard: Vec<CampaignGuard>,
pub sacrifice: Vec<GaugeRef>,
pub proposer_redacted: bool,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CampaignReach {
pub gauge: GaugeRef,
pub at_least: bool,
pub threshold: String,
pub unit: Option<String>,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CampaignGuard {
pub gauge: GaugeRef,
pub band_percent: String,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FileStoreDecl {
pub name: Ident,
pub root: String,
pub read_globs: Vec<String>,
pub write_globs: Vec<String>,
pub provider: Option<Ident>,
pub root_span: Option<SourceSpan>,
pub read_span: Option<SourceSpan>,
pub write_span: Option<SourceSpan>,
pub provider_span: Option<SourceSpan>,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MemoryPoolDecl {
pub name: Ident,
pub context_limit: Option<u64>,
pub context_limit_span: Option<SourceSpan>,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ActionParam {
pub name: Ident,
pub ty: TypeSyntax,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ActionDecl {
pub name: Ident,
pub params: Vec<ActionParam>,
pub body: BlockSource,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AgentDecl {
pub name: Ident,
pub harness: Option<Ident>,
pub delegated_to: Option<Ident>,
pub fields: Vec<AgentField>,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum AgentField {
Provider(Ident),
Profile(StringLiteral),
Capacity(u32, SourceSpan),
Skills(Vec<StringLiteral>, SourceSpan),
Capabilities(Vec<StringLiteral>, SourceSpan),
Requires(Vec<Ident>, SourceSpan),
Tools(Vec<Ident>, SourceSpan),
Compaction(Ident),
Thread(Ident),
Settings(Ident),
Unknown {
name: Ident,
span: SourceSpan,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct EnumDecl {
pub name: Ident,
pub variants: Vec<EnumVariantDecl>,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct EnumVariantDecl {
pub name: Ident,
pub fields: Vec<ClassField>,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ClassDecl {
pub name: Ident,
pub fields: Vec<ClassField>,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct LeaseDecl {
pub name: Ident,
pub key_type: Ident,
pub slots: u32,
pub ttl_seconds: u64,
pub shared: bool,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct LedgerDecl {
pub name: Ident,
pub entry_schema: Ident,
pub partition_field: Ident,
pub retain_seconds: u64,
pub shared: bool,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CounterDecl {
pub name: Ident,
pub key_type: Ident,
pub cap: i64,
pub reset: String,
pub timezone: Option<String>,
pub shared: bool,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct EventDecl {
pub name: String,
pub name_span: SourceSpan,
pub fields: Vec<ClassField>,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ClassField {
pub name: Ident,
pub ty: TypeSyntax,
pub is_key: bool,
pub presence_condition: Option<(String, String)>,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SourceDecl {
pub name: Ident,
pub provider: Ident,
pub clock: Option<ClockPolicy>,
pub path: Option<StringLiteral>,
pub watch: Option<StringLiteral>,
pub url: Option<StringLiteral>,
pub dedup: Option<SourceValue>,
pub observe_binding: Ident,
pub emit: SourceEmit,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ClockPolicy {
pub recurrence: Recurrence,
pub timezone: Option<StringLiteral>,
pub missed: Option<MissedPolicy>,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Recurrence {
At { time: TimeOfDay, span: SourceSpan },
EveryDuration {
seconds: u64,
source: String,
span: SourceSpan,
},
EveryCalendar {
pattern: CalendarPattern,
time: TimeOfDay,
span: SourceSpan,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CalendarPattern {
Day,
Weekday,
Weekly(Weekday),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Weekday {
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday,
Sunday,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TimeOfDay {
pub hour: u8,
pub minute: u8,
pub span: SourceSpan,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MissedPolicy {
Skip,
Coalesce,
CatchUp { limit: u32 },
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SourceEmit {
pub signal: String,
pub signal_span: SourceSpan,
pub from: Option<Ident>,
pub fields: Vec<SourceEmitField>,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SourceEmitField {
pub name: Ident,
pub value: SourceValue,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SourceValue {
Path {
binding: Ident,
segments: Vec<Ident>,
span: SourceSpan,
},
String(StringLiteral),
Number(String, SourceSpan),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TestDecl {
pub name: StringLiteral,
pub workflow: Option<Ident>,
pub clauses: Vec<TestClause>,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum TestClause {
Given(GivenClause),
Stub(StubClause),
Run(RunClause),
Expect(ExpectClause),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TestField {
pub name: Ident,
pub value: String,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum GivenClause {
Input {
fields: Vec<TestField>,
span: SourceSpan,
},
Fact {
ty: Ident,
fields: Vec<TestField>,
span: SourceSpan,
},
Signal {
name: String,
fields: Vec<TestField>,
span: SourceSpan,
},
Clock {
at: StringLiteral,
span: SourceSpan,
},
Tracker {
tracker: String,
fields: Vec<TestField>,
span: SourceSpan,
},
File {
store: String,
path: StringLiteral,
content: StringLiteral,
span: SourceSpan,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct StubClause {
pub surface: Vec<String>,
pub outcome: String,
pub payload: Option<StubPayload>,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum StubPayload {
Record(Vec<TestField>),
Message(StringLiteral),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RunClause {
pub kind: RunKind,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum RunKind {
UntilIdle,
UntilWorkflowCompleted,
UntilWorkflowFailed,
ForSteps(u32),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ExpectClause {
pub target: ExpectTarget,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ExpectTarget {
WorkflowCompleted,
WorkflowFailed { failure: Option<Ident> },
Rule { name: Ident, status: RuleStatus },
Effect { name: String, status: EffectStatus },
Diagnostic { code: String },
NoEffect { name: String },
Projection(ProjQuery),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum RuleStatus {
Fired,
FiredTimes(u32),
DidNotFire,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum EffectStatus {
Requested,
Completed,
Failed,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ProjQuery {
pub noun: String,
pub kind: ProjQueryKind,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ProjQueryKind {
Exists,
Count { predicate: String, count: u32 },
Where { predicate: String },
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TableDecl {
pub name: Ident,
pub tags: Vec<TagDecl>,
pub description: Option<StringLiteral>,
pub schema: Ident,
pub rows: Vec<TableRow>,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TableRow {
pub body: BlockSource,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CoerceDecl {
pub name: Ident,
pub params: Vec<ParamDecl>,
pub output: TypeSyntax,
pub body: BlockSource,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ParamDecl {
pub name: Ident,
pub ty: TypeSyntax,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum TypeSyntax {
Primitive {
name: String,
span: SourceSpan,
},
LiteralString {
value: String,
span: SourceSpan,
},
Ref {
name: Ident,
},
AgentRef {
agents: Vec<Ident>,
span: SourceSpan,
},
Optional {
inner: Box<TypeSyntax>,
span: SourceSpan,
},
Array {
inner: Box<TypeSyntax>,
span: SourceSpan,
},
Map {
inner: Box<TypeSyntax>,
span: SourceSpan,
},
Union {
variants: Vec<TypeSyntax>,
span: SourceSpan,
},
}
impl TypeSyntax {
fn span(&self) -> SourceSpan {
match self {
Self::Primitive { span, .. }
| Self::LiteralString { span, .. }
| Self::Optional { span, .. }
| Self::Array { span, .. }
| Self::Map { span, .. }
| Self::Union { span, .. }
| Self::AgentRef { span, .. } => *span,
Self::Ref { name } => name.span,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RuleDecl {
pub name: Ident,
pub tags: Vec<TagDecl>,
pub description: Option<StringLiteral>,
pub whens: Vec<WhenClause>,
pub body: BlockSource,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WhenClause {
pub text: String,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BlockSource {
pub text: String,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ParseOutput {
pub program: Program,
pub diagnostics: Vec<Diagnostic>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CompileOutput {
pub ir: Option<IrProgram>,
pub diagnostics: Vec<Diagnostic>,
pub warnings: Vec<Diagnostic>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrProgram {
pub workflow: String,
pub source_tags: Vec<IrSourceTag>,
pub source_descriptions: Vec<IrSourceDescription>,
pub includes: Vec<IrInclude>,
pub pattern_applications: Vec<IrPatternApplication>,
pub workflow_contracts: Vec<IrWorkflowContract>,
pub uses: Vec<IrUse>,
pub harnesses: Vec<IrHarness>,
pub trackers: Vec<IrTracker>,
pub streams: Vec<IrStream>,
pub channels: Vec<IrChannel>,
pub credentials: Vec<IrCredential>,
pub gauges: Vec<IrGauge>,
pub marks: Vec<IrMark>,
pub campaigns: Vec<IrCampaign>,
pub file_stores: Vec<IrFileStore>,
pub memory_pools: Vec<IrMemoryPool>,
pub events: Vec<IrEvent>,
pub sources: Vec<IrSource>,
pub tests: Vec<IrTest>,
pub leases: Vec<IrLease>,
pub ledgers: Vec<IrLedger>,
pub counters: Vec<IrCounter>,
pub shared_coordination_usage: Vec<IrSharedCoordinationUsage>,
pub schemas: Vec<IrSchema>,
pub agents: Vec<IrAgent>,
pub coerces: Vec<IrCoerce>,
pub assertions: Vec<IrAssertion>,
pub rules: Vec<IrRule>,
pub rule_dependencies: Vec<IrRuleDependency>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrSharedCoordinationUsage {
pub resource: String,
pub workflow_principals: Vec<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrSourceTag {
pub name: String,
pub target_kind: String,
pub target: String,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrSourceDescription {
pub value: String,
pub target_kind: String,
pub target: String,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrPatternApplication {
pub pattern: String,
pub alias: String,
pub type_args: Vec<IrType>,
pub value_args: Vec<IrPatternArgument>,
pub generated: Vec<String>,
pub definition_span: SourceSpan,
pub application_span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrPatternArgument {
pub name: String,
pub value: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrWorkflowContract {
pub kind: IrWorkflowContractKind,
pub name: String,
pub ty: IrType,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum IrWorkflowContractKind {
Input,
Output,
Failure,
}
impl IrWorkflowContractKind {
fn as_str(&self) -> &'static str {
match self {
Self::Input => "input",
Self::Output => "output",
Self::Failure => "failure",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrInclude {
pub path: String,
pub source_hash: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrAssertion {
pub expr: IrExpression,
pub projection_reads: Vec<IrProjectionRead>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrExpression {
pub source: String,
pub expr: Expr,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrUse {
pub kind: IrUseKind,
pub name: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum IrUseKind {
Package,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrStream {
pub name: String,
pub members: Vec<String>,
pub member_spans: Vec<SourceSpan>,
pub staleness_seconds: Option<u64>,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrTracker {
pub name: String,
pub provider: String,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrChannel {
pub name: String,
pub provider: String,
pub workspace: Option<String>,
pub destination: Option<String>,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrCredential {
pub name: String,
pub kind: String,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrMark {
pub name: String,
pub site: String,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrGauge {
pub name: String,
pub site: Option<String>,
pub judge_kind: String,
pub judge_target: String,
pub judge_args: Vec<String>,
pub expect: Option<IrGaugeBar>,
pub inputs: Vec<String>,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrGaugeBar {
pub form: String,
pub subject: String,
pub op: String,
pub threshold: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrCampaign {
pub name: String,
pub ascend: Vec<String>,
pub reach: Vec<IrCampaignReach>,
pub guard: Vec<IrCampaignGuard>,
pub sacrifice: Vec<String>,
pub proposer_redacted: bool,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrCampaignReach {
pub gauge: String,
pub op: String,
pub threshold: String,
pub unit: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrCampaignGuard {
pub gauge: String,
pub band_percent: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrFileStore {
pub name: String,
pub root: String,
pub read_globs: Vec<String>,
pub write_globs: Vec<String>,
pub provider: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrMemoryPool {
pub name: String,
pub context_limit: Option<u64>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrHarness {
pub name: String,
pub kind: String,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum IrSchema {
Enum(IrEnum),
Class(IrClass),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrEnum {
pub name: String,
pub variants: Vec<String>,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrClass {
pub name: String,
pub fields: Vec<IrClassField>,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrEvent {
pub name: String,
pub fields: Vec<IrClassField>,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrSource {
pub name: String,
pub provider: String,
pub is_clock: bool,
pub is_file: bool,
pub is_http: bool,
pub recurrence: Option<Recurrence>,
pub timezone: Option<String>,
pub missed: Option<MissedPolicy>,
pub path: Option<String>,
pub watch: Option<String>,
pub url: Option<String>,
pub dedup_field: Option<String>,
pub observe_binding: String,
pub emit_signal: String,
pub emit_from: Option<String>,
pub emit_fields: Vec<IrSourceEmitField>,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrSourceEmitField {
pub name: String,
pub value: SourceValue,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrTest {
pub name: String,
pub workflow: Option<String>,
pub clauses: Vec<TestClause>,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrLease {
pub name: String,
pub key_type: String,
pub slots: u32,
pub ttl_seconds: u64,
pub shared: bool,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrLedger {
pub name: String,
pub entry_schema: String,
pub partition_field: String,
pub retain_seconds: u64,
pub shared: bool,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrCounter {
pub name: String,
pub key_type: String,
pub cap: i64,
pub reset: String,
pub timezone: Option<String>,
pub shared: bool,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrClassField {
pub name: String,
pub ty: IrType,
pub is_key: bool,
pub presence_condition: Option<(String, String)>,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum IrType {
Primitive(IrPrimitiveType),
LiteralString(String),
Ref(String),
AgentRef(Vec<String>),
Object(Vec<IrClassField>),
Optional(Box<IrType>),
Array(Box<IrType>),
Map(Box<IrType>),
Union(Vec<IrType>),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum IrPrimitiveType {
String,
Int,
Float,
Bool,
Null,
Duration,
Time,
Image,
Audio,
Pdf,
Video,
Secret,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrAgent {
pub name: String,
pub span: SourceSpan,
pub harness: Option<String>,
pub provider: Option<String>,
pub profile: Option<String>,
pub capacity: Option<u32>,
pub skills: Vec<String>,
pub capabilities: Vec<String>,
pub requires: Vec<String>,
pub tools: Vec<String>,
pub compaction: Option<String>,
pub thread: Option<String>,
pub settings: Option<String>,
pub harness_class: HarnessClass,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrCoerce {
pub name: String,
pub span: SourceSpan,
pub params: Vec<IrParam>,
pub output: IrType,
pub body: String,
pub provider: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrParam {
pub name: String,
pub ty: IrType,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrRule {
pub name: String,
pub whens: Vec<IrWhen>,
pub body: String,
pub metadata: IrRuleMetadata,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrWhen {
pub source: String,
pub pattern: String,
pub guard: Option<IrExpression>,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrRuleDependency {
pub producer: String,
pub consumer: String,
pub fact: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrRegionEffect {
pub binding: String,
pub scope: Option<(String, String)>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrRegion {
pub until: bool,
pub condition: String,
pub lapse_binding: Option<String>,
pub effects: Vec<IrRegionEffect>,
pub body_removed: String,
pub body_lapsed: String,
pub arm_content: String,
pub arm_case_arms: Vec<(String, String)>,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct IrRuleMetadata {
pub fact_reads: Vec<String>,
pub projection_reads: Vec<IrProjectionRead>,
pub fact_writes: Vec<String>,
pub record_sources: Vec<IrRecordSource>,
pub fact_consumes: Vec<String>,
pub effects: Vec<IrEffectNode>,
pub dependencies: Vec<IrEffectDependency>,
pub region: Option<IrRegion>,
pub case_branches: Vec<IrRuleCaseBranch>,
pub terminal_outputs: Vec<IrTerminalOutput>,
pub terminal_branches: Vec<IrTerminalCaseBranch>,
pub terminal_completes: Vec<String>,
pub redactions: Vec<IrRedaction>,
pub egress_payload_reads: BTreeMap<String, BTreeSet<String>>,
pub declassified_roots: BTreeSet<String>,
pub endorsed_roots: BTreeSet<String>,
pub endorsed_claim_items: BTreeSet<String>,
pub record_field_reads: BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
pub coerce_input_roots: BTreeMap<String, BTreeSet<String>>,
pub after_aliases: BTreeMap<String, String>,
pub egress_case_influence: BTreeMap<String, BTreeSet<String>>,
pub complete_field_reads: BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
pub milestone_field_reads: BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
pub bounded_egresses: Vec<IrBoundedEgress>,
pub max_after_depth: usize,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrBoundedEgress {
pub sink: String,
pub source_schema: String,
pub keep: Vec<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrRedaction {
pub source: String,
pub keep: Vec<String>,
pub binding: String,
pub source_schema: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrRecordSource {
pub schema: String,
pub construct: String,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrProjectionRead {
pub kind: QueryKind,
pub head: String,
pub guard: Option<String>,
}
impl IrProjectionRead {
fn to_snapshot(&self) -> String {
let prefix = match self.kind {
QueryKind::Fact => format!("fact:{}", self.head),
QueryKind::Effect => format!("effect:{}", self.head),
};
match &self.guard {
Some(guard) => format!("{prefix} where {guard}"),
None => prefix,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrEffectNode {
pub id: String,
pub kind: IrEffectKind,
pub binding: Option<String>,
pub required_capabilities: Vec<String>,
pub construct_use: Option<IrConstructUse>,
pub idempotency_key: String,
pub span: SourceSpan,
pub timeout_seconds: Option<u64>,
pub access_grants: Vec<IrAccessGrant>,
pub turn_skills: Vec<String>,
pub on_stream: Option<String>,
pub selection_source: Option<String>,
pub transport_onto: Option<String>,
pub resource: Option<String>,
pub agent: Option<String>,
pub coerce_target: Option<String>,
pub workflow_target: Option<String>,
pub endorsed: bool,
pub declassified: bool,
pub selected_by: Option<(String, String)>,
pub exec_target: Option<IrExecTarget>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum IrExecTarget {
Raw,
Capability { name: String },
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrAccessGrant {
pub resource: String,
pub operations: Vec<IrAccessGrantOp>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrAccessGrantOp {
pub operation: String,
pub target: Option<String>,
pub globs: Vec<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrConstructUse {
pub keyword: String,
pub scope: String,
pub construct_family: String,
pub lowering_target: String,
pub target_capability: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum IrEffectKind {
AgentTell,
SchemaCoerce,
CapabilityCall,
EventEmit,
WorkflowInvoke,
TimerWait,
ExecCommand,
TrackerFile,
TrackerClaim,
TrackerRenew,
TrackerRelease,
TrackerFinish,
LeaseAcquire,
LeaseRenew,
LedgerAppend,
CounterConsume,
SignalEmit,
FileRead,
FileWrite,
FileImport,
FileExport,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrEffectDependency {
pub upstream: String,
pub predicate: DependencyPredicate,
pub downstream: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrRuleCaseBranch {
pub scrutinee: String,
pub scrutinee_type: IrType,
pub pattern: IrCasePattern,
pub guard: Option<IrExpression>,
pub body_hash: String,
pub pattern_span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum IrCasePattern {
EnumVariant(String),
LiteralString(String),
Agent(String),
OptionalSome { binding: String },
OptionalNone,
Wildcard,
}
impl IrCasePattern {
fn to_snapshot(&self) -> String {
match self {
IrCasePattern::EnumVariant(value) => format!("enum:{value}"),
IrCasePattern::LiteralString(value) => format!("literal:\"{value}\""),
IrCasePattern::Agent(value) => format!("agent:{value}"),
IrCasePattern::OptionalSome { binding } => format!("some:{binding}"),
IrCasePattern::OptionalNone => "none".to_owned(),
IrCasePattern::Wildcard => "_".to_owned(),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrTerminalOutput {
pub binding: String,
pub alternatives: Vec<IrTerminalAlternative>,
pub span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrTerminalAlternative {
pub tag: String,
pub payload_type: IrType,
pub source_span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IrTerminalCaseBranch {
pub scrutinee: String,
pub tag: Option<String>,
pub binding: Option<String>,
pub guard: Option<IrExpression>,
pub body_hash: String,
pub pattern_span: SourceSpan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DependencyPredicate {
Succeeds,
Fails,
TimedOut,
Cancelled,
Completes,
}
#[derive(Clone, Debug)]
struct SemanticContext {
workflow: Option<String>,
schemas: SchemaIndex,
agents: BTreeSet<String>,
agent_capabilities: BTreeMap<String, BTreeSet<String>>,
coerce_outputs: BTreeMap<String, TypeSyntax>,
coerce_params: BTreeMap<String, Vec<ParamDecl>>,
workflow_inputs: BTreeMap<String, WorkflowInputSurface>,
leases: BTreeSet<String>,
ledgers: BTreeSet<String>,
counters: BTreeSet<String>,
channels: BTreeSet<String>,
channel_providers: BTreeMap<String, String>,
#[allow(dead_code)]
credentials: BTreeMap<String, String>,
memory_pools: BTreeSet<String>,
regions: BTreeMap<String, IrRegion>,
}
#[derive(Clone, Debug, Default)]
struct WorkflowInputSurface {
inputs: BTreeMap<String, TypeSyntax>,
outputs: BTreeMap<String, TypeSyntax>,
failures: BTreeMap<String, TypeSyntax>,
schemas: SchemaIndex,
milestones: BTreeMap<String, String>,
}
#[derive(Clone, Debug, Default)]
struct SchemaIndex {
classes: BTreeMap<String, BTreeMap<String, TypeSyntax>>,
enums: BTreeMap<String, BTreeSet<String>>,
events: BTreeSet<String>,
presence: BTreeMap<String, BTreeMap<String, (String, String)>>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum BlockFrame {
After {
binding: String,
predicate: DependencyPredicate,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum LiteralExpr<'a> {
String(&'a str),
Number(&'a str),
Bool,
Null,
Ident(&'a str),
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum ExprType {
Bool,
Int,
Float,
String,
Duration,
Time,
Secret,
Null,
Object,
Optional(Box<ExprType>),
Array(Box<ExprType>),
Map(Box<ExprType>),
Finite {
label: String,
values: Vec<String>,
},
Collection,
Unknown,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Expr {
Literal(ExprLiteral),
Path(Vec<String>),
Index {
target: Box<Expr>,
key: Box<Expr>,
},
Array(Vec<Expr>),
Object(Vec<ExprObjectField>),
Unary {
op: UnaryOp,
expr: Box<Expr>,
},
Binary {
op: BinaryOp,
left: Box<Expr>,
right: Box<Expr>,
},
Call {
name: String,
args: Vec<Expr>,
},
Query {
kind: QueryKind,
head: String,
guard: Option<Box<Expr>>,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ExprObjectField {
pub key: String,
pub value: Expr,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ExprLiteral {
String(String),
Number(String),
Bool(bool),
Null,
Ident(String),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum UnaryOp {
Not,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum BinaryOp {
Or,
And,
Eq,
Ne,
Lt,
Le,
Gt,
Ge,
In,
NotIn,
Add,
Sub,
Mul,
Div,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum QueryKind {
Fact,
Effect,
}
pub fn parse_expression(expr: &str) -> Result<Expr, String> {
ExprParser::new(expr).parse()
}
impl Expr {
pub fn to_snapshot(&self) -> String {
match self {
Self::Literal(literal) => literal.to_snapshot(),
Self::Path(path) => path.join("."),
Self::Index { target, key } => {
format!(
"{}[{}]",
target.to_snapshot_with_parentheses(),
key.to_snapshot()
)
}
Self::Array(items) => {
let items = items
.iter()
.map(Self::to_snapshot)
.collect::<Vec<_>>()
.join(", ");
format!("[{items}]")
}
Self::Object(fields) => {
let fields = fields
.iter()
.map(|field| format!("{} {}", field.key, field.value.to_snapshot()))
.collect::<Vec<_>>()
.join(", ");
format!("{{{fields}}}")
}
Self::Unary { op, expr } => match op {
UnaryOp::Not => format!("!{}", expr.to_snapshot_with_parentheses()),
},
Self::Binary { op, left, right } => format!(
"{} {} {}",
left.to_snapshot_with_parentheses(),
op.to_snapshot(),
right.to_snapshot_with_parentheses()
),
Self::Call { name, args } => {
let args = args
.iter()
.map(Self::to_snapshot)
.collect::<Vec<_>>()
.join(", ");
format!("{name}({args})")
}
Self::Query { kind, head, guard } => {
let prefix = match kind {
QueryKind::Fact => head.clone(),
QueryKind::Effect => format!("effect {head}"),
};
match guard {
Some(guard) => format!("{prefix} where {}", guard.to_snapshot()),
None => prefix,
}
}
}
}
fn to_snapshot_with_parentheses(&self) -> String {
match self {
Self::Binary { .. } => format!("({})", self.to_snapshot()),
_ => self.to_snapshot(),
}
}
}
impl ExprLiteral {
fn to_snapshot(&self) -> String {
match self {
Self::String(value) => format!("{value:?}"),
Self::Number(value) | Self::Ident(value) => value.clone(),
Self::Bool(value) => value.to_string(),
Self::Null => "null".to_owned(),
}
}
}
impl BinaryOp {
fn to_snapshot(self) -> &'static str {
match self {
Self::Or => "||",
Self::And => "&&",
Self::Eq => "==",
Self::Ne => "!=",
Self::Lt => "<",
Self::Le => "<=",
Self::Gt => ">",
Self::Ge => ">=",
Self::In => "in",
Self::NotIn => "not in",
Self::Add => "+",
Self::Sub => "-",
Self::Mul => "*",
Self::Div => "/",
}
}
}
pub fn compile_program(source: &str) -> CompileOutput {
compile_program_with_root(source, None)
}
pub fn compile_program_with_root(source: &str, root: Option<&str>) -> CompileOutput {
let parsed = parse_program(source);
if !parsed.diagnostics.is_empty() {
return CompileOutput {
ir: None,
diagnostics: parsed.diagnostics,
warnings: Vec::new(),
};
}
let mut invoke_recursion_diagnostics = Vec::new();
detect_workflow_invoke_recursion(&parsed.program, &mut invoke_recursion_diagnostics);
detect_private_workflow_invocations(&parsed.program, &mut invoke_recursion_diagnostics);
if !invoke_recursion_diagnostics.is_empty() {
return CompileOutput {
ir: None,
diagnostics: invoke_recursion_diagnostics,
warnings: Vec::new(),
};
}
let workflow_inputs = collect_workflow_input_surfaces(&parsed.program);
let shared_coordination_usage = collect_shared_coordination_usage(&parsed.program);
if parsed.program.workflows.len() > 1 {
let global_names: BTreeSet<String> = parsed
.program
.items
.iter()
.filter_map(|item| referenced_decl_name(item).map(|(name, _)| name))
.collect();
let mut sibling_locals: BTreeMap<String, Vec<(String, SourceSpan)>> = BTreeMap::new();
for workflow in &parsed.program.workflows {
for item in &workflow.items {
if let Some((name, span)) = referenced_decl_name(item) {
sibling_locals
.entry(name)
.or_default()
.push((workflow.name.name.clone(), span));
}
}
}
let mut aggregated = Vec::new();
for workflow in &parsed.program.workflows {
let name = workflow.name.name.clone();
let own_locals: BTreeSet<String> = workflow
.items
.iter()
.filter_map(|item| referenced_decl_name(item).map(|(name, _)| name))
.collect();
let mut diagnostics = match select_root_workflow(parsed.program.clone(), Some(&name)) {
Ok(scoped) => {
lower_program(
scoped,
workflow_inputs.clone(),
shared_coordination_usage.clone(),
)
.diagnostics
}
Err(diagnostics) => diagnostics,
};
for diagnostic in &mut diagnostics {
annotate_cross_workflow_leak(
diagnostic,
&name,
&own_locals,
&global_names,
&sibling_locals,
);
}
aggregated.extend(diagnostics);
}
if !aggregated.is_empty() {
return CompileOutput {
ir: None,
diagnostics: aggregated,
warnings: Vec::new(),
};
}
}
match select_root_workflow(parsed.program, root) {
Ok(program) => lower_program(program, workflow_inputs, shared_coordination_usage),
Err(diagnostics) => CompileOutput {
ir: None,
diagnostics,
warnings: Vec::new(),
},
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DeclSymbol {
pub name: String,
pub kind: &'static str,
pub span: SourceSpan,
}
pub fn document_symbols(source: &str) -> Vec<DeclSymbol> {
let program = parse_program(source).program;
let mut symbols = Vec::new();
if let Some(workflow) = &program.workflow {
symbols.push(DeclSymbol {
name: workflow.name.clone(),
kind: "workflow",
span: workflow.span,
});
}
for workflow in &program.workflows {
symbols.push(DeclSymbol {
name: workflow.name.name.clone(),
kind: "workflow",
span: workflow.span,
});
}
for pattern in &program.patterns {
symbols.push(DeclSymbol {
name: pattern.name.name.clone(),
kind: "pattern",
span: pattern.span,
});
}
for item in &program.items {
let symbol = match item {
Item::Class(decl) => ("class", decl.name.name.clone(), decl.span),
Item::Enum(decl) => ("enum", decl.name.name.clone(), decl.span),
Item::Agent(decl) => ("agent", decl.name.name.clone(), decl.span),
Item::Rule(decl) => ("rule", decl.name.name.clone(), decl.span),
Item::Coerce(decl) => ("coerce", decl.name.name.clone(), decl.span),
Item::Action(decl) => ("action", decl.name.name.clone(), decl.span),
Item::Lease(decl) => ("lease", decl.name.name.clone(), decl.span),
Item::Ledger(decl) => ("ledger", decl.name.name.clone(), decl.span),
Item::Counter(decl) => ("counter", decl.name.name.clone(), decl.span),
Item::Tracker(decl) => ("tracker", decl.name.name.clone(), decl.span),
Item::Channel(decl) => ("channel", decl.name.name.clone(), decl.span),
Item::Credential(decl) => ("credential", decl.name.name.clone(), decl.span),
Item::FileStore(decl) => ("file store", decl.name.name.clone(), decl.span),
Item::MemoryPool(decl) => ("memory pool", decl.name.name.clone(), decl.span),
Item::Event(decl) => ("signal", decl.name.clone(), decl.span),
Item::Table(decl) => ("table", decl.name.name.clone(), decl.span),
Item::Gauge(decl) => ("gauge", decl.name.name.clone(), decl.span),
Item::Campaign(decl) => ("campaign", decl.name.name.clone(), decl.span),
Item::Mark(decl) => ("mark", decl.name.value.clone(), decl.span),
_ => continue,
};
symbols.push(DeclSymbol {
name: symbol.1,
kind: symbol.0,
span: symbol.2,
});
}
symbols
}
fn line_index(source: &str, offset: usize) -> usize {
source.as_bytes()[..offset]
.iter()
.filter(|&&byte| byte == b'\n')
.count()
}
fn classify_body_comments<'a>(
source: &str,
body: SourceSpan,
members: &[(SourceSpan, Vec<String>)],
comments: &'a [Comment],
) -> Option<(Vec<&'a Comment>, Vec<Option<&'a Comment>>)> {
let mut own_line: Vec<&Comment> = Vec::new();
let mut trailing: Vec<Option<&Comment>> = vec![None; members.len()];
for comment in comments {
if comment.span.start <= body.start || comment.span.start >= body.end {
continue;
}
if members.iter().any(|(span, lines)| {
lines.len() > 1 && span.start < comment.span.start && comment.span.start < span.end
}) {
return None;
}
let line_start = source[..comment.span.start]
.rfind('\n')
.map(|index| index + 1)
.unwrap_or(0);
if source[line_start..comment.span.start].trim().is_empty() {
own_line.push(comment);
continue;
}
let comment_line = line_index(source, comment.span.start);
let mut placed = false;
for (index, (span, lines)) in members.iter().enumerate() {
if lines.len() == 1 && line_index(source, span.start) == comment_line {
if trailing[index].is_some() {
return None;
}
trailing[index] = Some(comment);
placed = true;
break;
}
}
if !placed {
return None;
}
}
Some((own_line, trailing))
}
fn emit_members_with_comments(
members: &[(SourceSpan, Vec<String>)],
own_line: &[&Comment],
trailing: &[Option<&Comment>],
indent: &str,
formatted: &mut String,
) {
let mut next = 0;
for (index, (span, lines)) in members.iter().enumerate() {
while next < own_line.len() && own_line[next].span.start < span.start {
push_line(
formatted,
format!("{indent}{}", format_comment(own_line[next])),
);
next += 1;
}
let last = lines.len().saturating_sub(1);
for (offset, line) in lines.iter().enumerate() {
match trailing[index] {
Some(comment) if offset == last => {
push_line(formatted, format!("{line} {}", format_comment(comment)));
}
_ => push_line(formatted, line.clone()),
}
}
}
while next < own_line.len() {
push_line(
formatted,
format!("{indent}{}", format_comment(own_line[next])),
);
next += 1;
}
}
fn try_format_class_with_comments(
class_decl: &ClassDecl,
source: &str,
comments: &[Comment],
formatted: &mut String,
) -> bool {
let members: Vec<(SourceSpan, Vec<String>)> = class_decl
.fields
.iter()
.map(|field| {
let key = if field.is_key { " @key" } else { "" };
(
field.span,
vec![format!(
" {} {}{key}",
field.name.name,
field.ty.to_source()
)],
)
})
.collect();
let Some((own_line, trailing)) =
classify_body_comments(source, class_decl.span, &members, comments)
else {
return false;
};
push_line(formatted, format!("class {} {{", class_decl.name.name));
emit_members_with_comments(&members, &own_line, &trailing, " ", formatted);
push_line(formatted, "}");
true
}
fn try_format_tracker_with_comments(
queue: &TrackerDecl,
source: &str,
comments: &[Comment],
formatted: &mut String,
) -> bool {
let members: Vec<(SourceSpan, Vec<String>)> = vec![(
queue.provider.span,
vec![format!(" provider {}", queue.provider.name)],
)];
let Some((own_line, trailing)) = classify_body_comments(source, queue.span, &members, comments)
else {
return false;
};
push_line(formatted, format!("tracker {} {{", queue.name.name));
emit_members_with_comments(&members, &own_line, &trailing, " ", formatted);
push_line(formatted, "}");
true
}
fn try_format_filestore_with_comments(
file_store: &FileStoreDecl,
source: &str,
comments: &[Comment],
formatted: &mut String,
) -> bool {
let render = |globs: &[String]| {
globs
.iter()
.map(|glob| format!("{glob:?}"))
.collect::<Vec<_>>()
.join(", ")
};
let mut members: Vec<(SourceSpan, Vec<String>)> = Vec::new();
if let Some(span) = file_store.root_span {
members.push((span, vec![format!(" root {:?}", file_store.root)]));
}
if !file_store.read_globs.is_empty() {
if let Some(span) = file_store.read_span {
members.push((
span,
vec![format!(" allow read [{}]", render(&file_store.read_globs))],
));
}
}
if !file_store.write_globs.is_empty() {
if let Some(span) = file_store.write_span {
members.push((
span,
vec![format!(
" allow write [{}]",
render(&file_store.write_globs)
)],
));
}
}
if let Some(provider) = &file_store.provider {
if let Some(span) = file_store.provider_span {
members.push((span, vec![format!(" provider {}", provider.name)]));
}
}
members.sort_by_key(|(span, _)| span.start);
let Some((own_line, trailing)) =
classify_body_comments(source, file_store.span, &members, comments)
else {
return false;
};
push_line(formatted, format!("file store {} {{", file_store.name.name));
emit_members_with_comments(&members, &own_line, &trailing, " ", formatted);
push_line(formatted, "}");
true
}
fn try_format_event_with_comments(
event: &EventDecl,
source: &str,
comments: &[Comment],
formatted: &mut String,
) -> bool {
let members: Vec<(SourceSpan, Vec<String>)> = event
.fields
.iter()
.map(|field| {
(
field.span,
vec![format!(" {} {}", field.name.name, field.ty.to_source())],
)
})
.collect();
let Some((own_line, trailing)) = classify_body_comments(source, event.span, &members, comments)
else {
return false;
};
push_line(formatted, format!("signal {} {{", event.name));
emit_members_with_comments(&members, &own_line, &trailing, " ", formatted);
push_line(formatted, "}");
true
}
fn agent_field_span(field: &AgentField) -> SourceSpan {
match field {
AgentField::Provider(ident) => ident.span,
AgentField::Profile(profile) => profile.span,
AgentField::Capacity(_, span)
| AgentField::Skills(_, span)
| AgentField::Capabilities(_, span)
| AgentField::Requires(_, span)
| AgentField::Tools(_, span) => *span,
AgentField::Compaction(strategy) => strategy.span,
AgentField::Thread(mode) => mode.span,
AgentField::Settings(sources) => sources.span,
AgentField::Unknown { span, .. } => *span,
}
}
fn agent_field_line(field: &AgentField) -> String {
match field {
AgentField::Provider(provider) => format!(" provider {}", provider.name),
AgentField::Profile(profile) => format!(" profile {:?}", profile.value),
AgentField::Capacity(capacity, _) => format!(" capacity {capacity}"),
AgentField::Skills(skills, _) => {
let skills = skills
.iter()
.map(|skill| format!("{:?}", skill.value))
.collect::<Vec<_>>()
.join(", ");
format!(" skills [{skills}]")
}
AgentField::Capabilities(capabilities, _) => {
let capabilities = capabilities
.iter()
.map(|capability| format!("{:?}", capability.value))
.collect::<Vec<_>>()
.join(", ");
format!(" capabilities [{capabilities}]")
}
AgentField::Requires(classes, _) => {
let classes = classes
.iter()
.map(|class| class.name.as_str())
.collect::<Vec<_>>()
.join(", ");
format!(" requires [{classes}]")
}
AgentField::Tools(tools, _) => {
let tools = tools
.iter()
.map(|tool| tool.name.as_str())
.collect::<Vec<_>>()
.join(", ");
format!(" tools [{tools}]")
}
AgentField::Compaction(strategy) => format!(" compaction {}", strategy.name),
AgentField::Thread(mode) => format!(" thread {}", mode.name),
AgentField::Settings(sources) => format!(" settings {}", sources.name),
AgentField::Unknown { name, .. } => format!(" {}", name.name),
}
}
fn try_format_agent_with_comments(
agent: &AgentDecl,
source: &str,
comments: &[Comment],
formatted: &mut String,
) -> bool {
let members: Vec<(SourceSpan, Vec<String>)> = agent
.fields
.iter()
.map(|field| (agent_field_span(field), vec![agent_field_line(field)]))
.collect();
let Some((own_line, trailing)) = classify_body_comments(source, agent.span, &members, comments)
else {
return false;
};
let harness = agent
.harness
.as_ref()
.map(|harness| format!(" using {}", harness.name))
.or_else(|| {
agent
.delegated_to
.as_ref()
.map(|delegate| format!(" delegated to {}", delegate.name))
})
.unwrap_or_default();
push_line(
formatted,
format!("agent {}{} {{", agent.name.name, harness),
);
emit_members_with_comments(&members, &own_line, &trailing, " ", formatted);
push_line(formatted, "}");
true
}
fn enum_variant_lines_with_comments(
variant: &EnumVariantDecl,
source: &str,
comments: &[Comment],
) -> Option<Vec<String>> {
if variant.fields.is_empty() {
return Some(vec![format!(" {}", variant.name.name)]);
}
let members: Vec<(SourceSpan, Vec<String>)> = variant
.fields
.iter()
.map(|field| {
(
field.span,
vec![format!(" {} {}", field.name.name, field.ty.to_source())],
)
})
.collect();
let (own_line, trailing) = classify_body_comments(source, variant.span, &members, comments)?;
let mut block = String::new();
emit_members_with_comments(&members, &own_line, &trailing, " ", &mut block);
let mut lines = vec![format!(" {} {{", variant.name.name)];
lines.extend(block.lines().map(str::to_owned));
lines.push(" }".to_owned());
Some(lines)
}
fn try_format_enum_with_comments(
enum_decl: &EnumDecl,
source: &str,
comments: &[Comment],
formatted: &mut String,
) -> bool {
let mut members: Vec<(SourceSpan, Vec<String>)> = Vec::with_capacity(enum_decl.variants.len());
for variant in &enum_decl.variants {
let Some(lines) = enum_variant_lines_with_comments(variant, source, comments) else {
return false;
};
members.push((variant.span, lines));
}
let body_level: Vec<Comment> = comments
.iter()
.filter(|comment| {
!enum_decl.variants.iter().any(|variant| {
!variant.fields.is_empty()
&& variant.span.start < comment.span.start
&& comment.span.start < variant.span.end
})
})
.cloned()
.collect();
let Some((own_line, trailing)) =
classify_body_comments(source, enum_decl.span, &members, &body_level)
else {
return false;
};
push_line(formatted, format!("enum {} {{", enum_decl.name.name));
emit_members_with_comments(&members, &own_line, &trailing, " ", formatted);
push_line(formatted, "}");
true
}
fn referenced_decl_name(item: &Item) -> Option<(String, SourceSpan)> {
match item {
Item::Class(decl) => Some((decl.name.name.clone(), decl.span)),
Item::Enum(decl) => Some((decl.name.name.clone(), decl.span)),
Item::Agent(decl) => Some((decl.name.name.clone(), decl.span)),
Item::Coerce(decl) => Some((decl.name.name.clone(), decl.span)),
Item::Lease(decl) => Some((decl.name.name.clone(), decl.span)),
Item::Ledger(decl) => Some((decl.name.name.clone(), decl.span)),
Item::Counter(decl) => Some((decl.name.name.clone(), decl.span)),
Item::Tracker(decl) => Some((decl.name.name.clone(), decl.span)),
Item::Channel(decl) => Some((decl.name.name.clone(), decl.span)),
Item::FileStore(decl) => Some((decl.name.name.clone(), decl.span)),
Item::MemoryPool(decl) => Some((decl.name.name.clone(), decl.span)),
Item::Event(decl) => Some((decl.name.clone(), decl.span)),
Item::Table(decl) => Some((decl.name.name.clone(), decl.span)),
Item::Gauge(decl) => Some((decl.name.name.clone(), decl.span)),
Item::Campaign(decl) => Some((decl.name.name.clone(), decl.span)),
Item::Mark(decl) => Some((decl.name.value.clone(), decl.span)),
_ => None,
}
}
fn annotate_cross_workflow_leak(
diagnostic: &mut Diagnostic,
current: &str,
own_locals: &BTreeSet<String>,
global_names: &BTreeSet<String>,
sibling_locals: &BTreeMap<String, Vec<(String, SourceSpan)>>,
) {
for (name, owners) in sibling_locals {
if global_names.contains(name) || own_locals.contains(name) {
continue;
}
if !diagnostic.message.contains(&format!("`{name}`")) {
continue;
}
let Some((owner, span)) = owners.iter().find(|(owner, _)| owner != current) else {
continue;
};
diagnostic.related.push(RelatedInfo {
span: *span,
message: format!(
"`{name}` is declared inside workflow `{owner}`, which makes it \
private to that workflow; move it to a top-level declaration to \
share it across workflows"
),
});
return;
}
}
fn select_root_workflow(
mut program: Program,
root: Option<&str>,
) -> Result<Program, Vec<Diagnostic>> {
if program.workflow.is_none() && program.workflows.is_empty() {
return Err(vec![Diagnostic {
related: Vec::new(),
span: SourceSpan { start: 0, end: 0 },
message: "program declares no `workflow`".to_owned(),
suggestion: Some(
"add an explicit `workflow Name { ... }` declaration; a runnable \
program requires at least one workflow (files that only declare \
shared types or patterns are libraries, meant to be `include`d)"
.to_owned(),
),
}]);
}
if program.workflows.is_empty() {
if let Some(root) = root {
match program.workflow.as_ref() {
Some(workflow) if workflow.name == root => {}
Some(workflow) => {
return Err(vec![Diagnostic {
related: Vec::new(),
span: workflow.span,
message: format!("root workflow `{root}` was not found"),
suggestion: Some(format!("available workflow: `{}`", workflow.name)),
}]);
}
None => {
return Err(vec![Diagnostic {
related: Vec::new(),
span: SourceSpan { start: 0, end: 0 },
message: format!("root workflow `{root}` was not found"),
suggestion: Some(
"add an explicit `workflow Name { ... }` declaration".to_owned(),
),
}]);
}
}
}
return Ok(program);
}
let selected_index = match root {
Some(root) => match program
.workflows
.iter()
.position(|workflow| workflow.name.name == root)
{
Some(index) => index,
None => {
let names = program
.workflows
.iter()
.map(|workflow| format!("`{}`", workflow.name.name))
.collect::<Vec<_>>()
.join(", ");
return Err(vec![Diagnostic {
related: Vec::new(),
span: SourceSpan { start: 0, end: 0 },
message: format!("root workflow `{root}` was not found"),
suggestion: Some(format!("available workflows: {names}")),
}]);
}
},
None if program.workflows.len() == 1 => 0,
None => {
let names = program
.workflows
.iter()
.map(|workflow| format!("`{}`", workflow.name.name))
.collect::<Vec<_>>()
.join(", ");
return Err(vec![Diagnostic {
related: Vec::new(),
span: SourceSpan { start: 0, end: 0 },
message: "multiple workflow declarations require an explicit root".to_owned(),
suggestion: Some(format!(
"pass `--root <name>`; available workflows: {names}"
)),
}]);
}
};
let selected = program.workflows.remove(selected_index);
let mut items = program.items;
let workflow_tags = selected.tags;
let workflow_description = selected.description;
items.extend(selected.items);
Ok(Program {
workflow: Some(selected.name),
workflow_tags,
workflow_description,
explicit_workflow_body: true,
workflows: Vec::new(),
patterns: program.patterns,
items,
})
}
impl IrProgram {
pub fn construct_uses(&self) -> Vec<&IrConstructUse> {
self.rules
.iter()
.flat_map(|rule| rule.metadata.effects.iter())
.filter_map(|effect| effect.construct_use.as_ref())
.collect()
}
pub fn contract_registry(&self) -> ContractRegistry {
let mut libraries = BTreeMap::<String, LibraryRegistration>::new();
let mut contracts = BTreeMap::<(String, String), EffectContract>::new();
for use_decl in &self.uses {
libraries
.entry(use_decl.name.clone())
.or_insert_with(|| LibraryRegistration {
id: use_decl.name.clone(),
version: "unlocked".to_owned(),
standard: false,
});
}
if !self.harnesses.is_empty() || !self.agents.is_empty() {
register_standard_library(&mut libraries, "std.agent");
}
if !self.trackers.is_empty() {
register_standard_library(&mut libraries, "std.tracker");
}
if !self.events.is_empty() {
register_standard_library(&mut libraries, "std.ingress");
}
if !self.leases.is_empty() || !self.ledgers.is_empty() || !self.counters.is_empty() {
register_standard_library(&mut libraries, "std.coord");
}
if !self.channels.is_empty() {
register_standard_library(&mut libraries, "std.messaging");
}
if !self.credentials.is_empty() {
register_standard_library(&mut libraries, "std.custody");
}
if !self.file_stores.is_empty() {
register_standard_library(&mut libraries, "std.files");
}
if self.sources.iter().any(|source| source.is_clock) {
register_standard_library(&mut libraries, "std.time");
}
if !self.coerces.is_empty() {
register_standard_library(&mut libraries, "std.coercion");
register_effect_contract(
&mut libraries,
&mut contracts,
IrEffectKind::SchemaCoerce,
Vec::new(),
);
}
for rule in &self.rules {
for effect in &rule.metadata.effects {
register_effect_contract(
&mut libraries,
&mut contracts,
effect.kind.clone(),
effect.required_capabilities.clone(),
);
}
}
ContractRegistry {
libraries: libraries.into_values().collect(),
constructs: Vec::new(),
effect_contracts: contracts.into_values().collect(),
}
}
pub fn to_snapshot(&self) -> String {
let mut snapshot = String::new();
push_line(&mut snapshot, format!("workflow {}", self.workflow));
if !self.source_tags.is_empty() {
push_line(&mut snapshot, "source_tags");
for tag in &self.source_tags {
push_line(
&mut snapshot,
format!("@{} {} {}", tag.name, tag.target_kind, tag.target),
);
}
}
if !self.source_descriptions.is_empty() {
push_line(&mut snapshot, "source_descriptions");
for description in &self.source_descriptions {
push_line(
&mut snapshot,
format!(
"{:?} {} {}",
description.value, description.target_kind, description.target
),
);
}
}
if !self.shared_coordination_usage.is_empty() {
push_line(&mut snapshot, "shared_coordination_usage");
for usage in &self.shared_coordination_usage {
push_line(
&mut snapshot,
format!(
"{} <- {}",
usage.resource,
usage.workflow_principals.join(",")
),
);
}
}
if !self.includes.is_empty() {
push_line(&mut snapshot, "includes");
for include in &self.includes {
match &include.source_hash {
Some(source_hash) => {
push_line(
&mut snapshot,
format!(" {} hash {}", include.path, source_hash),
);
}
None => push_line(&mut snapshot, format!(" {}", include.path)),
}
}
}
if !self.pattern_applications.is_empty() {
push_line(&mut snapshot, "pattern_applications");
for application in &self.pattern_applications {
let type_args = application
.type_args
.iter()
.map(IrType::to_snapshot)
.collect::<Vec<_>>()
.join(", ");
push_line(
&mut snapshot,
format!(
" {} as {}<{}>",
application.pattern, application.alias, type_args
),
);
push_line(
&mut snapshot,
format!(
" defined-at {}..{}",
application.definition_span.start, application.definition_span.end
),
);
push_line(
&mut snapshot,
format!(
" applied-at {}..{}",
application.application_span.start, application.application_span.end
),
);
for argument in &application.value_args {
push_line(
&mut snapshot,
format!(" arg {} {}", argument.name, argument.value),
);
}
for generated in &application.generated {
push_line(&mut snapshot, format!(" generated {generated}"));
}
}
}
if !self.workflow_contracts.is_empty() {
push_line(&mut snapshot, "workflow_contracts");
for contract in &self.workflow_contracts {
push_line(
&mut snapshot,
format!(
" {} {} {}",
contract.kind.as_str(),
contract.name,
contract.ty.to_snapshot()
),
);
}
}
if !self.uses.is_empty() {
push_line(&mut snapshot, "uses");
for use_decl in &self.uses {
push_line(
&mut snapshot,
format!(" {} {}", use_decl.kind.as_str(), use_decl.name),
);
}
}
if !self.schemas.is_empty() {
push_line(&mut snapshot, "schemas");
for schema in &self.schemas {
match schema {
IrSchema::Enum(enum_decl) => {
push_line(
&mut snapshot,
format!(
" enum {} {{ {} }}",
enum_decl.name,
enum_decl.variants.join(", ")
),
);
}
IrSchema::Class(class_decl) => {
push_line(&mut snapshot, format!(" class {}", class_decl.name));
for field in &class_decl.fields {
let key = if field.is_key { " @key" } else { "" };
push_line(
&mut snapshot,
format!(" {} {}{key}", field.name, field.ty.to_snapshot()),
);
}
}
}
}
}
if !self.harnesses.is_empty() {
push_line(&mut snapshot, "harnesses");
for harness in &self.harnesses {
push_line(
&mut snapshot,
format!(" harness {} kind={}", harness.name, harness.kind),
);
}
}
if !self.trackers.is_empty() {
push_line(&mut snapshot, "trackers");
for queue in &self.trackers {
push_line(
&mut snapshot,
format!(" tracker {} provider={}", queue.name, queue.provider),
);
}
}
if !self.streams.is_empty() {
push_line(&mut snapshot, "streams");
for stream in &self.streams {
push_line(
&mut snapshot,
format!(
" stream {} members=[{}]{}",
stream.name,
stream.members.join(","),
stream
.staleness_seconds
.map(|seconds| format!(" staleness={seconds}s"))
.unwrap_or_default()
),
);
}
}
if !self.channels.is_empty() {
push_line(&mut snapshot, "channels");
for channel in &self.channels {
let mut line = format!(" channel {} provider={}", channel.name, channel.provider);
if let Some(workspace) = &channel.workspace {
line.push_str(&format!(" workspace={workspace}"));
}
if let Some(destination) = &channel.destination {
line.push_str(&format!(" destination={destination:?}"));
}
push_line(&mut snapshot, line);
}
}
if !self.credentials.is_empty() {
push_line(&mut snapshot, "credentials");
for credential in &self.credentials {
push_line(
&mut snapshot,
format!(" credential {} kind={}", credential.name, credential.kind),
);
}
}
if !self.gauges.is_empty() {
push_line(&mut snapshot, "gauges");
for gauge in &self.gauges {
let mut line = format!(
" gauge {} judge={}:{}",
gauge.name, gauge.judge_kind, gauge.judge_target
);
if !gauge.judge_args.is_empty() {
line.push_str(&format!(" args=({})", gauge.judge_args.join(",")));
}
if let Some(site) = &gauge.site {
line.push_str(&format!(" site={site}"));
}
if let Some(bar) = &gauge.expect {
line.push_str(&format!(
" expect={}:{}{}{}",
bar.form, bar.subject, bar.op, bar.threshold
));
}
if !gauge.inputs.is_empty() {
line.push_str(&format!(" inputs={}", gauge.inputs.join(",")));
}
push_line(&mut snapshot, line);
}
}
if !self.marks.is_empty() {
push_line(&mut snapshot, "marks");
for mark in &self.marks {
push_line(
&mut snapshot,
format!(" mark {:?} after {}", mark.name, mark.site),
);
}
}
if !self.campaigns.is_empty() {
push_line(&mut snapshot, "campaigns");
for campaign in &self.campaigns {
let mut line = format!(" campaign {}", campaign.name);
if !campaign.ascend.is_empty() {
line.push_str(&format!(" ascend={}", campaign.ascend.join(",")));
}
for reach in &campaign.reach {
line.push_str(&format!(
" reach={}{}{}{}",
reach.gauge,
reach.op,
reach.threshold,
reach.unit.as_deref().unwrap_or("")
));
}
for guard in &campaign.guard {
line.push_str(&format!(
" guard={}:within:{}%",
guard.gauge, guard.band_percent
));
}
if !campaign.sacrifice.is_empty() {
line.push_str(&format!(" sacrifice={}", campaign.sacrifice.join(",")));
}
if campaign.proposer_redacted {
line.push_str(" proposer=redacted");
}
push_line(&mut snapshot, line);
}
}
if !self.file_stores.is_empty() {
push_line(&mut snapshot, "file_stores");
for file_store in &self.file_stores {
push_line(
&mut snapshot,
format!(
" file store {} root={:?}",
file_store.name, file_store.root
),
);
if !file_store.read_globs.is_empty() {
push_line(
&mut snapshot,
format!(" allow read {:?}", file_store.read_globs),
);
}
if !file_store.write_globs.is_empty() {
push_line(
&mut snapshot,
format!(" allow write {:?}", file_store.write_globs),
);
}
if let Some(provider) = &file_store.provider {
push_line(&mut snapshot, format!(" provider {provider}"));
}
}
}
if !self.memory_pools.is_empty() {
push_line(&mut snapshot, "memory_pools");
for pool in &self.memory_pools {
push_line(&mut snapshot, format!(" memory pool {}", pool.name));
if let Some(limit) = pool.context_limit {
push_line(&mut snapshot, format!(" context limit {limit}"));
}
}
}
if !self.agents.is_empty() {
push_line(&mut snapshot, "agents");
for agent in &self.agents {
let profile = agent.profile.as_deref().unwrap_or("<missing>");
let harness = agent.harness.as_deref().unwrap_or("<fallback>");
let provider = agent.provider.as_deref().unwrap_or("<fallback>");
let capacity = agent
.capacity
.map(|capacity| capacity.to_string())
.unwrap_or_else(|| "<missing>".to_owned());
let skills = if agent.skills.is_empty() {
"[]".to_owned()
} else {
format!("[{}]", agent.skills.join(", "))
};
let capabilities = if agent.capabilities.is_empty() {
"[]".to_owned()
} else {
format!("[{}]", agent.capabilities.join(", "))
};
let tools = if agent.tools.is_empty() {
"[]".to_owned()
} else {
format!("[{}]", agent.tools.join(", "))
};
let requires = if agent.requires.is_empty() {
String::new()
} else {
format!(" requires=[{}]", agent.requires.join(", "))
};
let compaction = agent
.compaction
.as_deref()
.map(|strategy| format!(" compaction={strategy}"))
.unwrap_or_default();
let settings = agent
.settings
.as_deref()
.map(|sources| format!(" settings={sources}"))
.unwrap_or_default();
let thread = agent
.thread
.as_deref()
.map(|mode| format!(" thread={mode}"))
.unwrap_or_default();
let class = match agent.harness_class {
HarnessClass::Delegated => " class=delegated",
HarnessClass::Managed => "",
};
push_line(
&mut snapshot,
format!(
" agent {} harness={} provider={} profile={} capacity={} skills={} capabilities={} tools={}{}{}{}{}{}",
agent.name, harness, provider, profile, capacity, skills, capabilities, tools, requires, compaction, settings, thread, class
),
);
}
}
if !self.coerces.is_empty() {
push_line(&mut snapshot, "coerces");
for coerce in &self.coerces {
let params = coerce
.params
.iter()
.map(|param| format!("{} {}", param.name, param.ty.to_snapshot()))
.collect::<Vec<_>>()
.join(", ");
push_line(
&mut snapshot,
format!(
" coerce {}({}) -> {}",
coerce.name,
params,
coerce.output.to_snapshot()
),
);
}
}
if !self.assertions.is_empty() {
push_line(&mut snapshot, "assertions");
for assertion in &self.assertions {
push_line(
&mut snapshot,
format!(" assert {}", assertion.expr.expr.to_snapshot()),
);
if !assertion.projection_reads.is_empty() {
push_line(&mut snapshot, " reads");
for read in &assertion.projection_reads {
push_line(&mut snapshot, format!(" {}", read.to_snapshot()));
}
}
}
}
if !self.rules.is_empty() {
push_line(&mut snapshot, "rules");
for rule in &self.rules {
push_line(&mut snapshot, format!(" rule {}", rule.name));
for when in &rule.whens {
match &when.guard {
Some(guard) => push_line(
&mut snapshot,
format!(
" when {} where {}",
when.pattern,
guard.expr.to_snapshot()
),
),
None => push_line(&mut snapshot, format!(" when {}", when.pattern)),
}
}
if !rule.metadata.fact_reads.is_empty() {
push_line(&mut snapshot, " reads");
for read in &rule.metadata.fact_reads {
push_line(&mut snapshot, format!(" {}", read));
}
}
if !rule.metadata.projection_reads.is_empty() {
push_line(&mut snapshot, " projection_reads");
for read in &rule.metadata.projection_reads {
push_line(&mut snapshot, format!(" {}", read.to_snapshot()));
}
}
if !rule.metadata.fact_writes.is_empty() {
push_line(&mut snapshot, " writes");
for write in &rule.metadata.fact_writes {
push_line(&mut snapshot, format!(" {}", write));
}
}
if !rule.metadata.record_sources.is_empty() {
push_line(&mut snapshot, " record_sources");
for source in &rule.metadata.record_sources {
push_line(
&mut snapshot,
format!(
" schema:{} construct={} span={}..{}",
source.schema, source.construct, source.span.start, source.span.end
),
);
}
}
if !rule.metadata.fact_consumes.is_empty() {
push_line(&mut snapshot, " consumes");
for consumed in &rule.metadata.fact_consumes {
push_line(&mut snapshot, format!(" {}", consumed));
}
}
if !rule.metadata.effects.is_empty() {
push_line(&mut snapshot, " effects");
for effect in &rule.metadata.effects {
let binding = effect.binding.as_deref().unwrap_or("-");
let construct = effect
.construct_use
.as_ref()
.map(|form| {
format!(" construct={}->{}", form.keyword, form.target_capability)
})
.unwrap_or_default();
let grants = if effect.access_grants.is_empty() {
String::new()
} else {
let rendered = effect
.access_grants
.iter()
.map(|grant| {
let ops = grant
.operations
.iter()
.map(|op| op.operation.as_str())
.collect::<Vec<_>>()
.join(",");
format!("{}[{ops}]", grant.resource)
})
.collect::<Vec<_>>()
.join(";");
format!(" grants={rendered}")
};
let homing = effect
.on_stream
.as_ref()
.map(|stream| format!(" on_stream={stream}"))
.unwrap_or_default();
let skills = if effect.turn_skills.is_empty() {
String::new()
} else {
format!(" skills={}", effect.turn_skills.join(","))
};
push_line(
&mut snapshot,
format!(
" {} kind={} binding={}{} key={}{}{}{}",
effect.id,
effect.kind.as_str(),
binding,
construct,
effect.idempotency_key,
grants,
skills,
homing
),
);
}
}
if !rule.metadata.dependencies.is_empty() {
push_line(&mut snapshot, " dependencies");
for dependency in &rule.metadata.dependencies {
push_line(
&mut snapshot,
format!(
" {} --{}--> {}",
dependency.upstream,
dependency.predicate.as_str(),
dependency.downstream
),
);
}
}
if !rule.metadata.case_branches.is_empty() {
push_line(&mut snapshot, " case_branches");
for branch in &rule.metadata.case_branches {
let guard = branch
.guard
.as_ref()
.map(|guard| guard.expr.to_snapshot())
.unwrap_or_else(|| "-".to_owned());
push_line(
&mut snapshot,
format!(
" case {} type={} pattern={} guard={} body_hash={} span={}..{}",
branch.scrutinee,
branch.scrutinee_type.to_snapshot(),
branch.pattern.to_snapshot(),
guard,
branch.body_hash,
branch.pattern_span.start,
branch.pattern_span.end
),
);
}
}
if !rule.metadata.terminal_outputs.is_empty() {
push_line(&mut snapshot, " terminal_outputs");
for output in &rule.metadata.terminal_outputs {
push_line(
&mut snapshot,
format!(
" {} span={}..{}",
output.binding, output.span.start, output.span.end
),
);
for alternative in &output.alternatives {
push_line(
&mut snapshot,
format!(
" {} payload={} span={}..{}",
alternative.tag,
alternative.payload_type.to_snapshot(),
alternative.source_span.start,
alternative.source_span.end
),
);
}
}
}
if !rule.metadata.terminal_branches.is_empty() {
push_line(&mut snapshot, " terminal_branches");
for branch in &rule.metadata.terminal_branches {
let tag = branch.tag.as_deref().unwrap_or("_");
let binding = branch.binding.as_deref().unwrap_or("-");
let guard = branch
.guard
.as_ref()
.map(|guard| guard.expr.to_snapshot())
.unwrap_or_else(|| "-".to_owned());
push_line(
&mut snapshot,
format!(
" case {} {} binding={} guard={} body_hash={} span={}..{}",
branch.scrutinee,
tag,
binding,
guard,
branch.body_hash,
branch.pattern_span.start,
branch.pattern_span.end
),
);
}
}
push_line(
&mut snapshot,
format!(" body_hash {}", stable_hash(&rule.body)),
);
}
}
if !self.rule_dependencies.is_empty() {
push_line(&mut snapshot, "rule_dependencies");
for dependency in &self.rule_dependencies {
push_line(
&mut snapshot,
format!(
" {} --{}--> {}",
dependency.producer, dependency.fact, dependency.consumer
),
);
}
}
snapshot
}
}
fn register_standard_library(libraries: &mut BTreeMap<String, LibraryRegistration>, id: &str) {
libraries
.entry(id.to_owned())
.or_insert_with(|| LibraryRegistration {
id: id.to_owned(),
version: "0.1.0".to_owned(),
standard: true,
});
}
fn register_effect_contract(
libraries: &mut BTreeMap<String, LibraryRegistration>,
contracts: &mut BTreeMap<(String, String), EffectContract>,
kind: IrEffectKind,
required_capabilities: Vec<String>,
) {
let contract = effect_contract_for_kind(kind, required_capabilities);
register_standard_library(libraries, contract.library_id.as_str());
contracts
.entry((contract.id.clone(), contract.version.clone()))
.and_modify(|existing| {
merge_unique(
&mut existing.required_capabilities,
&contract.required_capabilities,
);
merge_unique(&mut existing.provider_kinds, &contract.provider_kinds);
merge_unique(&mut existing.source_forms, &contract.source_forms);
merge_unique(&mut existing.projected_facts, &contract.projected_facts);
})
.or_insert(contract);
}
fn merge_unique(target: &mut Vec<String>, values: &[String]) {
for value in values {
if !target.contains(value) {
target.push(value.clone());
}
}
target.sort();
}
fn strings(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_owned()).collect()
}
fn effect_contract_for_kind(
kind: IrEffectKind,
required_capabilities: Vec<String>,
) -> EffectContract {
let mut required_capabilities = required_capabilities;
required_capabilities.sort();
required_capabilities.dedup();
let effect_kind = kind.as_str().to_owned();
let (
library_id,
source_forms,
input_schema,
output_schema,
default_capabilities,
provider_kinds,
projected_facts,
validation,
) = match kind {
IrEffectKind::AgentTell => (
"std.agent",
strings(&["tell"]),
Some("agent.turn.request"),
Some("AgentTurn"),
strings(&["agent.turn"]),
strings(&["agent"]),
strings(&["effect.output"]),
TypedOutputValidation::RuntimeBoundary,
),
IrEffectKind::SchemaCoerce => (
"std.coercion",
strings(&["coerce", "decide", "prompt"]),
Some("schema.coerce.input"),
Some("typed-provider-output"),
strings(&["schema.coerce"]),
strings(&["schema_coercer"]),
strings(&["effect.output"]),
TypedOutputValidation::RuntimeBoundary,
),
IrEffectKind::CapabilityCall => (
"std.script",
strings(&["call"]),
Some("capability.call.input"),
Some("capability.call.output"),
Vec::new(),
strings(&["capability"]),
strings(&["effect.output"]),
TypedOutputValidation::RuntimeBoundary,
),
IrEffectKind::EventEmit => (
"std.ingress",
strings(&["emit"]),
Some("event.emit.input"),
None,
Vec::new(),
Vec::new(),
Vec::new(),
TypedOutputValidation::None,
),
IrEffectKind::WorkflowInvoke => (
"std.workflow",
strings(&["invoke"]),
Some("workflow.invoke.input"),
Some("workflow.terminal"),
Vec::new(),
Vec::new(),
strings(&["effect.output"]),
TypedOutputValidation::RuntimeBoundary,
),
IrEffectKind::TimerWait => (
"std.time",
strings(&["timer"]),
Some("timer.wait.input"),
Some("TimerElapsed"),
Vec::new(),
Vec::new(),
strings(&["effect.output"]),
TypedOutputValidation::None,
),
IrEffectKind::ExecCommand => (
"std.script",
strings(&["exec"]),
Some("exec.command.input"),
Some("exec.command.output"),
strings(&["exec.run"]),
strings(&["script", "command"]),
strings(&["effect.output"]),
TypedOutputValidation::RuntimeBoundary,
),
IrEffectKind::TrackerFile => (
"std.tracker",
strings(&["file"]),
Some("tracker.file.input"),
None,
strings(&["tracker.file"]),
Vec::new(),
Vec::new(),
TypedOutputValidation::None,
),
IrEffectKind::TrackerClaim => (
"std.tracker",
strings(&["claim"]),
Some("tracker.claim.input"),
Some("TrackerClaim"),
strings(&["tracker.claim"]),
Vec::new(),
strings(&["effect.output"]),
TypedOutputValidation::None,
),
IrEffectKind::TrackerRenew => (
"std.tracker",
strings(&["renew"]),
Some("tracker.renew.input"),
None,
strings(&["tracker.renew"]),
Vec::new(),
Vec::new(),
TypedOutputValidation::None,
),
IrEffectKind::TrackerRelease => (
"std.tracker",
strings(&["release"]),
Some("tracker.release.input"),
None,
strings(&["tracker.release"]),
Vec::new(),
Vec::new(),
TypedOutputValidation::None,
),
IrEffectKind::TrackerFinish => (
"std.tracker",
strings(&["finish"]),
Some("tracker.finish.input"),
None,
strings(&["tracker.finish"]),
Vec::new(),
Vec::new(),
TypedOutputValidation::None,
),
IrEffectKind::LeaseAcquire => (
"std.coord",
strings(&["acquire"]),
Some("lease.acquire.input"),
Some("LeaseAcquireOutcome"),
Vec::new(),
Vec::new(),
strings(&["effect.output"]),
TypedOutputValidation::None,
),
IrEffectKind::LeaseRenew => (
"std.coord",
strings(&["renew"]),
Some("lease.renew.input"),
Some("LeaseRenewOutcome"),
Vec::new(),
Vec::new(),
strings(&["effect.output"]),
TypedOutputValidation::None,
),
IrEffectKind::LedgerAppend => (
"std.coord",
strings(&["append"]),
Some("ledger.append.input"),
None,
Vec::new(),
Vec::new(),
Vec::new(),
TypedOutputValidation::None,
),
IrEffectKind::CounterConsume => (
"std.coord",
strings(&["consume"]),
Some("counter.consume.input"),
Some("CounterConsumeOutcome"),
Vec::new(),
Vec::new(),
strings(&["effect.output"]),
TypedOutputValidation::None,
),
IrEffectKind::SignalEmit => (
"std.ingress",
strings(&["emit", "signal"]),
Some("signal.emit.input"),
None,
Vec::new(),
Vec::new(),
Vec::new(),
TypedOutputValidation::None,
),
IrEffectKind::FileRead => (
"std.files",
strings(&["read"]),
Some("file.read.input"),
Some("FileReadResult"),
strings(&["file.read"]),
Vec::new(),
strings(&["effect.output"]),
TypedOutputValidation::RuntimeBoundary,
),
IrEffectKind::FileWrite => (
"std.files",
strings(&["write"]),
Some("file.write.input"),
Some("FileWriteResult"),
strings(&["file.write"]),
Vec::new(),
strings(&["effect.output"]),
TypedOutputValidation::RuntimeBoundary,
),
IrEffectKind::FileImport => (
"std.files",
strings(&["import"]),
Some("file.import.input"),
Some("FileImportResult"),
strings(&["file.import"]),
Vec::new(),
strings(&["effect.output"]),
TypedOutputValidation::RuntimeBoundary,
),
IrEffectKind::FileExport => (
"std.files",
strings(&["export"]),
Some("file.export.input"),
Some("FileExportResult"),
strings(&["file.export"]),
Vec::new(),
strings(&["effect.output"]),
TypedOutputValidation::RuntimeBoundary,
),
};
merge_unique(&mut required_capabilities, &default_capabilities);
EffectContract {
id: effect_kind.clone(),
library_id: library_id.to_owned(),
version: "0.1.0".to_owned(),
effect_kind,
source_forms,
input_schema: input_schema.map(str::to_owned),
output_schema: output_schema.map(str::to_owned),
required_capabilities,
provider_kinds,
projected_facts,
validation,
}
}
impl IrEffectKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::AgentTell => "agent.tell",
Self::SchemaCoerce => "schema.coerce",
Self::CapabilityCall => "capability.call",
Self::EventEmit => "event.emit",
Self::WorkflowInvoke => "workflow.invoke",
Self::TimerWait => "timer.wait",
Self::ExecCommand => "exec.command",
Self::TrackerFile => "tracker.file",
Self::TrackerClaim => "tracker.claim",
Self::TrackerRenew => "tracker.renew",
Self::TrackerRelease => "tracker.release",
Self::TrackerFinish => "tracker.finish",
Self::LeaseAcquire => "lease.acquire",
Self::LeaseRenew => "lease.renew",
Self::LedgerAppend => "ledger.append",
Self::CounterConsume => "counter.consume",
Self::SignalEmit => "signal.emit",
Self::FileRead => "file.read",
Self::FileWrite => "file.write",
Self::FileImport => "file.import",
Self::FileExport => "file.export",
}
}
}
impl DependencyPredicate {
fn as_str(&self) -> &'static str {
match self {
Self::Succeeds => "succeeds",
Self::Fails => "fails",
Self::TimedOut => "timed_out",
Self::Cancelled => "cancelled",
Self::Completes => "completes",
}
}
}
impl IrUseKind {
fn as_str(&self) -> &'static str {
match self {
Self::Package => "package",
}
}
}
impl IrType {
pub fn display_label(&self) -> String {
self.to_snapshot()
}
fn to_snapshot(&self) -> String {
match self {
Self::Primitive(primitive) => primitive.as_str().to_owned(),
Self::LiteralString(value) => format!("literal<{value:?}>"),
Self::Ref(name) => format!("ref<{name}>"),
Self::AgentRef(agents) => format!("agentref<{}>", agents.join(" | ")),
Self::Object(fields) => {
let fields = fields
.iter()
.map(|field| format!("{} {}", field.name, field.ty.to_snapshot()))
.collect::<Vec<_>>()
.join(", ");
format!("object<{{{fields}}}>")
}
Self::Optional(inner) => format!("optional<{}>", inner.to_snapshot()),
Self::Array(inner) => format!("array<{}>", inner.to_snapshot()),
Self::Map(inner) => format!("map<{}>", inner.to_snapshot()),
Self::Union(variants) => {
let variants = variants
.iter()
.map(Self::to_snapshot)
.collect::<Vec<_>>()
.join(" | ");
format!("union<{variants}>")
}
}
}
}
impl IrPrimitiveType {
fn as_str(&self) -> &'static str {
match self {
Self::String => "string",
Self::Int => "int",
Self::Float => "float",
Self::Bool => "bool",
Self::Null => "null",
Self::Duration => "duration",
Self::Time => "time",
Self::Image => "image",
Self::Audio => "audio",
Self::Pdf => "pdf",
Self::Video => "video",
Self::Secret => "secret",
}
}
}
fn validate_turn_access_grant_file_operations(ir: &IrProgram, diagnostics: &mut Vec<Diagnostic>) {
const FILE_OPERATIONS: [&str; 4] = ["read", "write", "import", "export"];
let file_stores: BTreeSet<&str> = ir
.file_stores
.iter()
.map(|store| store.name.as_str())
.collect();
for rule in &ir.rules {
for effect in &rule.metadata.effects {
for grant in &effect.access_grants {
if !file_stores.contains(grant.resource.as_str()) {
continue;
}
for op in &grant.operations {
if !FILE_OPERATIONS.contains(&op.operation.as_str()) {
diagnostics.push(Diagnostic { related: Vec::new(),
span: effect.span,
message: format!(
"rule `{}` grants `{}` on file store `{}`, which is not a file operation",
rule.name, op.operation, grant.resource
),
suggestion: Some(
"file-store grants allow `read`, `write`, `import`, or `export`"
.to_owned(),
),
});
}
}
}
}
}
}
fn validate_turn_access_grant_memory_operations(ir: &IrProgram, diagnostics: &mut Vec<Diagnostic>) {
const MEMORY_OPERATIONS: [&str; 3] = ["recall", "learn", "curate"];
let memory_pools: BTreeSet<&str> = ir
.memory_pools
.iter()
.map(|pool| pool.name.as_str())
.collect();
for rule in &ir.rules {
for effect in &rule.metadata.effects {
for grant in &effect.access_grants {
if !memory_pools.contains(grant.resource.as_str()) {
continue;
}
for op in &grant.operations {
if !MEMORY_OPERATIONS.contains(&op.operation.as_str()) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: effect.span,
message: format!(
"rule `{}` grants `{}` on memory pool `{}`, which is not a memory operation",
rule.name, op.operation, grant.resource
),
suggestion: Some(
"memory-pool grants allow `recall`, `learn`, or `curate`".to_owned(),
),
});
}
}
}
}
}
}
fn validate_file_store_write_policy(ir: &IrProgram, diagnostics: &mut Vec<Diagnostic>) {
let read_only: BTreeSet<&str> = ir
.file_stores
.iter()
.filter(|store| store.write_globs.is_empty())
.map(|store| store.name.as_str())
.collect();
if read_only.is_empty() {
return;
}
fn walk(
statements: &[body::BodyStmt],
rule_name: &str,
read_only: &BTreeSet<&str>,
diagnostics: &mut Vec<Diagnostic>,
) {
for statement in statements {
match statement {
body::BodyStmt::Effect(effect) => {
let store = match &effect.kind {
body::BodyEffectKind::FileWrite { store, .. }
| body::BodyEffectKind::FileExport { store, .. } => Some(store),
_ => None,
};
if let Some(store) = store {
if read_only.contains(store.as_str()) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: effect.span,
message: format!(
"rule `{rule_name}` writes to store `{store}`, which permits \
no writes — stores are read-only by default"
),
suggestion: Some(format!(
"declare `allow write [\"<glob>\", …]` on `file store {store}` \
to permit (and bound) writes"
)),
});
}
}
}
body::BodyStmt::After(after) => {
walk(&after.body, rule_name, read_only, diagnostics)
}
body::BodyStmt::Case(case) => {
for branch in &case.branches {
walk(&branch.body, rule_name, read_only, diagnostics);
}
}
_ => {}
}
}
}
for rule in &ir.rules {
let (ast, _) = body::parse_rule_body(&rule.body, 0);
walk(&ast.statements, &rule.name, &read_only, diagnostics);
}
}
fn expand_source_emit_from(ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
let events: BTreeMap<String, Vec<String>> = ir
.events
.iter()
.map(|event| {
(
event.name.clone(),
event
.fields
.iter()
.map(|field| field.name.clone())
.collect(),
)
})
.collect();
for source in &mut ir.sources {
let Some(from) = source.emit_from.clone() else {
continue;
};
if from != source.observe_binding {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: source.span,
message: format!(
"source `{}` emits `from {from}`, but the only binding in scope is the observe binding `{}`",
source.name, source.observe_binding
),
suggestion: Some(format!("write `emit {} from {}`", source.emit_signal, source.observe_binding)),
});
continue;
}
let Some(signal_fields) = events.get(&source.emit_signal) else {
continue;
};
for field in signal_fields {
if source
.emit_fields
.iter()
.any(|existing| &existing.name == field)
{
continue;
}
source.emit_fields.push(IrSourceEmitField {
name: field.clone(),
value: SourceValue::Path {
binding: Ident {
name: from.clone(),
span: source.span,
},
segments: vec![Ident {
name: field.clone(),
span: source.span,
}],
span: source.span,
},
span: source.span,
});
}
}
}
fn warn_unhandled_effect_failures(ir: &IrProgram, warnings: &mut Vec<Diagnostic>) {
let service = ir
.source_tags
.iter()
.any(|tag| tag.target_kind == "workflow" && tag.name == "service");
if service {
return;
}
for rule in &ir.rules {
for effect in &rule.metadata.effects {
let Some(binding) = effect.binding.as_deref() else {
continue;
};
if effect.kind == IrEffectKind::TimerWait {
continue;
}
if binding.starts_with(then_expand::THEN_BINDING_PREFIX) {
continue;
}
let observed = rule.body.lines().any(|line| {
let Some(rest) = line.trim().strip_prefix("after ") else {
return false;
};
let mut parts = rest.split_whitespace();
if parts.next() != Some(binding) {
return false;
}
matches!(
parts.next().map(|token| token.trim_end_matches('{')),
Some(
"fails"
| "times"
| "completes"
| "held"
| "contended"
| "ok"
| "over"
| "promoted"
| "conflicted"
)
)
});
if observed {
continue;
}
warnings.push(Diagnostic {
related: Vec::new(),
span: effect.span,
message: format!(
"effect `{binding}`'s failure is unhandled in rule `{}`; if it fails or \
times out, the instance will auto-fail with a generic reason",
rule.name
),
suggestion: Some(format!(
"handle it with `after {binding} fails {{ … }}` (typed failure or recovery) \
or observe every outcome with `after {binding} completes`"
)),
});
}
}
}
fn warn_counter_without_timezone(ir: &IrProgram, warnings: &mut Vec<Diagnostic>) {
for counter in &ir.counters {
if counter.timezone.is_none() {
warnings.push(Diagnostic {
related: Vec::new(),
span: counter.span,
message: format!(
"counter `{}` declares no `timezone`; its `{}` reset boundary anchors to UTC",
counter.name, counter.reset
),
suggestion: Some(
"declare `timezone \"<IANA zone>\"` (e.g. `timezone \"America/New_York\"`) to anchor the period locally"
.to_owned(),
),
});
}
}
}
fn warn_inert_memory_grant_on_native_adapter(ir: &IrProgram, warnings: &mut Vec<Diagnostic>) {
let memory_pools: BTreeSet<&str> = ir
.memory_pools
.iter()
.map(|pool| pool.name.as_str())
.collect();
if memory_pools.is_empty() {
return;
}
let harness_kind_of: BTreeMap<&str, &str> = ir
.harnesses
.iter()
.map(|harness| (harness.name.as_str(), harness.kind.as_str()))
.collect();
let agent_harness_kind: BTreeMap<&str, &str> = ir
.agents
.iter()
.filter_map(|agent| {
let harness = agent.harness.as_deref()?;
Some((agent.name.as_str(), *harness_kind_of.get(harness)?))
})
.collect();
for rule in &ir.rules {
for effect in &rule.metadata.effects {
let Some(agent) = effect.agent.as_deref() else {
continue;
};
let Some(kind) = agent_harness_kind.get(agent) else {
continue;
};
if !matches!(*kind, "codex" | "claude" | "command") {
continue;
}
for grant in &effect.access_grants {
if memory_pools.contains(grant.resource.as_str()) {
warnings.push(Diagnostic {
related: Vec::new(),
span: effect.span,
message: format!(
"rule `{}` grants memory pool `{}` on a tell to `{agent}`, whose \
harness kind `{kind}` is a native adapter — memory grants only \
take effect on the owned harness, so this grant is inert",
rule.name, grant.resource
),
suggestion: Some(
"target an owned-harness agent, or drop the memory grant".to_owned(),
),
});
}
}
}
}
}
fn detect_pattern_recursion(
patterns: &BTreeMap<String, PatternDecl>,
diagnostics: &mut Vec<Diagnostic>,
) -> BTreeSet<String> {
let mut edges: BTreeMap<&str, Vec<(&str, SourceSpan)>> = BTreeMap::new();
for pattern in patterns.values() {
let mut applied = Vec::new();
for item in &pattern.items {
if let Item::Apply(apply) = item {
applied.push((apply.pattern.name.as_str(), apply.span));
}
}
edges.insert(pattern.name.name.as_str(), applied);
}
let find_cycle = |start: &str| -> Option<(Vec<String>, SourceSpan)> {
let mut queue: VecDeque<&str> = VecDeque::new();
let mut predecessor: BTreeMap<&str, (&str, SourceSpan)> = BTreeMap::new();
for &(target, span) in edges.get(start).into_iter().flatten() {
if target == start {
return Some((vec![start.to_owned(), start.to_owned()], span));
}
if predecessor.insert(target, (start, span)).is_none() {
queue.push_back(target);
}
}
while let Some(node) = queue.pop_front() {
for &(target, span) in edges.get(node).into_iter().flatten() {
if target == start {
let mut path = vec![node.to_owned()];
let mut cursor = node;
while cursor != start {
let (from, _) = predecessor[cursor];
path.push(from.to_owned());
cursor = from;
}
path.reverse();
path.push(start.to_owned());
let first = &path[1];
let entry_span = edges
.get(start)
.into_iter()
.flatten()
.find(|(target, _)| target == first)
.map(|(_, span)| *span)
.unwrap_or(span);
return Some((path, entry_span));
}
if predecessor.insert(target, (node, span)).is_none() {
queue.push_back(target);
}
}
}
None
};
let mut recursive = BTreeSet::new();
for name in patterns.keys() {
if recursive.contains(name) {
continue;
}
if let Some((cycle, span)) = find_cycle(name) {
for member in &cycle {
recursive.insert(member.clone());
}
diagnostics.push(Diagnostic { related: Vec::new(),
span,
message: format!(
"recursive pattern application is not allowed (graph.unbounded_pattern_recursion): expansion cycle {}",
cycle.join(" -> ")
),
suggestion: Some(
"break the cycle: pattern expansion must elaborate into a finite program"
.to_owned(),
),
});
}
}
recursive
}
fn detect_workflow_invoke_recursion(program: &Program, diagnostics: &mut Vec<Diagnostic>) {
let mut edges: BTreeMap<String, Vec<(String, SourceSpan)>> = BTreeMap::new();
let record_invokes =
|name: &str, items: &[Item], edges: &mut BTreeMap<String, Vec<(String, SourceSpan)>>| {
let entry = edges.entry(name.to_owned()).or_default();
for item in items {
let Item::Rule(rule) = item else {
continue;
};
for statement in workflow_invoke_statements(&rule.body.text) {
if let Some((target, _)) = invoke_statement_parts(&statement) {
if target != name {
entry.push((target.to_owned(), rule.body.span));
}
}
}
}
};
if let Some(root) = &program.workflow {
record_invokes(&root.name, &program.items, &mut edges);
}
for workflow in &program.workflows {
record_invokes(&workflow.name.name, &workflow.items, &mut edges);
}
let find_cycle = |start: &str| -> Option<(Vec<String>, SourceSpan)> {
let mut queue: VecDeque<&str> = VecDeque::new();
let mut predecessor: BTreeMap<&str, (&str, SourceSpan)> = BTreeMap::new();
for (target, span) in edges.get(start).into_iter().flatten() {
if predecessor
.insert(target.as_str(), (start, *span))
.is_none()
{
queue.push_back(target.as_str());
}
}
while let Some(node) = queue.pop_front() {
for (target, span) in edges.get(node).into_iter().flatten() {
if target == start {
let mut path = vec![node.to_owned()];
let mut cursor = node;
while cursor != start {
let (from, _) = predecessor[cursor];
path.push(from.to_owned());
cursor = from;
}
path.reverse();
path.push(start.to_owned());
let first = &path[1];
let entry_span = edges
.get(start)
.into_iter()
.flatten()
.find(|(target, _)| target == first)
.map(|(_, span)| *span)
.unwrap_or(*span);
return Some((path, entry_span));
}
if predecessor.insert(target.as_str(), (node, *span)).is_none() {
queue.push_back(target.as_str());
}
}
}
None
};
let mut flagged: BTreeSet<String> = BTreeSet::new();
for name in edges.keys() {
if flagged.contains(name) {
continue;
}
if let Some((cycle, span)) = find_cycle(name) {
for member in &cycle {
flagged.insert(member.clone());
}
diagnostics.push(Diagnostic {
related: Vec::new(),
span,
message: format!(
"recursive workflow invocation is not allowed (graph.unbounded_workflow_invocation_recursion): invocation cycle {}",
cycle.join(" -> ")
),
suggestion: Some(
"break the cycle: a runtime `invoke` cycle has no compile-time convergence proof; route the recurrence through an external event, clock, or durable boundary instead"
.to_owned(),
),
});
}
}
}
fn detect_private_workflow_invocations(program: &Program, diagnostics: &mut Vec<Diagnostic>) {
let private_workflows = program
.workflows
.iter()
.filter(|workflow| workflow.tags.iter().any(|tag| tag.name == "private"))
.map(|workflow| workflow.name.name.as_str())
.collect::<BTreeSet<_>>();
if private_workflows.is_empty() {
return;
}
let mut record_private_invokes = |caller: &str, items: &[Item]| {
for item in items {
let Item::Rule(rule) = item else {
continue;
};
for statement in workflow_invoke_statements(&rule.body.text) {
let Some((target, _)) = invoke_statement_parts(&statement) else {
continue;
};
if caller == target || !private_workflows.contains(target) {
continue;
}
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!(
"rule `{}` invokes private workflow `{target}`",
rule.name.name
),
suggestion: Some(
"remove `@private` from the target workflow or expose a public wrapper workflow"
.to_owned(),
),
});
}
}
};
if let Some(root) = &program.workflow {
record_private_invokes(&root.name, &program.items);
}
for workflow in &program.workflows {
record_private_invokes(&workflow.name.name, &workflow.items);
}
}
fn expand_pattern_applications(
mut program: Program,
diagnostics: &mut Vec<Diagnostic>,
) -> (Program, Vec<IrPatternApplication>) {
let mut patterns = BTreeMap::new();
for pattern in &program.patterns {
if patterns
.insert(pattern.name.name.clone(), pattern.clone())
.is_some()
{
diagnostics.push(Diagnostic {
related: Vec::new(),
span: pattern.name.span,
message: format!("pattern `{}` is declared more than once", pattern.name.name),
suggestion: Some("rename one pattern declaration".to_owned()),
});
}
}
let recursive_patterns = detect_pattern_recursion(&patterns, diagnostics);
let mut expanded_items = Vec::new();
let mut applications = Vec::new();
for item in program.items {
let Item::Apply(apply) = item else {
expanded_items.push(item);
continue;
};
let Some(pattern) = patterns.get(&apply.pattern.name) else {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: apply.pattern.span,
message: format!("pattern `{}` was not found", apply.pattern.name),
suggestion: Some("declare the pattern before applying it".to_owned()),
});
continue;
};
if pattern.type_params.len() != apply.type_args.len() {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: apply.span,
message: format!(
"pattern `{}` expects {} type arguments but got {}",
pattern.name.name,
pattern.type_params.len(),
apply.type_args.len()
),
suggestion: Some("match the pattern type parameter list".to_owned()),
});
continue;
}
let type_substitutions = pattern
.type_params
.iter()
.map(|param| param.name.clone())
.zip(apply.type_args.iter().cloned())
.collect::<BTreeMap<_, _>>();
let value_substitutions = parse_pattern_value_arguments(&apply, diagnostics);
let local_names = pattern_local_names(pattern, &apply.alias.name);
let definition_span = pattern.span;
let application_span = apply.span;
let mut generated = Vec::new();
for pattern_item in pattern.items.iter().cloned() {
if let Some(diagnostic) = pattern_body_admission(&pattern_item, &recursive_patterns) {
diagnostics.push(diagnostic);
continue;
}
if let Some((generated_name, item)) = expand_pattern_item(
pattern_item,
&apply.alias.name,
&type_substitutions,
&value_substitutions,
&local_names,
) {
generated.push(generated_name);
expanded_items.push(item);
}
}
applications.push(IrPatternApplication {
pattern: pattern.name.name.clone(),
alias: apply.alias.name,
type_args: apply.type_args.into_iter().map(lower_type).collect(),
value_args: value_substitutions
.into_iter()
.map(|(name, value)| IrPatternArgument { name, value })
.collect(),
generated,
definition_span,
application_span,
});
}
program.items = expanded_items;
(program, applications)
}
fn pattern_local_names(pattern: &PatternDecl, alias: &str) -> BTreeMap<String, String> {
let mut names = BTreeMap::new();
for item in &pattern.items {
match item {
Item::Harness(harness) => {
names.insert(
harness.name.name.clone(),
generated_pattern_name(alias, &harness.name.name),
);
}
Item::Agent(agent) => {
names.insert(
agent.name.name.clone(),
generated_pattern_name(alias, &agent.name.name),
);
}
Item::Enum(enum_decl) => {
names.insert(
enum_decl.name.name.clone(),
generated_pattern_name(alias, &enum_decl.name.name),
);
}
Item::Class(class_decl) => {
names.insert(
class_decl.name.name.clone(),
generated_pattern_name(alias, &class_decl.name.name),
);
}
Item::Coerce(coerce) => {
names.insert(
coerce.name.name.clone(),
generated_pattern_name(alias, &coerce.name.name),
);
}
Item::Rule(rule) => {
names.insert(
rule.name.name.clone(),
generated_pattern_name(alias, &rule.name.name),
);
}
_ => {}
}
}
names
}
fn generated_pattern_name(alias: &str, name: &str) -> String {
format!("{alias}_{name}")
}
fn parse_pattern_value_arguments(
apply: &ApplyDecl,
diagnostics: &mut Vec<Diagnostic>,
) -> BTreeMap<String, String> {
let mut args = BTreeMap::new();
for line in apply
.body
.text
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
{
let mut parts = line.splitn(2, char::is_whitespace);
let Some(name) = parts.next().filter(|name| is_identifier(name)) else {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: apply.body.span,
message: format!(
"pattern application `{}` has malformed argument `{line}`",
apply.alias.name
),
suggestion: Some("write pattern arguments as `name value`".to_owned()),
});
continue;
};
let Some(value) = parts
.next()
.map(str::trim)
.filter(|value| !value.is_empty())
else {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: apply.body.span,
message: format!(
"pattern application `{}` argument `{name}` is missing a value",
apply.alias.name
),
suggestion: Some("write pattern arguments as `name value`".to_owned()),
});
continue;
};
if args.insert(name.to_owned(), value.to_owned()).is_some() {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: apply.body.span,
message: format!(
"pattern application `{}` passes argument `{name}` more than once",
apply.alias.name
),
suggestion: Some("remove the duplicate pattern argument".to_owned()),
});
}
}
args
}
fn pattern_body_admission(
item: &Item,
recursive_patterns: &BTreeSet<String>,
) -> Option<Diagnostic> {
match item {
Item::WorkflowContract(contract) => Some(Diagnostic {
related: Vec::new(),
span: contract.span,
message: "workflow contracts are not allowed in pattern bodies".to_owned(),
suggestion: Some(
"declare workflow inputs, outputs, and failures on the workflow".to_owned(),
),
}),
Item::Pattern(pattern) => Some(Diagnostic {
related: Vec::new(),
span: pattern.span,
message: "nested pattern declarations are not supported in pattern bodies".to_owned(),
suggestion: Some("declare reusable patterns at source top level".to_owned()),
}),
Item::Apply(apply) if !recursive_patterns.contains(&apply.pattern.name) => Some(Diagnostic {
related: Vec::new(),
span: apply.span,
message: "pattern applications inside pattern bodies are not supported yet".to_owned(),
suggestion: Some(
"apply patterns from workflow bodies only in this implementation slice".to_owned(),
),
}),
Item::Gauge(gauge) => Some(Diagnostic {
related: Vec::new(),
span: gauge.span,
message: "gauge declarations are not allowed in pattern bodies".to_owned(),
suggestion: Some("declare gauges at source top level".to_owned()),
}),
Item::Campaign(campaign) => Some(Diagnostic {
related: Vec::new(),
span: campaign.span,
message: "campaign declarations are not allowed in pattern bodies".to_owned(),
suggestion: Some("declare campaigns at source top level".to_owned()),
}),
Item::Mark(mark) => Some(Diagnostic {
related: Vec::new(),
span: mark.span,
message: "mark declarations are not allowed in pattern bodies".to_owned(),
suggestion: Some("declare marks at source top level".to_owned()),
}),
Item::Rule(rule) => pattern_rule_terminal_span(rule).map(|span| Diagnostic {
related: Vec::new(),
span,
message: format!(
"rule `{}` in a pattern body cannot reach a workflow terminal (`complete`/`fail`)",
rule.name.name
),
suggestion: Some(
"record a fact in the pattern rule and let a workflow rule decide the terminal outcome"
.to_owned(),
),
}),
_ => None,
}
}
fn pattern_rule_terminal_span(rule: &RuleDecl) -> Option<SourceSpan> {
let mut offset = 0usize;
for line in rule.body.text.split_inclusive('\n') {
let trimmed_start = line.trim_start();
let leading = line.len() - trimmed_start.len();
let statement = trimmed_start.trim_end();
if is_pattern_terminal_statement(statement) {
let start = rule.body.span.start + offset + leading;
return Some(SourceSpan {
start,
end: start + statement.len(),
});
}
offset += line.len();
}
None
}
fn is_pattern_terminal_statement(line: &str) -> bool {
for keyword in ["complete", "fail"] {
if let Some(rest) = line.strip_prefix(keyword) {
if rest.is_empty() || rest.starts_with('{') || rest.starts_with(char::is_whitespace) {
return true;
}
}
}
false
}
fn expand_pattern_item(
item: Item,
alias: &str,
type_substitutions: &BTreeMap<String, TypeSyntax>,
value_substitutions: &BTreeMap<String, String>,
local_names: &BTreeMap<String, String>,
) -> Option<(String, Item)> {
match item {
Item::Include(include) => Some((
format!("include:{}", include.path.value),
Item::Include(include),
)),
Item::Use(use_decl) => Some((format!("use:{}", use_decl.name.value), Item::Use(use_decl))),
Item::Tracker(queue) => {
Some((format!("tracker:{}", queue.name.name), Item::Tracker(queue)))
}
Item::Stream(stream) => {
Some((format!("stream:{}", stream.name.name), Item::Stream(stream)))
}
Item::Channel(channel) => Some((
format!("channel:{}", channel.name.name),
Item::Channel(channel),
)),
Item::Credential(credential) => Some((
format!("credential:{}", credential.name.name),
Item::Credential(credential),
)),
Item::Gauge(_) | Item::Campaign(_) | Item::Mark(_) => None,
Item::FileStore(file_store) => Some((
format!("file-store:{}", file_store.name.name),
Item::FileStore(file_store),
)),
Item::MemoryPool(pool) => Some((
format!("memory-pool:{}", pool.name.name),
Item::MemoryPool(pool),
)),
Item::Event(event) => Some((format!("event:{}", event.name), Item::Event(event))),
Item::Source(source) => {
Some((format!("source:{}", source.name.name), Item::Source(source)))
}
Item::Test(test) => Some((format!("test:{}", test.name.value), Item::Test(test))),
Item::Lease(lease) => Some((format!("lease:{}", lease.name.name), Item::Lease(lease))),
Item::Ledger(ledger) => {
Some((format!("ledger:{}", ledger.name.name), Item::Ledger(ledger)))
}
Item::Counter(counter) => Some((
format!("counter:{}", counter.name.name),
Item::Counter(counter),
)),
Item::Action(action) => {
Some((format!("action:{}", action.name.name), Item::Action(action)))
}
Item::Harness(mut harness) => {
let name = rename_ident(harness.name, alias, local_names);
let generated = format!("harness:{}", name.name);
harness.name = name;
Some((generated, Item::Harness(harness)))
}
Item::WorkflowContract(_) | Item::Pattern(_) | Item::Apply(_) => None,
Item::Agent(mut agent) => {
let name = rename_ident(agent.name, alias, local_names);
let generated = format!("agent:{}", name.name);
agent.name = name;
if let Some(harness) = agent.harness {
agent.harness = Some(Ident {
name: local_names
.get(&harness.name)
.cloned()
.unwrap_or(harness.name),
span: harness.span,
});
}
Some((generated, Item::Agent(agent)))
}
Item::Enum(mut enum_decl) => {
let name = rename_ident(enum_decl.name, alias, local_names);
let generated = format!("enum:{}", name.name);
enum_decl.name = name;
Some((generated, Item::Enum(enum_decl)))
}
Item::Class(mut class_decl) => {
let name = rename_ident(class_decl.name, alias, local_names);
let generated = format!("class:{}", name.name);
class_decl.name = name;
for field in &mut class_decl.fields {
field.ty =
substitute_pattern_type(field.ty.clone(), type_substitutions, local_names);
}
Some((generated, Item::Class(class_decl)))
}
Item::Table(mut table) => {
let name = rename_ident(table.name, alias, local_names);
let generated = format!("table:{}", name.name);
table.name = name;
for row in &mut table.rows {
row.body.text = substitute_pattern_text(
&row.body.text,
type_substitutions,
value_substitutions,
local_names,
);
}
Some((generated, Item::Table(table)))
}
Item::Coerce(mut coerce) => {
let name = rename_ident(coerce.name, alias, local_names);
let generated = format!("coerce:{}", name.name);
coerce.name = name;
for param in &mut coerce.params {
param.ty =
substitute_pattern_type(param.ty.clone(), type_substitutions, local_names);
}
coerce.output =
substitute_pattern_type(coerce.output.clone(), type_substitutions, local_names);
coerce.body.text = substitute_pattern_text(
&coerce.body.text,
type_substitutions,
value_substitutions,
local_names,
);
Some((generated, Item::Coerce(coerce)))
}
Item::Assert(mut assertion) => {
assertion.expr = substitute_pattern_text(
&assertion.expr,
type_substitutions,
value_substitutions,
local_names,
);
Some((format!("assert:{alias}"), Item::Assert(assertion)))
}
Item::Rule(mut rule) => {
let name = rename_ident(rule.name, alias, local_names);
let generated = format!("rule:{}", name.name);
rule.name = name;
for when in &mut rule.whens {
when.text = substitute_pattern_text(
&when.text,
type_substitutions,
value_substitutions,
local_names,
);
}
rule.body.text = substitute_pattern_text(
&rule.body.text,
type_substitutions,
value_substitutions,
local_names,
);
Some((generated, Item::Rule(rule)))
}
}
}
fn rename_ident(ident: Ident, alias: &str, local_names: &BTreeMap<String, String>) -> Ident {
Ident {
name: local_names
.get(&ident.name)
.cloned()
.unwrap_or_else(|| generated_pattern_name(alias, &ident.name)),
span: ident.span,
}
}
fn substitute_pattern_type(
ty: TypeSyntax,
type_substitutions: &BTreeMap<String, TypeSyntax>,
local_names: &BTreeMap<String, String>,
) -> TypeSyntax {
match ty {
TypeSyntax::Ref { name } => {
if let Some(replacement) = type_substitutions.get(&name.name) {
return replacement.clone();
}
TypeSyntax::Ref {
name: Ident {
name: local_names.get(&name.name).cloned().unwrap_or(name.name),
span: name.span,
},
}
}
TypeSyntax::AgentRef { agents, span } => TypeSyntax::AgentRef {
agents: agents
.into_iter()
.map(|agent| Ident {
name: local_names.get(&agent.name).cloned().unwrap_or(agent.name),
span: agent.span,
})
.collect(),
span,
},
TypeSyntax::Optional { inner, span } => TypeSyntax::Optional {
inner: Box::new(substitute_pattern_type(
*inner,
type_substitutions,
local_names,
)),
span,
},
TypeSyntax::Array { inner, span } => TypeSyntax::Array {
inner: Box::new(substitute_pattern_type(
*inner,
type_substitutions,
local_names,
)),
span,
},
TypeSyntax::Map { inner, span } => TypeSyntax::Map {
inner: Box::new(substitute_pattern_type(
*inner,
type_substitutions,
local_names,
)),
span,
},
TypeSyntax::Union { variants, span } => TypeSyntax::Union {
variants: variants
.into_iter()
.map(|variant| substitute_pattern_type(variant, type_substitutions, local_names))
.collect(),
span,
},
other => other,
}
}
fn substitute_pattern_text(
text: &str,
type_substitutions: &BTreeMap<String, TypeSyntax>,
value_substitutions: &BTreeMap<String, String>,
local_names: &BTreeMap<String, String>,
) -> String {
let mut output = String::with_capacity(text.len());
let mut rest = text;
while let Some(start) = rest.find(is_identifier_char) {
output.push_str(&rest[..start]);
let after = &rest[start..];
let end = after
.find(|ch| !is_identifier_char(ch))
.unwrap_or(after.len());
let token = &after[..end];
match resolve_pattern_token(token, type_substitutions, value_substitutions, local_names) {
Some(replacement) => output.push_str(&replacement),
None => output.push_str(token),
}
rest = &after[end..];
}
output.push_str(rest);
output
}
fn resolve_pattern_token(
token: &str,
type_substitutions: &BTreeMap<String, TypeSyntax>,
value_substitutions: &BTreeMap<String, String>,
local_names: &BTreeMap<String, String>,
) -> Option<String> {
if let Some(ty) = type_substitutions.get(token) {
return Some(ty.to_source());
}
if let Some(local) = local_names.get(token) {
return Some(local.clone());
}
value_substitutions.get(token).cloned()
}
fn is_identifier_char(ch: char) -> bool {
ch.is_ascii_alphanumeric() || ch == '_' || ch == '-'
}
fn collect_projection_reads(expr: &Expr) -> Vec<IrProjectionRead> {
let mut reads = Vec::new();
collect_projection_reads_into(expr, &mut reads);
reads
}
fn collect_projection_reads_into(expr: &Expr, reads: &mut Vec<IrProjectionRead>) {
match expr {
Expr::Literal(_) | Expr::Path(_) => {}
Expr::Index { target, key } => {
collect_projection_reads_into(target, reads);
collect_projection_reads_into(key, reads);
}
Expr::Array(items) => {
for item in items {
collect_projection_reads_into(item, reads);
}
}
Expr::Object(fields) => {
for field in fields {
collect_projection_reads_into(&field.value, reads);
}
}
Expr::Unary { expr, .. } => collect_projection_reads_into(expr, reads),
Expr::Binary { left, right, .. } => {
collect_projection_reads_into(left, reads);
collect_projection_reads_into(right, reads);
}
Expr::Call { args, .. } => {
for arg in args {
collect_projection_reads_into(arg, reads);
}
}
Expr::Query { kind, head, guard } => {
reads.push(IrProjectionRead {
kind: *kind,
head: head.clone(),
guard: guard.as_ref().map(|guard| guard.to_snapshot()),
});
if let Some(guard) = guard {
collect_projection_reads_into(guard, reads);
}
}
}
}
fn sort_projection_reads(reads: &mut Vec<IrProjectionRead>) {
reads.sort_by_key(IrProjectionRead::to_snapshot);
reads.dedup();
}
fn collect_schema_names(program: &Program, diagnostics: &mut Vec<Diagnostic>) -> BTreeSet<String> {
let mut names = BTreeSet::new();
let mut first_spans: BTreeMap<String, SourceSpan> = BTreeMap::new();
for item in &program.items {
let name = match item {
Item::Enum(enum_decl) => &enum_decl.name,
Item::Class(class_decl) => &class_decl.name,
_ => continue,
};
if !names.insert(name.name.clone()) {
let mut diagnostic = Diagnostic {
related: Vec::new(),
span: name.span,
message: format!("schema `{}` is declared more than once", name.name),
suggestion: Some("rename one declaration or merge the schemas".to_owned()),
};
if let Some(first) = first_spans.get(&name.name) {
diagnostic = diagnostic.with_related(*first, "first declared here");
}
diagnostics.push(diagnostic);
} else {
first_spans.insert(name.name.clone(), name.span);
}
}
names
}
fn collect_harness_kinds(
program: &Program,
diagnostics: &mut Vec<Diagnostic>,
) -> BTreeMap<String, String> {
let mut kinds: BTreeMap<String, String> = BTreeMap::new();
for item in &program.items {
let Item::Harness(harness) = item else {
continue;
};
if kinds
.insert(harness.name.name.clone(), harness.kind.name.clone())
.is_some()
{
diagnostics.push(Diagnostic {
related: Vec::new(),
span: harness.name.span,
message: format!("harness `{}` is declared more than once", harness.name.name),
suggestion: Some(
"rename one harness declaration or merge the harness settings".to_owned(),
),
});
}
}
kinds
}
fn collect_agent_names(program: &Program, diagnostics: &mut Vec<Diagnostic>) -> BTreeSet<String> {
let mut names = BTreeSet::new();
for item in &program.items {
let Item::Agent(agent) = item else {
continue;
};
if !names.insert(agent.name.name.clone()) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: agent.name.span,
message: format!("agent `{}` is declared more than once", agent.name.name),
suggestion: Some("rename one agent declaration or merge the settings".to_owned()),
});
}
}
names
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
struct WorkflowContractNames {
inputs: BTreeMap<String, TypeSyntax>,
outputs: BTreeMap<String, TypeSyntax>,
failures: BTreeMap<String, TypeSyntax>,
}
fn collect_workflow_contract_names(
program: &Program,
diagnostics: &mut Vec<Diagnostic>,
) -> WorkflowContractNames {
let mut names = WorkflowContractNames::default();
for item in &program.items {
let Item::WorkflowContract(contract) = item else {
continue;
};
let set = match contract.kind {
WorkflowContractKind::Input => &mut names.inputs,
WorkflowContractKind::Output => &mut names.outputs,
WorkflowContractKind::Failure => &mut names.failures,
};
if set
.insert(contract.name.name.clone(), contract.ty.clone())
.is_some()
{
diagnostics.push(Diagnostic {
related: Vec::new(),
span: contract.name.span,
message: format!(
"workflow declares {} `{}` more than once",
contract.kind.as_str(),
contract.name.name
),
suggestion: Some("remove the duplicate workflow contract".to_owned()),
});
}
}
names
}
impl SemanticContext {
fn from_program(
program: &Program,
workflow_inputs: BTreeMap<String, WorkflowInputSurface>,
) -> Self {
let mut schemas = SchemaIndex::with_builtins();
let mut agents = BTreeSet::new();
let mut agent_capabilities = BTreeMap::new();
let mut coerce_outputs = BTreeMap::new();
let mut coerce_params = BTreeMap::new();
let mut leases = BTreeSet::new();
let mut ledgers = BTreeSet::new();
let mut counters = BTreeSet::new();
let mut channels = BTreeSet::new();
let mut channel_providers = BTreeMap::new();
let mut credentials = BTreeMap::new();
let mut memory_pools = BTreeSet::new();
for item in &program.items {
schemas.insert_item(item);
match item {
Item::Agent(agent) => {
agents.insert(agent.name.name.clone());
let capabilities = agent
.fields
.iter()
.find_map(|field| match field {
AgentField::Capabilities(capabilities, _) => Some(
capabilities
.iter()
.map(|capability| capability.value.clone())
.collect::<BTreeSet<_>>(),
),
_ => None,
})
.unwrap_or_default();
agent_capabilities.insert(agent.name.name.clone(), capabilities);
}
Item::Coerce(coerce) => {
coerce_outputs.insert(coerce.name.name.clone(), coerce.output.clone());
coerce_params.insert(coerce.name.name.clone(), coerce.params.clone());
}
Item::Lease(lease) => {
leases.insert(lease.name.name.clone());
}
Item::Ledger(ledger) => {
ledgers.insert(ledger.name.name.clone());
}
Item::Counter(counter) => {
counters.insert(counter.name.name.clone());
}
Item::Channel(channel) => {
channels.insert(channel.name.name.clone());
channel_providers
.insert(channel.name.name.clone(), channel.provider.name.clone());
}
Item::Credential(credential) => {
credentials.insert(
credential.name.name.clone(),
credential.kind.name.replace('_', "-"),
);
}
Item::MemoryPool(pool) => {
memory_pools.insert(pool.name.name.clone());
}
_ => {}
}
}
Self {
workflow: program
.workflow
.as_ref()
.map(|workflow| workflow.name.clone()),
schemas,
agents,
agent_capabilities,
coerce_outputs,
coerce_params,
workflow_inputs,
leases,
ledgers,
counters,
channels,
channel_providers,
credentials,
memory_pools,
regions: BTreeMap::new(),
}
}
}
fn collect_workflow_input_surfaces(program: &Program) -> BTreeMap<String, WorkflowInputSurface> {
let mut surfaces = BTreeMap::new();
let top_level_schemas = schema_index_for_items(&program.items);
if let Some(workflow) = &program.workflow {
let inputs = workflow_inputs_for_items(&program.items);
surfaces.insert(
workflow.name.clone(),
WorkflowInputSurface {
inputs,
outputs: workflow_outputs_for_items(&program.items),
failures: workflow_failures_for_items(&program.items),
schemas: top_level_schemas.clone(),
milestones: collect_milestone_declarations(&program.items),
},
);
}
for workflow in &program.workflows {
let mut schemas = top_level_schemas.clone();
schemas.merge(schema_index_for_items(&workflow.items));
surfaces.insert(
workflow.name.name.clone(),
WorkflowInputSurface {
inputs: workflow_inputs_for_items(&workflow.items),
outputs: workflow_outputs_for_items(&workflow.items),
failures: workflow_failures_for_items(&workflow.items),
schemas,
milestones: collect_milestone_declarations(&workflow.items),
},
);
}
surfaces
}
fn collect_shared_coordination_usage(program: &Program) -> Vec<IrSharedCoordinationUsage> {
let global_shared = shared_coordination_declarations(&program.items);
let mut usage: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
let mut record_workflow = |workflow_name: &str, local_items: &[Item]| {
let mut shared = global_shared.clone();
shared.extend(shared_coordination_declarations(local_items));
if shared.is_empty() {
return;
}
let principal = format!("workflow:local/{workflow_name}");
for resource in coordination_resources_used_by_items(&program.items)
.into_iter()
.chain(coordination_resources_used_by_items(local_items))
{
if shared.contains(&resource) {
usage.entry(resource).or_default().insert(principal.clone());
}
}
};
if let Some(workflow) = &program.workflow {
record_workflow(&workflow.name, &[]);
}
for workflow in &program.workflows {
record_workflow(&workflow.name.name, &workflow.items);
}
usage
.into_iter()
.map(|(resource, principals)| IrSharedCoordinationUsage {
resource: format!("resource:{resource}"),
workflow_principals: principals.into_iter().collect(),
})
.collect()
}
fn shared_coordination_declarations(items: &[Item]) -> BTreeSet<String> {
items
.iter()
.filter_map(|item| match item {
Item::Lease(lease) if lease.shared => Some(lease.name.name.clone()),
Item::Ledger(ledger) if ledger.shared => Some(ledger.name.name.clone()),
Item::Counter(counter) if counter.shared => Some(counter.name.name.clone()),
_ => None,
})
.collect()
}
fn coordination_resources_used_by_items(items: &[Item]) -> BTreeSet<String> {
let mut resources = BTreeSet::new();
for item in items {
let Item::Rule(rule) = item else {
continue;
};
let (body, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
collect_coordination_resources_from_statements(&body.statements, &mut resources);
}
resources
}
fn collect_coordination_resources_from_statements(
statements: &[body::BodyStmt],
resources: &mut BTreeSet<String>,
) {
for statement in statements {
match statement {
body::BodyStmt::Effect(effect) => match &effect.kind {
body::BodyEffectKind::LeaseAcquire { resource, .. } => {
resources.insert(resource.clone());
}
body::BodyEffectKind::LedgerAppend { ledger, .. } => {
resources.insert(ledger.clone());
}
body::BodyEffectKind::CounterConsume { counter, .. } => {
resources.insert(counter.clone());
}
_ => {}
},
body::BodyStmt::After(after) => {
collect_coordination_resources_from_statements(&after.body, resources);
}
body::BodyStmt::Region(region) => {
collect_coordination_resources_from_statements(®ion.body, resources);
collect_coordination_resources_from_statements(®ion.lapse_body, resources);
}
body::BodyStmt::Case(case_stmt) => {
for branch in &case_stmt.branches {
collect_coordination_resources_from_statements(&branch.body, resources);
}
}
body::BodyStmt::Record(_)
| body::BodyStmt::Done { .. }
| body::BodyStmt::Terminal(_)
| body::BodyStmt::Cancel { .. }
| body::BodyStmt::Milestone { .. }
| body::BodyStmt::Redact { .. } => {}
}
}
}
fn collect_milestone_declarations(items: &[Item]) -> BTreeMap<String, String> {
let mut milestones = BTreeMap::new();
for item in items {
let Item::Rule(rule) = item else {
continue;
};
for (name, class) in milestone_emissions_in_body(&rule.body.text) {
milestones.entry(name).or_insert(class);
}
}
milestones
}
fn validate_milestone_statements(
rule: &RuleDecl,
semantic: &SemanticContext,
diagnostics: &mut Vec<Diagnostic>,
) {
for (name, class) in milestone_emissions_in_body(&rule.body.text) {
if !class.is_empty() && !semantic.schemas.class_exists(&class) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!(
"rule `{}` emits milestone `{name}` with unknown payload class `{class}`",
rule.name.name
),
suggestion: Some(format!("declare `class {class}` before projecting it")),
});
}
}
for (binding, milestone) in milestone_reaches_in_body(&rule.body.text) {
let Some(workflow) = invoke_binding_workflow(rule, &binding) else {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!(
"rule `{}` has `after {binding} reaches \"{milestone}\"` for `{binding}`, which is not a workflow-invoke binding in this rule",
rule.name.name
),
suggestion: Some(
"`reaches` observes a child workflow milestone; bind the child with `invoke W { ... } as <binding>` first"
.to_owned(),
),
});
continue;
};
let declared = semantic
.workflow_inputs
.get(&workflow)
.map(|surface| surface.milestones.contains_key(&milestone))
.unwrap_or(false);
if !declared {
let available = semantic
.workflow_inputs
.get(&workflow)
.map(|surface| {
surface
.milestones
.keys()
.map(|name| format!("\"{name}\""))
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default();
let suggestion = if available.is_empty() {
format!("workflow `{workflow}` declares no milestones; add `emit milestone \"{milestone}\" ...` to it")
} else {
format!("workflow `{workflow}` declares: {available}")
};
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!(
"rule `{}` reaches milestone `{milestone}` that workflow `{workflow}` does not declare",
rule.name.name
),
suggestion: Some(suggestion),
});
}
}
}
fn milestone_reaches_in_body(body: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
for raw in body.lines() {
let trimmed = raw.trim();
let Some(rest) = trimmed.strip_prefix("after ") else {
continue;
};
let mut words = rest.split_whitespace();
let Some(binding) = words.next() else {
continue;
};
if words.next() != Some("reaches") {
continue;
}
let Some(quoted) = words.next() else {
continue;
};
if !(quoted.starts_with('"') && quoted.ends_with('"') && quoted.len() >= 2) {
continue;
}
out.push((binding.to_owned(), quoted.trim_matches('"').to_owned()));
}
out
}
fn invoke_binding_workflow(rule: &RuleDecl, binding: &str) -> Option<String> {
for statement in workflow_invoke_statements(&rule.body.text) {
let (target, _) = invoke_statement_parts(&statement)?;
if let Some(as_binding) = binding_after_as(&statement) {
if as_binding == binding {
return Some(target.to_owned());
}
}
}
None
}
fn milestone_payload_class(
rule: &RuleDecl,
binding: &str,
milestone: &str,
semantic: &SemanticContext,
) -> Option<(String, String)> {
let workflow = invoke_binding_workflow(rule, binding)?;
let surface = semantic.workflow_inputs.get(&workflow)?;
let class = surface.milestones.get(milestone).cloned()?;
Some((workflow, class))
}
fn invoke_output_class(
rule: &RuleDecl,
binding: &str,
semantic: &SemanticContext,
) -> Option<(String, String)> {
let workflow = invoke_binding_workflow(rule, binding)?;
let surface = semantic.workflow_inputs.get(&workflow)?;
if surface.outputs.len() != 1 {
return None;
}
match surface.outputs.values().next()? {
TypeSyntax::Ref { name } if surface.schemas.class_exists(&name.name) => {
Some((workflow, name.name.clone()))
}
_ => None,
}
}
fn invoke_failure_class(
rule: &RuleDecl,
binding: &str,
semantic: &SemanticContext,
) -> Option<(String, String)> {
let workflow = invoke_binding_workflow(rule, binding)?;
let surface = semantic.workflow_inputs.get(&workflow)?;
if surface.failures.len() != 1 {
return None;
}
match surface.failures.values().next()? {
TypeSyntax::Ref { name } if surface.schemas.class_exists(&name.name) => {
Some((workflow, name.name.clone()))
}
_ => None,
}
}
fn milestone_emissions_in_body(body: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
for raw in body.lines() {
let trimmed = raw.trim();
let Some(rest) = trimmed.strip_prefix("emit milestone ") else {
continue;
};
let rest = rest.trim_start();
if !rest.starts_with('"') {
continue;
}
let Some(close) = rest[1..].find('"') else {
continue;
};
let name = rest[1..=close].to_owned();
let after_name = rest[close + 2..].trim_start();
let class = after_name
.strip_prefix("of ")
.map(|tail| {
tail.trim_start()
.split(|c: char| c.is_whitespace() || c == '{')
.next()
.unwrap_or("")
.to_owned()
})
.unwrap_or_default();
out.push((name, class));
}
out
}
fn schema_index_for_items(items: &[Item]) -> SchemaIndex {
let mut schemas = SchemaIndex::with_builtins();
for item in items {
schemas.insert_item(item);
}
schemas
}
fn workflow_inputs_for_items(items: &[Item]) -> BTreeMap<String, TypeSyntax> {
items
.iter()
.filter_map(|item| match item {
Item::WorkflowContract(contract) if contract.kind == WorkflowContractKind::Input => {
Some((contract.name.name.clone(), contract.ty.clone()))
}
_ => None,
})
.collect()
}
fn workflow_outputs_for_items(items: &[Item]) -> BTreeMap<String, TypeSyntax> {
items
.iter()
.filter_map(|item| match item {
Item::WorkflowContract(contract) if contract.kind == WorkflowContractKind::Output => {
Some((contract.name.name.clone(), contract.ty.clone()))
}
_ => None,
})
.collect()
}
fn workflow_failures_for_items(items: &[Item]) -> BTreeMap<String, TypeSyntax> {
items
.iter()
.filter_map(|item| match item {
Item::WorkflowContract(contract) if contract.kind == WorkflowContractKind::Failure => {
Some((contract.name.name.clone(), contract.ty.clone()))
}
_ => None,
})
.collect()
}
impl SchemaIndex {
fn with_builtins() -> Self {
let mut index = Self::default();
index.insert_class(
"AgentTurn",
[
("id", string_ty()),
("summary", string_ty()),
("agent", string_ty()),
("provider", string_ty()),
("status", string_ty()),
("run_id", string_ty()),
("effect_id", string_ty()),
],
);
index.insert_class(
"WorkItem",
[
("id", string_ty()),
("title", string_ty()),
("body", string_ty()),
("queue", string_ty()),
("status", string_ty()),
("labels", array_ty(string_ty())),
],
);
index.insert_class(
"VcsChange",
[
("branch", string_ty()),
("cut", string_ty()),
("path", string_ty()),
("origin", string_ty()),
("by", string_ty()),
("intent", string_ty()),
("at", string_ty()),
],
);
index.insert_class(
"VcsContention",
[
("branch", string_ty()),
("with", string_ty()),
("stream", string_ty()),
("slice", array_ty(string_ty())),
("at", string_ty()),
],
);
index.insert_class(
"VcsPromotion",
[
("branch", string_ty()),
("stream", string_ty()),
("cut", string_ty()),
("at", string_ty()),
],
);
index.insert_class(
"VcsStall",
[
("branch", string_ty()),
("stream", string_ty()),
("boundary", string_ty()),
("paths", array_ty(string_ty())),
("at", string_ty()),
],
);
index.insert_class(
"Evidence",
[
("title", string_ty()),
("path", string_ty()),
("summary", string_ty()),
],
);
index.insert_class(
"TerminalFailed",
[
("reason", string_ty()),
("summary", string_ty()),
("effect_id", string_ty()),
("run_id", string_ty()),
("kind", string_ty()),
],
);
index.insert_class(
"TerminalFailedExec",
[
("reason", string_ty()),
("summary", string_ty()),
("effect_id", string_ty()),
("run_id", string_ty()),
("kind", string_ty()),
("exit_code", optional_ty(int_ty())),
],
);
index.insert_class(
"TerminalFailedCoerce",
[
("reason", string_ty()),
("summary", string_ty()),
("effect_id", string_ty()),
("run_id", string_ty()),
("kind", string_ty()),
("error_class", string_ty()),
("http_status", optional_ty(int_ty())),
],
);
index.insert_class(
"TerminalFailedTell",
[
("reason", string_ty()),
("summary", string_ty()),
("effect_id", string_ty()),
("run_id", string_ty()),
("kind", string_ty()),
("error_class", string_ty()),
],
);
index.insert_class(
"TerminalTimedOut",
[
("summary", string_ty()),
("effect_id", string_ty()),
("run_id", string_ty()),
],
);
index.insert_class(
"TerminalCancelled",
[
("summary", string_ty()),
("effect_id", string_ty()),
("run_id", string_ty()),
],
);
index.insert_class(
"TerminalOutcome",
[
("tag", string_ty()),
("status", string_ty()),
("summary", string_ty()),
("effect_id", string_ty()),
("run_id", string_ty()),
],
);
index.insert_class(
"Message",
[
("message_id", string_ty()),
("channel", string_ty()),
("provider", string_ty()),
("received_at", string_ty()),
("sender", string_ty()),
("sender_claims", string_ty()),
("thread_id", string_ty()),
("text", string_ty()),
("markdown", string_ty()),
("attachments", array_ty(string_ty())),
("interaction", string_ty()),
("raw_ref", string_ty()),
("correlation", string_ty()),
],
);
index.insert_class(
"MessageSendReceipt",
[
("message_id", string_ty()),
("channel", string_ty()),
("provider", string_ty()),
("status", string_ty()),
("provider_message_id", string_ty()),
("thread_id", string_ty()),
("destination", string_ty()),
("accepted_at", string_ty()),
],
);
index
}
fn insert_class<const N: usize>(&mut self, name: &str, fields: [(&str, TypeSyntax); N]) {
self.classes.insert(
name.to_owned(),
fields
.into_iter()
.map(|(field, ty)| (field.to_owned(), ty))
.collect(),
);
}
fn insert_item(&mut self, item: &Item) {
match item {
Item::Enum(enum_decl) => {
self.enums.insert(
enum_decl.name.name.clone(),
enum_decl
.variants
.iter()
.map(|variant| variant.name.name.clone())
.collect(),
);
for variant in &enum_decl.variants {
if variant.fields.is_empty() {
continue;
}
let mut fields = BTreeMap::new();
fields.insert(
"variant".to_owned(),
TypeSyntax::LiteralString {
value: variant.name.name.clone(),
span: variant.name.span,
},
);
for field in &variant.fields {
fields.insert(field.name.name.clone(), field.ty.clone());
}
self.classes.insert(
format!("{}.{}", enum_decl.name.name, variant.name.name),
fields,
);
}
}
Item::Class(class_decl) => {
self.classes.insert(
class_decl.name.name.clone(),
class_decl
.fields
.iter()
.map(|field| (field.name.name.clone(), field.ty.clone()))
.collect(),
);
self.insert_presence(&class_decl.name.name, &class_decl.fields);
}
Item::Event(event) => {
self.events.insert(event.name.clone());
self.classes.insert(
event.name.clone(),
event
.fields
.iter()
.map(|field| (field.name.name.clone(), field.ty.clone()))
.collect(),
);
self.insert_presence(&event.name, &event.fields);
}
_ => {}
}
}
fn insert_presence(&mut self, schema: &str, fields: &[ClassField]) {
let conditions: BTreeMap<String, (String, String)> = fields
.iter()
.filter_map(|field| {
field
.presence_condition
.clone()
.map(|condition| (field.name.name.clone(), condition))
})
.collect();
if !conditions.is_empty() {
self.presence.insert(schema.to_owned(), conditions);
}
}
fn field_presence(&self, schema: &str, field: &str) -> Option<&(String, String)> {
self.presence
.get(schema)
.and_then(|fields| fields.get(field))
}
fn merge(&mut self, other: SchemaIndex) {
self.classes.extend(other.classes);
self.enums.extend(other.enums);
self.presence.extend(other.presence);
}
fn class_exists(&self, name: &str) -> bool {
self.classes.contains_key(name)
}
fn resolve_field_path(&self, root_schema: &str, path: &[String]) -> Result<TypeSyntax, String> {
if root_schema.contains('.') && !self.classes.contains_key(root_schema) {
return Ok(TypeSyntax::Ref {
name: Ident {
name: root_schema.to_owned(),
span: zero_span(),
},
});
}
let mut schema = root_schema.to_owned();
let mut current = TypeSyntax::Ref {
name: Ident {
name: schema.clone(),
span: zero_span(),
},
};
for field in path {
let Some(fields) = self.classes.get(&schema) else {
return Err(format!("schema `{schema}` has no declared fields"));
};
let Some(field_ty) = fields.get(field) else {
return Err(format!("schema `{schema}` has no field `{field}`"));
};
current = field_ty.clone();
match schema_name_for_path(¤t) {
Some(next_schema) => schema = next_schema,
None if field != path.last().expect("path is non-empty") => {
return Err(format!("field `{field}` is not a schema value"));
}
None => {}
}
}
Ok(current)
}
}
fn zero_span() -> SourceSpan {
SourceSpan { start: 0, end: 0 }
}
fn string_ty() -> TypeSyntax {
TypeSyntax::Primitive {
name: "string".to_owned(),
span: zero_span(),
}
}
fn int_ty() -> TypeSyntax {
TypeSyntax::Primitive {
name: "int".to_owned(),
span: zero_span(),
}
}
fn optional_ty(inner: TypeSyntax) -> TypeSyntax {
TypeSyntax::Optional {
inner: Box::new(inner),
span: zero_span(),
}
}
fn array_ty(inner: TypeSyntax) -> TypeSyntax {
TypeSyntax::Array {
inner: Box::new(inner),
span: zero_span(),
}
}
fn schema_name_for_path(ty: &TypeSyntax) -> Option<String> {
match ty {
TypeSyntax::Ref { name } => Some(name.name.clone()),
TypeSyntax::Optional { inner, .. } => schema_name_for_path(inner),
_ => None,
}
}
pub const STD_PACKAGE_IDS: &[&str] = &[
"std.agent",
"std.vcs",
"std.coercion",
"std.coord",
"std.files",
"std.human",
"std.ingress",
"std.memory",
"std.messaging",
"std.script",
"std.telemetry",
"std.time",
"std.tracker",
"std.workflow",
];
fn validate_streams(ir: &IrProgram, diagnostics: &mut Vec<Diagnostic>) {
let mut memberships: Vec<(&str, &str)> = Vec::new(); for stream in &ir.streams {
for (member, span) in stream.members.iter().zip(&stream.member_spans) {
if !ir.agents.iter().any(|agent| agent.name == *member) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: *span,
message: format!(
"stream `{}` member `{}` is not a declared agent",
stream.name, member
),
suggestion: Some(
"stream members are agent declarations; declare the agent \
or remove it from the stream"
.to_owned(),
),
});
continue;
}
if let Some((_, holder)) = memberships.iter().find(|(agent, _)| agent == member) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: *span,
message: format!("agent `{member}` is already a member of stream `{holder}`",),
suggestion: Some(
"membership is single-valued (the sync topology stays a \
tree): an agent homes to exactly one stream"
.to_owned(),
),
});
continue;
}
memberships.push((member, &stream.name));
}
}
for rule in &ir.rules {
for effect in &rule.metadata.effects {
if let Some(target) = &effect.on_stream {
if !ir.streams.iter().any(|stream| stream.name == *target) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: effect.span,
message: format!("`on stream {target}` names an undeclared stream"),
suggestion: Some(
"declare the stream at top level: `stream <name> { members [...] }`"
.to_owned(),
),
});
}
}
if let Some(source) = &effect.selection_source {
let trimmed = source.trim();
if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') {
let literal = &trimmed[1..trimmed.len() - 1];
if let Err(error) = whipplescript_core::selection::parse(literal) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: effect.span,
message: format!("the selection does not parse: {error}"),
suggestion: Some(
"selections compose atoms like `path(<glob>)`, `by(<prefix>)`, \
`intent(<prefix>)`, `cut(<id>)` with `|`, `&`, `~`, and \
`dependents-of(...)`"
.to_owned(),
),
});
}
}
}
for grant in &effect.access_grants {
if grant.resource != "vcs" {
continue;
}
if effect.kind != IrEffectKind::WorkflowInvoke {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: effect.span,
message: "a `vcs` access grant rides an `invoke` only".to_owned(),
suggestion: Some(
"repair authority is orchestration: grant it to a repair \
workflow via `invoke ... with access to vcs { repair for \
<binding> }`; agents never receive it"
.to_owned(),
),
});
continue;
}
for op in &grant.operations {
if op.operation != "repair" {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: effect.span,
message: format!("unknown `vcs` grant operation `{}`", op.operation),
suggestion: Some(
"the vcs resource grants `repair for <binding>`".to_owned(),
),
});
continue;
}
let Some(target) = &op.target else {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: effect.span,
message: "`repair` names no binding".to_owned(),
suggestion: Some(
"write `repair for <binding>` where the binding is a \
vcs arming fact this rule matched (e.g. `when reconcile \
stalled as r`)"
.to_owned(),
),
});
continue;
};
let bound = rule.whens.iter().any(|when| {
binding_after_as(when.pattern.as_str()).as_deref() == Some(target)
});
if !bound {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: effect.span,
message: format!("`repair for {target}` names no binding of this rule"),
suggestion: Some(
"bind the arming fact first: `when reconcile stalled as \
<binding>` (or the dotted `when fact vcs.* as <binding>` \
form)"
.to_owned(),
),
});
}
}
}
if let Some(target) = &effect.transport_onto {
if target != "mainline" && !ir.streams.iter().any(|stream| stream.name == *target) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: effect.span,
message: format!(
"`onto {target}` names neither `mainline` nor a declared stream"
),
suggestion: Some(
"transport targets are the nameable tiers: `onto mainline`, or \
`onto <stream>` for a declared stream's line"
.to_owned(),
),
});
}
}
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ChannelProviderReport {
pub short_name: &'static str,
pub provider_id: &'static str,
pub direction: &'static str,
pub identity: &'static str,
pub interactions: &'static [&'static str],
pub content: &'static [&'static str],
pub delivery_receipts: &'static [&'static str],
}
pub const CHANNEL_PROVIDER_REPORTS: &[ChannelProviderReport] = &[
ChannelProviderReport {
short_name: "fixture",
provider_id: "fixture",
direction: "bidirectional",
identity: "claimed_actor",
interactions: &["buttons", "reactions"],
content: &["text", "markdown"],
delivery_receipts: &["accepted", "failed"],
},
ChannelProviderReport {
short_name: "local",
provider_id: "std.messaging.local",
direction: "bidirectional",
identity: "claimed_actor",
interactions: &["buttons", "reactions"],
content: &["text", "markdown"],
delivery_receipts: &["accepted", "failed"],
},
ChannelProviderReport {
short_name: "desktop",
provider_id: "std.messaging.desktop",
direction: "outbound_only",
identity: "anonymous",
interactions: &[],
content: &["text"],
delivery_receipts: &["accepted", "failed"],
},
ChannelProviderReport {
short_name: "stdio",
provider_id: "std.messaging.stdio",
direction: "bidirectional",
identity: "claimed_actor",
interactions: &["buttons"],
content: &["text", "markdown"],
delivery_receipts: &["accepted", "failed"],
},
];
pub fn channel_provider_report(provider: &str) -> Option<&'static ChannelProviderReport> {
CHANNEL_PROVIDER_REPORTS
.iter()
.find(|report| report.short_name == provider || report.provider_id == provider)
}
pub const BUILTIN_GAUGES: &[&str] = &["std.spend", "std.latency", "std.tokens", "std.cache_hit"];
pub const FILE_STORE_PROVIDERS: &[&str] = &["local"];
fn validate_improve_declarations(ir: &IrProgram, diagnostics: &mut Vec<Diagnostic>) {
for mark in &ir.marks {
if !ir.rules.iter().any(|rule| rule.name == mark.site) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: mark.span,
message: format!("mark `{}` rides unknown site `{}`", mark.name, mark.site),
suggestion: Some(format!(
"declared rules: {}",
ir.rules
.iter()
.map(|rule| rule.name.as_str())
.collect::<Vec<_>>()
.join(", ")
)),
});
}
}
let gauge_names: BTreeSet<&str> = ir.gauges.iter().map(|gauge| gauge.name.as_str()).collect();
let resolves = |name: &str| gauge_names.contains(name) || BUILTIN_GAUGES.contains(&name);
let unknown = |name: &str, span: SourceSpan, diagnostics: &mut Vec<Diagnostic>| {
diagnostics.push(Diagnostic {
related: Vec::new(),
span,
message: format!("unknown gauge `{name}`"),
suggestion: Some(format!(
"declare `gauge {name} {{ ... }}` or use a built-in gauge ({})",
BUILTIN_GAUGES.join(", ")
)),
});
};
for gauge in &ir.gauges {
if gauge.judge_kind == "coerce" {
match ir
.coerces
.iter()
.find(|coerce| coerce.name == gauge.judge_target)
{
None => {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: gauge.span,
message: format!(
"gauge `{}` judges via undeclared coerce `{}`",
gauge.name, gauge.judge_target
),
suggestion: Some("declare the coerce this gauge judges with".to_owned()),
});
}
Some(coerce) if !gauge.judge_args.is_empty() => {
if gauge.judge_args.len() == 1 && gauge.judge_args[0] == "record" {
if coerce.params.len() != 1 {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: gauge.span,
message: format!(
"gauge `{}`: the reserved `(record)` form needs a \
single-parameter coerce; `{}` takes {}",
gauge.name,
gauge.judge_target,
coerce.params.len()
),
suggestion: Some(
"give the coerce one record-shaped parameter, or bind each \
parameter to an explicit path"
.to_owned(),
),
});
}
} else {
for arg in &gauge.judge_args {
let head = arg.split('.').next().unwrap_or_default();
let valid = match head {
"record" => false, "input" => true,
"facts" => arg.splitn(3, '.').count() == 3,
_ => false,
};
if !valid {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: gauge.span,
message: format!(
"gauge `{}`: judge argument `{arg}` is not a record \
path",
gauge.name
),
suggestion: Some(
"arguments are `input.<path>`, \
`facts.<Class>.<field...>`, or the single reserved \
`record`"
.to_owned(),
),
});
}
}
if gauge.judge_args.len() != coerce.params.len() {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: gauge.span,
message: format!(
"gauge `{}`: judge passes {} argument{} but coerce `{}` \
takes {}",
gauge.name,
gauge.judge_args.len(),
if gauge.judge_args.len() == 1 { "" } else { "s" },
gauge.judge_target,
coerce.params.len()
),
suggestion: Some(
"bind one path per coerce parameter, in order".to_owned(),
),
});
}
}
}
Some(_) => {}
}
}
if !gauge.inputs.is_empty() && gauge.judge_kind != "exec" {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: gauge.span,
message: format!(
"derived gauge `{}` must judge via exec (its judge receives the input score vector)",
gauge.name
),
suggestion: Some("use `judge via exec \"<validator>\"`".to_owned()),
});
}
for input in &gauge.inputs {
if input == &gauge.name {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: gauge.span,
message: format!("derived gauge `{}` cannot input itself", gauge.name),
suggestion: None,
});
} else if !resolves(input) {
unknown(input, gauge.span, diagnostics);
}
}
}
for campaign in &ir.campaigns {
let mut named: Vec<(&str, &'static str)> = Vec::new();
for name in &campaign.ascend {
named.push((name, "ascend"));
}
for reach in &campaign.reach {
named.push((&reach.gauge, "reach"));
}
for guard in &campaign.guard {
named.push((&guard.gauge, "guard"));
}
for name in &campaign.sacrifice {
named.push((name, "sacrifice"));
}
let mut seen: BTreeMap<&str, &'static str> = BTreeMap::new();
for (name, role) in named {
if !resolves(name) {
unknown(name, campaign.span, diagnostics);
}
if let Some(previous) = seen.insert(name, role) {
let message = if previous == role {
format!(
"campaign `{}` names gauge `{name}` twice in {role}",
campaign.name
)
} else {
format!(
"campaign `{}` names gauge `{name}` as both {previous} and {role}",
campaign.name
)
};
diagnostics.push(Diagnostic {
related: Vec::new(),
span: campaign.span,
message,
suggestion: Some("name each gauge once, in at most one clause".to_owned()),
});
}
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum HarnessClass {
Managed,
Delegated,
}
impl HarnessClass {
pub fn as_str(self) -> &'static str {
match self {
HarnessClass::Managed => "managed",
HarnessClass::Delegated => "delegated",
}
}
}
pub fn harness_class(kind: &str) -> HarnessClass {
match kind {
"owned" | "fixture" => HarnessClass::Managed,
_ => HarnessClass::Delegated,
}
}
fn validate_test_expr_source(
label: &str,
source: &str,
span: SourceSpan,
diagnostics: &mut Vec<Diagnostic>,
) {
if source.trim().is_empty() {
diagnostics.push(Diagnostic {
related: Vec::new(),
span,
message: format!("{label} is empty"),
suggestion: Some("provide an expression".to_owned()),
});
return;
}
if let Err(error) = parse_expression(source) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span,
message: format!("{label} is not a valid expression: {error}"),
suggestion: None,
});
}
}
fn literal_union_values(ty: &TypeSyntax) -> Option<Vec<String>> {
match ty {
TypeSyntax::LiteralString { value, .. } => Some(vec![value.clone()]),
TypeSyntax::Union { variants, .. } => {
let values = variants
.iter()
.filter_map(|variant| match variant {
TypeSyntax::LiteralString { value, .. } => Some(value.clone()),
_ => None,
})
.collect::<Vec<_>>();
(!values.is_empty() && values.len() == variants.len()).then_some(values)
}
_ => None,
}
}
fn validate_presence_conditions(
container: &str,
fields: &[ClassField],
diagnostics: &mut Vec<Diagnostic>,
) {
for field in fields {
let Some((disc, literal)) = &field.presence_condition else {
continue;
};
let Some(disc_field) = fields.iter().find(|candidate| &candidate.name.name == disc) else {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: field.span,
message: format!(
"`{container}` field `{}` is conditioned on unknown discriminant `{disc}`",
field.name.name
),
suggestion: Some(
"`when <field> is \"...\"` must name a literal-union field of the same schema"
.to_owned(),
),
});
continue;
};
match literal_union_values(&disc_field.ty) {
Some(values) if values.iter().any(|value| value == literal) => {}
Some(values) => diagnostics.push(Diagnostic {
related: Vec::new(),
span: field.span,
message: format!(
"`{container}` field `{}` is conditioned on `{disc} is \"{literal}\"`, which is not a value of `{disc}`",
field.name.name
),
suggestion: Some(format!("use one of: {}", values.join(", "))),
}),
None => diagnostics.push(Diagnostic {
related: Vec::new(),
span: field.span,
message: format!(
"`{container}` field `{}` is conditioned on `{disc}`, which is not a string-literal discriminant",
field.name.name
),
suggestion: Some(
"the discriminant must be a string-literal union, e.g. `kind \"a\" | \"b\"`"
.to_owned(),
),
}),
}
}
}
fn coerce_declared_provider(body: &str) -> Option<String> {
let mut in_prompt = false;
let mut awaiting_opener = false;
for line in body.lines() {
let trimmed = line.trim();
if in_prompt {
if trimmed.matches('"').count() >= 3 && trimmed.matches("\"\"\"").count() % 2 == 1 {
in_prompt = false;
}
continue;
}
if awaiting_opener {
if trimmed.is_empty() {
continue;
}
awaiting_opener = false;
if let Some(after_opener) = trimmed.strip_prefix("\"\"\"") {
if after_opener.matches("\"\"\"").count() % 2 == 0 {
in_prompt = true;
}
continue;
}
}
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if trimmed == "prompt" {
awaiting_opener = true;
continue;
}
if let Some(rest) = trimmed.strip_prefix("prompt ") {
if let Some(after_opener) = rest.strip_prefix("\"\"\"") {
if after_opener.matches("\"\"\"").count() % 2 == 0 {
in_prompt = true;
}
}
continue;
}
if let Some(rest) = trimmed.strip_prefix("provider ") {
let mut tokens = rest.split_whitespace();
if let (Some(name), None) = (tokens.next(), tokens.next()) {
return Some(name.to_owned());
}
}
}
None
}
#[cfg(test)]
#[path = "lib_tests/coerce_provider.rs"]
mod coerce_provider_tests;
fn validate_coerce_body_fields(coerce: &CoerceDecl, diagnostics: &mut Vec<Diagnostic>) {
let mut in_prompt = false;
let mut awaiting_opener = false;
for line in coerce.body.text.lines() {
let trimmed = line.trim();
if in_prompt {
if trimmed.matches("\"\"\"").count() % 2 == 1 {
in_prompt = false;
}
continue;
}
if awaiting_opener {
if trimmed.is_empty() {
continue;
}
awaiting_opener = false;
if let Some(after_opener) = trimmed.strip_prefix("\"\"\"") {
if after_opener.matches("\"\"\"").count() % 2 == 0 {
in_prompt = true;
}
continue;
}
}
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if trimmed == "prompt" {
awaiting_opener = true;
continue;
}
if let Some(rest) = trimmed.strip_prefix("prompt ") {
let rest = rest.trim_start();
if let Some(after_opener) = rest.strip_prefix("\"\"\"") {
if after_opener.matches("\"\"\"").count() % 2 == 0 {
in_prompt = true;
}
}
continue;
}
if let Some(rest) = trimmed.strip_prefix("provider ") {
if rest.split_whitespace().count() != 1 {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: coerce.name.span,
message: format!(
"coerce `{}` has a malformed `provider` clause: `{trimmed}`",
coerce.name.name
),
suggestion: Some("write `provider <name>`".to_owned()),
});
}
continue;
}
let field = trimmed.split_whitespace().next().unwrap_or(trimmed);
diagnostics.push(Diagnostic {
related: Vec::new(),
span: coerce.name.span,
message: format!(
"unknown coerce field `{field}` on coerce `{}`",
coerce.name.name
),
suggestion: Some("supported coerce fields are `prompt` and `provider`".to_owned()),
});
}
}
fn validate_type_refs(
ty: &TypeSyntax,
schema_names: &BTreeSet<String>,
agent_names: &BTreeSet<String>,
diagnostics: &mut Vec<Diagnostic>,
) {
match ty {
TypeSyntax::Primitive { .. } | TypeSyntax::LiteralString { .. } => {}
TypeSyntax::Ref { name } => {
if !schema_names.contains(&name.name) && !is_builtin_schema_ref(&name.name) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: name.span,
message: format!("unknown schema reference `{}`", name.name),
suggestion: Some(format!(
"declare `class {}` or `enum {}` before using it",
name.name, name.name
)),
});
}
}
TypeSyntax::AgentRef { agents, .. } => {
let mut seen = BTreeSet::new();
for agent in agents {
if !seen.insert(agent.name.clone()) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: agent.span,
message: format!("AgentRef lists agent `{}` more than once", agent.name),
suggestion: Some(
"remove the duplicate agent from the AgentRef domain".to_owned(),
),
});
}
if !agent_names.contains(&agent.name) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: agent.span,
message: format!("AgentRef references unknown agent `{}`", agent.name),
suggestion: Some(format!(
"declare `agent {}` before using it in AgentRef",
agent.name
)),
});
}
}
}
TypeSyntax::Optional { inner, .. }
| TypeSyntax::Array { inner, .. }
| TypeSyntax::Map { inner, .. } => {
validate_type_refs(inner, schema_names, agent_names, diagnostics)
}
TypeSyntax::Union { variants, .. } => {
for variant in variants {
validate_type_refs(variant, schema_names, agent_names, diagnostics);
}
}
}
}
fn is_builtin_schema_ref(name: &str) -> bool {
matches!(
name,
"AgentTurn"
| "WorkItem"
| "Evidence"
| "VcsChange"
| "VcsContention"
| "VcsPromotion"
| "VcsStall"
| "TerminalFailed"
| "TerminalTimedOut"
| "TerminalCancelled"
| "TerminalOutcome"
)
}
fn is_observer_only_schema(name: &str) -> bool {
matches!(
name,
"TerminalFailed"
| "TerminalTimedOut"
| "TerminalCancelled"
| "TerminalOutcome"
| "VcsChange"
| "VcsContention"
| "VcsPromotion"
| "VcsStall"
)
}
fn validate_canonical_rule_body_syntax(rule: &RuleDecl, diagnostics: &mut Vec<Diagnostic>) {
for line in rule.body.text.lines().map(str::trim) {
if line.starts_with("then ") {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!(
"rule `{}` uses unsupported `then` sequencing",
rule.name.name
),
suggestion: Some(
"use `after <effect> succeeds { ... }` blocks for effect sequencing".to_owned(),
),
});
}
if line.starts_with("after ") && line.contains("=>") {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!(
"rule `{}` uses unsupported `after ... =>` sequencing",
rule.name.name
),
suggestion: Some("write `after <effect> succeeds { ... }`".to_owned()),
});
}
}
}
fn build_rule_dependencies(rules: &[IrRule]) -> Vec<IrRuleDependency> {
let mut dependencies = Vec::new();
for producer in rules {
for produced_fact in &producer.metadata.fact_writes {
for consumer in rules {
if consumer.metadata.fact_reads.contains(produced_fact) {
dependencies.push(IrRuleDependency {
producer: producer.name.clone(),
consumer: consumer.name.clone(),
fact: produced_fact.clone(),
});
}
}
}
}
dependencies.sort_by(|left, right| {
(&left.producer, &left.consumer, &left.fact).cmp(&(
&right.producer,
&right.consumer,
&right.fact,
))
});
dependencies
}
fn validate_message_from_channels(
rule: &RuleDecl,
semantic: &SemanticContext,
diagnostics: &mut Vec<Diagnostic>,
) {
for when in &rule.whens {
let (pattern, _) = split_when_guard(&when.text);
let Some(rest) = pattern.trim_start().strip_prefix("message from ") else {
continue;
};
let Some(channel) = rest.split_whitespace().next() else {
continue;
};
if !semantic.channels.iter().any(|c| c.as_str() == channel) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: when.span,
message: format!("`when message from {channel}` names an unknown channel"),
suggestion: Some(
"declare it with `channel <name> { provider … }`, or correct the channel name"
.to_owned(),
),
});
continue;
}
if let Some(report) = semantic
.channel_providers
.get(channel)
.and_then(|provider| channel_provider_report(provider))
{
if report.direction == "outbound_only" {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: when.span,
message: format!(
"`when message from {channel}` observes a channel whose provider `{}` is outbound-only (its capability report cannot deliver inbound messages)",
report.short_name
),
suggestion: Some(
"route inbound observation through an inbound-capable provider (`local`, `stdio`, `fixture`)"
.to_owned(),
),
});
}
}
}
}
fn validate_send_channels(
rule: &RuleDecl,
semantic: &SemanticContext,
diagnostics: &mut Vec<Diagnostic>,
) {
let (ast, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
fn walk(
statements: &[body::BodyStmt],
semantic: &SemanticContext,
diagnostics: &mut Vec<Diagnostic>,
) {
for statement in statements {
match statement {
body::BodyStmt::Effect(effect) => {
if let body::BodyEffectKind::ConstructCapabilityCall {
keyword, fields, ..
} = &effect.kind
{
if keyword == "send" {
if let Some(channel) =
fields.iter().find(|field| field.name == "channel")
{
if !semantic.channels.contains(&channel.source) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: effect.span,
message: format!(
"`send via {}` names an unknown channel",
channel.source
),
suggestion: Some(
"declare it with `channel <name> { provider … }`, or correct the channel name"
.to_owned(),
),
});
} else if let Some(report) = semantic
.channel_providers
.get(&channel.source)
.and_then(|provider| channel_provider_report(provider))
{
if report.direction == "inbound_only" {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: effect.span,
message: format!(
"`send via {}` targets a channel whose provider `{}` is inbound-only (its capability report cannot accept outbound sends)",
channel.source, report.short_name
),
suggestion: Some(
"send through an outbound-capable provider (`local`, `desktop`, `stdio`, `fixture`)"
.to_owned(),
),
});
}
}
}
}
if matches!(keyword.as_str(), "recall" | "learn" | "curate") {
if let Some(pool) = fields.iter().find(|field| field.name == "pool") {
if !semantic.memory_pools.contains(&pool.source) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: effect.span,
message: format!(
"`{keyword}` names unknown memory pool `{}`",
pool.source
),
suggestion: Some(
"declare it with `memory pool <name> { … }`, or correct the pool name"
.to_owned(),
),
});
}
}
}
}
}
body::BodyStmt::After(after) => walk(&after.body, semantic, diagnostics),
body::BodyStmt::Case(case) => {
for branch in &case.branches {
walk(&branch.body, semantic, diagnostics);
}
}
_ => {}
}
}
}
walk(&ast.statements, semantic, diagnostics);
}
const EVIDENCE_ONLY_TURN_FACTS: [&str; 3] = [
"agent.turn.streamed",
"agent.turn.tool_requested",
"agent.turn.artifact_captured",
];
fn validate_turn_access_grants(
rule: &RuleDecl,
metadata: &IrRuleMetadata,
diagnostics: &mut Vec<Diagnostic>,
) {
for effect in &metadata.effects {
if effect.access_grants.is_empty() {
continue;
}
let mut seen = BTreeSet::new();
for grant in &effect.access_grants {
if grant.operations.is_empty() {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: effect.span,
message: format!(
"rule `{}` has a `with access to {}` grant that grants no operations",
rule.name.name, grant.resource
),
suggestion: Some(
"list at least one operation in the grant block, or drop the grant"
.to_owned(),
),
});
}
if !seen.insert(grant.resource.clone()) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: effect.span,
message: format!(
"rule `{}` lists access resource `{}` more than once on one effect",
rule.name.name, grant.resource
),
suggestion: Some(
"merge the grant clauses for a resource into a single block".to_owned(),
),
});
}
}
}
}
fn validate_evidence_fact_not_matched(rule: &RuleDecl, diagnostics: &mut Vec<Diagnostic>) {
for when in &rule.whens {
let (pattern, _) = split_when_guard(&when.text);
let Some(name) = runtime_fact_name_for_pattern(pattern) else {
continue;
};
if EVIDENCE_ONLY_TURN_FACTS.contains(&name.as_str()) {
diagnostics.push(Diagnostic { related: Vec::new(),
span: when.span,
message: format!(
"rule `{}` matches evidence-only fact `{name}`: in-turn observations are evidence, not rule-matchable facts",
rule.name.name
),
suggestion: Some(
"match a lifecycle fact (`agent.turn.completed`/`failed`/`timed_out`/`cancelled`) and read in-turn detail from its evidence".to_owned(),
),
});
}
}
}
fn extract_rule_regions(
items: &mut [Item],
diagnostics: &mut Vec<Diagnostic>,
) -> BTreeMap<String, IrRegion> {
let mut pending = BTreeMap::new();
for item in items.iter_mut() {
let Item::Rule(rule) = item else {
continue;
};
let (ast, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
let mut regions = Vec::new();
collect_region_blocks(&ast.statements, &[], &mut regions);
if regions.is_empty() {
continue;
}
if regions.len() > 1 {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: regions[1].0.span,
message: format!(
"rule `{}` declares more than one `during`/`until` region",
rule.name.name
),
suggestion: Some(
"v1 supports one region per rule (including nested regions); split the \
rule or merge the conditions"
.to_owned(),
),
});
continue;
}
let (region, region_case_arms) = regions[0].clone();
if count_effect_statements(®ion.body) == 0 {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: region.span,
message: format!(
"the `{}` region in rule `{}` contains no progression",
if region.until { "until" } else { "during" },
rule.name.name
),
suggestion: Some(
"a region around purely-atomic actions commits with admission and can \
never lapse between steps; it needs at least one effect with a \
continuation"
.to_owned(),
),
});
continue;
}
let mut region_bindings = BTreeSet::new();
collect_all_binding_names(®ion.body, &mut region_bindings);
if let Some(view) = ®ion.lapse_binding {
region_bindings.remove(view);
}
let mut arm_roots = BTreeSet::new();
collect_statement_roots(®ion.lapse_body, &mut arm_roots);
for root in &arm_roots {
if region_bindings.contains(root) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: region.span,
message: format!(
"the `on lapse` arm of rule `{}` references `{root}`, a binding the \
region introduces — it may not exist when the arm runs",
rule.name.name
),
suggestion: Some(
"reference only bindings from before the region, or bind the \
progress view (`on lapse as got`) and read `got.<binding>` — its \
fields are present exactly if that step settled"
.to_owned(),
),
});
}
}
let base = rule.body.span.start;
let text = rule.body.text.clone();
let clamp = |offset: usize| offset.saturating_sub(base).min(text.len());
let (r_start, r_end) = (clamp(region.span.start), clamp(region.span.end));
let (b_start, b_end) = (clamp(region.body_span.start), clamp(region.body_span.end));
let (l_start, l_end) = (clamp(region.lapse_span.start), clamp(region.lapse_span.end));
if !(r_start <= b_start
&& b_start <= b_end
&& b_end <= l_start
&& l_start <= l_end
&& l_end <= r_end)
{
diagnostics.push(Diagnostic {
related: Vec::new(),
span: region.span,
message: format!(
"internal: region span reconstruction failed for rule `{}`",
rule.name.name
),
suggestion: None,
});
continue;
}
let body_content = &text[b_start..b_end];
let arm_content = &text[l_start..l_end];
let variant_holds = format!("{}{}{}", &text[..r_start], body_content, &text[r_end..]);
let variant_removed = format!("{}{}", &text[..r_start], &text[r_end..]);
let variant_lapsed = format!("{}{}{}", &text[..r_start], arm_content, &text[r_end..]);
let mut effect_bindings = BTreeSet::new();
collect_effect_binding_names(®ion.body, &mut effect_bindings);
let (holds_ast, _) = body::parse_rule_body(&variant_holds, 0);
let mut region_effects = Vec::new();
assign_region_effect_scopes(
&holds_ast.statements,
None,
&effect_bindings,
&mut region_effects,
);
pending.insert(
rule.name.name.clone(),
IrRegion {
until: region.until,
condition: region.condition.clone(),
lapse_binding: region.lapse_binding.clone(),
effects: region_effects,
body_removed: variant_removed,
body_lapsed: variant_lapsed,
arm_content: arm_content.to_owned(),
arm_case_arms: region_case_arms,
},
);
rule.body.text = variant_holds;
}
pending
}
fn collect_region_blocks(
statements: &[body::BodyStmt],
case_arms: &[(String, String)],
out: &mut Vec<(body::RegionBlock, Vec<(String, String)>)>,
) {
for statement in statements {
match statement {
body::BodyStmt::Region(region) => {
out.push((region.clone(), case_arms.to_vec()));
collect_region_blocks(®ion.body, case_arms, out);
collect_region_blocks(®ion.lapse_body, case_arms, out);
}
body::BodyStmt::After(after) => collect_region_blocks(&after.body, case_arms, out),
body::BodyStmt::Case(case) => {
for branch in &case.branches {
let mut nested = case_arms.to_vec();
nested.push((case.scrutinee.clone(), branch.pattern.clone()));
collect_region_blocks(&branch.body, &nested, out);
}
}
_ => {}
}
}
}
fn count_effect_statements(statements: &[body::BodyStmt]) -> usize {
let mut count = 0;
for statement in statements {
match statement {
body::BodyStmt::Effect(_) => count += 1,
body::BodyStmt::After(after) => count += count_effect_statements(&after.body),
body::BodyStmt::Case(case) => {
for branch in &case.branches {
count += count_effect_statements(&branch.body);
}
}
body::BodyStmt::Region(region) => {
count += count_effect_statements(®ion.body);
}
_ => {}
}
}
count
}
fn collect_effect_binding_names(statements: &[body::BodyStmt], out: &mut BTreeSet<String>) {
for statement in statements {
match statement {
body::BodyStmt::Effect(effect) => {
if let Some(binding) = &effect.binding {
out.insert(binding.clone());
}
}
body::BodyStmt::After(after) => collect_effect_binding_names(&after.body, out),
body::BodyStmt::Case(case) => {
for branch in &case.branches {
collect_effect_binding_names(&branch.body, out);
}
}
body::BodyStmt::Region(region) => {
collect_effect_binding_names(®ion.body, out);
}
_ => {}
}
}
}
fn assign_region_effect_scopes(
statements: &[body::BodyStmt],
level1: Option<&(String, String)>,
region_bindings: &BTreeSet<String>,
out: &mut Vec<IrRegionEffect>,
) {
for statement in statements {
match statement {
body::BodyStmt::Effect(effect) => {
if let Some(binding) = &effect.binding {
if region_bindings.contains(binding)
&& !out.iter().any(|known| &known.binding == binding)
{
out.push(IrRegionEffect {
binding: binding.clone(),
scope: level1.cloned(),
});
}
}
}
body::BodyStmt::After(after) => {
let own = (
after.binding.clone(),
after.predicate.kernel_str().to_owned(),
);
let next = level1.cloned().unwrap_or(own);
assign_region_effect_scopes(&after.body, Some(&next), region_bindings, out);
}
body::BodyStmt::Case(case) => {
for branch in &case.branches {
assign_region_effect_scopes(&branch.body, level1, region_bindings, out);
}
}
body::BodyStmt::Region(region) => {
assign_region_effect_scopes(®ion.body, level1, region_bindings, out);
}
_ => {}
}
}
}
fn collect_statement_roots(statements: &[body::BodyStmt], out: &mut BTreeSet<String>) {
fn roots_in_expr(source: &str, out: &mut BTreeSet<String>) {
let bytes = source.as_bytes();
let mut i = 0;
let mut in_string = false;
while i < bytes.len() {
let c = bytes[i] as char;
if c == '"' {
in_string = !in_string;
i += 1;
continue;
}
if in_string {
i += 1;
continue;
}
if c.is_ascii_alphabetic() || c == '_' {
let start = i;
while i < bytes.len() {
let cj = bytes[i] as char;
if cj.is_ascii_alphanumeric() || cj == '_' {
i += 1;
} else {
break;
}
}
let preceded_by_dot = start > 0 && bytes[start - 1] as char == '.';
if !preceded_by_dot {
out.insert(source[start..i].to_owned());
}
continue;
}
i += 1;
}
}
fn roots_in_fields(fields: &[body::FieldAssign], out: &mut BTreeSet<String>) {
for field in fields {
match &field.value {
body::FieldValue::Expr { source, .. } => roots_in_expr(source, out),
body::FieldValue::Nested { fields, .. } => roots_in_fields(fields, out),
body::FieldValue::Shorthand => {
out.insert(field.name.clone());
}
}
}
}
fn roots_in_prompt(text: &str, out: &mut BTreeSet<String>) {
let mut rest = text;
while let Some(open) = rest.find("{{") {
let tail = &rest[open + 2..];
let Some(close) = tail.find("}}") else {
break;
};
roots_in_expr(&tail[..close], out);
rest = &tail[close + 2..];
}
}
for statement in statements {
match statement {
body::BodyStmt::Record(record) => roots_in_fields(&record.fields, out),
body::BodyStmt::Done {
binding,
replacement,
..
} => {
out.insert(binding.clone());
if let Some(record) = replacement {
roots_in_fields(&record.fields, out);
}
}
body::BodyStmt::Cancel { binding, .. } => {
out.insert(binding.clone());
}
body::BodyStmt::Effect(effect) => {
if let Some(prompt) = &effect.prompt {
roots_in_prompt(&prompt.text, out);
}
match &effect.kind {
body::BodyEffectKind::Coerce { args, .. } => {
for arg in args {
roots_in_expr(arg, out);
}
}
body::BodyEffectKind::TrackerFinish { item, fields } => {
out.insert(item.clone());
roots_in_fields(fields, out);
}
body::BodyEffectKind::TrackerRelease { item } => {
out.insert(item.clone());
}
_ => {}
}
}
body::BodyStmt::Terminal(terminal) => {
roots_in_fields(&terminal.fields, out);
if let Some(body::FieldValue::Expr { source, .. }) = &terminal.scalar {
roots_in_expr(source, out);
}
}
body::BodyStmt::Milestone { fields, .. } => roots_in_fields(fields, out),
body::BodyStmt::After(after) => collect_statement_roots(&after.body, out),
body::BodyStmt::Case(case) => {
roots_in_expr(&case.scrutinee, out);
for branch in &case.branches {
collect_statement_roots(&branch.body, out);
}
}
body::BodyStmt::Region(region) => {
collect_statement_roots(®ion.body, out);
collect_statement_roots(®ion.lapse_body, out);
}
body::BodyStmt::Redact { source, .. } => {
out.insert(source.clone());
}
}
}
}
fn validate_effectful_self_trigger(
rule: &RuleDecl,
metadata: &IrRuleMetadata,
diagnostics: &mut Vec<Diagnostic>,
) {
if metadata.effects.is_empty() {
return;
}
for written_fact in &metadata.fact_writes {
if metadata.fact_reads.contains(written_fact)
&& !metadata.fact_consumes.contains(written_fact)
{
diagnostics.push(Diagnostic { related: Vec::new(),
span: rule.body.span,
message: format!(
"effectful rule `{}` preserves trigger fact `{written_fact}`",
rule.name.name
),
suggestion: Some(
"consume or advance the triggering fact, or move the next effect behind an external completion event"
.to_owned(),
),
});
}
}
}
fn binding_types_for_rule(rule: &RuleDecl) -> BTreeMap<String, String> {
let mut binding_types = BTreeMap::new();
for when in &rule.whens {
if let Some((binding, schema)) = binding_from_when(&when.text) {
binding_types.insert(binding, schema);
}
}
binding_types
}
fn validate_workflow_terminal_actions(
rule: &RuleDecl,
semantic: &SemanticContext,
binding_types: &BTreeMap<String, String>,
known_roots: &BTreeSet<String>,
contracts: &WorkflowContractNames,
diagnostics: &mut Vec<Diagnostic>,
) {
for line in rule.body.text.lines().map(str::trim) {
let terminal = line
.strip_prefix("complete ")
.map(|rest| ("complete", rest, &contracts.outputs))
.or_else(|| {
line.strip_prefix("fail ")
.map(|rest| ("fail", rest, &contracts.failures))
});
let Some((action, rest, declared)) = terminal else {
continue;
};
if !rest.contains('{') {
let tokens: Vec<&str> = rest.split_whitespace().collect();
let is_from = matches!(tokens.as_slice(), [_, "from", ..]) && action == "complete";
if tokens.len() >= 2 && !is_from {
let name = tokens[0];
let value = rest.trim().get(name.len()..).unwrap_or("").trim();
if !declared.contains_key(name) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!(
"rule `{}` {action}s unknown workflow terminal `{name}`",
rule.name.name
),
suggestion: Some(format!(
"declare `{kind} {name} Type` on the workflow first",
kind = if action == "complete" {
"output"
} else {
"failure"
}
)),
});
continue;
}
if let Some(contract_ty) = declared.get(name) {
validate_scalar_terminal_payload(
rule,
action,
name,
value,
contract_ty,
semantic,
binding_types,
known_roots,
diagnostics,
);
}
continue;
}
}
let Some(name) = rest.split('{').next().and_then(|header| {
let mut parts = header.split_whitespace();
match (parts.next(), parts.next(), parts.next()) {
(Some(name), None, _) => Some(name),
(Some(name), Some("from"), Some(binding))
if action == "complete" && is_identifier(binding) =>
{
Some(name)
}
_ => None,
}
}) else {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!("rule `{}` has malformed `{action}` action", rule.name.name),
suggestion: Some(format!(
"{action} a declared workflow terminal with a payload block"
)),
});
continue;
};
if !declared.contains_key(name) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!(
"rule `{}` {action}s unknown workflow terminal `{name}`",
rule.name.name
),
suggestion: Some(format!(
"declare `{kind} {name} Type` on the workflow first",
kind = if action == "complete" {
"output"
} else {
"failure"
}
)),
});
continue;
}
let Some(contract_ty) = declared.get(name) else {
continue;
};
validate_workflow_terminal_payload(
rule,
action,
name,
contract_ty,
semantic,
binding_types,
known_roots,
diagnostics,
);
}
}
#[allow(clippy::too_many_arguments)]
fn validate_workflow_terminal_payload(
rule: &RuleDecl,
action: &str,
terminal_name: &str,
contract_ty: &TypeSyntax,
semantic: &SemanticContext,
binding_types: &BTreeMap<String, String>,
known_roots: &BTreeSet<String>,
diagnostics: &mut Vec<Diagnostic>,
) {
let Some((_, _, body)) = workflow_terminal_blocks(&rule.body.text).into_iter().find(
|(candidate_action, candidate_name, _)| {
candidate_action == action && candidate_name == terminal_name
},
) else {
return;
};
let schema = match contract_ty {
TypeSyntax::Ref { name } if semantic.schemas.class_exists(&name.name) => &name.name,
TypeSyntax::Primitive { .. }
| TypeSyntax::LiteralString { .. }
| TypeSyntax::Union { .. } => {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!(
"workflow terminal `{terminal_name}` has a scalar payload contract but is given a field block"
),
suggestion: Some(format!(
"write a bare scalar value: `{action} {terminal_name} <value>`"
)),
});
return;
}
_ => {
diagnostics.push(Diagnostic { related: Vec::new(),
span: rule.body.span,
message: format!(
"workflow terminal `{terminal_name}` uses an unsupported payload contract type"
),
suggestion: Some(
"declare the terminal payload as a class (field block) or a scalar type (number/string/bool)"
.to_owned(),
),
});
return;
}
};
for assignment in collect_field_assignments(&body) {
let (field, value) = match assignment {
RecordFieldAssignment::Value { field, value } => (field, value),
RecordFieldAssignment::Shorthand { field } => (field.clone(), field),
};
let line = format!("{field} {value}");
validate_record_field(
rule,
&line,
schema,
semantic,
binding_types,
known_roots,
diagnostics,
);
}
validate_required_terminal_fields(rule, schema, terminal_name, &body, semantic, diagnostics);
}
#[allow(clippy::too_many_arguments)]
fn validate_scalar_terminal_payload(
rule: &RuleDecl,
action: &str,
terminal_name: &str,
value: &str,
contract_ty: &TypeSyntax,
semantic: &SemanticContext,
binding_types: &BTreeMap<String, String>,
known_roots: &BTreeSet<String>,
diagnostics: &mut Vec<Diagnostic>,
) {
if let TypeSyntax::Ref { name } = contract_ty {
if semantic.schemas.class_exists(&name.name) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!(
"workflow terminal `{terminal_name}` has a class payload contract `{}` but is given a bare scalar value",
name.name
),
suggestion: Some(format!("write a field block: `{action} {terminal_name} {{ … }}`")),
});
return;
}
}
if value.is_empty() {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!("workflow terminal `{terminal_name}` is missing its scalar value"),
suggestion: Some(format!("write `{action} {terminal_name} <value>`")),
});
return;
}
validate_literal_assignment(
rule,
terminal_name,
"value",
contract_ty,
value,
semantic,
diagnostics,
);
if let Some(root) = dangling_value_root(value, known_roots) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!(
"rule `{}` has unknown binding `{root}` in `{action} {terminal_name}` value",
rule.name.name
),
suggestion: Some(
"reference a binding from a `when ... as name` clause, an effect `as` binding, or a `case` pattern"
.to_owned(),
),
});
} else if let Some((root, path)) = expression_path(value) {
check_field_path(
rule,
&root,
&path,
rule.body.span,
SchemaScopes::local(&semantic.schemas),
binding_types,
diagnostics,
);
}
}
fn validate_required_terminal_fields(
rule: &RuleDecl,
schema: &str,
terminal_name: &str,
body: &str,
semantic: &SemanticContext,
diagnostics: &mut Vec<Diagnostic>,
) {
let Some(schema_fields) = semantic.schemas.classes.get(schema) else {
return;
};
let seen = collect_field_assignments(body)
.into_iter()
.map(|assignment| match assignment {
RecordFieldAssignment::Value { field, .. }
| RecordFieldAssignment::Shorthand { field } => field,
})
.collect::<BTreeSet<_>>();
for (required, ty) in schema_fields {
if seen.contains(required) || matches!(ty, TypeSyntax::Optional { .. }) {
continue;
}
diagnostics.push(Diagnostic { related: Vec::new(),
span: rule.body.span,
message: format!(
"workflow terminal `{terminal_name}` is missing required field `{schema}.{required}`"
),
suggestion: Some(format!("add `{required}` to the `{terminal_name}` payload")),
});
}
}
fn max_after_depth(statements: &[body::BodyStmt]) -> usize {
use body::BodyStmt;
statements
.iter()
.map(|statement| match statement {
BodyStmt::After(after) => 1 + max_after_depth(&after.body),
BodyStmt::Case(case) => case
.branches
.iter()
.map(|branch| max_after_depth(&branch.body))
.max()
.unwrap_or(0),
_ => 0,
})
.max()
.unwrap_or(0)
}
fn analyze_rule(
rule: &RuleDecl,
semantic: &SemanticContext,
diagnostics: &mut Vec<Diagnostic>,
) -> IrRuleMetadata {
let (body_ast, body_diagnostics) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
diagnostics.extend(body_diagnostics);
let mut metadata = IrRuleMetadata {
fact_reads: rule
.whens
.iter()
.map(|when| fact_read_from_when(&when.text))
.collect(),
max_after_depth: max_after_depth(&body_ast.statements),
..IrRuleMetadata::default()
};
let mut seen_bindings = BTreeSet::new();
let mut binding_types = BTreeMap::new();
let mut foreign_schemas: BTreeMap<String, String> = BTreeMap::new();
for when in &rule.whens {
let (pattern_text, _) = split_when_guard(&when.text);
if binding_after_as(pattern_text).is_some()
&& binding_from_when(&when.text).is_none()
&& !pattern_text.ends_with(" is available")
{
diagnostics.push(Diagnostic {
related: Vec::new(),
span: when.span,
message: format!(
"rule `{}` has unknown readiness pattern `{pattern_text}`",
rule.name.name
),
suggestion: Some(
"match a class (`when Class as x`) or a runtime fact (`when fact <name> as x`)"
.to_owned(),
),
});
}
if let Some((binding, schema)) = binding_from_when(&when.text) {
validate_binding_name(rule, &binding, when.span, diagnostics);
if !schema.contains('.') && !semantic.schemas.class_exists(&schema) {
let suggestion = match closest_name(&schema, semantic.schemas.classes.keys()) {
Some(candidate) => {
format!("did you mean `{candidate}`? otherwise declare `class {schema}`")
}
None => format!("declare `class {schema}` before matching it"),
};
diagnostics.push(Diagnostic {
related: Vec::new(),
span: when.span,
message: format!("rule `{}` matches unknown class `{schema}`", rule.name.name),
suggestion: Some(suggestion),
});
}
if schema.contains('.')
&& !pattern_text.trim_start().starts_with("fact ")
&& !semantic.schemas.events.contains(&schema)
{
diagnostics.push(Diagnostic { related: Vec::new(),
span: when.span,
message: format!(
"rule `{}` reacts to undeclared signal `{schema}`",
rule.name.name
),
suggestion: Some(format!(
"declare `signal {schema} {{ ... }}` for a typed reaction, or use `when fact {schema} as ...` for an untyped one"
)),
});
}
binding_types.insert(binding, schema);
}
}
let mut effect_payload_types = collect_effect_payload_types(rule, semantic, diagnostics);
collect_exec_payload_types(&body_ast.statements, semantic, &mut effect_payload_types);
collect_decide_payload_types(
&body_ast.statements,
&rule.name.name,
&mut effect_payload_types,
);
collect_prompt_payload_types(&body_ast.statements, &mut effect_payload_types);
collect_redact_payload_types(
&body_ast.statements,
&rule.name.name,
&mut effect_payload_types,
);
for (binding, payload_type) in &effect_payload_types {
if let IrType::Ref(schema) = payload_type {
binding_types.insert(binding.clone(), schema.clone());
}
}
let vcs_verb_bindings: BTreeMap<String, (&'static str, &'static str)> = rule
.body
.text
.lines()
.filter_map(|line| {
let line = line.trim();
let (verb, negative) = if line.starts_with("promote ") {
("promote", "Conflicted")
} else if line.starts_with("undo ") {
("undo", "Stranded")
} else if line.starts_with("transport ") {
("transport", "Conflicted")
} else {
return None;
};
Some((binding_after_as(line)?, (verb, negative)))
})
.collect();
let mut effect_binding_kinds: BTreeMap<String, IrEffectKind> = rule
.body
.text
.lines()
.filter_map(|line| {
let line = line.trim();
if line.starts_with("exec ") {
return Some((binding_after_as(line)?, IrEffectKind::ExecCommand));
}
let (kind, binding) = parse_effect_line(line)?;
Some((binding?, kind))
})
.collect();
for statement in effect_payload_statements(&rule.body.text) {
if let Some((kind, Some(binding))) = parse_effect_line(statement.trim()) {
effect_binding_kinds.insert(binding, kind);
}
}
for line in rule.body.text.lines() {
let Some(rest) = line.trim().strip_prefix("after ") else {
continue;
};
let mut words = rest.split_whitespace();
let Some(binding) = words.next() else {
continue;
};
let Some(predicate) = words.next() else {
continue;
};
if predicate == "succeeds" {
if let Some((verb, negative)) = vcs_verb_bindings.get(binding) {
let positive = if *verb == "promote" {
"promoted"
} else {
"applied"
};
let negative_arm = negative.to_lowercase();
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!(
"rule `{}` observes {verb} `{binding}` with `succeeds`, which also \
matches a {negative} outcome (the op completes either way)",
rule.name.name
),
suggestion: Some(format!(
"use `after {binding} {positive}` / `after {binding} {negative_arm}` \
for the outcome variants, or `after {binding} completes` for any \
settled outcome"
)),
});
}
}
if predicate == "succeeds" {
match effect_binding_kinds.get(binding) {
Some(IrEffectKind::LeaseAcquire) => {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!(
"rule `{}` observes acquire `{binding}` with `succeeds`, which also \
matches a Contended outcome (the acquire op completes either way)",
rule.name.name
),
suggestion: Some(format!(
"use `after {binding} held` / `after {binding} contended` for the \
outcome variants, or `after {binding} completes` for any settled \
outcome"
)),
});
}
Some(IrEffectKind::CounterConsume) => {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!(
"rule `{}` observes counter consume `{binding}` with `succeeds`, \
which also matches an Over outcome (the consume op completes \
either way)",
rule.name.name
),
suggestion: Some(format!(
"use `after {binding} ok` / `after {binding} over` for the outcome \
variants, or `after {binding} completes` for any settled outcome"
)),
});
}
_ => {}
}
}
if predicate == "reaches" {
let Some(quoted) = words.next() else {
continue;
};
let milestone = quoted.trim_matches('"');
let (Some("as"), Some(alias)) = (words.next(), words.next()) else {
continue;
};
let alias = alias.trim_end_matches('{').trim();
if alias.is_empty() {
continue;
}
if let Some((workflow, class)) =
milestone_payload_class(rule, binding, milestone, semantic)
{
if !class.is_empty() {
binding_types.insert(alias.to_owned(), class);
foreign_schemas.insert(alias.to_owned(), workflow);
}
}
continue;
}
if predicate == "times" && words.next() != Some("out") {
continue;
}
let (Some(keyword), Some(alias)) = (words.next(), words.next()) else {
continue;
};
if keyword != "as" {
continue;
}
let alias = alias.trim_end_matches('{').trim();
if alias.is_empty() {
continue;
}
match predicate {
"times" => {
binding_types.insert(alias.to_owned(), "TerminalTimedOut".to_owned());
}
"cancelled" => {
binding_types.insert(alias.to_owned(), "TerminalCancelled".to_owned());
}
"completes" => {
binding_types.insert(alias.to_owned(), "TerminalOutcome".to_owned());
}
"fails" => {
if let Some((workflow, class)) = invoke_failure_class(rule, binding, semantic) {
binding_types.insert(alias.to_owned(), class);
foreign_schemas.insert(alias.to_owned(), workflow);
} else {
let schema = match effect_binding_kinds.get(binding) {
Some(IrEffectKind::ExecCommand) => "TerminalFailedExec",
Some(IrEffectKind::SchemaCoerce) => "TerminalFailedCoerce",
Some(IrEffectKind::AgentTell) => "TerminalFailedTell",
_ => "TerminalFailed",
};
binding_types.insert(alias.to_owned(), schema.to_owned());
}
}
_ => {
if let Some(IrType::Ref(schema)) = effect_payload_types.get(binding) {
binding_types.insert(alias.to_owned(), schema.clone());
} else if let Some((workflow, class)) = invoke_output_class(rule, binding, semantic)
{
binding_types.insert(alias.to_owned(), class);
foreign_schemas.insert(alias.to_owned(), workflow);
}
}
}
}
for when in &rule.whens {
if let (_, Some(guard)) = split_when_guard(&when.text) {
validate_expression(rule, guard, semantic, &binding_types, "guard", diagnostics);
validate_known_field_paths(rule, guard, semantic, &binding_types, diagnostics);
if let Some(expr) = lower_expression(guard, when.span) {
metadata
.projection_reads
.extend(collect_projection_reads(&expr.expr));
}
}
validate_availability_when(rule, &when.text, semantic, &binding_types, diagnostics);
}
validate_case_blocks(rule, semantic, &binding_types, diagnostics);
metadata.case_branches =
collect_rule_case_metadata(rule, semantic, &binding_types, diagnostics);
let terminal_metadata = collect_terminal_case_metadata(
rule,
semantic,
&binding_types,
&effect_payload_types,
diagnostics,
);
let mut known_roots: BTreeSet<String> = binding_types.keys().cloned().collect();
collect_all_binding_names(&body_ast.statements, &mut known_roots);
validate_record_blocks(rule, semantic, &binding_types, &known_roots, diagnostics);
validate_effect_payloads(rule, semantic, &binding_types, &known_roots, diagnostics);
validate_effect_field_roots(rule, &body_ast.statements, &known_roots, diagnostics);
validate_emit_signal_declarations(
rule,
&body_ast.statements,
&semantic.schemas.events,
diagnostics,
);
validate_workflow_invocations(rule, semantic, &binding_types, &known_roots, diagnostics);
validate_milestone_statements(rule, semantic, diagnostics);
let mut block_stack: Vec<BlockFrame> = Vec::new();
let mut misplaced_effect_bindings = BTreeSet::new();
seed_ast_only_effect_bindings(&body_ast.statements, &mut seen_bindings, &mut binding_types);
validate_body_effect_operands(
rule,
&body_ast.statements,
semantic,
&binding_types,
diagnostics,
);
validate_coordination_discipline(rule, &body_ast.statements, diagnostics);
validate_redactions(
rule,
&body_ast.statements,
semantic,
&binding_types,
diagnostics,
);
validate_conditioned_field_reads(
rule,
&body_ast.statements,
semantic,
&binding_types,
&BTreeSet::new(),
diagnostics,
);
let mut anonymous_effects = 0usize;
let mut record_depth = 0i32;
for raw_line in rule.body.text.lines() {
let line = raw_line.trim();
if line.is_empty() {
continue;
}
if record_depth > 0 {
record_depth += brace_delta(line);
continue;
}
if let Some(binding) = binding_after_multiline_string_end(line) {
misplaced_effect_bindings.insert(binding.clone());
diagnostics.push(Diagnostic { related: Vec::new(),
span: rule.body.span,
message: format!(
"rule `{}` places effect binding `{binding}` after a multiline string delimiter",
rule.name.name
),
suggestion: Some(format!(
"move `as {binding}` onto the effect line, before the multiline string body"
)),
});
continue;
}
validate_rule_prompt_content_type_annotation(rule, line, diagnostics);
if line.starts_with('}') {
block_stack.pop();
continue;
}
if line.starts_with("case ") || (!line.starts_with("after ") && is_case_branch_start(line))
{
validate_known_field_paths_scoped(
rule,
line,
semantic,
&binding_types,
&foreign_schemas,
diagnostics,
);
continue;
}
let active_afters = after_scopes(&block_stack);
validate_binding_uses(rule, line, &seen_bindings, &active_afters, diagnostics);
validate_known_field_paths_scoped(
rule,
line,
semantic,
&binding_types,
&foreign_schemas,
diagnostics,
);
if let Some(binding) = parse_consume_line(line) {
match binding_types.get(&binding) {
Some(schema) => metadata.fact_consumes.push(format!("schema:{schema}")),
None => diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!(
"rule `{}` consumes unknown fact binding `{binding}`",
rule.name.name
),
suggestion: Some(
"consume a binding introduced by a `when Class as binding` clause"
.to_owned(),
),
}),
}
if !line.contains("->") {
continue;
}
}
if line.starts_with("after ") {
if let Some(alias) = binding_after_as(line) {
validate_binding_name(rule, &alias, rule.body.span, diagnostics);
}
match parse_after_line(line) {
Some((binding, predicate)) => {
if !seen_bindings.contains(&binding) {
let suggestion = if misplaced_effect_bindings.contains(&binding) {
format!(
"move `as {binding}` onto the effect line before the multiline string"
)
} else {
format!("create an effect with `as {binding}` before the `after` block")
};
diagnostics.push(Diagnostic { related: Vec::new(),
span: rule.body.span,
message: format!(
"rule `{}` has `after` block for unknown effect binding `{binding}`",
rule.name.name
),
suggestion: Some(suggestion),
});
}
block_stack.push(BlockFrame::After { binding, predicate });
}
None => {
diagnostics.push(Diagnostic { related: Vec::new(),
span: rule.body.span,
message: format!(
"rule `{}` has unsupported `after` dependency predicate",
rule.name.name
),
suggestion: Some(
"use `after name succeeds`, `after name fails`, `after name completes`, `after name times out`, or `after name cancelled`"
.to_owned(),
),
});
}
}
continue;
}
if let Some((schema, _)) = parse_record_start(line) {
if is_observer_only_schema(&schema) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!(
"rule `{}` cannot record kernel-owned terminal schema `{schema}`",
rule.name.name
),
suggestion: Some(
"the terminal family (`TerminalFailed`/`TerminalTimedOut`/`TerminalCancelled`) is produced only by the kernel; to fail this workflow use `fail <failure> { ... }`, and to react to an effect terminal use `after <effect> fails/times out/cancels as f`"
.to_owned(),
),
});
} else if !semantic.schemas.class_exists(&schema) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!("rule `{}` records unknown class `{schema}`", rule.name.name),
suggestion: Some(format!("declare `class {schema}` before recording it")),
});
}
metadata.fact_writes.push(format!("schema:{schema}"));
record_depth = brace_delta(line).max(1);
continue;
}
if let Some((kind, binding)) = parse_effect_line(line) {
validate_agent_tell_target(
rule,
line,
&kind,
semantic,
&binding_types,
&known_roots,
diagnostics,
);
anonymous_effects += 1;
let id = binding
.clone()
.unwrap_or_else(|| format!("effect{anonymous_effects}"));
if let Some(binding) = &binding {
validate_binding_name(rule, binding, rule.body.span, diagnostics);
seen_bindings.insert(binding.clone());
if let Some(schema) = effect_binding_schema(line, &kind, semantic) {
binding_types.insert(binding.clone(), schema);
}
}
for (upstream, predicate) in after_scopes(&block_stack) {
metadata.dependencies.push(IrEffectDependency {
upstream,
predicate,
downstream: id.clone(),
});
}
let idempotency_key = effect_idempotency_key(&rule.name.name, &id, &kind, &binding);
metadata.effects.push(IrEffectNode {
id,
kind,
binding,
required_capabilities: parse_required_capabilities(line),
construct_use: None,
idempotency_key,
span: rule.body.span,
timeout_seconds: None,
access_grants: Vec::new(),
turn_skills: Vec::new(),
on_stream: None,
selection_source: None,
transport_onto: None,
resource: None,
agent: None,
coerce_target: None,
workflow_target: None,
endorsed: false,
declassified: false,
selected_by: None,
exec_target: None,
});
}
}
let (ast_effects, ast_dependencies) =
collect_effects_from_ast(&body_ast.statements, &rule.name.name);
metadata.effects = ast_effects;
metadata.dependencies = ast_dependencies;
push_ingest_fact_writes(&body_ast.statements, &mut metadata.fact_writes);
metadata.fact_reads.sort();
metadata.fact_reads.dedup();
sort_projection_reads(&mut metadata.projection_reads);
metadata.fact_writes.sort();
metadata.fact_writes.dedup();
metadata.fact_consumes.sort();
metadata.fact_consumes.dedup();
metadata.terminal_outputs = terminal_metadata.outputs;
metadata.terminal_branches = terminal_metadata.branches;
for branch in &metadata.case_branches {
if let Some(guard) = &branch.guard {
metadata
.projection_reads
.extend(collect_projection_reads(&guard.expr));
}
}
for branch in &metadata.terminal_branches {
if let Some(guard) = &branch.guard {
metadata
.projection_reads
.extend(collect_projection_reads(&guard.expr));
}
}
sort_projection_reads(&mut metadata.projection_reads);
if let Some(region) = semantic.regions.get(&rule.name.name) {
validate_lapse_arm(
rule,
region,
semantic,
&binding_types,
&foreign_schemas,
&effect_payload_types,
diagnostics,
);
}
collect_terminal_complete_bindings(&body_ast.statements, &mut metadata.terminal_completes);
metadata.terminal_completes.sort();
metadata.terminal_completes.dedup();
collect_redaction_metadata(
&body_ast.statements,
&binding_types,
&mut metadata.redactions,
);
collect_bounded_egresses(
&body_ast.statements,
&binding_types,
&mut metadata.bounded_egresses,
);
let mut egress_reads = Vec::new();
collect_egress_payload_reads(&body_ast.statements, &mut egress_reads);
for (sink, roots) in egress_reads {
metadata
.egress_payload_reads
.entry(sink)
.or_default()
.extend(roots);
}
collect_complete_field_reads(&body_ast.statements, &mut metadata.complete_field_reads);
collect_record_field_reads(&body_ast.statements, &mut metadata.record_field_reads);
collect_milestone_field_reads(&body_ast.statements, &mut metadata.milestone_field_reads);
collect_crossing_roots(
&body_ast.statements,
&mut metadata.declassified_roots,
&mut metadata.endorsed_roots,
&mut metadata.endorsed_claim_items,
);
collect_provenance_metadata(
&body_ast.statements,
&mut metadata.coerce_input_roots,
&mut metadata.after_aliases,
);
collect_egress_case_influence(
&body_ast.statements,
&mut Vec::new(),
&mut metadata.egress_case_influence,
);
loop {
let mut changed = false;
for redaction in &metadata.redactions {
if metadata.declassified_roots.contains(&redaction.source)
&& metadata
.declassified_roots
.insert(redaction.binding.clone())
{
changed = true;
}
if metadata.endorsed_roots.contains(&redaction.source)
&& metadata.endorsed_roots.insert(redaction.binding.clone())
{
changed = true;
}
}
if !changed {
break;
}
}
metadata
}
fn collect_egress_case_influence(
statements: &[body::BodyStmt],
active: &mut Vec<BTreeSet<String>>,
out: &mut BTreeMap<String, BTreeSet<String>>,
) {
let record_sink = |sink: String,
active: &[BTreeSet<String>],
out: &mut BTreeMap<String, BTreeSet<String>>| {
if active.is_empty() {
return;
}
let entry = out.entry(sink).or_default();
for roots in active {
entry.extend(roots.iter().cloned());
}
};
for statement in statements {
match statement {
body::BodyStmt::Terminal(terminal) if terminal.kind == body::TerminalKind::Complete => {
record_sink(terminal.name.clone(), active, out);
}
body::BodyStmt::Record(record) => {
record_sink(format!("fact:{}", record.schema), active, out);
}
body::BodyStmt::Done {
replacement: Some(record),
..
} => {
record_sink(format!("fact:{}", record.schema), active, out);
}
body::BodyStmt::Milestone { name, .. } => {
record_sink(format!("milestone:{name}"), active, out);
}
body::BodyStmt::Effect(effect) => match &effect.kind {
body::BodyEffectKind::ConstructCapabilityCall {
keyword, fields, ..
} if keyword == "send" => {
if let Some(channel) = fields
.iter()
.find(|field| field.name == "channel")
.map(|field| field.source.clone())
{
record_sink(channel, active, out);
}
}
body::BodyEffectKind::FileWrite { store, .. } => {
record_sink(store.clone(), active, out);
}
_ => {}
},
body::BodyStmt::After(after) => {
collect_egress_case_influence(&after.body, active, out);
}
body::BodyStmt::Case(case) => {
let mut roots = BTreeSet::new();
if let Ok(expr) = parse_expression(&case.scrutinee) {
collect_expr_binding_roots(&expr, &mut roots);
} else {
collect_template_binding_roots(&case.scrutinee, &mut roots);
}
active.push(roots);
for branch in &case.branches {
collect_egress_case_influence(&branch.body, active, out);
}
active.pop();
}
_ => {}
}
}
}
fn collect_provenance_metadata(
statements: &[body::BodyStmt],
coerce_input_roots: &mut BTreeMap<String, BTreeSet<String>>,
after_aliases: &mut BTreeMap<String, String>,
) {
for statement in statements {
match statement {
body::BodyStmt::Effect(effect) => {
if let body::BodyEffectKind::Coerce { args, .. } = &effect.kind {
if let Some(binding) = &effect.binding {
let mut roots = BTreeSet::new();
for arg in args {
if let Ok(expr) = parse_expression(arg) {
collect_expr_binding_roots(&expr, &mut roots);
} else {
collect_template_binding_roots(arg, &mut roots);
}
}
coerce_input_roots
.entry(binding.clone())
.or_default()
.extend(roots);
}
}
}
body::BodyStmt::After(after) => {
if matches!(
after.predicate,
body::AfterPredicate::Succeeds | body::AfterPredicate::Completes
) {
if let Some(alias) = &after.alias {
after_aliases.insert(alias.clone(), after.binding.clone());
}
}
collect_provenance_metadata(&after.body, coerce_input_roots, after_aliases);
}
body::BodyStmt::Case(case) => {
for branch in &case.branches {
collect_provenance_metadata(&branch.body, coerce_input_roots, after_aliases);
}
}
_ => {}
}
}
}
fn collect_crossing_roots(
statements: &[body::BodyStmt],
declassified: &mut BTreeSet<String>,
endorsed: &mut BTreeSet<String>,
claim_items: &mut BTreeSet<String>,
) {
fn collect_marked(
statements: &[body::BodyStmt],
declassified: &mut BTreeSet<String>,
endorsed: &mut BTreeSet<String>,
claim_items: &mut BTreeSet<String>,
) {
for statement in statements {
match statement {
body::BodyStmt::Effect(effect) => {
if let body::BodyEffectKind::Coerce {
declassified: is_declassified,
endorsed: is_endorsed,
..
} = &effect.kind
{
if let Some(binding) = &effect.binding {
if *is_declassified {
declassified.insert(binding.clone());
}
if *is_endorsed {
endorsed.insert(binding.clone());
}
}
}
if let body::BodyEffectKind::TrackerClaim {
endorsed: is_endorsed,
item,
..
} = &effect.kind
{
if *is_endorsed {
endorsed.insert(item.clone());
claim_items.insert(item.clone());
}
}
}
body::BodyStmt::After(after) => {
collect_marked(&after.body, declassified, endorsed, claim_items)
}
body::BodyStmt::Case(case) => {
for branch in &case.branches {
collect_marked(&branch.body, declassified, endorsed, claim_items);
}
}
_ => {}
}
}
}
fn collect_aliases(
statements: &[body::BodyStmt],
declassified: &mut BTreeSet<String>,
endorsed: &mut BTreeSet<String>,
) {
for statement in statements {
match statement {
body::BodyStmt::After(after) => {
if matches!(
after.predicate,
body::AfterPredicate::Succeeds | body::AfterPredicate::Completes
) {
if let Some(alias) = &after.alias {
if declassified.contains(&after.binding) {
declassified.insert(alias.clone());
}
if endorsed.contains(&after.binding) {
endorsed.insert(alias.clone());
}
}
}
collect_aliases(&after.body, declassified, endorsed);
}
body::BodyStmt::Case(case) => {
for branch in &case.branches {
collect_aliases(&branch.body, declassified, endorsed);
}
}
_ => {}
}
}
}
collect_marked(statements, declassified, endorsed, claim_items);
loop {
let before = (declassified.len(), endorsed.len());
collect_aliases(statements, declassified, endorsed);
if (declassified.len(), endorsed.len()) == before {
break;
}
}
}
fn collect_complete_field_reads(
statements: &[body::BodyStmt],
out: &mut BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
) {
for statement in statements {
match statement {
body::BodyStmt::Terminal(terminal) if terminal.kind == body::TerminalKind::Complete => {
let per_field = out.entry(terminal.name.clone()).or_default();
for field in &terminal.fields {
let mut roots = BTreeSet::new();
match &field.value {
body::FieldValue::Shorthand => {
if let Some(root) = &terminal.from {
roots.insert(root.clone());
}
}
body::FieldValue::Expr { expr, .. } => {
collect_expr_binding_roots(expr, &mut roots)
}
body::FieldValue::Nested { fields, .. } => collect_payload_field_roots(
fields,
terminal.from.as_deref(),
&mut roots,
),
}
per_field
.entry(field.name.clone())
.or_default()
.extend(roots);
}
}
body::BodyStmt::After(after) => collect_complete_field_reads(&after.body, out),
body::BodyStmt::Case(case) => {
for branch in &case.branches {
collect_complete_field_reads(&branch.body, out);
}
}
_ => {}
}
}
}
fn collect_milestone_field_reads(
statements: &[body::BodyStmt],
out: &mut BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
) {
for statement in statements {
match statement {
body::BodyStmt::Milestone { name, fields, .. } => {
let per_field = out.entry(name.clone()).or_default();
for field in fields {
let mut roots = BTreeSet::new();
match &field.value {
body::FieldValue::Shorthand => {}
body::FieldValue::Expr { expr, .. } => {
collect_expr_binding_roots(expr, &mut roots)
}
body::FieldValue::Nested { fields, .. } => {
collect_payload_field_roots(fields, None, &mut roots)
}
}
per_field
.entry(field.name.clone())
.or_default()
.extend(roots);
}
}
body::BodyStmt::After(after) => collect_milestone_field_reads(&after.body, out),
body::BodyStmt::Case(case) => {
for branch in &case.branches {
collect_milestone_field_reads(&branch.body, out);
}
}
_ => {}
}
}
}
fn collect_redaction_metadata(
statements: &[body::BodyStmt],
binding_types: &BTreeMap<String, String>,
out: &mut Vec<IrRedaction>,
) {
let mut redacts = Vec::new();
collect_redact_effects(statements, &mut redacts);
for (source, keep, binding, _span) in redacts {
out.push(IrRedaction {
source: source.to_owned(),
keep: keep.to_vec(),
binding: binding.to_owned(),
source_schema: binding_types.get(source).cloned(),
});
}
}
fn push_bounded_projection(
from: Option<&str>,
fields: &[body::FieldAssign],
sink: String,
binding_types: &BTreeMap<String, String>,
out: &mut Vec<IrBoundedEgress>,
) {
let Some(source_schema) = from.and_then(|src| binding_types.get(src)) else {
return;
};
if fields.is_empty()
|| !fields
.iter()
.all(|field| matches!(field.value, body::FieldValue::Shorthand))
{
return;
}
out.push(IrBoundedEgress {
sink,
source_schema: source_schema.clone(),
keep: fields.iter().map(|field| field.name.clone()).collect(),
});
}
fn push_bounded_record(
record: &body::RecordStmt,
binding_types: &BTreeMap<String, String>,
out: &mut Vec<IrBoundedEgress>,
) {
push_bounded_projection(
record.from.as_deref(),
&record.fields,
format!("fact:{}", record.schema),
binding_types,
out,
);
}
fn collect_bounded_egresses(
statements: &[body::BodyStmt],
binding_types: &BTreeMap<String, String>,
out: &mut Vec<IrBoundedEgress>,
) {
for statement in statements {
match statement {
body::BodyStmt::Record(record) => push_bounded_record(record, binding_types, out),
body::BodyStmt::Done {
replacement: Some(record),
..
} => push_bounded_record(record, binding_types, out),
body::BodyStmt::Terminal(terminal)
if terminal.kind == body::TerminalKind::Complete && terminal.from.is_some() =>
{
push_bounded_projection(
terminal.from.as_deref(),
&terminal.fields,
terminal.name.clone(),
binding_types,
out,
);
}
body::BodyStmt::After(after) => {
collect_bounded_egresses(&after.body, binding_types, out)
}
body::BodyStmt::Case(case) => {
for branch in &case.branches {
collect_bounded_egresses(&branch.body, binding_types, out);
}
}
_ => {}
}
}
}
fn collect_expr_binding_roots(expr: &Expr, out: &mut BTreeSet<String>) {
match expr {
Expr::Literal(ExprLiteral::String(text)) => collect_template_binding_roots(text, out),
Expr::Literal(ExprLiteral::Ident(name)) => {
out.insert(name.clone());
}
Expr::Literal(ExprLiteral::Number(_) | ExprLiteral::Bool(_) | ExprLiteral::Null) => {}
Expr::Path(segments) => {
if let Some(root) = segments.first() {
out.insert(root.clone());
}
}
Expr::Index { target, key } => {
collect_expr_binding_roots(target, out);
collect_expr_binding_roots(key, out);
}
Expr::Array(items) => {
for item in items {
collect_expr_binding_roots(item, out);
}
}
Expr::Object(fields) => {
for field in fields {
collect_expr_binding_roots(&field.value, out);
}
}
Expr::Unary { expr, .. } => collect_expr_binding_roots(expr, out),
Expr::Binary { left, right, .. } => {
collect_expr_binding_roots(left, out);
collect_expr_binding_roots(right, out);
}
Expr::Call { args, .. } => {
for arg in args {
collect_expr_binding_roots(arg, out);
}
}
Expr::Query { head, guard, .. } => {
out.insert(head.clone());
if let Some(guard) = guard {
collect_expr_binding_roots(guard, out);
}
}
}
}
fn collect_template_binding_roots(text: &str, out: &mut BTreeSet<String>) {
let mut rest = text;
while let Some(open) = rest.find("{{") {
let after_open = &rest[open + 2..];
let Some(close) = after_open.find("}}") else {
break;
};
let body = after_open[..close].trim();
if let Ok(expr) = parse_expression(body) {
collect_expr_binding_roots(&expr, out);
} else {
for token in body.split(|ch: char| !ch.is_alphanumeric() && ch != '_') {
if token
.as_bytes()
.first()
.is_some_and(|byte| is_ident_start(*byte))
{
out.insert(token.to_owned());
}
}
}
rest = &after_open[close + 2..];
}
}
fn collect_payload_field_roots(
fields: &[body::FieldAssign],
from_binding: Option<&str>,
out: &mut BTreeSet<String>,
) {
for field in fields {
match &field.value {
body::FieldValue::Shorthand => {
if let Some(root) = from_binding {
out.insert(root.to_owned());
}
}
body::FieldValue::Expr { expr, .. } => collect_expr_binding_roots(expr, out),
body::FieldValue::Nested { fields, .. } => {
collect_payload_field_roots(fields, from_binding, out)
}
}
}
}
fn collect_egress_payload_reads(
statements: &[body::BodyStmt],
out: &mut Vec<(String, BTreeSet<String>)>,
) {
for statement in statements {
match statement {
body::BodyStmt::Terminal(terminal) if terminal.kind == body::TerminalKind::Complete => {
let mut roots = BTreeSet::new();
collect_payload_field_roots(&terminal.fields, None, &mut roots);
if let Some(body::FieldValue::Expr { expr, .. }) = &terminal.scalar {
collect_expr_binding_roots(expr, &mut roots);
}
out.push((terminal.name.clone(), roots));
}
body::BodyStmt::Record(record) => out.push(record_payload_reads(record)),
body::BodyStmt::Done {
replacement: Some(record),
..
} => out.push(record_payload_reads(record)),
body::BodyStmt::Milestone { name, fields, .. } => {
let mut roots = BTreeSet::new();
collect_payload_field_roots(fields, None, &mut roots);
out.push((format!("milestone:{name}"), roots));
}
body::BodyStmt::Effect(effect) => match &effect.kind {
body::BodyEffectKind::ConstructCapabilityCall {
keyword, fields, ..
} if keyword == "send" => {
if let Some(reads) = send_payload_reads(fields) {
out.push(reads);
}
}
body::BodyEffectKind::FileWrite {
store, path, body, ..
} => {
let mut roots = BTreeSet::new();
for source in [path, body] {
if let Ok(expr) = parse_expression(source) {
collect_expr_binding_roots(&expr, &mut roots);
} else {
collect_template_binding_roots(source, &mut roots);
}
}
out.push((store.clone(), roots));
}
_ => {}
},
body::BodyStmt::After(after) => collect_egress_payload_reads(&after.body, out),
body::BodyStmt::Case(case) => {
for branch in &case.branches {
collect_egress_payload_reads(&branch.body, out);
}
}
_ => {}
}
}
}
fn send_payload_reads(fields: &[body::ConstructUseField]) -> Option<(String, BTreeSet<String>)> {
let channel = fields
.iter()
.find(|field| field.name == "channel")
.map(|field| field.source.clone())?;
let mut roots = BTreeSet::new();
for field in fields.iter().filter(|field| field.name != "channel") {
if let Ok(expr) = parse_expression(&field.source) {
collect_expr_binding_roots(&expr, &mut roots);
} else {
collect_template_binding_roots(&field.source, &mut roots);
}
}
Some((channel, roots))
}
fn collect_record_field_reads(
statements: &[body::BodyStmt],
out: &mut BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
) {
fn record_fields(
record: &body::RecordStmt,
out: &mut BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
) {
let per_field = out.entry(format!("fact:{}", record.schema)).or_default();
for field in &record.fields {
let mut roots = BTreeSet::new();
match &field.value {
body::FieldValue::Shorthand => {
if let Some(root) = &record.from {
roots.insert(root.clone());
}
}
body::FieldValue::Expr { expr, .. } => collect_expr_binding_roots(expr, &mut roots),
body::FieldValue::Nested { fields, .. } => {
collect_payload_field_roots(fields, record.from.as_deref(), &mut roots)
}
}
per_field
.entry(field.name.clone())
.or_default()
.extend(roots);
}
}
for statement in statements {
match statement {
body::BodyStmt::Record(record) => record_fields(record, out),
body::BodyStmt::Done {
replacement: Some(record),
..
} => record_fields(record, out),
body::BodyStmt::After(after) => collect_record_field_reads(&after.body, out),
body::BodyStmt::Case(case) => {
for branch in &case.branches {
collect_record_field_reads(&branch.body, out);
}
}
_ => {}
}
}
}
fn record_payload_reads(record: &body::RecordStmt) -> (String, BTreeSet<String>) {
let mut roots = BTreeSet::new();
if let Some(from) = &record.from {
roots.insert(from.clone());
}
collect_payload_field_roots(&record.fields, record.from.as_deref(), &mut roots);
(format!("fact:{}", record.schema), roots)
}
#[derive(Clone, Debug, Default)]
struct TerminalMetadata {
outputs: Vec<IrTerminalOutput>,
branches: Vec<IrTerminalCaseBranch>,
}
#[derive(Clone, Debug)]
struct TerminalBranchSource {
scrutinee: String,
pattern: String,
guard: Option<String>,
body: String,
pattern_span: SourceSpan,
}
#[derive(Clone, Debug)]
struct RuleCaseBranchSource {
scrutinee: String,
scrutinee_type: TypeSyntax,
pattern: String,
guard: Option<String>,
body: String,
pattern_span: SourceSpan,
}
fn collect_effect_payload_types(
rule: &RuleDecl,
semantic: &SemanticContext,
diagnostics: &mut Vec<Diagnostic>,
) -> BTreeMap<String, IrType> {
let mut payloads = BTreeMap::new();
for statement in effect_payload_statements(&rule.body.text) {
let line = statement.trim();
let Some((kind, Some(binding))) = parse_effect_line(line) else {
continue;
};
let payload = terminal_completed_payload_type(line, &kind, semantic);
match payloads.get(&binding) {
Some(existing) if existing != &payload => {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!(
"rule `{}` reuses effect binding `{binding}` for effects with conflicting result types",
rule.name.name
),
suggestion: Some(format!(
"give each effect a distinct binding — `as {binding}` is reused with a different result type, so `after {binding} …` is ambiguous"
)),
});
}
Some(_) => {}
None => {
payloads.insert(binding, payload);
}
}
}
payloads
}
fn terminal_completed_payload_type(
line: &str,
kind: &IrEffectKind,
semantic: &SemanticContext,
) -> IrType {
match kind {
IrEffectKind::SchemaCoerce if line.starts_with("prompt ") => {
IrType::Primitive(IrPrimitiveType::String)
}
IrEffectKind::SchemaCoerce => parse_coerce_call_name(line)
.and_then(|name| semantic.coerce_outputs.get(name))
.cloned()
.map(lower_type)
.unwrap_or_else(terminal_unknown_payload_type),
IrEffectKind::AgentTell => IrType::Ref("AgentTurn".to_owned()),
IrEffectKind::CapabilityCall
| IrEffectKind::EventEmit
| IrEffectKind::WorkflowInvoke
| IrEffectKind::TimerWait
| IrEffectKind::ExecCommand
| IrEffectKind::TrackerFile
| IrEffectKind::TrackerClaim
| IrEffectKind::TrackerRenew
| IrEffectKind::TrackerRelease
| IrEffectKind::TrackerFinish
| IrEffectKind::LeaseAcquire
| IrEffectKind::LeaseRenew
| IrEffectKind::LedgerAppend
| IrEffectKind::CounterConsume
| IrEffectKind::SignalEmit
| IrEffectKind::FileRead
| IrEffectKind::FileWrite
| IrEffectKind::FileImport
| IrEffectKind::FileExport => terminal_unknown_payload_type(),
}
}
fn collect_rule_case_metadata(
rule: &RuleDecl,
semantic: &SemanticContext,
binding_types: &BTreeMap<String, String>,
diagnostics: &mut Vec<Diagnostic>,
) -> Vec<IrRuleCaseBranch> {
let mut branches = Vec::new();
for branch in rule_case_branch_sources(rule, semantic, binding_types) {
let mut branch_scope = binding_types.clone();
if let Some((binding, schema)) =
case_branch_payload_binding(&branch.pattern, &branch.scrutinee_type, semantic)
{
branch_scope.insert(binding, schema);
}
if let Some(guard) = &branch.guard {
validate_expression(
rule,
guard,
semantic,
&branch_scope,
"case guard",
diagnostics,
);
validate_known_field_paths_at_span(
rule,
guard,
branch.pattern_span,
semantic,
&branch_scope,
diagnostics,
);
}
validate_known_field_paths_at_span(
rule,
&branch.body,
branch.pattern_span,
semantic,
&branch_scope,
diagnostics,
);
if let Some(pattern) = lower_case_pattern(&branch.pattern, &branch.scrutinee_type, semantic)
{
branches.push(IrRuleCaseBranch {
scrutinee: branch.scrutinee,
scrutinee_type: lower_type(branch.scrutinee_type),
pattern,
guard: branch.guard.as_ref().and_then(|guard| {
lower_expression(
guard,
SourceSpan {
start: branch.pattern_span.start,
end: branch.pattern_span.end,
},
)
}),
body_hash: stable_hash(&branch.body),
pattern_span: branch.pattern_span,
});
}
}
branches.sort_by(|left, right| {
(left.scrutinee.as_str(), left.pattern_span.start)
.cmp(&(right.scrutinee.as_str(), right.pattern_span.start))
});
branches
}
fn rule_case_branch_sources(
rule: &RuleDecl,
semantic: &SemanticContext,
binding_types: &BTreeMap<String, String>,
) -> Vec<RuleCaseBranchSource> {
let lines = rule
.body
.text
.lines()
.scan(0usize, |offset, line| {
let current = *offset;
*offset += line.len() + 1;
Some((line, current))
})
.collect::<Vec<_>>();
let text_lines = lines.iter().map(|(line, _)| *line).collect::<Vec<_>>();
let mut branches = Vec::new();
let mut index = 0usize;
while index < lines.len() {
let (line, _) = lines[index];
let trimmed = line.trim();
let Some(scrutinee) = case_scrutinee(trimmed) else {
index += 1;
continue;
};
if active_completes_binding_for_case(&text_lines, index, scrutinee) {
index += 1;
continue;
}
let Some(scrutinee_type) = expression_type(scrutinee, semantic, binding_types) else {
index += 1;
continue;
};
let mut depth = brace_delta(trimmed).max(1);
index += 1;
while index < lines.len() && depth > 0 {
let (branch_line, branch_line_offset) = lines[index];
let branch_trimmed = branch_line.trim();
if depth == 1 {
if let Some((pattern, guard, body_start)) = terminal_branch_header(branch_trimmed) {
let pattern_column = case_pattern_column(branch_line, pattern);
let pattern_span = SourceSpan {
start: rule_body_text_start(rule) + branch_line_offset + pattern_column,
end: rule_body_text_start(rule)
+ branch_line_offset
+ pattern_column
+ pattern.len(),
};
let mut body_lines = Vec::new();
let mut branch_depth = brace_delta(body_start).max(1);
index += 1;
while index < lines.len() && branch_depth > 0 {
let body_line = lines[index].0;
let next_depth = branch_depth + brace_delta(body_line);
if next_depth >= 1 {
body_lines.push(body_line.to_owned());
}
branch_depth = next_depth;
index += 1;
}
branches.push(RuleCaseBranchSource {
scrutinee: scrutinee.to_owned(),
scrutinee_type: scrutinee_type.clone(),
pattern: pattern.to_owned(),
guard,
body: body_lines.join("\n"),
pattern_span,
});
continue;
}
}
depth += brace_delta(branch_trimmed);
index += 1;
}
}
branches
}
fn case_branch_payload_binding(
pattern: &str,
scrutinee_type: &TypeSyntax,
semantic: &SemanticContext,
) -> Option<(String, String)> {
if let TypeSyntax::Ref { name } = scrutinee_type {
if semantic.schemas.enums.contains_key(&name.name) {
let (variant, binding) = sum_case_pattern_parts(pattern);
let binding = binding?;
let generated = format!("{}.{variant}", name.name);
if binding.is_empty() || !semantic.schemas.class_exists(&generated) {
return None;
}
return Some((binding.to_owned(), generated));
}
}
let binding = pattern.strip_prefix("Some ").map(str::trim)?;
if binding.is_empty() {
return None;
}
let TypeSyntax::Optional { inner, .. } = scrutinee_type else {
return None;
};
let schema = match inner.as_ref() {
TypeSyntax::Ref { name } if semantic.schemas.class_exists(&name.name) => {
Some(name.name.clone())
}
_ => None,
}?;
Some((binding.to_owned(), schema))
}
fn collect_terminal_case_metadata(
rule: &RuleDecl,
semantic: &SemanticContext,
binding_types: &BTreeMap<String, String>,
effect_payload_types: &BTreeMap<String, IrType>,
diagnostics: &mut Vec<Diagnostic>,
) -> TerminalMetadata {
let mut metadata = TerminalMetadata::default();
let mut output_bindings = BTreeSet::new();
for branch in terminal_case_branch_sources(rule) {
if output_bindings.insert(branch.scrutinee.clone()) {
let completed_payload = effect_payload_types
.get(&branch.scrutinee)
.cloned()
.unwrap_or_else(terminal_unknown_payload_type);
metadata.outputs.push(IrTerminalOutput {
binding: branch.scrutinee.clone(),
alternatives: terminal_alternatives(completed_payload, branch.pattern_span),
span: branch.pattern_span,
});
}
let (tag, binding) = parse_terminal_pattern_parts(&branch.pattern);
let mut branch_scope = binding_types.clone();
if let (Some(tag), Some(binding)) = (&tag, &binding) {
if let Some(schema) =
terminal_payload_schema_for_tag(tag, &branch.scrutinee, effect_payload_types)
{
branch_scope.insert(binding.clone(), schema);
}
}
if let Some(guard) = &branch.guard {
validate_expression(
rule,
guard,
semantic,
&branch_scope,
"case guard",
diagnostics,
);
validate_known_field_paths(rule, guard, semantic, &branch_scope, diagnostics);
}
validate_known_field_paths(rule, &branch.body, semantic, &branch_scope, diagnostics);
metadata.branches.push(IrTerminalCaseBranch {
scrutinee: branch.scrutinee,
tag,
binding,
guard: branch.guard.as_ref().and_then(|guard| {
lower_expression(
guard,
SourceSpan {
start: branch.pattern_span.start,
end: branch.pattern_span.end,
},
)
}),
body_hash: stable_hash(&branch.body),
pattern_span: branch.pattern_span,
});
}
metadata
.outputs
.sort_by(|left, right| left.binding.cmp(&right.binding));
metadata.branches.sort_by(|left, right| {
(left.scrutinee.as_str(), left.pattern_span.start)
.cmp(&(right.scrutinee.as_str(), right.pattern_span.start))
});
metadata
}
fn terminal_case_branch_sources(rule: &RuleDecl) -> Vec<TerminalBranchSource> {
let lines = rule
.body
.text
.lines()
.scan(0usize, |offset, line| {
let current = *offset;
*offset += line.len() + 1;
Some((line, current))
})
.collect::<Vec<_>>();
let text_lines = lines.iter().map(|(line, _)| *line).collect::<Vec<_>>();
let mut branches = Vec::new();
let mut index = 0usize;
while index < lines.len() {
let (line, line_offset) = lines[index];
let trimmed = line.trim();
let Some(scrutinee) = case_scrutinee(trimmed) else {
index += 1;
continue;
};
if !active_completes_binding_for_case(&text_lines, index, scrutinee) {
index += 1;
continue;
}
let mut depth = brace_delta(trimmed).max(1);
index += 1;
while index < lines.len() && depth > 0 {
let (branch_line, branch_line_offset) = lines[index];
let branch_trimmed = branch_line.trim();
if depth == 1 {
if let Some((pattern, guard, body_start)) = terminal_branch_header(branch_trimmed) {
let pattern_column = case_pattern_column(branch_line, pattern);
let pattern_span = SourceSpan {
start: rule_body_text_start(rule) + branch_line_offset + pattern_column,
end: rule_body_text_start(rule)
+ branch_line_offset
+ pattern_column
+ pattern.len(),
};
let mut body_lines = Vec::new();
let mut branch_depth = brace_delta(body_start).max(1);
index += 1;
while index < lines.len() && branch_depth > 0 {
let body_line = lines[index].0;
let next_depth = branch_depth + brace_delta(body_line);
if next_depth >= 1 {
body_lines.push(body_line.to_owned());
}
branch_depth = next_depth;
index += 1;
}
branches.push(TerminalBranchSource {
scrutinee: scrutinee.to_owned(),
pattern: pattern.to_owned(),
guard,
body: body_lines.join("\n"),
pattern_span,
});
continue;
}
}
depth += brace_delta(branch_trimmed);
index += 1;
}
let _ = line_offset;
}
branches
}
fn rule_body_text_start(rule: &RuleDecl) -> usize {
rule.body.span.end.saturating_sub(2 + rule.body.text.len())
}
fn terminal_branch_header(line: &str) -> Option<(&str, Option<String>, &str)> {
let (head, body_start) = line.split_once("=>")?;
let body_start = body_start.trim();
if !body_start.starts_with('{') {
return None;
}
let head = head.trim();
let (pattern, guard) = match head.split_once(" where ") {
Some((pattern, guard)) => (pattern.trim(), Some(guard.trim().to_owned())),
None => (head, None),
};
Some((pattern, guard, body_start))
}
fn case_pattern_column(line: &str, pattern: &str) -> usize {
line.find(pattern).unwrap_or_else(|| {
let indent = line.len().saturating_sub(line.trim_start().len());
indent + line.trim_start().find(pattern).unwrap_or(0)
})
}
fn parse_terminal_pattern_parts(pattern: &str) -> (Option<String>, Option<String>) {
if is_fallback_pattern(pattern) {
return (None, None);
}
let mut parts = pattern.split_whitespace();
let tag = parts.next().map(str::to_owned);
let second = parts.next();
let binding = match second {
Some("as") => parts.next().map(str::to_owned),
Some(_) => return (tag, None),
None => None,
};
if parts.next().is_some() {
return (tag, None);
}
(tag, binding)
}
fn terminal_payload_schema_for_tag(
tag: &str,
scrutinee: &str,
effect_payload_types: &BTreeMap<String, IrType>,
) -> Option<String> {
match tag {
"Completed" => match effect_payload_types.get(scrutinee) {
Some(IrType::Ref(schema)) => Some(schema.clone()),
_ => None,
},
"Failed" => Some("TerminalFailed".to_owned()),
"TimedOut" => Some("TerminalTimedOut".to_owned()),
"Cancelled" => Some("TerminalCancelled".to_owned()),
_ => None,
}
}
fn terminal_alternatives(
completed_payload: IrType,
span: SourceSpan,
) -> Vec<IrTerminalAlternative> {
[
("Completed", completed_payload),
("Failed", terminal_failure_payload_type()),
("TimedOut", terminal_timeout_payload_type()),
("Cancelled", terminal_cancelled_payload_type()),
]
.into_iter()
.map(|(tag, payload_type)| IrTerminalAlternative {
tag: tag.to_owned(),
payload_type,
source_span: span,
})
.collect()
}
fn terminal_failure_payload_type() -> IrType {
IrType::Object(vec![
ir_field("reason", IrType::Primitive(IrPrimitiveType::String)),
ir_field("summary", IrType::Primitive(IrPrimitiveType::String)),
ir_field("effect_id", IrType::Primitive(IrPrimitiveType::String)),
ir_field("run_id", IrType::Primitive(IrPrimitiveType::String)),
])
}
fn terminal_timeout_payload_type() -> IrType {
IrType::Object(vec![
ir_field("summary", IrType::Primitive(IrPrimitiveType::String)),
ir_field("effect_id", IrType::Primitive(IrPrimitiveType::String)),
ir_field("run_id", IrType::Primitive(IrPrimitiveType::String)),
])
}
fn terminal_cancelled_payload_type() -> IrType {
IrType::Object(vec![
ir_field("summary", IrType::Primitive(IrPrimitiveType::String)),
ir_field("effect_id", IrType::Primitive(IrPrimitiveType::String)),
ir_field("run_id", IrType::Primitive(IrPrimitiveType::String)),
])
}
fn terminal_unknown_payload_type() -> IrType {
IrType::Object(vec![
ir_field("summary", IrType::Primitive(IrPrimitiveType::String)),
ir_field("effect_id", IrType::Primitive(IrPrimitiveType::String)),
ir_field("run_id", IrType::Primitive(IrPrimitiveType::String)),
])
}
fn ir_field(name: &str, ty: IrType) -> IrClassField {
IrClassField {
name: name.to_owned(),
ty,
is_key: false,
presence_condition: None,
span: SourceSpan { start: 0, end: 0 },
}
}
fn ir_access_grants_for_body(kind: &body::BodyEffectKind) -> Vec<IrAccessGrant> {
match kind {
body::BodyEffectKind::Tell { access_grants, .. }
| body::BodyEffectKind::Invoke { access_grants, .. } => access_grants
.iter()
.map(|grant| IrAccessGrant {
resource: grant.resource.clone(),
operations: grant
.operations
.iter()
.map(|op| IrAccessGrantOp {
operation: op.operation.clone(),
target: op.target.clone(),
globs: op.globs.clone(),
})
.collect(),
})
.collect(),
_ => Vec::new(),
}
}
fn ir_effect_kind_for_body(kind: &body::BodyEffectKind) -> IrEffectKind {
match kind {
body::BodyEffectKind::Tell { .. } => IrEffectKind::AgentTell,
body::BodyEffectKind::Coerce { .. }
| body::BodyEffectKind::Prompt { .. }
| body::BodyEffectKind::Decide { .. } => IrEffectKind::SchemaCoerce,
body::BodyEffectKind::Call { .. }
| body::BodyEffectKind::ConstructCapabilityCall { .. } => IrEffectKind::CapabilityCall,
body::BodyEffectKind::Invoke { .. } => IrEffectKind::WorkflowInvoke,
body::BodyEffectKind::Timer { .. } => IrEffectKind::TimerWait,
body::BodyEffectKind::Exec { .. } => IrEffectKind::ExecCommand,
body::BodyEffectKind::TrackerFile { .. } => IrEffectKind::TrackerFile,
body::BodyEffectKind::TrackerClaim { .. } => IrEffectKind::TrackerClaim,
body::BodyEffectKind::TrackerRelease { .. } => IrEffectKind::TrackerRelease,
body::BodyEffectKind::TrackerFinish { .. } => IrEffectKind::TrackerFinish,
body::BodyEffectKind::LeaseAcquire { .. } => IrEffectKind::LeaseAcquire,
body::BodyEffectKind::LeaseRenew { .. } => IrEffectKind::LeaseRenew,
body::BodyEffectKind::LedgerAppend { .. } => IrEffectKind::LedgerAppend,
body::BodyEffectKind::CounterConsume { .. } => IrEffectKind::CounterConsume,
body::BodyEffectKind::Notify { .. } => IrEffectKind::SignalEmit,
body::BodyEffectKind::FileRead { .. } => IrEffectKind::FileRead,
body::BodyEffectKind::FileWrite { .. } => IrEffectKind::FileWrite,
body::BodyEffectKind::FileImport { .. } => IrEffectKind::FileImport,
body::BodyEffectKind::FileExport { .. } => IrEffectKind::FileExport,
}
}
fn agent_for_body(kind: &body::BodyEffectKind) -> Option<String> {
match kind {
body::BodyEffectKind::Tell { target, .. } => Some(target.clone()),
_ => None,
}
}
fn coerce_target_for_body(kind: &body::BodyEffectKind) -> Option<String> {
match kind {
body::BodyEffectKind::Coerce { name, .. } => Some(name.clone()),
_ => None,
}
}
fn turn_skills_for_body(kind: &body::BodyEffectKind) -> Vec<String> {
match kind {
body::BodyEffectKind::Tell { skills, .. } => skills.clone(),
_ => Vec::new(),
}
}
fn on_stream_for_body(kind: &body::BodyEffectKind) -> Option<String> {
match kind {
body::BodyEffectKind::Tell { on_stream, .. } => on_stream.clone(),
_ => None,
}
}
fn vcs_selective_for_body(kind: &body::BodyEffectKind) -> (Option<String>, Option<String>) {
let body::BodyEffectKind::ConstructCapabilityCall {
keyword, fields, ..
} = kind
else {
return (None, None);
};
if keyword != "undo" && keyword != "transport" {
return (None, None);
}
let field = |name: &str| {
fields
.iter()
.find(|field| field.name == name)
.map(|field| field.source.clone())
};
(field("selection"), field("onto"))
}
fn workflow_target_for_body(kind: &body::BodyEffectKind) -> Option<String> {
match kind {
body::BodyEffectKind::Invoke { workflow, .. } => Some(workflow.clone()),
_ => None,
}
}
fn exec_target_for_body(kind: &body::BodyEffectKind) -> Option<IrExecTarget> {
match kind {
body::BodyEffectKind::Exec { target, .. } => Some(match target {
body::ExecTarget::RawCommand(_) => IrExecTarget::Raw,
body::ExecTarget::Capability { name, .. } => {
IrExecTarget::Capability { name: name.clone() }
}
}),
_ => None,
}
}
fn endorsed_for_body(kind: &body::BodyEffectKind) -> bool {
matches!(kind, body::BodyEffectKind::Coerce { endorsed: true, .. })
}
fn declassified_for_body(kind: &body::BodyEffectKind) -> bool {
matches!(
kind,
body::BodyEffectKind::Coerce {
declassified: true,
..
}
)
}
fn resource_for_body(kind: &body::BodyEffectKind) -> Option<String> {
match kind {
body::BodyEffectKind::FileRead { store, .. }
| body::BodyEffectKind::FileWrite { store, .. }
| body::BodyEffectKind::FileImport { store, .. }
| body::BodyEffectKind::FileExport { store, .. } => Some(store.clone()),
body::BodyEffectKind::ConstructCapabilityCall {
keyword, fields, ..
} if keyword == "send" => fields
.iter()
.find(|field| field.name == "channel")
.map(|field| field.source.clone()),
body::BodyEffectKind::Notify { event, .. } => Some(format!("signal:{event}")),
body::BodyEffectKind::LeaseAcquire { resource, .. } => Some(format!("resource:{resource}")),
body::BodyEffectKind::LedgerAppend { ledger, .. } => Some(format!("resource:{ledger}")),
body::BodyEffectKind::CounterConsume { counter, .. } => Some(format!("resource:{counter}")),
_ => None,
}
}
fn construct_use_for_body(kind: &body::BodyEffectKind) -> Option<IrConstructUse> {
match kind {
body::BodyEffectKind::ConstructCapabilityCall {
keyword,
target_capability,
..
} => Some(IrConstructUse {
keyword: keyword.clone(),
scope: "rule_body".to_owned(),
construct_family: "effect_operation".to_owned(),
lowering_target: "capability_call".to_owned(),
target_capability: target_capability.clone(),
}),
_ => None,
}
}
fn is_ast_only_effect_kind(kind: &body::BodyEffectKind) -> bool {
if let body::BodyEffectKind::ConstructCapabilityCall { keyword, .. } = kind {
return keyword == "send";
}
matches!(
kind,
body::BodyEffectKind::Prompt { .. }
| body::BodyEffectKind::Timer { .. }
| body::BodyEffectKind::Exec { .. }
| body::BodyEffectKind::Decide { .. }
| body::BodyEffectKind::TrackerFile { .. }
| body::BodyEffectKind::TrackerClaim { .. }
| body::BodyEffectKind::TrackerRelease { .. }
| body::BodyEffectKind::TrackerFinish { .. }
| body::BodyEffectKind::LeaseAcquire { .. }
| body::BodyEffectKind::LeaseRenew { .. }
| body::BodyEffectKind::LedgerAppend { .. }
| body::BodyEffectKind::CounterConsume { .. }
| body::BodyEffectKind::Notify { .. }
| body::BodyEffectKind::Invoke { .. }
| body::BodyEffectKind::FileWrite { .. }
| body::BodyEffectKind::FileExport { .. }
)
}
fn seed_ast_only_effect_bindings(
statements: &[body::BodyStmt],
seen_bindings: &mut BTreeSet<String>,
binding_types: &mut BTreeMap<String, String>,
) {
for statement in statements {
match statement {
body::BodyStmt::Effect(effect) if is_ast_only_effect_kind(&effect.kind) => {
if let Some(binding) = &effect.binding {
seen_bindings.insert(binding.clone());
let _ = binding_types;
}
}
body::BodyStmt::After(after) => {
seed_ast_only_effect_bindings(&after.body, seen_bindings, binding_types)
}
body::BodyStmt::Case(case) => {
for branch in &case.branches {
seed_ast_only_effect_bindings(&branch.body, seen_bindings, binding_types);
}
}
_ => {}
}
}
}
fn collect_terminal_complete_bindings(statements: &[body::BodyStmt], out: &mut Vec<String>) {
for statement in statements {
match statement {
body::BodyStmt::Terminal(terminal) if terminal.kind == body::TerminalKind::Complete => {
out.push(terminal.name.clone());
}
body::BodyStmt::After(after) => collect_terminal_complete_bindings(&after.body, out),
body::BodyStmt::Case(case) => {
for branch in &case.branches {
collect_terminal_complete_bindings(&branch.body, out);
}
}
_ => {}
}
}
}
fn collect_effects_from_ast(
statements: &[body::BodyStmt],
rule_name: &str,
) -> (Vec<IrEffectNode>, Vec<IrEffectDependency>) {
let mut effects = Vec::new();
let mut dependencies = Vec::new();
let mut counter = 0usize;
let mut after_stack: Vec<(String, DependencyPredicate)> = Vec::new();
let mut case_stack: Vec<(String, String)> = Vec::new();
let claim_bindings = collect_claim_bindings(statements);
walk_effects(
statements,
rule_name,
&claim_bindings,
&mut counter,
&mut after_stack,
&mut case_stack,
&mut effects,
&mut dependencies,
);
(effects, dependencies)
}
fn collect_claim_bindings(statements: &[body::BodyStmt]) -> BTreeSet<String> {
let mut bindings = BTreeSet::new();
for_each_body(statements, &mut |stmt| {
if let body::BodyStmt::Effect(effect) = stmt {
if matches!(effect.kind, body::BodyEffectKind::TrackerClaim { .. }) {
if let Some(binding) = &effect.binding {
bindings.insert(binding.clone());
}
}
}
});
bindings
}
#[allow(clippy::too_many_arguments)]
fn walk_effects(
statements: &[body::BodyStmt],
rule_name: &str,
claim_bindings: &BTreeSet<String>,
counter: &mut usize,
after_stack: &mut Vec<(String, DependencyPredicate)>,
case_stack: &mut Vec<(String, String)>,
effects: &mut Vec<IrEffectNode>,
dependencies: &mut Vec<IrEffectDependency>,
) {
for statement in statements {
match statement {
body::BodyStmt::Effect(effect) => {
*counter += 1;
let id = effect
.binding
.clone()
.unwrap_or_else(|| format!("effect{counter}"));
let kind = match &effect.kind {
body::BodyEffectKind::LeaseRenew {
acquire_binding, ..
} if claim_bindings.contains(acquire_binding) => IrEffectKind::TrackerRenew,
other => ir_effect_kind_for_body(other),
};
for (upstream, predicate) in after_stack.iter() {
dependencies.push(IrEffectDependency {
upstream: upstream.clone(),
predicate: predicate.clone(),
downstream: id.clone(),
});
}
let idempotency_key =
effect_idempotency_key(rule_name, &id, &kind, &effect.binding);
let mut required_capabilities = effect.requires.clone();
match &effect.kind {
body::BodyEffectKind::Call { capability, .. } => {
required_capabilities.push(capability.clone());
}
body::BodyEffectKind::ConstructCapabilityCall {
target_capability, ..
} => {
required_capabilities.push(target_capability.clone());
}
_ => {}
}
required_capabilities.sort();
required_capabilities.dedup();
let construct_use = construct_use_for_body(&effect.kind);
let access_grants = ir_access_grants_for_body(&effect.kind);
let turn_skills = turn_skills_for_body(&effect.kind);
let on_stream = on_stream_for_body(&effect.kind);
let (selection_source, transport_onto) = vcs_selective_for_body(&effect.kind);
let resource = resource_for_body(&effect.kind);
let agent = agent_for_body(&effect.kind);
let coerce_target = coerce_target_for_body(&effect.kind);
let workflow_target = workflow_target_for_body(&effect.kind);
let endorsed = endorsed_for_body(&effect.kind);
let declassified = declassified_for_body(&effect.kind);
let exec_target = exec_target_for_body(&effect.kind);
effects.push(IrEffectNode {
id,
kind,
binding: effect.binding.clone(),
required_capabilities,
construct_use,
idempotency_key,
span: effect.span,
timeout_seconds: effect.timeout_seconds,
access_grants,
turn_skills,
on_stream,
selection_source,
transport_onto,
resource,
agent,
coerce_target,
workflow_target,
endorsed,
declassified,
selected_by: case_stack.last().cloned(),
exec_target,
});
}
body::BodyStmt::After(after) => {
let predicate = match after.predicate {
body::AfterPredicate::Succeeds => DependencyPredicate::Succeeds,
body::AfterPredicate::Fails => DependencyPredicate::Fails,
body::AfterPredicate::TimedOut => DependencyPredicate::TimedOut,
body::AfterPredicate::Cancelled => DependencyPredicate::Cancelled,
body::AfterPredicate::Completes
| body::AfterPredicate::Held
| body::AfterPredicate::Contended
| body::AfterPredicate::Ok
| body::AfterPredicate::Over
| body::AfterPredicate::Promoted
| body::AfterPredicate::Conflicted
| body::AfterPredicate::Applied
| body::AfterPredicate::Stranded => DependencyPredicate::Completes,
body::AfterPredicate::Reaches => DependencyPredicate::Completes,
};
after_stack.push((after.binding.clone(), predicate));
walk_effects(
&after.body,
rule_name,
claim_bindings,
counter,
after_stack,
case_stack,
effects,
dependencies,
);
after_stack.pop();
}
body::BodyStmt::Case(case) => {
for branch in &case.branches {
case_stack.push((case.scrutinee.clone(), branch.pattern.clone()));
walk_effects(
&branch.body,
rule_name,
claim_bindings,
counter,
after_stack,
case_stack,
effects,
dependencies,
);
case_stack.pop();
}
}
_ => {}
}
}
}
fn effect_idempotency_key(
rule_name: &str,
effect_id: &str,
kind: &IrEffectKind,
binding: &Option<String>,
) -> String {
stable_hash(&format!(
"rule={rule_name};effect={effect_id};kind={};binding={}",
kind.as_str(),
binding.as_deref().unwrap_or("-")
))
}
fn validate_coerce_call(
rule: &RuleDecl,
line: &str,
semantic: &SemanticContext,
binding_types: &BTreeMap<String, String>,
known_roots: &BTreeSet<String>,
diagnostics: &mut Vec<Diagnostic>,
) {
let Some((function_name, args)) = parse_coerce_call(line) else {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!("rule `{}` has malformed coerce call", rule.name.name),
suggestion: Some("write `coerce functionName(arg, ...) as name`".to_owned()),
});
return;
};
let Some(params) = semantic.coerce_params.get(function_name) else {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!(
"rule `{}` calls unknown coerce function `{function_name}`",
rule.name.name
),
suggestion: Some(format!(
"declare `coerce {function_name}(...) -> Output {{ ... }}` before using it"
)),
});
return;
};
if args.len() != params.len() {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!(
"rule `{}` calls coerce `{function_name}` with {} argument(s), expected {}",
rule.name.name,
args.len(),
params.len()
),
suggestion: Some("pass one argument for each declared coerce parameter".to_owned()),
});
return;
}
let scope = ExprScope::from_bindings(binding_types);
for (arg, param) in args.iter().zip(params) {
if let Some(root) = dangling_value_root(arg, known_roots) {
diagnostics.push(Diagnostic { related: Vec::new(),
span: rule.body.span,
message: format!(
"rule `{}` has unknown binding `{root}` in coerce `{function_name}` argument",
rule.name.name
),
suggestion: Some(
"reference a binding from a `when ... as name` clause, an effect `as` binding, or a `case` pattern"
.to_owned(),
),
});
}
validate_expr_source_against_type(
rule,
&format!("coerce `{function_name}`"),
¶m.name.name,
¶m.ty,
arg,
semantic,
&scope,
diagnostics,
);
}
}
fn validate_effect_payloads(
rule: &RuleDecl,
semantic: &SemanticContext,
binding_types: &BTreeMap<String, String>,
known_roots: &BTreeSet<String>,
diagnostics: &mut Vec<Diagnostic>,
) {
for statement in effect_payload_statements(&rule.body.text) {
let trimmed = statement.trim();
if trimmed.starts_with("coerce ") {
validate_coerce_call(
rule,
trimmed,
semantic,
binding_types,
known_roots,
diagnostics,
);
}
}
}
fn validate_workflow_invocations(
rule: &RuleDecl,
semantic: &SemanticContext,
binding_types: &BTreeMap<String, String>,
known_roots: &BTreeSet<String>,
diagnostics: &mut Vec<Diagnostic>,
) {
for statement in workflow_invoke_statements(&rule.body.text) {
let Some((target, body)) = invoke_statement_parts(&statement) else {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!(
"rule `{}` has malformed workflow invocation",
rule.name.name
),
suggestion: Some("write `invoke Workflow { input value } as binding`".to_owned()),
});
continue;
};
if semantic.workflow.as_deref() == Some(target) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!(
"rule `{}` recursively invokes workflow `{target}`",
rule.name.name
),
suggestion: Some(
"split recursive orchestration into an explicit bounded scheduler workflow"
.to_owned(),
),
});
continue;
}
let Some(surface) = semantic.workflow_inputs.get(target) else {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!(
"rule `{}` invokes unknown workflow `{target}`",
rule.name.name
),
suggestion: Some("invoke a workflow declared in this source bundle".to_owned()),
});
continue;
};
let mut invocation_semantic = semantic.clone();
invocation_semantic.schemas.merge(surface.schemas.clone());
let assignments = collect_field_assignments(body);
let mut seen = BTreeSet::new();
for assignment in assignments {
let (field, value) = match assignment {
RecordFieldAssignment::Value { field, value } => (field, value),
RecordFieldAssignment::Shorthand { field } => (field.clone(), field),
};
if !seen.insert(field.clone()) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!("workflow invocation `{target}` repeats input `{field}`"),
suggestion: Some("remove the duplicate invocation input".to_owned()),
});
continue;
}
let Some(input_ty) = surface.inputs.get(&field) else {
let known = surface
.inputs
.keys()
.map(|input| format!("`{input}`"))
.collect::<Vec<_>>()
.join(", ");
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!("workflow `{target}` has no input `{field}`"),
suggestion: Some(if known.is_empty() {
"remove the invocation payload; the target declares no inputs".to_owned()
} else {
format!("pass one of: {known}")
}),
});
continue;
};
if let Some(root) = dangling_value_root(&value, known_roots) {
diagnostics.push(Diagnostic { related: Vec::new(),
span: rule.body.span,
message: format!(
"rule `{}` has unknown binding `{root}` in `invoke {target}` input `{field}`",
rule.name.name
),
suggestion: Some(
"reference a binding from a `when ... as name` clause, an effect `as` binding, or a `case` pattern"
.to_owned(),
),
});
}
validate_expr_source_against_type(
rule,
target,
&field,
input_ty,
&value,
&invocation_semantic,
&ExprScope::from_bindings(binding_types),
diagnostics,
);
}
for input in surface.inputs.keys() {
if seen.contains(input) {
continue;
}
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!("workflow invocation `{target}` is missing input `{input}`"),
suggestion: Some(format!(
"add `{input}` to the `{target}` invocation payload"
)),
});
}
}
}
fn validate_agent_tell_target(
rule: &RuleDecl,
line: &str,
kind: &IrEffectKind,
semantic: &SemanticContext,
binding_types: &BTreeMap<String, String>,
known_roots: &BTreeSet<String>,
diagnostics: &mut Vec<Diagnostic>,
) {
if kind != &IrEffectKind::AgentTell {
return;
}
let Some(target) = parse_tell_target(line) else {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!("rule `{}` has malformed tell target", rule.name.name),
suggestion: Some("write `tell agentName ...` or `tell task.agentRef ...`".to_owned()),
});
return;
};
if target.starts_with('"') {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!(
"rule `{}` uses a string literal as a tell target",
rule.name.name
),
suggestion: Some("use a declared agent name or an AgentRef field".to_owned()),
});
return;
}
let required_capabilities = parse_required_capabilities(line);
if target.contains('.') {
let Some(ty) = expression_type(target, semantic, binding_types) else {
if let Some(root) = dangling_value_root(target, known_roots) {
diagnostics.push(Diagnostic { related: Vec::new(),
span: rule.body.span,
message: format!(
"rule `{}` has unknown binding `{root}` in tell target `{target}`",
rule.name.name
),
suggestion: Some(
"reference a binding from a `when ... as name` clause or an effect `as` binding"
.to_owned(),
),
});
}
return;
};
if let TypeSyntax::AgentRef { agents, .. } = ty {
for agent in agents {
validate_agent_capabilities(
rule,
&agent.name,
&required_capabilities,
semantic,
diagnostics,
);
}
} else {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!(
"rule `{}` uses non-AgentRef dynamic tell target `{target}`",
rule.name.name
),
suggestion: Some(
"declare the field as `AgentRef<...>` before using it as a tell target"
.to_owned(),
),
});
}
return;
}
if !semantic.agents.contains(target) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!("rule `{}` tells unknown agent `{target}`", rule.name.name),
suggestion: Some("declare the target agent before telling it".to_owned()),
});
return;
}
validate_agent_capabilities(rule, target, &required_capabilities, semantic, diagnostics);
}
fn validate_agent_capabilities(
rule: &RuleDecl,
agent: &str,
required_capabilities: &[String],
semantic: &SemanticContext,
diagnostics: &mut Vec<Diagnostic>,
) {
if required_capabilities.is_empty() {
return;
}
let declared = semantic
.agent_capabilities
.get(agent)
.cloned()
.unwrap_or_default();
for capability in required_capabilities {
if !declared.contains(capability) {
diagnostics.push(Diagnostic { related: Vec::new(),
span: rule.body.span,
message: format!(
"rule `{}` tells agent `{agent}` requiring undeclared capability `{capability}`",
rule.name.name
),
suggestion: Some(format!(
"add `{capability}` to agent `{agent}` capabilities or choose another AgentRef target"
)),
});
}
}
}
fn validate_availability_when(
rule: &RuleDecl,
when: &str,
semantic: &SemanticContext,
binding_types: &BTreeMap<String, String>,
diagnostics: &mut Vec<Diagnostic>,
) {
let (pattern, _) = split_when_guard(when);
let Some(target) = pattern.strip_suffix(" is available").map(str::trim) else {
return;
};
if target.contains('.') {
let Some(ty) = expression_type(target, semantic, binding_types) else {
return;
};
if !matches!(ty, TypeSyntax::AgentRef { .. }) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!(
"rule `{}` checks availability for non-AgentRef `{target}`",
rule.name.name
),
suggestion: Some(
"availability checks must name a declared agent or an AgentRef field"
.to_owned(),
),
});
}
return;
}
if !semantic.agents.contains(target) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!("rule `{}` checks unknown agent `{target}`", rule.name.name),
suggestion: Some("declare the target agent before checking availability".to_owned()),
});
}
}
#[derive(Clone, Debug, Default)]
struct ExprScope {
binding_types: BTreeMap<String, String>,
implicit_schema: Option<String>,
}
impl ExprScope {
fn from_bindings(binding_types: &BTreeMap<String, String>) -> Self {
Self {
binding_types: binding_types.clone(),
implicit_schema: None,
}
}
fn with_implicit_schema(&self, schema: String) -> Self {
let mut scope = self.clone();
scope.implicit_schema = Some(schema);
scope
}
}
#[derive(Clone, Debug)]
struct ExprValidationContext {
subject: String,
span: SourceSpan,
}
impl ExprValidationContext {
fn rule(rule: &RuleDecl) -> Self {
Self {
subject: format!("rule `{}`", rule.name.name),
span: rule.body.span,
}
}
fn assertion(span: SourceSpan) -> Self {
Self {
subject: "assertion".to_owned(),
span,
}
}
}
fn validate_expression(
rule: &RuleDecl,
expr: &str,
semantic: &SemanticContext,
binding_types: &BTreeMap<String, String>,
label: &str,
diagnostics: &mut Vec<Diagnostic>,
) {
match parse_expression(expr) {
Ok(expr) => {
validate_parsed_expression(
&expr,
semantic,
&ExprScope::from_bindings(binding_types),
&ExprValidationContext::rule(rule),
label,
diagnostics,
);
}
Err(message) => diagnostics.push(Diagnostic { related: Vec::new(),
span: rule.body.span,
message: format!("rule `{}` has invalid {label} expression: {message}", rule.name.name),
suggestion: Some("use deterministic field paths, literals, boolean operators, comparisons, membership, count, or exists".to_owned()),
}),
}
}
fn validate_parsed_expression(
expr: &Expr,
semantic: &SemanticContext,
scope: &ExprScope,
context: &ExprValidationContext,
label: &str,
diagnostics: &mut Vec<Diagnostic>,
) {
let presence_proofs = BTreeSet::new();
validate_expr_node(
expr,
semantic,
scope,
context,
&presence_proofs,
diagnostics,
);
let ty = infer_expr_type(expr, semantic, scope, context, diagnostics);
if ty != ExprType::Bool && ty != ExprType::Unknown {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: context.span,
message: format!("{} has non-boolean {label} expression", context.subject),
suggestion: Some(format!("{label} expressions must evaluate to bool")),
});
}
}
fn validate_expr_node(
expr: &Expr,
semantic: &SemanticContext,
scope: &ExprScope,
context: &ExprValidationContext,
presence_proofs: &BTreeSet<String>,
diagnostics: &mut Vec<Diagnostic>,
) {
match expr {
Expr::Path(path) => {
if path.len() < 2 {
return;
}
let root = &path[0];
let Some(schema) = scope.binding_types.get(root) else {
if let Some(schema) = &scope.implicit_schema {
if let Err(message) =
validate_optional_path_access(schema, path, semantic, presence_proofs)
{
diagnostics.push(Diagnostic {
related: Vec::new(),
span: context.span,
message: format!(
"{} has unsafe optional path `{}`: {message}",
context.subject,
path.join(".")
),
suggestion: Some(
"prove the optional value is present before reading through it"
.to_owned(),
),
});
return;
}
if let Err(message) = semantic.schemas.resolve_field_path(schema, path) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: context.span,
message: format!(
"{} has invalid expression path `{}`: {message}",
context.subject,
path.join(".")
),
suggestion: Some(
"use a field declared on the queried schema".to_owned(),
),
});
}
return;
}
diagnostics.push(Diagnostic {
related: Vec::new(),
span: context.span,
message: format!("{} has unknown expression root `{root}`", context.subject),
suggestion: Some(
"use a binding introduced by a `when ... as name` clause".to_owned(),
),
});
return;
};
if let Err(message) =
validate_optional_path_access(schema, &path[1..], semantic, presence_proofs)
{
diagnostics.push(Diagnostic {
related: Vec::new(),
span: context.span,
message: format!(
"{} has unsafe optional path `{}`: {message}",
context.subject,
path.join(".")
),
suggestion: Some(
"prove the optional value is present before reading through it".to_owned(),
),
});
return;
}
if let Err(message) = semantic.schemas.resolve_field_path(schema, &path[1..]) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: context.span,
message: format!(
"{} has invalid expression path `{}`: {message}",
context.subject,
path.join(".")
),
suggestion: Some("use a field declared on the bound schema".to_owned()),
});
}
}
Expr::Index { target, key } => {
validate_expr_node(
target,
semantic,
scope,
context,
presence_proofs,
diagnostics,
);
validate_expr_node(key, semantic, scope, context, presence_proofs, diagnostics);
let key_ty = infer_expr_type(key, semantic, scope, context, diagnostics);
if !matches!(key_ty, ExprType::String | ExprType::Unknown) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: context.span,
message: format!("{} indexes a map with a non-string key", context.subject),
suggestion: Some(
"use a string literal or string expression as the map key".to_owned(),
),
});
}
}
Expr::Array(items) => {
for item in items {
validate_expr_node(item, semantic, scope, context, presence_proofs, diagnostics);
}
}
Expr::Object(fields) => {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: context.span,
message: format!(
"{} uses an object literal without an expected object or map type",
context.subject
),
suggestion: Some(
"use object literals only in typed record fields or typed effect arguments"
.to_owned(),
),
});
for field in fields {
validate_expr_node(
&field.value,
semantic,
scope,
context,
presence_proofs,
diagnostics,
);
}
}
Expr::Unary { expr, .. } => {
validate_expr_node(expr, semantic, scope, context, presence_proofs, diagnostics)
}
Expr::Binary {
op: BinaryOp::And,
left,
right,
} => {
validate_expr_node(left, semantic, scope, context, presence_proofs, diagnostics);
let mut right_proofs = presence_proofs.clone();
collect_presence_proofs(left, &mut right_proofs);
validate_expr_node(right, semantic, scope, context, &right_proofs, diagnostics);
}
Expr::Binary { op, left, right } => {
validate_expr_node(left, semantic, scope, context, presence_proofs, diagnostics);
validate_expr_node(
right,
semantic,
scope,
context,
presence_proofs,
diagnostics,
);
validate_unknown_implicit_idents(
*op,
left,
right,
semantic,
scope,
context,
diagnostics,
);
validate_finite_domain_expr(*op, left, right, semantic, scope, context, diagnostics);
}
Expr::Call { name, args } => {
validate_function_call(name, args, semantic, scope, context, diagnostics);
for arg in args {
validate_expr_node(arg, semantic, scope, context, presence_proofs, diagnostics);
}
}
Expr::Query { guard, .. } => {
validate_query_expr(expr, semantic, scope, context, diagnostics);
if let Some(guard) = guard {
let guard_scope = query_guard_scope(expr, semantic, scope);
validate_expr_node(
guard,
semantic,
&guard_scope,
context,
presence_proofs,
diagnostics,
);
}
}
Expr::Literal(_) => {}
}
}
fn validate_unknown_implicit_idents(
op: BinaryOp,
left: &Expr,
right: &Expr,
semantic: &SemanticContext,
scope: &ExprScope,
context: &ExprValidationContext,
diagnostics: &mut Vec<Diagnostic>,
) {
if !matches!(
op,
BinaryOp::Eq | BinaryOp::Ne | BinaryOp::Lt | BinaryOp::Le | BinaryOp::Gt | BinaryOp::Ge
) {
return;
}
validate_unknown_implicit_ident(left, right, semantic, scope, context, diagnostics);
validate_unknown_implicit_ident(right, left, semantic, scope, context, diagnostics);
}
fn validate_unknown_implicit_ident(
expr: &Expr,
other: &Expr,
semantic: &SemanticContext,
scope: &ExprScope,
context: &ExprValidationContext,
diagnostics: &mut Vec<Diagnostic>,
) {
let Expr::Literal(ExprLiteral::Ident(name)) = expr else {
return;
};
let Some(schema) = &scope.implicit_schema else {
return;
};
let field_exists = semantic
.schemas
.classes
.get(schema)
.is_some_and(|fields| fields.contains_key(name));
if field_exists
|| expr_domain(other, semantic, scope).is_some()
|| implicit_ident_field_exists(other, semantic, scope)
{
return;
}
diagnostics.push(Diagnostic {
related: Vec::new(),
span: context.span,
message: format!(
"{} fact query `{schema}` has unknown field `{name}`",
context.subject
),
suggestion: Some(format!(
"use a field declared on `{schema}` inside the query `where` expression"
)),
});
}
fn implicit_ident_field_exists(expr: &Expr, semantic: &SemanticContext, scope: &ExprScope) -> bool {
let Expr::Literal(ExprLiteral::Ident(name)) = expr else {
return false;
};
let Some(schema) = &scope.implicit_schema else {
return false;
};
semantic
.schemas
.classes
.get(schema)
.is_some_and(|fields| fields.contains_key(name))
}
fn validate_function_call(
name: &str,
args: &[Expr],
semantic: &SemanticContext,
scope: &ExprScope,
context: &ExprValidationContext,
diagnostics: &mut Vec<Diagnostic>,
) {
match name {
"count" => {
if args.len() != 1 {
diagnostics.push(Diagnostic { related: Vec::new(),
span: context.span,
message: format!(
"{} calls `count` with {} arguments, expected 1",
context.subject,
args.len()
),
suggestion: Some(
"call `count` with exactly one array, map, fact query, or effect query argument"
.to_owned(),
),
});
return;
}
let ty = infer_expr_type(&args[0], semantic, scope, context, diagnostics);
if !is_countable_type(&ty) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: context.span,
message: format!(
"{} calls `count` with unsupported argument type `{}`",
context.subject,
expr_type_label(&ty)
),
suggestion: Some(
"use `count` only with arrays, maps, fact queries, or effect queries"
.to_owned(),
),
});
}
}
"exists" => {
if args.len() != 1 {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: context.span,
message: format!(
"{} calls `exists` with {} arguments, expected 1",
context.subject,
args.len()
),
suggestion: Some("call `exists` with exactly one argument".to_owned()),
});
return;
}
let ty = infer_expr_type(&args[0], semantic, scope, context, diagnostics);
if !matches!(args[0], Expr::Index { .. }) && !is_exists_type(&ty) {
diagnostics.push(Diagnostic { related: Vec::new(),
span: context.span,
message: format!(
"{} calls `exists` with unsupported argument type `{}`",
context.subject,
expr_type_label(&ty)
),
suggestion: Some(
"use `exists path` for optional/map presence checks or pass an array, map, fact query, or effect query"
.to_owned(),
),
});
}
}
"empty" => {
if args.len() != 1 {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: context.span,
message: format!(
"{} calls `empty` with {} arguments, expected 1",
context.subject,
args.len()
),
suggestion: Some(
"call `empty` with exactly one array, map, string, fact query, or effect query argument"
.to_owned(),
),
});
return;
}
let ty = infer_expr_type(&args[0], semantic, scope, context, diagnostics);
if !is_emptiable_type(&ty) {
let optional = matches!(ty, ExprType::Optional(_));
diagnostics.push(Diagnostic {
related: Vec::new(),
span: context.span,
message: format!(
"{} calls `empty` with unsupported {}argument type `{}`",
context.subject,
if optional { "optional " } else { "" },
expr_type_label(&ty)
),
suggestion: Some(
"use `empty` only with arrays, maps, strings, fact queries, effect queries, null, or supported optional values"
.to_owned(),
),
});
}
}
_ => {}
}
}
fn validate_query_expr(
expr: &Expr,
semantic: &SemanticContext,
scope: &ExprScope,
context: &ExprValidationContext,
diagnostics: &mut Vec<Diagnostic>,
) {
let Expr::Query { kind, head, guard } = expr else {
return;
};
if *kind == QueryKind::Fact {
let Some(schema) = query_head_schema(head, semantic) else {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: context.span,
message: format!(
"{} queries unknown fact schema `{}`",
context.subject,
head.trim()
),
suggestion: Some("use a declared class name in fact queries".to_owned()),
});
return;
};
if let Some(guard) = guard {
let guard_scope = scope.with_implicit_schema(schema);
let ty = infer_expr_type(guard, semantic, &guard_scope, context, diagnostics);
if !matches!(ty, ExprType::Bool | ExprType::Unknown) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: context.span,
message: format!(
"{} fact query `{}` has non-boolean `where` expression",
context.subject,
head.trim()
),
suggestion: Some("query `where` expressions must evaluate to bool".to_owned()),
});
}
}
}
}
fn validate_optional_path_access(
root_schema: &str,
path: &[String],
semantic: &SemanticContext,
presence_proofs: &BTreeSet<String>,
) -> Result<(), String> {
let mut schema = root_schema.to_owned();
let mut prefix = Vec::new();
for (index, field) in path.iter().enumerate() {
let Some(fields) = semantic.schemas.classes.get(&schema) else {
return Ok(());
};
let Some(field_ty) = fields.get(field) else {
return Ok(());
};
prefix.push(field.clone());
if let TypeSyntax::Optional { inner, .. } = field_ty {
if index + 1 < path.len() && !presence_proofs.contains(&prefix.join(".")) {
return Err(format!(
"`{}` must be proven present before accessing `{}`",
prefix.join("."),
path[index + 1..].join(".")
));
}
if let Some(next_schema) = schema_name_for_path(inner) {
schema = next_schema;
}
continue;
}
if let Some(next_schema) = schema_name_for_path(field_ty) {
schema = next_schema;
}
}
Ok(())
}
fn collect_presence_proofs(expr: &Expr, proofs: &mut BTreeSet<String>) {
match expr {
Expr::Binary {
op: BinaryOp::Ne,
left,
right,
} => {
if matches!(**right, Expr::Literal(ExprLiteral::Null)) {
if let Some(path) = expr_path_key(left) {
proofs.insert(path);
}
}
if matches!(**left, Expr::Literal(ExprLiteral::Null)) {
if let Some(path) = expr_path_key(right) {
proofs.insert(path);
}
}
}
Expr::Unary {
op: UnaryOp::Not,
expr,
} => {
if let Expr::Binary {
op: BinaryOp::Eq,
left,
right,
} = expr.as_ref()
{
if matches!(**right, Expr::Literal(ExprLiteral::Null)) {
if let Some(path) = expr_path_key(left) {
proofs.insert(path);
}
}
if matches!(**left, Expr::Literal(ExprLiteral::Null)) {
if let Some(path) = expr_path_key(right) {
proofs.insert(path);
}
}
}
}
Expr::Call { name, args } if name == "exists" && args.len() == 1 => {
if let Some(path) = expr_path_key(&args[0]) {
proofs.insert(path);
}
}
Expr::Binary {
op: BinaryOp::And,
left,
right,
} => {
collect_presence_proofs(left, proofs);
collect_presence_proofs(right, proofs);
}
_ => {}
}
}
fn expr_path_key(expr: &Expr) -> Option<String> {
match expr {
Expr::Literal(ExprLiteral::Ident(name)) => Some(name.clone()),
Expr::Path(path) if path.len() >= 2 => Some(path[1..].join(".")),
Expr::Index { target, key } => {
let target = expr_path_key(target)?;
let key = match key.as_ref() {
Expr::Literal(ExprLiteral::String(value) | ExprLiteral::Ident(value)) => value,
_ => return None,
};
Some(format!("{target}[{key:?}]"))
}
_ => None,
}
}
fn query_guard_scope(expr: &Expr, semantic: &SemanticContext, scope: &ExprScope) -> ExprScope {
let Expr::Query {
kind: QueryKind::Fact,
head,
..
} = expr
else {
return scope.clone();
};
query_head_schema(head, semantic)
.map(|schema| scope.with_implicit_schema(schema))
.unwrap_or_else(|| scope.clone())
}
fn query_head_schema(head: &str, semantic: &SemanticContext) -> Option<String> {
let mut parts = head.split_whitespace();
let schema = parts.next()?;
if parts.next().is_some() {
return None;
}
semantic
.schemas
.class_exists(schema)
.then(|| schema.to_owned())
}
fn implicit_field_type(
name: &str,
semantic: &SemanticContext,
scope: &ExprScope,
) -> Option<TypeSyntax> {
let schema = scope.implicit_schema.as_ref()?;
semantic
.schemas
.resolve_field_path(schema, &[name.to_owned()])
.ok()
}
fn infer_expr_type(
expr: &Expr,
semantic: &SemanticContext,
scope: &ExprScope,
context: &ExprValidationContext,
diagnostics: &mut Vec<Diagnostic>,
) -> ExprType {
match expr {
Expr::Literal(ExprLiteral::Ident(name)) => implicit_field_type(name, semantic, scope)
.map(|ty| expr_type_from_type_syntax(&ty, semantic))
.unwrap_or_else(|| expr_literal_type(&ExprLiteral::Ident(name.clone()))),
Expr::Literal(literal) => expr_literal_type(literal),
Expr::Path(path) => expr_path_type(path, semantic, scope).unwrap_or(ExprType::Unknown),
Expr::Index { target, key } => {
let target_ty = infer_expr_type(target, semantic, scope, context, diagnostics);
let key_ty = infer_expr_type(key, semantic, scope, context, diagnostics);
if !matches!(key_ty, ExprType::String | ExprType::Unknown) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: context.span,
message: format!("{} indexes a map with a non-string key", context.subject),
suggestion: Some(
"use a string literal or string expression as the map key".to_owned(),
),
});
}
match target_ty {
ExprType::Map(inner) => *inner,
ExprType::Unknown => ExprType::Unknown,
_ => {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: context.span,
message: format!("{} indexes a non-map expression", context.subject),
suggestion: Some("use indexing only on map values".to_owned()),
});
ExprType::Unknown
}
}
}
Expr::Array(items) => infer_array_type(items, semantic, scope, context, diagnostics),
Expr::Object(fields) => {
for field in fields {
infer_expr_type(&field.value, semantic, scope, context, diagnostics);
}
ExprType::Object
}
Expr::Unary {
op: UnaryOp::Not,
expr,
} => {
let inner = infer_expr_type(expr, semantic, scope, context, diagnostics);
if !matches!(inner, ExprType::Bool | ExprType::Unknown) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: context.span,
message: format!(
"{} applies `!` to a non-boolean expression",
context.subject
),
suggestion: Some("use `!` only with boolean expressions".to_owned()),
});
}
ExprType::Bool
}
Expr::Binary { op, left, right } => {
infer_binary_type(*op, left, right, semantic, scope, context, diagnostics)
}
Expr::Call { name, args } => match name.as_str() {
"count" => ExprType::Int,
"exists" => ExprType::Bool,
"empty" => ExprType::Bool,
_ => {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: context.span,
message: format!(
"{} calls unsupported expression function `{name}`",
context.subject
),
suggestion: Some("use `count`, `exists`, or `empty`".to_owned()),
});
for arg in args {
infer_expr_type(arg, semantic, scope, context, diagnostics);
}
ExprType::Unknown
}
},
Expr::Query { guard, .. } => {
if let Some(guard) = guard {
let guard_scope = query_guard_scope(expr, semantic, scope);
infer_expr_type(guard, semantic, &guard_scope, context, diagnostics);
}
ExprType::Collection
}
}
}
fn infer_binary_type(
op: BinaryOp,
left: &Expr,
right: &Expr,
semantic: &SemanticContext,
scope: &ExprScope,
context: &ExprValidationContext,
diagnostics: &mut Vec<Diagnostic>,
) -> ExprType {
let left_ty = infer_expr_type(left, semantic, scope, context, diagnostics);
let right_ty = infer_expr_type(right, semantic, scope, context, diagnostics);
match op {
BinaryOp::And | BinaryOp::Or => {
for ty in [&left_ty, &right_ty] {
if !matches!(ty, ExprType::Bool | ExprType::Unknown) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: context.span,
message: format!(
"{} uses boolean operator with non-boolean operand",
context.subject
),
suggestion: Some(
"use `&&` and `||` only with boolean expressions".to_owned(),
),
});
break;
}
}
ExprType::Bool
}
BinaryOp::Eq | BinaryOp::Ne => {
if !types_comparable(&left_ty, &right_ty) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: context.span,
message: format!("{} compares incompatible expression types", context.subject),
suggestion: Some(
"compare values with compatible scalar or finite-domain types".to_owned(),
),
});
}
ExprType::Bool
}
BinaryOp::Lt | BinaryOp::Le | BinaryOp::Gt | BinaryOp::Ge => {
if !is_orderable_pair(&left_ty, &right_ty) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: context.span,
message: format!("{} orders non-orderable expression values", context.subject),
suggestion: Some(
"use ordering only with int, float, duration, or time values".to_owned(),
),
});
}
ExprType::Bool
}
BinaryOp::In | BinaryOp::NotIn => {
match &right_ty {
ExprType::Array(item_ty) => {
if !types_comparable(&left_ty, item_ty) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: context.span,
message: format!(
"{} uses membership with incompatible item type",
context.subject
),
suggestion: Some(
"make the left value compatible with the array item type"
.to_owned(),
),
});
}
}
ExprType::Map(_) => {
if !is_string_like_key_type(&left_ty) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: context.span,
message: format!(
"{} uses map membership with a non-string key",
context.subject
),
suggestion: Some(
"use a string value on the left side of map membership".to_owned(),
),
});
}
}
ExprType::Unknown => {}
_ => diagnostics.push(Diagnostic {
related: Vec::new(),
span: context.span,
message: format!(
"{} uses membership against a non-array/non-map expression",
context.subject
),
suggestion: Some(
"use `in` with an array literal, array value, or map value".to_owned(),
),
}),
}
ExprType::Bool
}
BinaryOp::Add | BinaryOp::Sub | BinaryOp::Mul | BinaryOp::Div => {
for ty in [&left_ty, &right_ty] {
if !matches!(ty, ExprType::Int | ExprType::Float | ExprType::Unknown) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: context.span,
message: format!(
"{} uses arithmetic with a non-numeric operand",
context.subject
),
suggestion: Some("use `+ - * /` only with int or float values".to_owned()),
});
break;
}
}
if matches!(left_ty, ExprType::Float) || matches!(right_ty, ExprType::Float) {
ExprType::Float
} else if matches!(left_ty, ExprType::Int) && matches!(right_ty, ExprType::Int) {
ExprType::Int
} else {
ExprType::Unknown
}
}
}
}
fn infer_array_type(
items: &[Expr],
semantic: &SemanticContext,
scope: &ExprScope,
context: &ExprValidationContext,
diagnostics: &mut Vec<Diagnostic>,
) -> ExprType {
let mut item_ty: Option<ExprType> = None;
for item in items {
let ty = infer_expr_type(item, semantic, scope, context, diagnostics);
if matches!(ty, ExprType::Unknown) {
continue;
}
match &item_ty {
None => item_ty = Some(ty),
Some(existing) if types_comparable(existing, &ty) => {}
Some(_) => {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: context.span,
message: format!("{} has mixed-type array literal", context.subject),
suggestion: Some("use array literals whose elements share one type".to_owned()),
});
return ExprType::Array(Box::new(ExprType::Unknown));
}
}
}
ExprType::Array(Box::new(item_ty.unwrap_or(ExprType::Unknown)))
}
fn expr_path_type(
path: &[String],
semantic: &SemanticContext,
scope: &ExprScope,
) -> Option<ExprType> {
if path.len() < 2 {
return None;
}
if let Some(schema) = scope.binding_types.get(&path[0]) {
if schema.contains('.') {
return Some(ExprType::Unknown);
}
return semantic
.schemas
.resolve_field_path(schema, &path[1..])
.ok()
.map(|ty| expr_type_from_type_syntax(&ty, semantic));
}
let schema = scope.implicit_schema.as_ref()?;
if schema.contains('.') {
return Some(ExprType::Unknown);
}
semantic
.schemas
.resolve_field_path(schema, path)
.ok()
.map(|ty| expr_type_from_type_syntax(&ty, semantic))
}
fn expr_type_from_type_syntax(ty: &TypeSyntax, semantic: &SemanticContext) -> ExprType {
match ty {
TypeSyntax::Primitive { name, .. } => match name.as_str() {
"bool" => ExprType::Bool,
"int" => ExprType::Int,
"float" => ExprType::Float,
"string" => ExprType::String,
"duration" => ExprType::Duration,
"time" => ExprType::Time,
"secret" => ExprType::Secret,
_ => ExprType::Unknown,
},
TypeSyntax::LiteralString { value, .. } => ExprType::Finite {
label: "literal".to_owned(),
values: vec![value.clone()],
},
TypeSyntax::AgentRef { agents, .. } => ExprType::Finite {
label: "AgentRef".to_owned(),
values: agents.iter().map(|agent| agent.name.clone()).collect(),
},
TypeSyntax::Ref { name } => semantic
.schemas
.enums
.get(&name.name)
.map(|variants| ExprType::Finite {
label: format!("enum `{}`", name.name),
values: variants.iter().cloned().collect(),
})
.unwrap_or(ExprType::Object),
TypeSyntax::Optional { inner, .. } => {
ExprType::Optional(Box::new(expr_type_from_type_syntax(inner, semantic)))
}
TypeSyntax::Array { inner, .. } => {
ExprType::Array(Box::new(expr_type_from_type_syntax(inner, semantic)))
}
TypeSyntax::Map { inner, .. } => {
ExprType::Map(Box::new(expr_type_from_type_syntax(inner, semantic)))
}
TypeSyntax::Union { variants, .. } => {
let values = variants
.iter()
.filter_map(|variant| match variant {
TypeSyntax::LiteralString { value, .. } => Some(value.clone()),
_ => None,
})
.collect::<Vec<_>>();
if values.len() == variants.len() && !values.is_empty() {
ExprType::Finite {
label: "literal union".to_owned(),
values,
}
} else {
ExprType::Unknown
}
}
}
}
fn expr_literal_type(literal: &ExprLiteral) -> ExprType {
match literal {
ExprLiteral::String(_) | ExprLiteral::Ident(_) => ExprType::String,
ExprLiteral::Number(value) if value.contains('.') => ExprType::Float,
ExprLiteral::Number(_) => ExprType::Int,
ExprLiteral::Bool(_) => ExprType::Bool,
ExprLiteral::Null => ExprType::Null,
}
}
fn types_comparable(left: &ExprType, right: &ExprType) -> bool {
if matches!(left, ExprType::Unknown) || matches!(right, ExprType::Unknown) {
return true;
}
if matches!(left, ExprType::Null) || matches!(right, ExprType::Null) {
return true;
}
if is_numeric_type(left) && is_numeric_type(right) {
return true;
}
match (left, right) {
(ExprType::Optional(left), right) | (right, ExprType::Optional(left)) => {
types_comparable(left, right)
}
(ExprType::Finite { .. }, ExprType::String)
| (ExprType::String, ExprType::Finite { .. })
| (ExprType::Finite { .. }, ExprType::Finite { .. }) => true,
_ => left == right,
}
}
fn is_numeric_type(ty: &ExprType) -> bool {
matches!(ty, ExprType::Int | ExprType::Float)
}
fn is_string_like_key_type(ty: &ExprType) -> bool {
match ty {
ExprType::String | ExprType::Unknown | ExprType::Finite { .. } => true,
ExprType::Optional(inner) => is_string_like_key_type(inner),
_ => false,
}
}
fn is_orderable_pair(left: &ExprType, right: &ExprType) -> bool {
if matches!(left, ExprType::Unknown) || matches!(right, ExprType::Unknown) {
return true;
}
(is_numeric_type(left) && is_numeric_type(right))
|| matches!(
(left, right),
(ExprType::Duration, ExprType::Duration)
| (ExprType::Time, ExprType::Time)
| (ExprType::Time, ExprType::String)
| (ExprType::String, ExprType::Time)
)
}
fn is_countable_type(ty: &ExprType) -> bool {
matches!(
ty,
ExprType::Array(_) | ExprType::Map(_) | ExprType::Collection | ExprType::Unknown
)
}
fn is_exists_type(ty: &ExprType) -> bool {
matches!(
ty,
ExprType::Array(_)
| ExprType::Map(_)
| ExprType::Collection
| ExprType::Optional(_)
| ExprType::Unknown
)
}
fn is_emptiable_type(ty: &ExprType) -> bool {
match ty {
ExprType::Array(_)
| ExprType::Map(_)
| ExprType::String
| ExprType::Collection
| ExprType::Null
| ExprType::Unknown => true,
ExprType::Optional(inner) => is_emptiable_type(inner),
_ => false,
}
}
fn expr_type_label(ty: &ExprType) -> String {
match ty {
ExprType::Bool => "bool".to_owned(),
ExprType::Int => "int".to_owned(),
ExprType::Float => "float".to_owned(),
ExprType::String => "string".to_owned(),
ExprType::Finite { label, values } => format!("{label}<{}>", values.join(" | ")),
ExprType::Duration => "duration".to_owned(),
ExprType::Time => "time".to_owned(),
ExprType::Secret => "secret".to_owned(),
ExprType::Null => "null".to_owned(),
ExprType::Object => "object".to_owned(),
ExprType::Array(inner) => format!("{}[]", expr_type_label(inner)),
ExprType::Map(inner) => format!("map<{}>", expr_type_label(inner)),
ExprType::Optional(inner) => format!("{}?", expr_type_label(inner)),
ExprType::Collection => "query".to_owned(),
ExprType::Unknown => "unknown".to_owned(),
}
}
fn validate_finite_domain_expr(
op: BinaryOp,
left: &Expr,
right: &Expr,
semantic: &SemanticContext,
scope: &ExprScope,
context: &ExprValidationContext,
diagnostics: &mut Vec<Diagnostic>,
) {
if !matches!(
op,
BinaryOp::Eq | BinaryOp::Ne | BinaryOp::In | BinaryOp::NotIn
) {
return;
}
let Some((domain, literals)) = finite_domain_comparison(left, right, semantic, scope)
.or_else(|| finite_domain_comparison(right, left, semantic, scope))
else {
validate_finite_domain_relation(op, left, right, semantic, scope, context, diagnostics);
return;
};
for literal in literals.into_iter().flatten() {
if !domain.iter().any(|value| value == &literal) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: context.span,
message: format!(
"{} compares finite-domain value to unknown `{literal}`",
context.subject
),
suggestion: Some(format!("use one of: {}", domain.join(", "))),
});
}
}
validate_finite_domain_relation(op, left, right, semantic, scope, context, diagnostics);
}
fn validate_finite_domain_relation(
op: BinaryOp,
left: &Expr,
right: &Expr,
semantic: &SemanticContext,
scope: &ExprScope,
context: &ExprValidationContext,
diagnostics: &mut Vec<Diagnostic>,
) {
match op {
BinaryOp::Eq => {
let Some(left_domain) = expr_domain(left, semantic, scope) else {
return;
};
let Some(right_domain) = expr_domain(right, semantic, scope) else {
return;
};
if left_domain
.iter()
.all(|value| !right_domain.iter().any(|right| right == value))
{
diagnostics.push(Diagnostic {
related: Vec::new(),
span: context.span,
message: format!(
"{} has statically unsatisfiable finite-domain equality",
context.subject
),
suggestion: Some(format!(
"compare domains with at least one shared value; left: {}, right: {}",
left_domain.join(", "),
right_domain.join(", ")
)),
});
}
}
BinaryOp::In => {
let Some(domain) = expr_domain(left, semantic, scope) else {
return;
};
let Some(literals) = literal_array_values(right) else {
return;
};
if literals
.iter()
.all(|literal| !domain.iter().any(|value| value == literal))
{
diagnostics.push(Diagnostic {
related: Vec::new(),
span: context.span,
message: format!(
"{} has statically unsatisfiable finite-domain membership",
context.subject
),
suggestion: Some(format!("use one of: {}", domain.join(", "))),
});
}
}
BinaryOp::NotIn => {
let Some(domain) = expr_domain(left, semantic, scope) else {
return;
};
let Some(literals) = literal_array_values(right) else {
return;
};
if !domain.is_empty()
&& domain
.iter()
.all(|value| literals.iter().any(|literal| literal == value))
{
diagnostics.push(Diagnostic {
related: Vec::new(),
span: context.span,
message: format!(
"{} has statically unsatisfiable finite-domain exclusion",
context.subject
),
suggestion: Some(
"leave at least one domain value outside the exclusion set".to_owned(),
),
});
}
}
_ => {}
}
}
fn finite_domain_comparison(
domain_expr: &Expr,
literal_expr: &Expr,
semantic: &SemanticContext,
scope: &ExprScope,
) -> Option<(Vec<String>, Vec<Option<String>>)> {
let domain = expr_domain(domain_expr, semantic, scope)?;
let literals = match literal_expr {
Expr::Literal(literal) => vec![expr_literal_name(literal)],
Expr::Array(items) => items
.iter()
.filter_map(|item| match item {
Expr::Literal(literal) => Some(expr_literal_name(literal)),
_ => None,
})
.collect(),
_ => Vec::new(),
};
Some((domain, literals))
}
fn expr_domain(expr: &Expr, semantic: &SemanticContext, scope: &ExprScope) -> Option<Vec<String>> {
let ty = match expr {
Expr::Path(path) => {
let root = path.first()?;
if let Some(schema) = scope.binding_types.get(root) {
semantic
.schemas
.resolve_field_path(schema, path.get(1..)?)
.ok()?
} else {
let schema = scope.implicit_schema.as_ref()?;
semantic.schemas.resolve_field_path(schema, path).ok()?
}
}
Expr::Literal(ExprLiteral::Ident(name)) => implicit_field_type(name, semantic, scope)?,
_ => return None,
};
finite_expr_domain(&ty, semantic)
}
fn finite_expr_domain(ty: &TypeSyntax, semantic: &SemanticContext) -> Option<Vec<String>> {
match ty {
TypeSyntax::Ref { name } => semantic
.schemas
.enums
.get(&name.name)
.map(|variants| variants.iter().cloned().collect()),
TypeSyntax::Union { variants, .. } => {
let values = variants
.iter()
.filter_map(|variant| match variant {
TypeSyntax::LiteralString { value, .. } => Some(value.clone()),
_ => None,
})
.collect::<Vec<_>>();
(!values.is_empty()).then_some(values)
}
TypeSyntax::AgentRef { agents, .. } => {
Some(agents.iter().map(|agent| agent.name.clone()).collect())
}
_ => None,
}
}
fn expr_literal_name(literal: &ExprLiteral) -> Option<String> {
match literal {
ExprLiteral::String(value) | ExprLiteral::Ident(value) => Some(value.clone()),
_ => None,
}
}
fn literal_array_values(expr: &Expr) -> Option<Vec<String>> {
let Expr::Array(items) = expr else {
return None;
};
items
.iter()
.map(|item| match item {
Expr::Literal(literal) => expr_literal_name(literal),
_ => None,
})
.collect()
}
fn parse_tell_target(line: &str) -> Option<&str> {
line.strip_prefix("tell ")?
.split_whitespace()
.next()
.filter(|target| !target.is_empty())
}
fn parse_required_capabilities(line: &str) -> Vec<String> {
let Some(rest) = line.split_once(" requires ") else {
return Vec::new();
};
let Some(list) = rest.1.trim_start().strip_prefix('[') else {
return Vec::new();
};
let Some((items, _)) = list.split_once(']') else {
return Vec::new();
};
let mut capabilities = items
.split(',')
.filter_map(|item| {
let value = item.trim().trim_matches('"');
(!value.is_empty()).then(|| value.to_owned())
})
.collect::<Vec<_>>();
capabilities.sort();
capabilities.dedup();
capabilities
}
fn validate_case_blocks(
rule: &RuleDecl,
semantic: &SemanticContext,
binding_types: &BTreeMap<String, String>,
diagnostics: &mut Vec<Diagnostic>,
) {
let lines = rule
.body
.text
.lines()
.scan(0usize, |offset, line| {
let current = *offset;
*offset += line.len() + 1;
Some((line, current))
})
.collect::<Vec<_>>();
let text_lines = lines.iter().map(|(line, _)| *line).collect::<Vec<_>>();
let mut index = 0usize;
while index < lines.len() {
let trimmed = lines[index].0.trim();
let Some(scrutinee) = case_scrutinee(trimmed) else {
index += 1;
continue;
};
let scrutinee_ty = expression_type(scrutinee, semantic, binding_types);
let terminal_case = scrutinee_ty.is_none()
&& active_completes_binding_for_case(&text_lines, index, scrutinee);
if scrutinee_ty.is_none() && !terminal_case {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!(
"rule `{}` has case scrutinee `{scrutinee}` that is not a typed path",
rule.name.name
),
suggestion: Some("match on a bound field such as `task.provider`".to_owned()),
});
}
let mut depth = brace_delta(trimmed).max(1);
let mut case_index = index + 1;
let mut branches = Vec::new();
while case_index < lines.len() && depth > 0 {
let (raw_line, line_offset) = lines[case_index];
let line = raw_line.trim();
if depth == 1 {
if let Some(branch) = parse_case_branch_head(line) {
let pattern_column = case_pattern_column(raw_line, branch.pattern);
let branch = SpanCaseBranchHead {
pattern: branch.pattern,
guard: branch.guard,
pattern_span: SourceSpan {
start: rule_body_text_start(rule) + line_offset + pattern_column,
end: rule_body_text_start(rule)
+ line_offset
+ pattern_column
+ branch.pattern.len(),
},
};
branches.push(branch);
if terminal_case {
validate_terminal_case_pattern(
rule,
branch.pattern,
branch.pattern_span,
diagnostics,
);
} else {
validate_case_pattern(
rule,
branch.pattern,
scrutinee_ty.as_ref(),
branch.pattern_span,
semantic,
diagnostics,
);
}
if let Some(guard) = branch.guard.filter(|_| !terminal_case) {
let mut branch_scope = binding_types.clone();
if let Some(scrutinee_ty) = scrutinee_ty.as_ref() {
if let Some((binding, schema)) =
case_branch_payload_binding(branch.pattern, scrutinee_ty, semantic)
{
branch_scope.insert(binding, schema);
}
}
validate_expression(
rule,
guard,
semantic,
&branch_scope,
"case guard",
diagnostics,
);
validate_known_field_paths_at_span(
rule,
guard,
branch.pattern_span,
semantic,
&branch_scope,
diagnostics,
);
}
}
}
depth += brace_delta(line);
case_index += 1;
}
if terminal_case {
validate_terminal_case_coverage(rule, &branches, diagnostics);
} else {
validate_case_coverage(
rule,
scrutinee_ty.as_ref(),
&branches,
semantic,
diagnostics,
);
}
index += 1;
}
}
fn active_completes_binding_for_case(lines: &[&str], case_index: usize, scrutinee: &str) -> bool {
let mut scopes: Vec<(String, DependencyPredicate, i32)> = Vec::new();
for line in lines.iter().take(case_index) {
let trimmed = line.trim();
if let Some((binding, predicate)) = parse_after_line(trimmed) {
scopes.push((binding, predicate, brace_delta(trimmed).max(1)));
} else {
let delta = brace_delta(trimmed);
for (_, _, depth) in &mut scopes {
*depth += delta;
}
scopes.retain(|(_, _, depth)| *depth > 0);
}
}
scopes.iter().any(|(binding, predicate, _)| {
binding == scrutinee && predicate == &DependencyPredicate::Completes
})
}
fn brace_delta(line: &str) -> i32 {
line.chars().fold(0, |depth, ch| match ch {
'{' => depth + 1,
'}' => depth - 1,
_ => depth,
})
}
fn case_scrutinee(line: &str) -> Option<&str> {
let rest = line.strip_prefix("case ")?;
let expr = rest.strip_suffix('{').unwrap_or(rest).trim();
(!expr.is_empty()).then_some(expr)
}
fn is_case_branch_start(line: &str) -> bool {
line.contains("=>")
}
#[derive(Clone, Copy)]
struct CaseBranchHead<'a> {
pattern: &'a str,
guard: Option<&'a str>,
}
#[derive(Clone, Copy)]
struct SpanCaseBranchHead<'a> {
pattern: &'a str,
guard: Option<&'a str>,
pattern_span: SourceSpan,
}
fn parse_case_branch_head(line: &str) -> Option<CaseBranchHead<'_>> {
let (pattern, _) = line.split_once("=>")?;
let pattern = pattern.trim();
if pattern.is_empty() {
return None;
}
match pattern.split_once(" where ") {
Some((pattern, guard)) => Some(CaseBranchHead {
pattern: pattern.trim(),
guard: Some(guard.trim()),
}),
None => Some(CaseBranchHead {
pattern,
guard: None,
}),
}
}
fn expression_type(
expr: &str,
semantic: &SemanticContext,
binding_types: &BTreeMap<String, String>,
) -> Option<TypeSyntax> {
let is_bare_ident = !expr.is_empty()
&& expr.chars().all(|ch| ch.is_alphanumeric() || ch == '_')
&& expr.chars().next().is_some_and(char::is_alphabetic);
if is_bare_ident {
let schema = binding_types.get(expr)?;
if semantic.schemas.enums.contains_key(schema) {
return Some(TypeSyntax::Ref {
name: Ident {
name: schema.clone(),
span: zero_span(),
},
});
}
return None;
}
let (root, path) = expression_path(expr)?;
let schema = binding_types.get(&root)?;
semantic.schemas.resolve_field_path(schema, &path).ok()
}
fn validate_case_pattern(
rule: &RuleDecl,
pattern: &str,
scrutinee_ty: Option<&TypeSyntax>,
span: SourceSpan,
semantic: &SemanticContext,
diagnostics: &mut Vec<Diagnostic>,
) {
if matches!(pattern, "_" | "default") {
return;
}
if pattern == "None" {
if !matches!(scrutinee_ty, Some(TypeSyntax::Optional { .. })) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span,
message: format!(
"rule `{}` uses `None` for a non-optional case",
rule.name.name
),
suggestion: Some("use `None` only when matching an optional field".to_owned()),
});
}
return;
}
if pattern.starts_with("Some ") {
if !matches!(scrutinee_ty, Some(TypeSyntax::Optional { .. })) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span,
message: format!(
"rule `{}` uses `Some` for a non-optional case",
rule.name.name
),
suggestion: Some("use `Some name` only when matching an optional field".to_owned()),
});
}
return;
}
let Some(scrutinee_ty) = scrutinee_ty else {
return;
};
match scrutinee_ty {
TypeSyntax::Ref { name } => {
let Some(variants) = semantic.schemas.enums.get(&name.name) else {
return;
};
let (variant, binding) = sum_case_pattern_parts(pattern);
if !variants.contains(variant) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span,
message: format!("enum `{}` has no variant `{variant}`", name.name),
suggestion: Some(format!(
"use one of: {}",
variants.iter().cloned().collect::<Vec<_>>().join(", ")
)),
});
return;
}
if binding.is_some()
&& !semantic
.schemas
.class_exists(&format!("{}.{variant}", name.name))
{
diagnostics.push(Diagnostic {
related: Vec::new(),
span,
message: format!(
"variant `{variant}` of enum `{}` carries no payload to bind",
name.name
),
suggestion: Some(format!("write `{variant} => {{ ... }}` without `as`")),
});
}
}
TypeSyntax::Union { variants, .. } => {
let Some(literal) = parse_literal_expr(pattern) else {
diagnostics.push(Diagnostic {
related: Vec::new(),
span,
message: format!(
"rule `{}` has unsupported case pattern `{pattern}`",
rule.name.name
),
suggestion: Some("use a literal branch value or `_`".to_owned()),
});
return;
};
validate_union_case_pattern(rule, variants, &literal, span, diagnostics);
}
TypeSyntax::AgentRef { agents, .. } => {
let Some(literal) = parse_literal_expr(pattern) else {
diagnostics.push(Diagnostic {
related: Vec::new(),
span,
message: format!(
"rule `{}` has unsupported AgentRef case pattern `{pattern}`",
rule.name.name
),
suggestion: Some(
"use a declared agent name, a string literal, or `_`".to_owned(),
),
});
return;
};
validate_agent_ref_case_pattern(rule, agents, &literal, span, diagnostics);
}
TypeSyntax::Optional { inner, .. } => {
validate_case_pattern(rule, pattern, Some(inner), span, semantic, diagnostics);
}
TypeSyntax::Primitive { name, .. } if name == "bool" => {
if !matches!(pattern, "true" | "false") {
diagnostics.push(Diagnostic {
related: Vec::new(),
span,
message: format!(
"rule `{}` has case pattern `{pattern}` that is not a `bool` value",
rule.name.name
),
suggestion: Some("match `true`, `false`, or `_`".to_owned()),
});
}
}
_ => {
diagnostics.push(Diagnostic {
related: Vec::new(),
span,
message: format!(
"rule `{}` cannot pattern-match this scrutinee type",
rule.name.name
),
suggestion: Some(
"match an enum, literal union, optional, or tagged output union".to_owned(),
),
});
}
}
}
fn terminal_case_tags() -> [&'static str; 4] {
["Completed", "Failed", "TimedOut", "Cancelled"]
}
fn validate_terminal_case_pattern(
rule: &RuleDecl,
pattern: &str,
span: SourceSpan,
diagnostics: &mut Vec<Diagnostic>,
) {
if is_fallback_pattern(pattern) {
return;
}
let mut parts = pattern.split_whitespace();
let Some(tag) = parts.next() else {
return;
};
let second = parts.next();
let binding = match second {
Some("as") => parts.next(),
other => other,
};
let uses_as = matches!(second, Some("as"));
if parts.next().is_some() || binding.is_none() || !uses_as {
diagnostics.push(Diagnostic { related: Vec::new(),
span,
message: format!(
"rule `{}` has malformed terminal-output case pattern `{pattern}`",
rule.name.name
),
suggestion: Some("write `Completed as result`, `Failed as failure`, `TimedOut as timeout`, or `Cancelled as cancel` (the `as` is required)".to_owned()),
});
return;
}
let tags = terminal_case_tags();
if !tags.contains(&tag) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span,
message: format!(
"rule `{}` terminal-output case pattern cannot be `{tag}`",
rule.name.name
),
suggestion: Some(format!("use one of: {}", tags.join(", "))),
});
}
}
fn validate_terminal_case_coverage(
rule: &RuleDecl,
branches: &[SpanCaseBranchHead<'_>],
diagnostics: &mut Vec<Diagnostic>,
) {
validate_unreachable_after_fallback(rule, branches, diagnostics);
if branches.is_empty()
|| branches
.iter()
.any(|branch| is_fallback_pattern(branch.pattern))
{
validate_duplicate_terminal_case_patterns(rule, branches, diagnostics);
return;
}
validate_duplicate_terminal_case_patterns(rule, branches, diagnostics);
let covered = branches
.iter()
.filter(|branch| branch.guard.is_none())
.filter_map(|branch| normalized_terminal_case_pattern(branch.pattern))
.collect::<BTreeSet<_>>();
let missing = terminal_case_tags()
.iter()
.filter(|tag| !covered.contains(**tag))
.copied()
.collect::<Vec<_>>();
if !missing.is_empty() {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!(
"rule `{}` has non-exhaustive terminal-output case; missing {}",
rule.name.name,
missing.join(", ")
),
suggestion: Some(
"add terminal branches for every value or add `_ => { ... }`".to_owned(),
),
});
}
}
fn validate_duplicate_terminal_case_patterns(
rule: &RuleDecl,
branches: &[SpanCaseBranchHead<'_>],
diagnostics: &mut Vec<Diagnostic>,
) {
let mut seen = BTreeSet::new();
for branch in branches.iter().filter(|branch| branch.guard.is_none()) {
let Some(pattern) = normalized_terminal_case_pattern(branch.pattern) else {
continue;
};
if !seen.insert(pattern.to_owned()) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: branch.pattern_span,
message: format!(
"rule `{}` has duplicate unguarded terminal-output case pattern `{pattern}`",
rule.name.name
),
suggestion: Some(
"remove the duplicate branch or add mutually exclusive `where` guards"
.to_owned(),
),
});
}
}
}
fn validate_case_coverage(
rule: &RuleDecl,
scrutinee_ty: Option<&TypeSyntax>,
branches: &[SpanCaseBranchHead<'_>],
semantic: &SemanticContext,
diagnostics: &mut Vec<Diagnostic>,
) {
validate_unreachable_after_fallback(rule, branches, diagnostics);
if branches.is_empty()
|| branches
.iter()
.any(|branch| is_fallback_pattern(branch.pattern))
{
validate_duplicate_case_patterns(rule, branches, diagnostics);
return;
}
validate_duplicate_case_patterns(rule, branches, diagnostics);
let Some(domain) = finite_case_domain(scrutinee_ty, semantic) else {
return;
};
let covered = branches
.iter()
.filter(|branch| branch.guard.is_none())
.filter_map(|branch| normalized_case_pattern(branch.pattern))
.collect::<BTreeSet<_>>();
let missing = domain
.iter()
.filter(|value| !covered.contains(value.as_str()))
.cloned()
.collect::<Vec<_>>();
if !missing.is_empty() {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!(
"rule `{}` has non-exhaustive case; missing {}",
rule.name.name,
missing.join(", ")
),
suggestion: Some("add branches for every value or add `_ => { ... }`".to_owned()),
});
}
}
fn validate_duplicate_case_patterns(
rule: &RuleDecl,
branches: &[SpanCaseBranchHead<'_>],
diagnostics: &mut Vec<Diagnostic>,
) {
let mut seen = BTreeSet::new();
for branch in branches.iter().filter(|branch| branch.guard.is_none()) {
let Some(pattern) = normalized_case_pattern(branch.pattern) else {
continue;
};
if !seen.insert(pattern.to_owned()) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: branch.pattern_span,
message: format!(
"rule `{}` has duplicate unguarded case pattern `{pattern}`",
rule.name.name
),
suggestion: Some(
"remove the duplicate branch or add mutually exclusive `where` guards"
.to_owned(),
),
});
}
}
}
fn validate_unreachable_after_fallback(
rule: &RuleDecl,
branches: &[SpanCaseBranchHead<'_>],
diagnostics: &mut Vec<Diagnostic>,
) {
let mut ordered: Vec<&SpanCaseBranchHead<'_>> = branches.iter().collect();
ordered.sort_by_key(|branch| branch.pattern_span.start);
let mut fallback_span: Option<SourceSpan> = None;
for branch in ordered {
if let Some(prior) = fallback_span {
diagnostics.push(
Diagnostic {
related: Vec::new(),
span: branch.pattern_span,
message: format!(
"rule `{}` has an unreachable case branch after the `_` wildcard",
rule.name.name
),
suggestion: Some(
"move this branch before the wildcard, or remove it".to_owned(),
),
}
.with_related(
prior,
"this unguarded wildcard already matches every remaining value",
),
);
} else if branch.guard.is_none() && is_fallback_pattern(branch.pattern) {
fallback_span = Some(branch.pattern_span);
}
}
}
fn finite_case_domain(
scrutinee_ty: Option<&TypeSyntax>,
semantic: &SemanticContext,
) -> Option<Vec<String>> {
match scrutinee_ty? {
TypeSyntax::Ref { name } => semantic
.schemas
.enums
.get(&name.name)
.map(|variants| variants.iter().cloned().collect()),
TypeSyntax::Union { variants, .. } => {
let values = variants
.iter()
.filter_map(|variant| match variant {
TypeSyntax::LiteralString { value, .. } => Some(value.clone()),
_ => None,
})
.collect::<Vec<_>>();
(!values.is_empty()).then_some(values)
}
TypeSyntax::Optional { .. } => Some(vec!["Some".to_owned(), "None".to_owned()]),
TypeSyntax::AgentRef { agents, .. } => {
Some(agents.iter().map(|agent| agent.name.clone()).collect())
}
TypeSyntax::Primitive { name, .. } if name == "bool" => {
Some(vec!["true".to_owned(), "false".to_owned()])
}
_ => None,
}
}
fn sum_case_pattern_parts(pattern: &str) -> (&str, Option<&str>) {
match pattern.split_once(" as ") {
Some((variant, binding)) => (variant.trim(), Some(binding.trim())),
None => (pattern.trim(), None),
}
}
fn normalized_case_pattern(pattern: &str) -> Option<&str> {
if is_fallback_pattern(pattern) {
return None;
}
if pattern.starts_with("Some ") {
return Some("Some");
}
if pattern == "None" {
return Some("None");
}
let (pattern, _) = sum_case_pattern_parts(pattern);
if matches!(pattern, "true" | "false") {
return Some(pattern);
}
parse_literal_expr(pattern).and_then(|literal| match literal {
LiteralExpr::String(value) | LiteralExpr::Ident(value) => Some(value),
_ => None,
})
}
fn normalized_terminal_case_pattern(pattern: &str) -> Option<&str> {
if is_fallback_pattern(pattern) {
return None;
}
pattern.split_whitespace().next()
}
fn is_fallback_pattern(pattern: &str) -> bool {
matches!(pattern, "_" | "default")
}
fn validate_union_case_pattern(
rule: &RuleDecl,
variants: &[TypeSyntax],
literal: &LiteralExpr<'_>,
span: SourceSpan,
diagnostics: &mut Vec<Diagnostic>,
) {
let allowed = variants
.iter()
.filter_map(|variant| match variant {
TypeSyntax::LiteralString { value, .. } => Some(value.as_str()),
_ => None,
})
.collect::<Vec<_>>();
if allowed.is_empty() {
return;
}
let LiteralExpr::String(value) = literal else {
diagnostics.push(Diagnostic {
related: Vec::new(),
span,
message: format!(
"rule `{}` case pattern must be one of its literal variants",
rule.name.name
),
suggestion: Some(format!("use one of: {}", allowed.join(", "))),
});
return;
};
if !allowed.contains(value) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span,
message: format!("rule `{}` case pattern cannot be `{value}`", rule.name.name),
suggestion: Some(format!("use one of: {}", allowed.join(", "))),
});
}
}
fn validate_agent_ref_case_pattern(
rule: &RuleDecl,
agents: &[Ident],
literal: &LiteralExpr<'_>,
span: SourceSpan,
diagnostics: &mut Vec<Diagnostic>,
) {
let allowed = agents
.iter()
.map(|agent| agent.name.as_str())
.collect::<Vec<_>>();
let (LiteralExpr::String(value) | LiteralExpr::Ident(value)) = literal else {
diagnostics.push(Diagnostic {
related: Vec::new(),
span,
message: format!("rule `{}` has non-agent case pattern", rule.name.name),
suggestion: Some(format!("use one of: {}", allowed.join(", "))),
});
return;
};
if !allowed.contains(value) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span,
message: format!("AgentRef has no agent `{value}`"),
suggestion: Some(format!("use one of: {}", allowed.join(", "))),
});
}
}
fn validate_binding_uses(
rule: &RuleDecl,
line: &str,
seen_bindings: &BTreeSet<String>,
scope_stack: &[(String, DependencyPredicate)],
diagnostics: &mut Vec<Diagnostic>,
) {
for root in interpolation_roots(line) {
if !seen_bindings.contains(&root) {
continue;
}
if scope_stack.iter().any(|(binding, _)| binding == &root) {
continue;
}
diagnostics.push(Diagnostic { related: Vec::new(),
span: rule.body.span,
message: format!(
"rule `{}` uses effect output `{root}` outside a matching `after {root} ...` block",
rule.name.name
),
suggestion: Some(format!(
"move this use into `after {root} succeeds {{ ... }}` or another matching terminal branch"
)),
});
}
}
fn after_scopes(block_stack: &[BlockFrame]) -> Vec<(String, DependencyPredicate)> {
block_stack
.iter()
.map(|frame| match frame {
BlockFrame::After { binding, predicate } => (binding.clone(), predicate.clone()),
})
.collect()
}
pub fn runtime_fact_name_for_pattern(pattern: &str) -> Option<String> {
let pattern = pattern.trim();
if let Some(rest) = pattern.strip_prefix("fact ") {
let name = rest.split_whitespace().next()?;
return Some(name.to_owned());
}
if let Some(rest) = pattern.strip_prefix("message from ") {
if let Some(channel) = rest.split_whitespace().next() {
return Some(format!("message.{channel}"));
}
}
if pattern == "line changed" || pattern == "line changed by others" {
return Some("vcs.cut.recorded".to_owned());
}
if pattern == "reconcile stalled" {
return Some("vcs.reconcile.stalled".to_owned());
}
{
let words: Vec<&str> = pattern.split_whitespace().collect();
match words.as_slice() {
[_, "has", "contention"] => {
return Some("vcs.contention.predicted".to_owned());
}
[_, "promoted"] => {
return Some("vcs.stream.promoted".to_owned());
}
[_, "is", "quiescent"] => {
return Some("vcs.stream.quiescent".to_owned());
}
_ => {}
}
}
let mut words = pattern.split_whitespace();
let first = words.next()?;
if words.next() == Some("completed") && words.next() == Some("turn") {
let _ = first;
return Some("agent.turn.completed".to_owned());
}
{
let mut words = pattern.split_whitespace();
let _tracker = words.next();
if words.next() == Some("has")
&& words.next() == Some("ready")
&& words.next() == Some("issue")
{
return Some("tracker.issue.ready".to_owned());
}
}
if first.chars().next().is_some_and(char::is_uppercase) {
return Some(first.to_owned());
}
None
}
fn binding_from_when(when: &str) -> Option<(String, String)> {
let (pattern, _) = split_when_guard(when);
let binding = binding_after_as(pattern)?;
let first = pattern.split_whitespace().next()?;
let completed_turn = {
let mut words = pattern.split_whitespace();
words.next();
words.next() == Some("completed") && words.next() == Some("turn")
};
let has_ready_issue = {
let mut words = pattern.split_whitespace();
words.next();
words.next() == Some("has")
&& words.next() == Some("ready")
&& words.next() == Some("issue")
};
let schema = if let Some(rest) = pattern.strip_prefix("fact ") {
rest.split_whitespace().next()?.to_owned()
} else if first.chars().next().is_some_and(char::is_uppercase) {
first.to_owned()
} else if first.contains('.') {
first.to_owned()
} else if completed_turn {
"AgentTurn".to_owned()
} else if has_ready_issue {
"WorkItem".to_owned()
} else if pattern.starts_with("message from ") {
"Message".to_owned()
} else {
let schema = vcs_sugar_schema(pattern.split(" as ").next().unwrap_or(pattern).trim())?;
schema.to_owned()
};
Some((binding, schema))
}
fn vcs_sugar_schema(phrase: &str) -> Option<&'static str> {
if phrase == "line changed" || phrase == "line changed by others" {
return Some("VcsChange");
}
if phrase == "reconcile stalled" {
return Some("VcsStall");
}
let words: Vec<&str> = phrase.split_whitespace().collect();
match words.as_slice() {
[_, "has", "contention"] => Some("VcsContention"),
[_, "promoted"] => Some("VcsPromotion"),
_ => None,
}
}
pub(crate) fn split_when_guard(when: &str) -> (&str, Option<&str>) {
match when.split_once(" where ") {
Some((pattern, guard)) => (pattern.trim(), Some(guard.trim())),
None => (when.trim(), None),
}
}
fn effect_binding_schema(
line: &str,
kind: &IrEffectKind,
semantic: &SemanticContext,
) -> Option<String> {
match kind {
IrEffectKind::SchemaCoerce => parse_coerce_call_name(line).and_then(|name| {
semantic
.coerce_outputs
.get(name)
.and_then(schema_name_for_path)
}),
IrEffectKind::AgentTell
| IrEffectKind::CapabilityCall
| IrEffectKind::EventEmit
| IrEffectKind::WorkflowInvoke
| IrEffectKind::TimerWait
| IrEffectKind::ExecCommand
| IrEffectKind::TrackerFile
| IrEffectKind::TrackerClaim
| IrEffectKind::TrackerRenew
| IrEffectKind::TrackerRelease
| IrEffectKind::TrackerFinish
| IrEffectKind::LeaseAcquire
| IrEffectKind::LeaseRenew
| IrEffectKind::LedgerAppend
| IrEffectKind::CounterConsume
| IrEffectKind::SignalEmit
| IrEffectKind::FileRead
| IrEffectKind::FileWrite
| IrEffectKind::FileImport
| IrEffectKind::FileExport => None,
}
}
fn parse_coerce_call_name(line: &str) -> Option<&str> {
let rest = line.strip_prefix("coerce ")?;
rest.split_once('(').map(|(name, _)| name.trim())
}
fn parse_coerce_call(line: &str) -> Option<(&str, Vec<&str>)> {
let rest = line.strip_prefix("coerce ")?;
let call = rest.split(" as ").next().unwrap_or(rest).trim();
let (name, tail) = call.split_once('(')?;
let (args, _) = tail.rsplit_once(')')?;
Some((name.trim(), split_expression_args(args)))
}
fn split_expression_args(args: &str) -> Vec<&str> {
let mut values = Vec::new();
let mut start = 0usize;
let mut depth = 0i32;
let mut in_string = false;
let mut previous = '\0';
for (index, ch) in args.char_indices() {
if ch == '"' && previous != '\\' {
in_string = !in_string;
} else if !in_string {
match ch {
'(' | '[' | '{' => depth += 1,
')' | ']' | '}' => depth -= 1,
',' if depth == 0 => {
let value = args[start..index].trim();
if !value.is_empty() {
values.push(value);
}
start = index + ch.len_utf8();
}
_ => {}
}
}
previous = ch;
}
let value = args[start..].trim();
if !value.is_empty() {
values.push(value);
}
values
}
fn effect_payload_statements(body: &str) -> Vec<String> {
collect_body_statements(body, effect_payload_statement_balance)
}
fn workflow_invoke_statements(body: &str) -> Vec<String> {
collect_body_statements(body, workflow_invoke_statement_balance)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum StatementBalance {
None,
Parens,
Braces,
}
fn collect_body_statements(
body: &str,
statement_balance: fn(&str) -> Option<StatementBalance>,
) -> Vec<String> {
let lines = body.lines().collect::<Vec<_>>();
let mut statements = Vec::new();
let mut index = 0usize;
let mut record_depth = 0i32;
let mut multiline_string = false;
while index < lines.len() {
let trimmed = lines[index].trim();
if trimmed.is_empty() {
index += 1;
continue;
}
if multiline_string {
if trimmed.contains("\"\"\"") {
multiline_string = false;
}
index += 1;
continue;
}
if record_depth > 0 {
record_depth += brace_delta(trimmed);
index += 1;
continue;
}
if parse_record_start(trimmed).is_some() {
record_depth = brace_delta(trimmed).max(1);
index += 1;
continue;
}
if trimmed.contains("\"\"\"") {
multiline_string = trimmed.matches("\"\"\"").count() % 2 == 1;
index += 1;
continue;
}
if let Some(balance) = statement_balance(trimmed) {
match balance {
StatementBalance::None => statements.push(trimmed.to_owned()),
StatementBalance::Parens => {
let (statement, next_index) =
statement_until_balanced(&lines, index, trimmed, paren_delta);
statements.push(statement);
index = next_index + 1;
continue;
}
StatementBalance::Braces => {
let (statement, next_index) =
statement_until_balanced(&lines, index, trimmed, brace_delta);
statements.push(statement);
index = next_index + 1;
continue;
}
}
}
index += 1;
}
statements
}
fn effect_payload_statement_balance(trimmed: &str) -> Option<StatementBalance> {
if trimmed.starts_with("coerce ") {
Some(StatementBalance::Parens)
} else if trimmed.starts_with("claim ") {
Some(StatementBalance::None)
} else {
None
}
}
fn workflow_invoke_statement_balance(trimmed: &str) -> Option<StatementBalance> {
trimmed
.starts_with("invoke ")
.then_some(StatementBalance::Braces)
}
fn invoke_statement_parts(statement: &str) -> Option<(&str, &str)> {
let rest = statement.trim().strip_prefix("invoke ")?;
let target = rest
.split_whitespace()
.next()
.unwrap_or("")
.trim_end_matches('{');
if target.is_empty() {
return None;
}
let open = statement.find('{')?;
let mut depth = 0i32;
let mut close = None;
for (offset, ch) in statement[open..].char_indices() {
match ch {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
close = Some(open + offset);
break;
}
}
_ => {}
}
}
let close = close?;
(close > open).then_some((target, statement[open + 1..close].trim()))
}
fn statement_until_balanced(
lines: &[&str],
index: usize,
trimmed: &str,
delta: fn(&str) -> i32,
) -> (String, usize) {
let mut statement = trimmed.to_owned();
let mut depth = delta(trimmed);
let mut cursor = index;
while depth > 0 && cursor + 1 < lines.len() {
cursor += 1;
let next = lines[cursor].trim();
statement.push(' ');
statement.push_str(next);
depth += delta(next);
}
(statement, cursor)
}
fn paren_delta(line: &str) -> i32 {
line.chars().fold(0, |depth, ch| match ch {
'(' => depth + 1,
')' => depth - 1,
_ => depth,
})
}
pub fn inline_decide_schema_name(rule: &str, binding: &str) -> String {
format!("decide.{rule}.{binding}")
}
fn decide_field_type_syntax(ty: &str, span: SourceSpan) -> TypeSyntax {
if is_primitive_type(ty) {
TypeSyntax::Primitive {
name: ty.to_owned(),
span,
}
} else {
TypeSyntax::Ref {
name: Ident {
name: ty.to_owned(),
span,
},
}
}
}
#[allow(clippy::type_complexity)]
fn collect_decide_effects<'a>(
statements: &'a [body::BodyStmt],
out: &mut Vec<(&'a str, &'a [(String, String)], SourceSpan)>,
) {
for statement in statements {
match statement {
body::BodyStmt::Effect(effect) => {
if let body::BodyEffectKind::Decide { result_fields } = &effect.kind {
if let Some(binding) = &effect.binding {
out.push((binding.as_str(), result_fields.as_slice(), effect.span));
}
}
}
body::BodyStmt::After(after) => collect_decide_effects(&after.body, out),
body::BodyStmt::Case(case) => {
for branch in &case.branches {
collect_decide_effects(&branch.body, out);
}
}
_ => {}
}
}
}
fn collect_decide_payload_types(
statements: &[body::BodyStmt],
rule_name: &str,
payloads: &mut BTreeMap<String, IrType>,
) {
let mut decides = Vec::new();
collect_decide_effects(statements, &mut decides);
for (binding, _fields, _span) in decides {
payloads.insert(
binding.to_owned(),
IrType::Ref(inline_decide_schema_name(rule_name, binding)),
);
}
}
fn collect_prompt_payload_types(
statements: &[body::BodyStmt],
payloads: &mut BTreeMap<String, IrType>,
) {
for statement in statements {
match statement {
body::BodyStmt::Effect(effect) => {
if matches!(&effect.kind, body::BodyEffectKind::Prompt { .. }) {
if let Some(binding) = &effect.binding {
payloads
.insert(binding.clone(), IrType::Primitive(IrPrimitiveType::String));
}
}
}
body::BodyStmt::After(after) => collect_prompt_payload_types(&after.body, payloads),
body::BodyStmt::Case(case) => {
for branch in &case.branches {
collect_prompt_payload_types(&branch.body, payloads);
}
}
_ => {}
}
}
}
fn collect_inline_decide_schemas(
items: &[Item],
semantic: &mut SemanticContext,
ir: &mut IrProgram,
) {
for item in items {
let Item::Rule(rule) = item else {
continue;
};
let (body_ast, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
let mut decides = Vec::new();
collect_decide_effects(&body_ast.statements, &mut decides);
for (binding, fields, span) in decides {
let name = inline_decide_schema_name(&rule.name.name, binding);
let mut syntax_fields: BTreeMap<String, TypeSyntax> = BTreeMap::new();
let mut ir_fields = Vec::new();
for (field_name, field_ty) in fields {
let ty = decide_field_type_syntax(field_ty, span);
ir_fields.push(IrClassField {
name: field_name.clone(),
ty: lower_type(ty.clone()),
is_key: false,
presence_condition: None,
span,
});
syntax_fields.insert(field_name.clone(), ty);
}
semantic.schemas.classes.insert(name.clone(), syntax_fields);
ir.schemas.push(IrSchema::Class(IrClass {
name,
fields: ir_fields,
span,
}));
}
}
}
pub fn redact_schema_name(rule: &str, binding: &str) -> String {
format!("redact.{rule}.{binding}")
}
#[allow(clippy::type_complexity)]
fn collect_redact_effects<'a>(
statements: &'a [body::BodyStmt],
out: &mut Vec<(&'a str, &'a [String], &'a str, SourceSpan)>,
) {
for statement in statements {
match statement {
body::BodyStmt::Redact {
source,
keep,
binding,
span,
} => out.push((source.as_str(), keep.as_slice(), binding.as_str(), *span)),
body::BodyStmt::After(after) => collect_redact_effects(&after.body, out),
body::BodyStmt::Case(case) => {
for branch in &case.branches {
collect_redact_effects(&branch.body, out);
}
}
_ => {}
}
}
}
fn rule_binding_schemas(rule: &RuleDecl, semantic: &SemanticContext) -> BTreeMap<String, String> {
let mut schemas = binding_types_for_rule(rule);
let (body_ast, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
let mut payloads = collect_effect_payload_types(rule, semantic, &mut Vec::new());
collect_exec_payload_types(&body_ast.statements, semantic, &mut payloads);
collect_decide_payload_types(&body_ast.statements, &rule.name.name, &mut payloads);
collect_redact_payload_types(&body_ast.statements, &rule.name.name, &mut payloads);
for line in rule.body.text.lines() {
let Some(rest) = line.trim().strip_prefix("after ") else {
continue;
};
let mut words = rest.split_whitespace();
let Some(binding) = words.next() else {
continue;
};
let Some(predicate) = words.next() else {
continue;
};
if predicate == "times" && words.next() != Some("out") {
continue;
}
let (Some("as"), Some(alias)) = (words.next(), words.next()) else {
continue;
};
let alias = alias.trim_end_matches('{').trim();
if alias.is_empty() {
continue;
}
if let Some(IrType::Ref(schema)) = payloads.get(binding) {
schemas.insert(alias.to_owned(), schema.clone());
}
}
for (binding, ty) in payloads {
if let IrType::Ref(schema) = ty {
schemas.insert(binding, schema);
}
}
schemas
}
fn collect_redact_schemas(items: &[Item], semantic: &mut SemanticContext, ir: &mut IrProgram) {
for item in items {
let Item::Rule(rule) = item else {
continue;
};
let (body_ast, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
let mut redacts = Vec::new();
collect_redact_effects(&body_ast.statements, &mut redacts);
if redacts.is_empty() {
continue;
}
let binding_schemas = rule_binding_schemas(rule, semantic);
let mut local: BTreeMap<String, String> = BTreeMap::new();
for (source, keep, binding, span) in redacts {
let name = redact_schema_name(&rule.name.name, binding);
let source_schema = binding_schemas
.get(source)
.cloned()
.or_else(|| local.get(source).cloned());
let projected: Vec<(String, TypeSyntax)> = source_schema
.as_ref()
.and_then(|schema| semantic.schemas.classes.get(schema))
.map(|src_fields| {
keep.iter()
.filter_map(|field| {
src_fields.get(field).map(|ty| (field.clone(), ty.clone()))
})
.collect()
})
.unwrap_or_default();
let mut syntax_fields: BTreeMap<String, TypeSyntax> = BTreeMap::new();
let mut ir_fields = Vec::new();
for (field_name, ty) in &projected {
syntax_fields.insert(field_name.clone(), ty.clone());
ir_fields.push(IrClassField {
name: field_name.clone(),
ty: lower_type(ty.clone()),
is_key: false,
presence_condition: None,
span,
});
}
semantic.schemas.classes.insert(name.clone(), syntax_fields);
ir.schemas.push(IrSchema::Class(IrClass {
name: name.clone(),
fields: ir_fields,
span,
}));
local.insert(binding.to_owned(), name);
}
}
}
fn collect_redact_payload_types(
statements: &[body::BodyStmt],
rule_name: &str,
payloads: &mut BTreeMap<String, IrType>,
) {
let mut redacts = Vec::new();
collect_redact_effects(statements, &mut redacts);
for (_source, _keep, binding, _span) in redacts {
payloads.insert(
binding.to_owned(),
IrType::Ref(redact_schema_name(rule_name, binding)),
);
}
}
fn validate_redactions(
rule: &RuleDecl,
statements: &[body::BodyStmt],
semantic: &SemanticContext,
binding_schemas: &BTreeMap<String, String>,
diagnostics: &mut Vec<Diagnostic>,
) {
let mut redacts = Vec::new();
collect_redact_effects(statements, &mut redacts);
let mut local: BTreeMap<String, String> = BTreeMap::new();
for (source, keep, binding, span) in redacts {
let source_schema = binding_schemas
.get(source)
.cloned()
.or_else(|| local.get(source).cloned());
local.insert(
binding.to_owned(),
redact_schema_name(&rule.name.name, binding),
);
let Some(schema) = source_schema else {
diagnostics.push(Diagnostic {
related: Vec::new(),
span,
message: format!(
"rule `{}` redacts `{source}`, which has no known schema",
rule.name.name
),
suggestion: Some(
"redact a binding with a known record type — a matched `when Class as x`, or a \
coerce/decide/exec result"
.to_owned(),
),
});
continue;
};
let Some(src_fields) = semantic.schemas.classes.get(&schema) else {
continue;
};
for field in keep {
if !src_fields.contains_key(field) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span,
message: format!(
"rule `{}` redacts `{source}` keeping unknown field `{field}` of `{schema}`",
rule.name.name
),
suggestion: Some(format!("keep a field declared on `{schema}`")),
});
}
}
}
}
fn collect_exec_payload_types(
statements: &[body::BodyStmt],
semantic: &SemanticContext,
payloads: &mut BTreeMap<String, IrType>,
) {
for statement in statements {
match statement {
body::BodyStmt::Effect(effect) => {
if let body::BodyEffectKind::Exec {
parse_target: Some(parse),
..
} = &effect.kind
{
if !parse.each {
if let Some(binding) = &effect.binding {
if semantic.schemas.class_exists(&parse.schema) {
payloads.insert(binding.clone(), IrType::Ref(parse.schema.clone()));
}
}
}
}
}
body::BodyStmt::After(after) => {
collect_exec_payload_types(&after.body, semantic, payloads)
}
body::BodyStmt::Case(case) => {
for branch in &case.branches {
collect_exec_payload_types(&branch.body, semantic, payloads);
}
}
_ => {}
}
}
}
fn push_ingest_fact_writes(statements: &[body::BodyStmt], fact_writes: &mut Vec<String>) {
for statement in statements {
match statement {
body::BodyStmt::Effect(effect) => {
match &effect.kind {
body::BodyEffectKind::Exec {
parse_target: Some(parse),
..
} if parse.each => {
fact_writes.push(format!("schema:{}", parse.schema));
}
body::BodyEffectKind::FileImport { schema, .. } => {
fact_writes.push(format!("schema:{schema}"));
}
_ => {}
}
}
body::BodyStmt::After(after) => push_ingest_fact_writes(&after.body, fact_writes),
body::BodyStmt::Case(case) => {
for branch in &case.branches {
push_ingest_fact_writes(&branch.body, fact_writes);
}
}
_ => {}
}
}
}
fn validate_coordination_discipline(
rule: &RuleDecl,
statements: &[body::BodyStmt],
diagnostics: &mut Vec<Diagnostic>,
) {
let mut acquires = Vec::new();
let mut consumes = Vec::new();
let mut claims = Vec::new();
collect_coordination_effects(statements, &mut acquires, &mut consumes, &mut claims);
let mut vcs_verbs: Vec<(&'static str, [&'static str; 2], String, SourceSpan)> = Vec::new();
for_each_body(statements, &mut |stmt| {
if let body::BodyStmt::Effect(effect) = stmt {
if let body::BodyEffectKind::ConstructCapabilityCall { keyword, .. } = &effect.kind {
let arms: Option<(&'static str, [&'static str; 2])> = match keyword.as_str() {
"promote" => Some(("promote", ["promoted", "conflicted"])),
"undo" => Some(("undo", ["applied", "stranded"])),
"transport" => Some(("transport", ["applied", "conflicted"])),
_ => None,
};
if let (Some((verb, required)), Some(binding)) = (arms, &effect.binding) {
vcs_verbs.push((verb, required, binding.clone(), effect.span));
}
}
}
});
for (verb, required_arms, binding, span) in &vcs_verbs {
let mut predicates = BTreeSet::new();
collect_after_predicates(statements, binding, &mut predicates);
for required in required_arms {
if !predicates.contains(*required) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: *span,
message: format!(
"rule `{}` does not handle the `{required}` outcome of {verb} `{binding}`",
rule.name.name
),
suggestion: Some(format!(
"{verb} outcomes are exhaustive: add `after {binding} {required} {{ ... }}`"
)),
});
}
}
}
let claim_bindings = collect_claim_bindings(statements);
let renewable: BTreeSet<&str> = acquires
.iter()
.map(|(b, _, _)| b.as_str())
.chain(claim_bindings.iter().map(String::as_str))
.collect();
for_each_body(statements, &mut |stmt| {
if let body::BodyStmt::Effect(effect) = stmt {
if let body::BodyEffectKind::LeaseRenew {
acquire_binding, ..
} = &effect.kind
{
if !renewable.contains(acquire_binding.as_str()) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: effect.span,
message: format!(
"rule `{}` renews unbound coordination binding `{}`",
rule.name.name, acquire_binding
),
suggestion: Some(format!(
"`renew {acquire_binding}` must name a lease acquired here (`acquire ... as {acquire_binding}`) or an issue claimed here (`claim ... as {acquire_binding}`)"
)),
});
}
}
}
});
let work_items: Vec<String> = rule
.whens
.iter()
.filter_map(|when| when_has_ready_binding(&when.text))
.collect();
let releasable: BTreeSet<&str> = acquires
.iter()
.map(|(b, _, _)| b.as_str())
.chain(claims.iter().map(|(item, _)| item.as_str()))
.chain(work_items.iter().map(String::as_str))
.collect();
for_each_body(statements, &mut |stmt| {
if let body::BodyStmt::Effect(effect) = stmt {
if let body::BodyEffectKind::TrackerRelease { item } = &effect.kind {
if !releasable.contains(item.as_str()) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: effect.span,
message: format!(
"rule `{}` releases unbound coordination item `{}`",
rule.name.name, item
),
suggestion: Some(format!(
"`release {item}` must name a lease acquired here (`acquire ... as {item}`), an item claimed here (`claim {item} as ...`), or a work item bound by a `when <queue> has ready ... as {item}` reaction"
)),
});
}
}
}
});
if acquires.len() > 1 {
diagnostics.push(Diagnostic { related: Vec::new(),
span: acquires[1].2,
message: format!(
"rule `{}` acquires more than one lease in a single progression",
rule.name.name
),
suggestion: Some(
"the hard default is at most one held lease per progression (it breaks hold-and-wait); restructure into separate rules"
.to_owned(),
),
});
}
for (binding, until_ttl, span) in &acquires {
if *until_ttl {
continue;
}
let mut predicates = BTreeSet::new();
collect_after_predicates(statements, binding, &mut predicates);
for required in ["held", "contended"] {
if !predicates.contains(required) {
diagnostics.push(Diagnostic { related: Vec::new(),
span: *span,
message: format!(
"rule `{}` does not handle the `{required}` outcome of lease `{binding}`",
rule.name.name
),
suggestion: Some(format!(
"coordination outcomes are exhaustive: add `after {binding} {required} {{ ... }}`"
)),
});
}
}
if let Some(held_body) = find_after_body(statements, binding, body::AfterPredicate::Held) {
if !releases_or_terminates(held_body, binding) {
diagnostics.push(Diagnostic { related: Vec::new(),
span: *span,
message: format!(
"rule `{}` can hold lease `{binding}` forever: the `held` branch neither releases it nor reaches a workflow terminal",
rule.name.name
),
suggestion: Some(format!(
"add `release {binding}` on every non-terminal path, or use `acquire ... until ttl` for fire-and-forget"
)),
});
}
}
}
for (binding, span) in &consumes {
let mut predicates = BTreeSet::new();
collect_after_predicates(statements, binding, &mut predicates);
for required in ["ok", "over"] {
if !predicates.contains(required) {
diagnostics.push(Diagnostic { related: Vec::new(),
span: *span,
message: format!(
"rule `{}` does not handle the `{required}` outcome of counter consume `{binding}`",
rule.name.name
),
suggestion: Some(format!(
"coordination outcomes are exhaustive: add `after {binding} {required} {{ ... }}`"
)),
});
}
}
}
}
fn collect_coordination_effects(
statements: &[body::BodyStmt],
acquires: &mut Vec<(String, bool, SourceSpan)>,
consumes: &mut Vec<(String, SourceSpan)>,
claims: &mut Vec<(String, SourceSpan)>,
) {
for_each_body(statements, &mut |stmt| {
if let body::BodyStmt::Effect(effect) = stmt {
match &effect.kind {
body::BodyEffectKind::LeaseAcquire { until_ttl, .. } => {
if let Some(binding) = &effect.binding {
acquires.push((binding.clone(), *until_ttl, effect.span));
}
}
body::BodyEffectKind::CounterConsume { .. } => {
if let Some(binding) = &effect.binding {
consumes.push((binding.clone(), effect.span));
}
}
body::BodyEffectKind::TrackerClaim { item, .. } => {
claims.push((item.clone(), effect.span));
}
_ => {}
}
}
});
}
fn when_has_ready_binding(when: &str) -> Option<String> {
let (pattern, _) = split_when_guard(when);
let mut words = pattern.split_whitespace();
let _queue = words.next()?;
if words.next() == Some("has") && words.next() == Some("ready") {
return binding_after_as(pattern);
}
None
}
fn collect_after_predicates(
statements: &[body::BodyStmt],
binding: &str,
predicates: &mut BTreeSet<&'static str>,
) {
for_each_body(statements, &mut |stmt| {
if let body::BodyStmt::After(after) = stmt {
if after.binding == binding {
predicates.insert(after.predicate.as_str());
}
}
});
}
fn find_after_body<'a>(
statements: &'a [body::BodyStmt],
binding: &str,
predicate: body::AfterPredicate,
) -> Option<&'a [body::BodyStmt]> {
for statement in statements {
match statement {
body::BodyStmt::After(after) => {
if after.binding == binding && after.predicate == predicate {
return Some(&after.body);
}
if let Some(found) = find_after_body(&after.body, binding, predicate) {
return Some(found);
}
}
body::BodyStmt::Case(case) => {
for branch in &case.branches {
if let Some(found) = find_after_body(&branch.body, binding, predicate) {
return Some(found);
}
}
}
_ => {}
}
}
None
}
fn releases_or_terminates(statements: &[body::BodyStmt], binding: &str) -> bool {
statements.iter().any(|statement| match statement {
body::BodyStmt::Effect(effect) => matches!(
&effect.kind,
body::BodyEffectKind::TrackerRelease { item } if item == binding
),
body::BodyStmt::Terminal(_) => true,
body::BodyStmt::After(after) => releases_or_terminates(&after.body, binding),
body::BodyStmt::Case(case) => {
!case.branches.is_empty()
&& case
.branches
.iter()
.all(|branch| releases_or_terminates(&branch.body, binding))
}
_ => false,
})
}
fn for_each_body(statements: &[body::BodyStmt], visit: &mut impl FnMut(&body::BodyStmt)) {
for statement in statements {
visit(statement);
match statement {
body::BodyStmt::After(after) => for_each_body(&after.body, visit),
body::BodyStmt::Case(case) => {
for branch in &case.branches {
for_each_body(&branch.body, visit);
}
}
_ => {}
}
}
}
fn family_b_arm_allowed(
scrutinee: &str,
pattern: &str,
binding_types: &BTreeMap<String, String>,
semantic: &SemanticContext,
) -> BTreeSet<(String, String)> {
let mut allowed = BTreeSet::new();
let Some((root, disc)) = scrutinee.split_once('.') else {
return allowed;
};
if disc.contains('.') {
return allowed;
}
let trimmed = pattern.trim();
if trimmed == "_" || trimmed == "default" {
return allowed;
}
let literal = trimmed.trim_matches('"');
if literal.is_empty() {
return allowed;
}
let Some(schema) = binding_types.get(root) else {
return allowed;
};
if let Some(conditions) = semantic.schemas.presence.get(schema) {
for (field, (cond_disc, cond_literal)) in conditions {
if cond_disc == disc && cond_literal == literal {
allowed.insert((root.to_owned(), field.clone()));
}
}
}
allowed
}
#[allow(clippy::too_many_arguments)]
fn check_conditioned_read(
rule: &RuleDecl,
root: &str,
field: &str,
span: SourceSpan,
semantic: &SemanticContext,
binding_types: &BTreeMap<String, String>,
allowed: &BTreeSet<(String, String)>,
diagnostics: &mut Vec<Diagnostic>,
) {
let Some(schema) = binding_types.get(root) else {
return;
};
let Some((disc, _literal)) = semantic.schemas.field_presence(schema, field) else {
return;
};
if allowed.contains(&(root.to_owned(), field.to_owned())) {
return;
}
diagnostics.push(Diagnostic {
related: Vec::new(),
span,
message: format!(
"rule `{}` reads conditional field `{root}.{field}` outside a matching `case {root}.{disc}` arm",
rule.name.name
),
suggestion: Some(format!(
"read `{root}.{field}` inside `case {root}.{disc} {{ \"...\" => ... }}` — it is present only for a specific `{disc}`"
)),
});
}
fn check_conditioned_reads_in_text(
rule: &RuleDecl,
text: &str,
span: SourceSpan,
semantic: &SemanticContext,
binding_types: &BTreeMap<String, String>,
allowed: &BTreeSet<(String, String)>,
diagnostics: &mut Vec<Diagnostic>,
) {
for (root, path) in dotted_paths(text) {
let Some(first) = path.first() else {
continue;
};
check_conditioned_read(
rule,
&root,
first,
span,
semantic,
binding_types,
allowed,
diagnostics,
);
}
}
fn interpolation_paths(text: &str) -> Vec<(String, Vec<String>)> {
let mut paths = Vec::new();
let mut rest = text;
while let Some(open) = rest.find("{{") {
let after_open = &rest[open + 2..];
let Some(close) = after_open.find("}}") else {
break;
};
paths.extend(dotted_paths(&after_open[..close]));
rest = &after_open[close + 2..];
}
paths
}
fn check_conditioned_reads_in_interpolations(
rule: &RuleDecl,
text: &str,
span: SourceSpan,
semantic: &SemanticContext,
binding_types: &BTreeMap<String, String>,
allowed: &BTreeSet<(String, String)>,
diagnostics: &mut Vec<Diagnostic>,
) {
for (root, path) in interpolation_paths(text) {
let Some(first) = path.first() else {
continue;
};
check_conditioned_read(
rule,
&root,
first,
span,
semantic,
binding_types,
allowed,
diagnostics,
);
}
}
fn check_conditioned_reads_in_fields(
rule: &RuleDecl,
fields: &[body::FieldAssign],
from_binding: Option<&str>,
semantic: &SemanticContext,
binding_types: &BTreeMap<String, String>,
allowed: &BTreeSet<(String, String)>,
diagnostics: &mut Vec<Diagnostic>,
) {
for field in fields {
match &field.value {
body::FieldValue::Expr { source, .. } => check_conditioned_reads_in_text(
rule,
source,
field.span,
semantic,
binding_types,
allowed,
diagnostics,
),
body::FieldValue::Nested { fields, .. } => check_conditioned_reads_in_fields(
rule,
fields,
from_binding,
semantic,
binding_types,
allowed,
diagnostics,
),
body::FieldValue::Shorthand => {
if let Some(root) = from_binding {
check_conditioned_read(
rule,
root,
&field.name,
field.span,
semantic,
binding_types,
allowed,
diagnostics,
);
}
}
}
}
}
#[allow(clippy::too_many_arguments)]
fn check_conditioned_implicit_copies(
rule: &RuleDecl,
from_binding: Option<&str>,
target_fields: Option<&BTreeMap<String, TypeSyntax>>,
fields: &[body::FieldAssign],
span: SourceSpan,
semantic: &SemanticContext,
binding_types: &BTreeMap<String, String>,
allowed: &BTreeSet<(String, String)>,
diagnostics: &mut Vec<Diagnostic>,
) {
let (Some(root), Some(target_fields)) = (from_binding, target_fields) else {
return;
};
let written: BTreeSet<&str> = fields.iter().map(|field| field.name.as_str()).collect();
for name in target_fields.keys() {
if written.contains(name.as_str()) {
continue;
}
check_conditioned_read(
rule,
root,
name,
span,
semantic,
binding_types,
allowed,
diagnostics,
);
}
}
fn check_conditioned_record_reads(
rule: &RuleDecl,
record: &body::RecordStmt,
semantic: &SemanticContext,
binding_types: &BTreeMap<String, String>,
allowed: &BTreeSet<(String, String)>,
diagnostics: &mut Vec<Diagnostic>,
) {
check_conditioned_reads_in_fields(
rule,
&record.fields,
record.from.as_deref(),
semantic,
binding_types,
allowed,
diagnostics,
);
check_conditioned_implicit_copies(
rule,
record.from.as_deref(),
semantic.schemas.classes.get(&record.schema),
&record.fields,
record.span,
semantic,
binding_types,
allowed,
diagnostics,
);
}
fn check_conditioned_effect_reads(
rule: &RuleDecl,
effect: &body::EffectStmt,
semantic: &SemanticContext,
binding_types: &BTreeMap<String, String>,
allowed: &BTreeSet<(String, String)>,
diagnostics: &mut Vec<Diagnostic>,
) {
let span = effect.span;
let expression = |text: &str, diagnostics: &mut Vec<Diagnostic>| {
check_conditioned_reads_in_text(
rule,
text,
span,
semantic,
binding_types,
allowed,
diagnostics,
);
};
if let Some(prompt) = &effect.prompt {
check_conditioned_reads_in_interpolations(
rule,
&prompt.text,
span,
semantic,
binding_types,
allowed,
diagnostics,
);
}
match &effect.kind {
body::BodyEffectKind::Coerce { args, .. } => {
for arg in args {
expression(arg, diagnostics);
}
}
body::BodyEffectKind::ConstructCapabilityCall { fields, .. } => {
for field in fields {
expression(&field.source, diagnostics);
}
}
body::BodyEffectKind::Invoke { payload, .. } => check_conditioned_reads_in_fields(
rule,
payload,
None,
semantic,
binding_types,
allowed,
diagnostics,
),
body::BodyEffectKind::Timer { until, .. } => {
if let Some(until) = until {
expression(until, diagnostics);
}
}
body::BodyEffectKind::Exec {
target,
parse_target: _,
} => match target {
body::ExecTarget::RawCommand(command) => {
check_conditioned_reads_in_interpolations(
rule,
command,
span,
semantic,
binding_types,
allowed,
diagnostics,
);
}
body::ExecTarget::Capability { .. } => {}
},
body::BodyEffectKind::TrackerFile { fields, .. }
| body::BodyEffectKind::TrackerFinish { fields, .. }
| body::BodyEffectKind::LedgerAppend { fields, .. } => check_conditioned_reads_in_fields(
rule,
fields,
None,
semantic,
binding_types,
allowed,
diagnostics,
),
body::BodyEffectKind::LeaseAcquire { key_expr, .. } => expression(key_expr, diagnostics),
body::BodyEffectKind::CounterConsume {
key_expr,
amount_expr,
..
} => {
expression(key_expr, diagnostics);
expression(amount_expr, diagnostics);
}
body::BodyEffectKind::Notify {
target_expr,
event,
from,
fields,
} => {
expression(target_expr, diagnostics);
check_conditioned_reads_in_fields(
rule,
fields,
from.as_deref(),
semantic,
binding_types,
allowed,
diagnostics,
);
check_conditioned_implicit_copies(
rule,
from.as_deref(),
semantic.schemas.classes.get(event),
fields,
span,
semantic,
binding_types,
allowed,
diagnostics,
);
}
body::BodyEffectKind::FileRead { path, .. }
| body::BodyEffectKind::FileImport { path, .. } => expression(path, diagnostics),
body::BodyEffectKind::FileWrite { path, body, .. } => {
expression(path, diagnostics);
expression(body, diagnostics);
}
body::BodyEffectKind::FileExport {
path, predicate, ..
} => {
expression(path, diagnostics);
if let Some(predicate) = predicate {
expression(predicate, diagnostics);
}
}
body::BodyEffectKind::Tell { .. }
| body::BodyEffectKind::Prompt { .. }
| body::BodyEffectKind::Decide { .. }
| body::BodyEffectKind::Call { .. }
| body::BodyEffectKind::TrackerClaim { .. }
| body::BodyEffectKind::TrackerRelease { .. }
| body::BodyEffectKind::LeaseRenew { .. } => {}
}
}
fn terminal_output_fields<'a>(
terminal: &body::TerminalStmt,
semantic: &'a SemanticContext,
) -> Option<&'a BTreeMap<String, TypeSyntax>> {
if terminal.kind != body::TerminalKind::Complete {
return None;
}
let workflow = semantic.workflow.as_ref()?;
let surface = semantic.workflow_inputs.get(workflow)?;
match surface.outputs.get(&terminal.name)? {
TypeSyntax::Ref { name } => semantic.schemas.classes.get(&name.name),
_ => None,
}
}
fn validate_conditioned_field_reads(
rule: &RuleDecl,
statements: &[body::BodyStmt],
semantic: &SemanticContext,
binding_types: &BTreeMap<String, String>,
allowed: &BTreeSet<(String, String)>,
diagnostics: &mut Vec<Diagnostic>,
) {
for statement in statements {
match statement {
body::BodyStmt::Record(record) => {
check_conditioned_record_reads(
rule,
record,
semantic,
binding_types,
allowed,
diagnostics,
);
}
body::BodyStmt::Terminal(terminal) => {
check_conditioned_reads_in_fields(
rule,
&terminal.fields,
terminal.from.as_deref(),
semantic,
binding_types,
allowed,
diagnostics,
);
check_conditioned_implicit_copies(
rule,
terminal.from.as_deref(),
terminal_output_fields(terminal, semantic),
&terminal.fields,
terminal.span,
semantic,
binding_types,
allowed,
diagnostics,
);
if let Some(body::FieldValue::Expr { source, .. }) = &terminal.scalar {
check_conditioned_reads_in_text(
rule,
source,
terminal.span,
semantic,
binding_types,
allowed,
diagnostics,
);
}
}
body::BodyStmt::Done {
replacement: Some(record),
..
} => check_conditioned_record_reads(
rule,
record,
semantic,
binding_types,
allowed,
diagnostics,
),
body::BodyStmt::Milestone { fields, .. } => check_conditioned_reads_in_fields(
rule,
fields,
None,
semantic,
binding_types,
allowed,
diagnostics,
),
body::BodyStmt::Effect(effect) => check_conditioned_effect_reads(
rule,
effect,
semantic,
binding_types,
allowed,
diagnostics,
),
body::BodyStmt::Done { .. }
| body::BodyStmt::Cancel { .. }
| body::BodyStmt::Redact { .. } => {}
body::BodyStmt::After(after) => validate_conditioned_field_reads(
rule,
&after.body,
semantic,
binding_types,
allowed,
diagnostics,
),
body::BodyStmt::Region(region) => {
validate_conditioned_field_reads(
rule,
®ion.body,
semantic,
binding_types,
allowed,
diagnostics,
);
validate_conditioned_field_reads(
rule,
®ion.lapse_body,
semantic,
binding_types,
allowed,
diagnostics,
);
}
body::BodyStmt::Case(case) => {
for arm in &case.branches {
let mut arm_allowed = allowed.clone();
arm_allowed.extend(family_b_arm_allowed(
&case.scrutinee,
&arm.pattern,
binding_types,
semantic,
));
if let Some(guard) = &arm.guard {
check_conditioned_reads_in_text(
rule,
guard,
arm.span,
semantic,
binding_types,
&arm_allowed,
diagnostics,
);
}
validate_conditioned_field_reads(
rule,
&arm.body,
semantic,
binding_types,
&arm_allowed,
diagnostics,
);
}
}
}
}
}
fn validate_body_effect_operands(
rule: &RuleDecl,
statements: &[body::BodyStmt],
semantic: &SemanticContext,
binding_types: &BTreeMap<String, String>,
diagnostics: &mut Vec<Diagnostic>,
) {
for statement in statements {
match statement {
body::BodyStmt::Effect(effect) => {
match &effect.kind {
body::BodyEffectKind::LeaseAcquire { resource, .. }
if !semantic.leases.contains(resource) =>
{
diagnostics.push(Diagnostic { related: Vec::new(),
span: effect.span,
message: format!(
"rule `{}` acquires undeclared lease `{resource}`",
rule.name.name
),
suggestion: Some(format!(
"declare `lease {resource} {{ key <Type> slots <N> ttl <duration> }}`"
)),
});
}
body::BodyEffectKind::LedgerAppend { ledger, schema, .. } => {
if !semantic.ledgers.contains(ledger) {
diagnostics.push(Diagnostic { related: Vec::new(),
span: effect.span,
message: format!(
"rule `{}` appends to undeclared ledger `{ledger}`",
rule.name.name
),
suggestion: Some(format!(
"declare `ledger {ledger} {{ entry <Type> partition by <field> retain <duration> }}`"
)),
});
}
if !semantic.schemas.class_exists(schema) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: effect.span,
message: format!(
"rule `{}` appends unknown entry class `{schema}`",
rule.name.name
),
suggestion: Some(format!("declare `class {schema}` first")),
});
}
}
body::BodyEffectKind::CounterConsume { counter, .. }
if !semantic.counters.contains(counter) =>
{
diagnostics.push(Diagnostic { related: Vec::new(),
span: effect.span,
message: format!(
"rule `{}` consumes undeclared counter `{counter}`",
rule.name.name
),
suggestion: Some(format!(
"declare `counter {counter} {{ key <Type> cap <N> reset <period> }}`"
)),
});
}
_ => {}
}
if let body::BodyEffectKind::Exec {
target:
body::ExecTarget::Capability {
name,
stdin_binding,
},
..
} = &effect.kind
{
match binding_types.get(stdin_binding) {
None => {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: effect.span,
message: format!(
"rule `{}` uses unknown binding `{stdin_binding}` in `exec {name} with {stdin_binding}` — `with` requires a typed record binding",
rule.name.name
),
suggestion: Some(format!(
"bind a typed record first (e.g. `when <Class> as {stdin_binding}` or `coerce ... -> <Class> as {stdin_binding}`) and pass that binding to `with`"
)),
});
}
Some(schema)
if schema.contains('.') && !semantic.schemas.class_exists(schema) =>
{
diagnostics.push(Diagnostic {
related: Vec::new(),
span: effect.span,
message: format!(
"rule `{}` passes untyped fact binding `{stdin_binding}` to `exec {name} with` — `with` requires a typed record binding",
rule.name.name
),
suggestion: Some(format!(
"declare `signal {schema} {{ ... }}` for a typed reaction, or bind a declared class and pass that to `with`"
)),
});
}
Some(_) => {}
}
}
if let body::BodyEffectKind::Exec {
parse_target: Some(parse),
..
} = &effect.kind
{
if !semantic.schemas.class_exists(&parse.schema) {
let suggestion =
match closest_name(&parse.schema, semantic.schemas.classes.keys()) {
Some(candidate) => format!(
"did you mean `{candidate}`? otherwise declare `class {}`",
parse.schema
),
None => format!(
"declare `class {}` before parsing into it",
parse.schema
),
};
diagnostics.push(Diagnostic {
related: Vec::new(),
span: effect.span,
message: format!(
"rule `{}` parses exec output into unknown schema `{}`",
rule.name.name, parse.schema
),
suggestion: Some(suggestion),
});
}
}
let body::BodyEffectKind::Timer {
until: Some(until), ..
} = &effect.kind
else {
continue;
};
if body::is_iso8601_instant(until) {
continue;
}
let mut segments = until.split('.');
let root = segments.next().unwrap_or_default();
let path = segments.map(str::to_owned).collect::<Vec<_>>();
let Some(schema) = binding_types.get(root) else {
diagnostics.push(Diagnostic { related: Vec::new(),
span: effect.span,
message: format!(
"rule `{}` uses unknown binding `{root}` in `timer until {until}`",
rule.name.name
),
suggestion: Some(
"bind a fact in `when` and reference a `time` field on it, or use an ISO-8601 literal"
.to_owned(),
),
});
continue;
};
if schema.contains('.') {
continue;
}
let resolved = if path.is_empty() {
Err(format!(
"`{root}` is a `{schema}` record, not a `time` value"
))
} else {
semantic.schemas.resolve_field_path(schema, &path)
};
match resolved {
Ok(TypeSyntax::Primitive { ref name, .. }) if name == "time" => {}
Ok(_) => {
diagnostics.push(Diagnostic { related: Vec::new(),
span: effect.span,
message: format!(
"rule `{}` uses non-time operand `{until}` in `timer until`",
rule.name.name
),
suggestion: Some(format!(
"declare the field as `time` on `{schema}` or use an ISO-8601 literal"
)),
});
}
Err(message) => {
diagnostics.push(Diagnostic { related: Vec::new(),
span: effect.span,
message: format!(
"rule `{}` has invalid `timer until` operand `{until}`: {message}",
rule.name.name
),
suggestion: Some(
"reference a `time`-typed field on a bound fact, or use an ISO-8601 literal"
.to_owned(),
),
});
}
}
}
body::BodyStmt::After(after) => {
validate_body_effect_operands(
rule,
&after.body,
semantic,
binding_types,
diagnostics,
);
}
body::BodyStmt::Case(case) => {
for branch in &case.branches {
validate_body_effect_operands(
rule,
&branch.body,
semantic,
binding_types,
diagnostics,
);
}
}
_ => {}
}
}
}
const PROGRESS_VIEW_NAMESPACE: &str = "region";
fn validate_lapse_arm(
rule: &RuleDecl,
region: &IrRegion,
semantic: &SemanticContext,
binding_types: &BTreeMap<String, String>,
foreign_schemas: &BTreeMap<String, String>,
effect_payload_types: &BTreeMap<String, IrType>,
diagnostics: &mut Vec<Diagnostic>,
) {
let mut schemas = semantic.schemas.clone();
let mut arm_bindings = binding_types.clone();
if let Some(view) = ®ion.lapse_binding {
let progress = format!("{PROGRESS_VIEW_NAMESPACE}.{}.Progress", rule.name.name);
let steps = format!("{PROGRESS_VIEW_NAMESPACE}.{}.Steps", rule.name.name);
let mut progress_fields = BTreeMap::new();
let mut steps_fields = BTreeMap::new();
for effect in ®ion.effects {
let step = effect
.binding
.strip_prefix(then_expand::THEN_BINDING_PREFIX)
.unwrap_or(&effect.binding)
.to_owned();
steps_fields.insert(step.clone(), string_ty());
let settled = match effect_payload_types.get(&effect.binding) {
Some(IrType::Ref(name)) if semantic.schemas.class_exists(name) => TypeSyntax::Ref {
name: Ident {
name: name.clone(),
span: zero_span(),
},
},
_ => string_ty(),
};
progress_fields.insert(step, optional_ty(settled));
}
progress_fields.insert(
"steps".to_owned(),
TypeSyntax::Ref {
name: Ident {
name: steps.clone(),
span: zero_span(),
},
},
);
schemas.classes.insert(steps, steps_fields);
schemas.classes.insert(progress.clone(), progress_fields);
arm_bindings.insert(view.clone(), progress);
}
for line in region.arm_content.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
validate_known_field_paths_in_index(
rule,
line,
rule.body.span,
SchemaScopes {
local: &schemas,
foreign: foreign_schemas,
workflows: &semantic.workflow_inputs,
},
&arm_bindings,
diagnostics,
);
}
let (arm_ast, _) = body::parse_rule_body(®ion.arm_content, 0);
let mut allowed = BTreeSet::new();
for (scrutinee, pattern) in ®ion.arm_case_arms {
allowed.extend(family_b_arm_allowed(
scrutinee,
pattern,
&arm_bindings,
semantic,
));
}
let mut arm_diagnostics = Vec::new();
validate_conditioned_field_reads(
rule,
&arm_ast.statements,
semantic,
&arm_bindings,
&allowed,
&mut arm_diagnostics,
);
for mut diagnostic in arm_diagnostics {
diagnostic.span = rule.body.span;
diagnostics.push(diagnostic);
}
}
static NO_FOREIGN_SCHEMAS: BTreeMap<String, String> = BTreeMap::new();
static NO_WORKFLOW_SURFACES: BTreeMap<String, WorkflowInputSurface> = BTreeMap::new();
#[derive(Clone, Copy)]
struct SchemaScopes<'a> {
local: &'a SchemaIndex,
foreign: &'a BTreeMap<String, String>,
workflows: &'a BTreeMap<String, WorkflowInputSurface>,
}
impl<'a> SchemaScopes<'a> {
fn local(local: &'a SchemaIndex) -> Self {
Self {
local,
foreign: &NO_FOREIGN_SCHEMAS,
workflows: &NO_WORKFLOW_SURFACES,
}
}
fn index_for(&self, binding: &str) -> &'a SchemaIndex {
self.foreign
.get(binding)
.and_then(|workflow| self.workflows.get(workflow))
.map_or(self.local, |surface| &surface.schemas)
}
}
fn validate_known_field_paths(
rule: &RuleDecl,
line: &str,
semantic: &SemanticContext,
binding_types: &BTreeMap<String, String>,
diagnostics: &mut Vec<Diagnostic>,
) {
validate_known_field_paths_at_span(
rule,
line,
rule.body.span,
semantic,
binding_types,
diagnostics,
);
}
fn validate_known_field_paths_scoped(
rule: &RuleDecl,
line: &str,
semantic: &SemanticContext,
binding_types: &BTreeMap<String, String>,
foreign: &BTreeMap<String, String>,
diagnostics: &mut Vec<Diagnostic>,
) {
validate_known_field_paths_in_index(
rule,
line,
rule.body.span,
SchemaScopes {
local: &semantic.schemas,
foreign,
workflows: &semantic.workflow_inputs,
},
binding_types,
diagnostics,
);
}
fn validate_known_field_paths_at_span(
rule: &RuleDecl,
line: &str,
span: SourceSpan,
semantic: &SemanticContext,
binding_types: &BTreeMap<String, String>,
diagnostics: &mut Vec<Diagnostic>,
) {
validate_known_field_paths_in_index(
rule,
line,
span,
SchemaScopes::local(&semantic.schemas),
binding_types,
diagnostics,
);
}
#[derive(Clone, Copy, Eq, PartialEq)]
enum FieldPathCheck {
Unbound,
SchemaNotIndexed,
Resolved,
}
fn check_field_path(
rule: &RuleDecl,
root: &str,
path: &[String],
span: SourceSpan,
scopes: SchemaScopes,
binding_types: &BTreeMap<String, String>,
diagnostics: &mut Vec<Diagnostic>,
) -> FieldPathCheck {
let Some(schema) = binding_types.get(root) else {
return FieldPathCheck::Unbound;
};
let schemas = scopes.index_for(root);
if !schemas.class_exists(schema) {
return FieldPathCheck::SchemaNotIndexed;
}
if let Err(message) = schemas.resolve_field_path(schema, path) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span,
message: format!(
"rule `{}` has invalid field path `{root}.{}`: {message}",
rule.name.name,
path.join(".")
),
suggestion: Some(
"use a field declared on the bound schema or add it to the class declaration"
.to_owned(),
),
});
}
FieldPathCheck::Resolved
}
fn validate_known_field_paths_in_index(
rule: &RuleDecl,
line: &str,
span: SourceSpan,
scopes: SchemaScopes,
binding_types: &BTreeMap<String, String>,
diagnostics: &mut Vec<Diagnostic>,
) {
for (root, path) in dotted_paths(line) {
check_field_path(rule, &root, &path, span, scopes, binding_types, diagnostics);
}
}
fn dotted_paths(line: &str) -> Vec<(String, Vec<String>)> {
let bytes = line.as_bytes();
let mut paths = Vec::new();
let mut index = 0;
while index < bytes.len() {
if !is_ident_start(bytes[index]) {
index += 1;
continue;
}
let root_start = index;
index += 1;
while index < bytes.len() && is_ident_continue(bytes[index]) {
index += 1;
}
let root = &line[root_start..index];
let mut fields = Vec::new();
while bytes.get(index) == Some(&b'.')
&& bytes
.get(index + 1)
.is_some_and(|byte| is_ident_start(*byte))
{
index += 1;
let field_start = index;
index += 1;
while index < bytes.len() && is_ident_continue(bytes[index]) {
index += 1;
}
fields.push(line[field_start..index].to_owned());
}
if !fields.is_empty() {
paths.push((root.to_owned(), fields));
}
}
paths
}
fn interpolation_roots(line: &str) -> Vec<String> {
let mut roots = Vec::new();
let mut rest = line;
while let Some(open) = rest.find("{{") {
let after_open = &rest[open + 2..];
let Some(close) = after_open.find("}}") else {
break;
};
let expr = after_open[..close].trim();
if let Some(root) = expr
.split(|ch: char| !ch.is_alphanumeric() && ch != '_')
.find(|part| !part.is_empty())
{
roots.push(root.to_owned());
}
rest = &after_open[close + 2..];
}
roots
}
const RESERVED_BINDING_KEYWORDS: &[&str] = &[
"after", "call", "case", "coerce", "complete", "consume", "done", "emit", "fail", "invoke",
"record", "tell", "when", "where",
];
fn validate_binding_name(
rule: &RuleDecl,
binding: &str,
span: SourceSpan,
diagnostics: &mut Vec<Diagnostic>,
) {
if RESERVED_BINDING_KEYWORDS.contains(&binding) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span,
message: format!(
"rule `{}` binds reserved keyword `{binding}`",
rule.name.name
),
suggestion: Some(format!(
"`{binding}` is a rule body keyword; choose another binding name"
)),
});
}
}
fn closest_name<'a>(target: &str, candidates: impl Iterator<Item = &'a String>) -> Option<String> {
let target_lower = target.to_lowercase();
candidates
.map(|candidate| {
let distance = edit_distance(&target_lower, &candidate.to_lowercase());
(distance, candidate)
})
.filter(|(distance, candidate)| {
*distance <= 2 && *distance < target.len().min(candidate.len())
})
.min_by_key(|(distance, candidate)| (*distance, candidate.as_str().to_owned()))
.map(|(_, candidate)| candidate.clone())
}
fn edit_distance(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
let mut previous: Vec<usize> = (0..=b.len()).collect();
let mut current = vec![0usize; b.len() + 1];
for (i, a_char) in a.iter().enumerate() {
current[0] = i + 1;
for (j, b_char) in b.iter().enumerate() {
let substitution = previous[j] + usize::from(a_char != b_char);
current[j + 1] = substitution.min(previous[j + 1] + 1).min(current[j] + 1);
}
std::mem::swap(&mut previous, &mut current);
}
previous[b.len()]
}
fn fact_read_from_when(when: &str) -> String {
let (pattern, _) = split_when_guard(when);
let first = pattern.split_whitespace().next().unwrap_or("<empty>");
if first.chars().next().is_some_and(char::is_uppercase) {
format!("schema:{first}")
} else {
format!("pattern:{pattern}")
}
}
fn parse_record_start(line: &str) -> Option<(String, Option<String>)> {
let rest = line.strip_prefix("record ").or_else(|| {
line.strip_prefix("done ")
.and_then(|rest| rest.split_once("->"))
.map(|(_, record)| record.trim())
.and_then(|record| record.strip_prefix("record "))
})?;
let before_brace = rest.split('{').next().unwrap_or(rest).trim();
let mut parts = before_brace.split_whitespace();
let schema = parts.next()?.to_owned();
let from_binding = match (parts.next(), parts.next(), parts.next()) {
(None, None, None) => None,
(Some("from"), Some(binding), None) => Some(binding.to_owned()),
_ => return None,
};
Some((schema, from_binding))
}
fn validate_record_field(
rule: &RuleDecl,
line: &str,
record_schema: &str,
semantic: &SemanticContext,
binding_types: &BTreeMap<String, String>,
known_roots: &BTreeSet<String>,
diagnostics: &mut Vec<Diagnostic>,
) {
let Some((field, expr)) = record_field_assignment(line) else {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!(
"rule `{}` has malformed field assignment in `record {record_schema}`",
rule.name.name
),
suggestion: Some("write record fields as `field value`".to_owned()),
});
return;
};
let Some(fields) = semantic.schemas.classes.get(record_schema) else {
return;
};
let Some(field_ty) = fields.get(field) else {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!("class `{record_schema}` has no field `{field}`"),
suggestion: Some(format!(
"add `{field}` to `class {record_schema}` or record an existing field"
)),
});
return;
};
if let Some((root, path)) = expression_path(expr) {
match check_field_path(
rule,
&root,
&path,
rule.body.span,
SchemaScopes::local(&semantic.schemas),
binding_types,
diagnostics,
) {
FieldPathCheck::SchemaNotIndexed => return,
FieldPathCheck::Resolved => {}
FieldPathCheck::Unbound => {
if let Some(root) = dangling_value_root(expr, known_roots) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!(
"rule `{}` has unknown binding `{root}` in `record {record_schema}` field `{field}`",
rule.name.name
),
suggestion: Some(
"reference a binding from a `when ... as name` clause, an effect `as` binding, or a `case` pattern"
.to_owned(),
),
});
}
}
}
}
validate_literal_assignment(
rule,
record_schema,
field,
field_ty,
expr,
semantic,
diagnostics,
);
validate_expected_assignment(
rule,
record_schema,
field,
field_ty,
expr,
semantic,
binding_types,
diagnostics,
);
}
fn record_field_assignment(line: &str) -> Option<(&str, &str)> {
let field_end = line.find(char::is_whitespace)?;
let field = &line[..field_end];
let expr = line[field_end..].trim();
(!field.is_empty() && !expr.is_empty()).then_some((field, expr))
}
const SPECIAL_VALUE_ROOTS: &[&str] = &["external", "ctx"];
fn collect_all_binding_names(statements: &[body::BodyStmt], out: &mut BTreeSet<String>) {
for statement in statements {
match statement {
body::BodyStmt::Effect(effect) => {
if let Some(binding) = &effect.binding {
out.insert(binding.clone());
}
}
body::BodyStmt::Region(region) => {
if let Some(view) = ®ion.lapse_binding {
out.insert(view.clone());
}
collect_all_binding_names(®ion.body, out);
collect_all_binding_names(®ion.lapse_body, out);
}
body::BodyStmt::After(after) => {
if let Some(alias) = &after.alias {
out.insert(alias.clone());
}
collect_all_binding_names(&after.body, out);
}
body::BodyStmt::Case(case) => {
for branch in &case.branches {
if let Some(binding) = &branch.binding {
out.insert(binding.clone());
}
collect_all_binding_names(&branch.body, out);
}
}
body::BodyStmt::Redact { binding, .. } => {
out.insert(binding.clone());
}
body::BodyStmt::Record(_)
| body::BodyStmt::Done { .. }
| body::BodyStmt::Terminal(_)
| body::BodyStmt::Milestone { .. }
| body::BodyStmt::Cancel { .. } => {}
}
}
}
fn validate_source_emit_signal_declared(
source: &SourceDecl,
declared_signals: &BTreeSet<String>,
diagnostics: &mut Vec<Diagnostic>,
) {
let signal = &source.emit.signal;
if signal.contains('.') && !declared_signals.contains(signal) {
let suggestion = match closest_name(signal, declared_signals.iter()) {
Some(candidate) => {
format!("did you mean `{candidate}`? otherwise declare `signal {signal} {{ ... }}`")
}
None => format!("declare `signal {signal} {{ ... }}` so rules can react to it"),
};
diagnostics.push(Diagnostic {
related: Vec::new(),
span: source.emit.signal_span,
message: format!(
"source `{}` emits undeclared signal `{}`",
source.name.name, signal
),
suggestion: Some(suggestion),
});
}
let observation_fields: Option<&[&str]> = match source.provider.name.as_str() {
"clock" => Some(&[
"scheduled_at",
"observed_at",
"occurrence_id",
"missed_count",
"schedule_name",
]),
"file" if source.watch.is_some() => Some(&["path", "content_hash", "watch"]),
"file" => Some(&["line", "line_index", "path"]),
"http" => Some(&["item", "item_index", "url"]),
_ => None,
};
if let (Some(fields), Some(SourceValue::Path { segments, .. })) =
(observation_fields, &source.dedup)
{
if let [field] = segments.as_slice() {
if !fields.contains(&field.name.as_str()) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: field.span,
message: format!(
"source `{}` `dedup` reads `{}.{}`, but a `{}` source's observation has no field `{}`",
source.name.name,
source.observe_binding.name,
field.name,
source.provider.name,
field.name
),
suggestion: Some(format!(
"available observation fields: {}",
fields.join(", ")
)),
});
}
}
}
if let Some(fields) = observation_fields {
let observe = &source.observe_binding.name;
for emit_field in &source.emit.fields {
let SourceValue::Path {
binding,
segments,
span,
} = &emit_field.value
else {
continue;
};
if &binding.name != observe {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: *span,
message: format!(
"source `{}` emit reads unknown binding `{}`",
source.name.name, binding.name
),
suggestion: Some(format!(
"the source's observation binding is `{observe}` (declared by `observe as {observe}`)"
)),
});
continue;
}
if let Some(obs_field) = segments.first() {
if !fields.contains(&obs_field.name.as_str()) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: obs_field.span,
message: format!(
"source `{}` emit reads `{}.{}`, but a `{}` source's observation has no field `{}`",
source.name.name, observe, obs_field.name, source.provider.name, obs_field.name
),
suggestion: Some(format!(
"available observation fields: {}",
fields.join(", ")
)),
});
}
}
}
}
}
fn validate_emit_signal_declarations(
rule: &RuleDecl,
statements: &[body::BodyStmt],
declared_signals: &BTreeSet<String>,
diagnostics: &mut Vec<Diagnostic>,
) {
for statement in statements {
match statement {
body::BodyStmt::Effect(effect) => {
if let body::BodyEffectKind::Notify { event, .. } = &effect.kind {
if !declared_signals.contains(event) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: effect.span,
message: format!(
"rule `{}` emits undeclared signal `{event}`",
rule.name.name
),
suggestion: Some(format!(
"declare `signal {event} {{ ... }}` so the emitted payload is typed and admissible, \
or check the signal name"
)),
});
}
}
}
body::BodyStmt::After(after) => {
validate_emit_signal_declarations(rule, &after.body, declared_signals, diagnostics)
}
body::BodyStmt::Case(case) => {
for branch in &case.branches {
validate_emit_signal_declarations(
rule,
&branch.body,
declared_signals,
diagnostics,
);
}
}
_ => {}
}
}
}
fn validate_effect_field_roots(
rule: &RuleDecl,
statements: &[body::BodyStmt],
known_roots: &BTreeSet<String>,
diagnostics: &mut Vec<Diagnostic>,
) {
for statement in statements {
match statement {
body::BodyStmt::Effect(effect) => match &effect.kind {
body::BodyEffectKind::Notify {
target_expr,
event,
from,
fields,
} => {
if let Some(from) = from {
check_operand_root(
rule,
&format!("emit `{event}` from"),
from,
known_roots,
diagnostics,
);
}
check_operand_root(
rule,
&format!("emit `{event}` target"),
target_expr,
known_roots,
diagnostics,
);
check_field_value_roots(
rule,
&format!("emit `{event}`"),
fields,
known_roots,
diagnostics,
);
}
body::BodyEffectKind::TrackerFile { queue, fields } => {
check_field_value_roots(
rule,
&format!("file into `{queue}`"),
fields,
known_roots,
diagnostics,
);
}
body::BodyEffectKind::TrackerFinish { item, fields } => {
check_operand_root(rule, "finish item", item, known_roots, diagnostics);
check_field_value_roots(rule, "finish", fields, known_roots, diagnostics);
}
body::BodyEffectKind::LedgerAppend { ledger, fields, .. } => {
check_field_value_roots(
rule,
&format!("append to `{ledger}`"),
fields,
known_roots,
diagnostics,
);
}
body::BodyEffectKind::LeaseAcquire {
resource, key_expr, ..
} => {
check_operand_root(
rule,
&format!("acquire `{resource}` key"),
key_expr,
known_roots,
diagnostics,
);
}
body::BodyEffectKind::CounterConsume {
counter,
key_expr,
amount_expr,
} => {
check_operand_root(
rule,
&format!("consume `{counter}` key"),
key_expr,
known_roots,
diagnostics,
);
check_operand_root(
rule,
&format!("consume `{counter}` amount"),
amount_expr,
known_roots,
diagnostics,
);
}
_ => {}
},
body::BodyStmt::After(after) => {
validate_effect_field_roots(rule, &after.body, known_roots, diagnostics)
}
body::BodyStmt::Case(case) => {
for branch in &case.branches {
validate_effect_field_roots(rule, &branch.body, known_roots, diagnostics);
}
}
_ => {}
}
}
}
fn dangling_value_root(value: &str, known_roots: &BTreeSet<String>) -> Option<String> {
let (root, path) = expression_path(value)?;
if !path.is_empty()
&& !value.contains('"')
&& !known_roots.contains(&root)
&& !SPECIAL_VALUE_ROOTS.contains(&root.as_str())
{
Some(root)
} else {
None
}
}
fn check_operand_root(
rule: &RuleDecl,
context: &str,
operand: &str,
known_roots: &BTreeSet<String>,
diagnostics: &mut Vec<Diagnostic>,
) {
if let Some(root) = dangling_value_root(operand, known_roots) {
diagnostics.push(Diagnostic { related: Vec::new(),
span: rule.body.span,
message: format!(
"rule `{}` has unknown binding `{root}` in {context} `{operand}`",
rule.name.name
),
suggestion: Some(
"reference a binding from a `when ... as name` clause, an effect `as` binding, or a `case` pattern"
.to_owned(),
),
});
}
}
fn check_field_value_roots(
rule: &RuleDecl,
context: &str,
fields: &[body::FieldAssign],
known_roots: &BTreeSet<String>,
diagnostics: &mut Vec<Diagnostic>,
) {
for field in fields {
match &field.value {
body::FieldValue::Expr { source, .. } => {
if let Some(root) = dangling_value_root(source, known_roots) {
diagnostics.push(Diagnostic { related: Vec::new(),
span: rule.body.span,
message: format!(
"rule `{}` has unknown binding `{root}` in {context} field `{}`",
rule.name.name, field.name
),
suggestion: Some(
"reference a binding from a `when ... as name` clause, an effect `as` binding, or a `case` pattern"
.to_owned(),
),
});
}
}
body::FieldValue::Nested { fields, .. } => {
check_field_value_roots(rule, context, fields, known_roots, diagnostics)
}
body::FieldValue::Shorthand => {}
}
}
}
fn known_roots_for_rule(rule: &RuleDecl) -> BTreeSet<String> {
let mut roots: BTreeSet<String> = binding_types_for_rule(rule).into_keys().collect();
let (body_ast, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
collect_all_binding_names(&body_ast.statements, &mut roots);
roots
}
fn validate_record_blocks(
rule: &RuleDecl,
semantic: &SemanticContext,
binding_types: &BTreeMap<String, String>,
known_roots: &BTreeSet<String>,
diagnostics: &mut Vec<Diagnostic>,
) {
for (schema, from_binding, body) in record_blocks(&rule.body.text) {
for stray in body::stray_value_tokens(&body) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!(
"rule `{}` has a value with no field name in `record {schema}`: `{stray}`",
rule.name.name
),
suggestion: Some(format!(
"give it a field name (`<field> {stray}`), or remove it"
)),
});
}
for assignment in collect_field_assignments(&body) {
let (field, value) = match assignment {
RecordFieldAssignment::Value { field, value } => (field, value),
RecordFieldAssignment::Shorthand { field } => {
let value = from_binding
.as_ref()
.map(|binding| format!("{binding}.{field}"))
.unwrap_or_else(|| field.clone());
(field, value)
}
};
let line = format!("{field} {value}");
validate_record_field(
rule,
&line,
&schema,
semantic,
binding_types,
known_roots,
diagnostics,
);
}
}
}
fn record_blocks(body: &str) -> Vec<(String, Option<String>, String)> {
let mut blocks = Vec::new();
let lines = body.lines().collect::<Vec<_>>();
let mut index = 0usize;
while index < lines.len() {
let trimmed = lines[index].trim();
let Some((schema, from_binding)) = parse_record_start(trimmed) else {
index += 1;
continue;
};
if brace_delta(trimmed) == 0 && trimmed.contains('{') {
if let (Some(open), Some(close)) = (trimmed.find('{'), trimmed.rfind('}')) {
if close > open {
blocks.push((
schema,
from_binding,
trimmed[open + 1..close].trim().to_owned(),
));
}
}
index += 1;
continue;
}
let mut depth = brace_delta(trimmed);
let mut record_lines = Vec::new();
index += 1;
while index < lines.len() && depth > 0 {
let line = lines[index];
let before = depth;
depth += brace_delta(line);
if !(before == 1 && depth == 0 && line.trim() == "}") {
record_lines.push(line.to_owned());
}
index += 1;
}
blocks.push((schema, from_binding, record_lines.join("\n")));
}
blocks
}
fn workflow_terminal_blocks(body: &str) -> Vec<(String, String, String)> {
let mut blocks = Vec::new();
let lines = body.lines().collect::<Vec<_>>();
let mut index = 0usize;
while index < lines.len() {
let trimmed = lines[index].trim();
let terminal = trimmed
.strip_prefix("complete ")
.map(|rest| ("complete", rest))
.or_else(|| trimmed.strip_prefix("fail ").map(|rest| ("fail", rest)));
let Some((action, rest)) = terminal else {
index += 1;
continue;
};
let Some(name) = rest.split('{').next().and_then(|header| {
let mut parts = header.split_whitespace();
match (parts.next(), parts.next()) {
(Some(name), None) => Some(name.to_owned()),
_ => None,
}
}) else {
index += 1;
continue;
};
let mut depth = brace_delta(trimmed);
let mut terminal_lines = Vec::new();
if depth == 0 && trimmed.contains('{') {
if let (Some(open), Some(close)) = (trimmed.find('{'), trimmed.rfind('}')) {
if close > open {
let inner = trimmed[open + 1..close].trim();
if !inner.is_empty() {
terminal_lines.push(inner.to_owned());
}
}
}
index += 1;
} else {
index += 1;
while index < lines.len() && depth > 0 {
let line = lines[index];
let before = depth;
depth += brace_delta(line);
if !(before == 1 && depth == 0 && line.trim() == "}") {
terminal_lines.push(line.to_owned());
}
index += 1;
}
}
blocks.push((action.to_owned(), name, terminal_lines.join("\n")));
}
blocks
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum RecordFieldAssignment {
Value { field: String, value: String },
Shorthand { field: String },
}
fn collect_field_assignments(body: &str) -> Vec<RecordFieldAssignment> {
body::split_field_assignments(body)
.into_iter()
.map(|assignment| match assignment.value {
Some(value) => RecordFieldAssignment::Value {
field: assignment.name,
value,
},
None => RecordFieldAssignment::Shorthand {
field: assignment.name,
},
})
.collect()
}
fn expression_path(expr: &str) -> Option<(String, Vec<String>)> {
let mut paths = dotted_paths(expr);
if paths.len() != 1 {
return None;
}
Some(paths.remove(0))
}
fn validate_literal_assignment(
rule: &RuleDecl,
record_schema: &str,
field: &str,
field_ty: &TypeSyntax,
expr: &str,
semantic: &SemanticContext,
diagnostics: &mut Vec<Diagnostic>,
) {
let Some(literal) = parse_literal_expr(expr) else {
return;
};
match field_ty {
TypeSyntax::Primitive { name, .. } => {
validate_primitive_literal(rule, record_schema, field, name, &literal, diagnostics)
}
TypeSyntax::LiteralString { value, .. } => {
if literal != LiteralExpr::String(value.as_str()) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!(
"field `{record_schema}.{field}` expects literal string `{value}`"
),
suggestion: Some(format!("record `{field} {value:?}`")),
});
}
}
TypeSyntax::Ref { name } => {
validate_enum_literal(
rule,
record_schema,
field,
&name.name,
&literal,
semantic,
diagnostics,
);
}
TypeSyntax::Union { variants, .. } => {
validate_union_literal(rule, record_schema, field, variants, &literal, diagnostics);
}
TypeSyntax::AgentRef { agents, .. } => {
validate_agent_ref_literal(rule, record_schema, field, agents, &literal, diagnostics);
}
TypeSyntax::Optional { inner, .. } => {
if literal != LiteralExpr::Null {
validate_literal_assignment(
rule,
record_schema,
field,
inner,
expr,
semantic,
diagnostics,
);
}
}
TypeSyntax::Array { .. } | TypeSyntax::Map { .. } => {}
}
}
#[allow(clippy::too_many_arguments)]
fn validate_expected_assignment(
rule: &RuleDecl,
record_schema: &str,
field: &str,
field_ty: &TypeSyntax,
expr: &str,
semantic: &SemanticContext,
binding_types: &BTreeMap<String, String>,
diagnostics: &mut Vec<Diagnostic>,
) {
if !(expr.trim_start().starts_with('{') || expr.trim_start().starts_with('[')) {
return;
}
validate_expr_source_against_type(
rule,
record_schema,
field,
field_ty,
expr,
semantic,
&ExprScope::from_bindings(binding_types),
diagnostics,
);
}
#[allow(clippy::too_many_arguments)]
fn validate_expr_source_against_type(
rule: &RuleDecl,
record_schema: &str,
field: &str,
expected_ty: &TypeSyntax,
expr: &str,
semantic: &SemanticContext,
scope: &ExprScope,
diagnostics: &mut Vec<Diagnostic>,
) {
match expected_ty {
TypeSyntax::Map { inner, .. } => {
let parsed = match parse_expression(expr) {
Ok(Expr::Object(fields)) => fields,
Ok(_) => {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!("field `{record_schema}.{field}` expects a map literal"),
suggestion: Some(format!("record `{field} {{ key value }}`")),
});
return;
}
Err(message) => {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!(
"field `{record_schema}.{field}` expects a map literal: {message}"
),
suggestion: Some(format!("record `{field} {{ key value }}`")),
});
return;
}
};
for map_field in &parsed {
validate_expr_against_type(
rule,
record_schema,
field,
inner,
&map_field.value,
semantic,
scope,
diagnostics,
);
}
}
TypeSyntax::Array { inner, .. } => match parse_expression(expr) {
Ok(Expr::Array(items)) => {
for item in items {
validate_expr_against_type(
rule,
record_schema,
field,
inner,
&item,
semantic,
scope,
diagnostics,
);
}
}
Ok(expr) => validate_inferred_assignment_type(
rule,
record_schema,
field,
expected_ty,
&expr,
semantic,
scope,
diagnostics,
),
Err(message) => {
push_invalid_assignment_expr(rule, record_schema, field, message, diagnostics)
}
},
TypeSyntax::Optional { inner, .. } => {
if expr.trim() != "null" {
validate_expr_source_against_type(
rule,
record_schema,
field,
inner,
expr,
semantic,
scope,
diagnostics,
);
}
}
TypeSyntax::Ref { name } if semantic.schemas.class_exists(&name.name) => {
let parsed = match parse_expression(expr) {
Ok(Expr::Object(fields)) => fields,
Ok(expr) => {
validate_inferred_assignment_type(
rule,
record_schema,
field,
expected_ty,
&expr,
semantic,
scope,
diagnostics,
);
return;
}
Err(message) => {
push_invalid_assignment_expr(rule, record_schema, field, message, diagnostics);
return;
}
};
validate_object_literal_fields(
rule,
record_schema,
field,
&name.name,
&parsed,
semantic,
scope,
diagnostics,
);
}
_ => match parse_expression(expr) {
Ok(expr) => validate_inferred_assignment_type(
rule,
record_schema,
field,
expected_ty,
&expr,
semantic,
scope,
diagnostics,
),
Err(message) => {
push_invalid_assignment_expr(rule, record_schema, field, message, diagnostics)
}
},
}
}
#[allow(clippy::too_many_arguments)]
fn validate_expr_against_type(
rule: &RuleDecl,
record_schema: &str,
field: &str,
expected_ty: &TypeSyntax,
expr: &Expr,
semantic: &SemanticContext,
scope: &ExprScope,
diagnostics: &mut Vec<Diagnostic>,
) {
match expr {
Expr::Array(items) if matches!(expected_ty, TypeSyntax::Array { .. }) => {
if let TypeSyntax::Array { inner, .. } = expected_ty {
for item in items {
validate_expr_against_type(
rule,
record_schema,
field,
inner,
item,
semantic,
scope,
diagnostics,
);
}
}
}
Expr::Object(fields) => match expected_ty {
TypeSyntax::Map { inner, .. } => {
for field in fields {
validate_expr_against_type(
rule,
record_schema,
field.key.as_str(),
inner,
&field.value,
semantic,
scope,
diagnostics,
);
}
}
TypeSyntax::Ref { name } if semantic.schemas.class_exists(&name.name) => {
validate_object_literal_fields(
rule,
record_schema,
field,
&name.name,
fields,
semantic,
scope,
diagnostics,
);
}
_ => validate_inferred_assignment_type(
rule,
record_schema,
field,
expected_ty,
expr,
semantic,
scope,
diagnostics,
),
},
_ => validate_inferred_assignment_type(
rule,
record_schema,
field,
expected_ty,
expr,
semantic,
scope,
diagnostics,
),
}
}
fn push_invalid_assignment_expr(
rule: &RuleDecl,
record_schema: &str,
field: &str,
message: String,
diagnostics: &mut Vec<Diagnostic>,
) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!(
"rule `{}` has invalid expression for field `{record_schema}.{field}`: {message}",
rule.name.name
),
suggestion: Some(
"use array literals or expected-schema object literals for collection fields"
.to_owned(),
),
});
}
#[allow(clippy::too_many_arguments)]
fn validate_object_literal_fields(
rule: &RuleDecl,
record_schema: &str,
field: &str,
object_schema: &str,
object_fields: &[ExprObjectField],
semantic: &SemanticContext,
scope: &ExprScope,
diagnostics: &mut Vec<Diagnostic>,
) {
let Some(schema_fields) = semantic.schemas.classes.get(object_schema) else {
return;
};
let mut seen = BTreeSet::new();
for object_field in object_fields {
if !seen.insert(object_field.key.clone()) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!(
"field `{record_schema}.{field}` repeats object field `{}`",
object_field.key
),
suggestion: Some("remove the duplicate object field".to_owned()),
});
continue;
}
let Some(field_ty) = schema_fields.get(&object_field.key) else {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!(
"class `{object_schema}` has no field `{}`",
object_field.key
),
suggestion: Some(format!(
"add `{}` to `class {object_schema}` or use an existing field",
object_field.key
)),
});
continue;
};
validate_expr_against_type(
rule,
object_schema,
&object_field.key,
field_ty,
&object_field.value,
semantic,
scope,
diagnostics,
);
}
for (required, ty) in schema_fields {
if seen.contains(required) || matches!(ty, TypeSyntax::Optional { .. }) {
continue;
}
diagnostics.push(Diagnostic { related: Vec::new(),
span: rule.body.span,
message: format!(
"field `{record_schema}.{field}` is missing required object field `{object_schema}.{required}`"
),
suggestion: Some(format!("add `{required}` to the `{field}` object literal")),
});
}
}
#[allow(clippy::too_many_arguments)]
fn validate_inferred_assignment_type(
rule: &RuleDecl,
record_schema: &str,
field: &str,
expected_ty: &TypeSyntax,
expr: &Expr,
semantic: &SemanticContext,
scope: &ExprScope,
diagnostics: &mut Vec<Diagnostic>,
) {
let literal = expr_literal_as_literal_expr(expr);
if let Some(literal) = literal {
validate_literal_against_type(
rule,
record_schema,
field,
expected_ty,
&literal,
semantic,
diagnostics,
);
return;
}
let context = ExprValidationContext::rule(rule);
let mut local_diagnostics = Vec::new();
let actual_ty = infer_expr_type(expr, semantic, scope, &context, &mut local_diagnostics);
diagnostics.extend(local_diagnostics);
let expected_expr_ty = expr_type_from_type_syntax(expected_ty, semantic);
if !types_comparable(&actual_ty, &expected_expr_ty) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!(
"field `{record_schema}.{field}` receives incompatible expression type"
),
suggestion: Some(format!(
"record a value compatible with `{}`",
expected_ty.to_source()
)),
});
}
}
fn validate_literal_against_type(
rule: &RuleDecl,
record_schema: &str,
field: &str,
field_ty: &TypeSyntax,
literal: &LiteralExpr<'_>,
semantic: &SemanticContext,
diagnostics: &mut Vec<Diagnostic>,
) {
match field_ty {
TypeSyntax::Primitive { name, .. } => {
validate_primitive_literal(rule, record_schema, field, name, literal, diagnostics)
}
TypeSyntax::LiteralString { value, .. } => {
if literal != &LiteralExpr::String(value.as_str()) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!(
"field `{record_schema}.{field}` expects literal string `{value}`"
),
suggestion: Some(format!("record `{field} {value:?}`")),
});
}
}
TypeSyntax::Ref { name } => {
validate_enum_literal(
rule,
record_schema,
field,
&name.name,
literal,
semantic,
diagnostics,
);
}
TypeSyntax::Union { variants, .. } => {
validate_union_literal(rule, record_schema, field, variants, literal, diagnostics);
}
TypeSyntax::AgentRef { agents, .. } => {
validate_agent_ref_literal(rule, record_schema, field, agents, literal, diagnostics);
}
TypeSyntax::Optional { inner, .. } => {
if literal != &LiteralExpr::Null {
validate_literal_against_type(
rule,
record_schema,
field,
inner,
literal,
semantic,
diagnostics,
);
}
}
TypeSyntax::Array { .. } | TypeSyntax::Map { .. } => {}
}
}
fn expr_literal_as_literal_expr(expr: &Expr) -> Option<LiteralExpr<'_>> {
match expr {
Expr::Literal(ExprLiteral::String(value)) => Some(LiteralExpr::String(value)),
Expr::Literal(ExprLiteral::Number(value)) => Some(LiteralExpr::Number(value)),
Expr::Literal(ExprLiteral::Bool(_)) => Some(LiteralExpr::Bool),
Expr::Literal(ExprLiteral::Null) => Some(LiteralExpr::Null),
Expr::Literal(ExprLiteral::Ident(value)) => Some(LiteralExpr::Ident(value)),
_ => None,
}
}
fn validate_agent_ref_literal(
rule: &RuleDecl,
record_schema: &str,
field: &str,
agents: &[Ident],
literal: &LiteralExpr<'_>,
diagnostics: &mut Vec<Diagnostic>,
) {
let allowed = agents
.iter()
.map(|agent| agent.name.as_str())
.collect::<Vec<_>>();
if let LiteralExpr::String(value) = literal {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!(
"field `{record_schema}.{field}` expects an AgentRef value, not string `{value}`"
),
suggestion: Some(format!(
"use an unquoted declared agent name: {}",
allowed.join(", ")
)),
});
return;
}
let LiteralExpr::Ident(value) = literal else {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!("field `{record_schema}.{field}` expects an AgentRef value"),
suggestion: Some(format!("use one of: {}", allowed.join(", "))),
});
return;
};
if !allowed.contains(value) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!("field `{record_schema}.{field}` cannot reference agent `{value}`"),
suggestion: Some(format!("use one of: {}", allowed.join(", "))),
});
}
}
fn parse_literal_expr(expr: &str) -> Option<LiteralExpr<'_>> {
let expr = expr.trim().trim_end_matches(',');
if let Some(value) = expr
.strip_prefix('"')
.and_then(|rest| rest.strip_suffix('"'))
{
return Some(LiteralExpr::String(value));
}
if expr.chars().all(|ch| ch.is_ascii_digit() || ch == '.')
&& expr.chars().any(|ch| ch.is_ascii_digit())
{
return Some(LiteralExpr::Number(expr));
}
match expr {
"true" => Some(LiteralExpr::Bool),
"false" => Some(LiteralExpr::Bool),
"null" => Some(LiteralExpr::Null),
value if value.chars().all(|ch| ch.is_alphanumeric() || ch == '_') => {
Some(LiteralExpr::Ident(value))
}
_ => None,
}
}
struct ExprParser<'a> {
source: &'a str,
tokens: Vec<ExprToken>,
pos: usize,
depth: usize,
}
const MAX_EXPR_DEPTH: usize = 256;
#[derive(Clone, Debug, Eq, PartialEq)]
struct ExprToken {
kind: ExprTokenKind,
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum ExprTokenKind {
Ident(String),
String(String),
Number(String),
Symbol(char),
Op(&'static str),
}
impl<'a> ExprParser<'a> {
fn new(source: &'a str) -> Self {
Self {
source,
tokens: lex_expr(source),
pos: 0,
depth: 0,
}
}
fn parse(mut self) -> Result<Expr, String> {
let expr = self.parse_or()?;
if self.peek().is_some() {
return Err(format!(
"unexpected token in expression `{}`",
self.source.trim()
));
}
Ok(expr)
}
fn parse_or(&mut self) -> Result<Expr, String> {
let mut expr = self.parse_and()?;
while self.consume_op("||") || self.consume_ident("or") {
let right = self.parse_and()?;
expr = Expr::Binary {
op: BinaryOp::Or,
left: Box::new(expr),
right: Box::new(right),
};
}
Ok(expr)
}
fn parse_and(&mut self) -> Result<Expr, String> {
let mut expr = self.parse_comparison()?;
while self.consume_op("&&") || self.consume_ident("and") {
let right = self.parse_comparison()?;
expr = Expr::Binary {
op: BinaryOp::And,
left: Box::new(expr),
right: Box::new(right),
};
}
Ok(expr)
}
fn parse_comparison(&mut self) -> Result<Expr, String> {
let mut expr = self.parse_additive()?;
loop {
let op = if self.consume_op("==") {
Some(BinaryOp::Eq)
} else if self.consume_op("!=") {
Some(BinaryOp::Ne)
} else if self.consume_op("<=") {
Some(BinaryOp::Le)
} else if self.consume_op(">=") {
Some(BinaryOp::Ge)
} else if self.consume_symbol('<') {
Some(BinaryOp::Lt)
} else if self.consume_symbol('>') {
Some(BinaryOp::Gt)
} else if self.consume_ident("not") {
if !self.consume_ident("in") {
return Err("expected `in` after `not`".to_owned());
}
Some(BinaryOp::NotIn)
} else if self.consume_ident("in") {
Some(BinaryOp::In)
} else {
None
};
let Some(op) = op else {
return Ok(expr);
};
let right = self.parse_additive()?;
expr = Expr::Binary {
op,
left: Box::new(expr),
right: Box::new(right),
};
}
}
fn parse_additive(&mut self) -> Result<Expr, String> {
let mut expr = self.parse_multiplicative()?;
loop {
let op = if self.consume_symbol('+') {
Some(BinaryOp::Add)
} else if self.consume_symbol('-') {
Some(BinaryOp::Sub)
} else {
None
};
let Some(op) = op else {
return Ok(expr);
};
let right = self.parse_multiplicative()?;
expr = Expr::Binary {
op,
left: Box::new(expr),
right: Box::new(right),
};
}
}
fn parse_multiplicative(&mut self) -> Result<Expr, String> {
let mut expr = self.parse_unary()?;
loop {
let op = if self.consume_symbol('*') {
Some(BinaryOp::Mul)
} else if self.consume_symbol('/') {
Some(BinaryOp::Div)
} else {
None
};
let Some(op) = op else {
return Ok(expr);
};
let right = self.parse_unary()?;
expr = Expr::Binary {
op,
left: Box::new(expr),
right: Box::new(right),
};
}
}
fn parse_unary(&mut self) -> Result<Expr, String> {
self.depth += 1;
if self.depth > MAX_EXPR_DEPTH {
self.depth -= 1;
return Err(format!(
"expression in `{}` is nested too deeply (limit {MAX_EXPR_DEPTH})",
self.source.trim()
));
}
let result = self.parse_unary_inner();
self.depth -= 1;
result
}
fn parse_unary_inner(&mut self) -> Result<Expr, String> {
if self.consume_symbol('!') {
return Ok(Expr::Unary {
op: UnaryOp::Not,
expr: Box::new(self.parse_unary()?),
});
}
if self.consume_ident("not") {
return Ok(Expr::Unary {
op: UnaryOp::Not,
expr: Box::new(self.parse_comparison()?),
});
}
self.parse_postfix()
}
fn parse_postfix(&mut self) -> Result<Expr, String> {
let mut expr = self.parse_primary()?;
loop {
if self.consume_symbol('[') {
let key = self.parse_or()?;
self.expect_symbol(']')?;
expr = Expr::Index {
target: Box::new(expr),
key: Box::new(key),
};
continue;
}
return Ok(expr);
}
}
fn parse_primary(&mut self) -> Result<Expr, String> {
if self.consume_symbol('(') {
let expr = self.parse_or()?;
self.expect_symbol(')')?;
return Ok(expr);
}
if self.consume_symbol('[') {
let mut items = Vec::new();
if self.consume_symbol(']') {
return Ok(Expr::Array(items));
}
loop {
items.push(self.parse_or()?);
if self.consume_symbol(']') {
break;
}
self.expect_symbol(',')?;
}
return Ok(Expr::Array(items));
}
if self.consume_symbol('{') {
let mut fields = Vec::new();
if self.consume_symbol('}') {
return Ok(Expr::Object(fields));
}
loop {
let key = match self.advance().map(|token| token.kind.clone()) {
Some(ExprTokenKind::Ident(value) | ExprTokenKind::String(value)) => value,
_ => return Err("expected object field name".to_owned()),
};
let value = self.parse_or()?;
fields.push(ExprObjectField { key, value });
if self.consume_symbol('}') {
break;
}
let _ = self.consume_symbol(',');
}
return Ok(Expr::Object(fields));
}
match self.advance().map(|token| token.kind.clone()) {
Some(ExprTokenKind::String(value)) => Ok(Expr::Literal(ExprLiteral::String(value))),
Some(ExprTokenKind::Number(value)) => Ok(Expr::Literal(ExprLiteral::Number(value))),
Some(ExprTokenKind::Ident(value)) if value == "true" => {
Ok(Expr::Literal(ExprLiteral::Bool(true)))
}
Some(ExprTokenKind::Ident(value)) if value == "false" => {
Ok(Expr::Literal(ExprLiteral::Bool(false)))
}
Some(ExprTokenKind::Ident(value)) if value == "null" => {
Ok(Expr::Literal(ExprLiteral::Null))
}
Some(ExprTokenKind::Ident(value)) if value == "exists" && !self.at_symbol('(') => {
let arg = match self.parse_postfix()? {
Expr::Literal(ExprLiteral::Ident(path)) => Expr::Path(vec![path]),
expr => expr,
};
Ok(Expr::Call {
name: value,
args: vec![arg],
})
}
Some(ExprTokenKind::Ident(value))
if matches!(value.as_str(), "count" | "exists" | "empty")
&& self.at_symbol('(') =>
{
self.expect_symbol('(')?;
if let Some(query) = self.try_parse_query()? {
self.expect_symbol(')')?;
Ok(Expr::Call {
name: value,
args: vec![query],
})
} else {
let mut args = Vec::new();
if self.consume_symbol(')') {
return Ok(Expr::Call { name: value, args });
}
loop {
args.push(self.parse_or()?);
if self.consume_symbol(')') {
break;
}
self.expect_symbol(',')?;
}
Ok(Expr::Call { name: value, args })
}
}
Some(ExprTokenKind::Ident(value)) => {
let mut path = vec![value];
while self.consume_symbol('.') {
let Some(ExprTokenKind::Ident(field)) =
self.advance().map(|token| token.kind.clone())
else {
return Err("expected field name after `.`".to_owned());
};
path.push(field);
}
if path.len() == 1 {
Ok(Expr::Literal(ExprLiteral::Ident(path.remove(0))))
} else {
Ok(Expr::Path(path))
}
}
_ => Err(format!("expected expression in `{}`", self.source.trim())),
}
}
fn try_parse_query(&mut self) -> Result<Option<Expr>, String> {
let checkpoint = self.pos;
let kind = if self.consume_ident("effect") {
QueryKind::Effect
} else if matches!(
self.peek().map(|token| &token.kind),
Some(ExprTokenKind::Ident(value)) if value.chars().next().is_some_and(char::is_uppercase)
) {
QueryKind::Fact
} else {
return Ok(None);
};
let mut head = Vec::new();
while let Some(token) = self.peek() {
if self.at_symbol(')') || self.at_ident("where") {
break;
}
head.push(self.token_text(token));
self.pos += 1;
}
if head.is_empty() {
self.pos = checkpoint;
return Ok(None);
}
let guard = if self.consume_ident("where") {
Some(Box::new(self.parse_or()?))
} else {
None
};
Ok(Some(Expr::Query {
kind,
head: join_query_head(&head),
guard,
}))
}
fn token_text(&self, token: &ExprToken) -> String {
match &token.kind {
ExprTokenKind::Ident(value) | ExprTokenKind::Number(value) => value.clone(),
ExprTokenKind::String(value) => format!("{value:?}"),
ExprTokenKind::Symbol(value) => value.to_string(),
ExprTokenKind::Op(value) => value.to_string(),
}
}
fn peek(&self) -> Option<&ExprToken> {
self.tokens.get(self.pos)
}
fn advance(&mut self) -> Option<&ExprToken> {
let token = self.tokens.get(self.pos)?;
self.pos += 1;
Some(token)
}
fn at_symbol(&self, symbol: char) -> bool {
matches!(
self.peek().map(|token| &token.kind),
Some(ExprTokenKind::Symbol(value)) if *value == symbol
)
}
fn consume_symbol(&mut self, symbol: char) -> bool {
if self.at_symbol(symbol) {
self.pos += 1;
true
} else {
false
}
}
fn expect_symbol(&mut self, symbol: char) -> Result<(), String> {
if self.consume_symbol(symbol) {
Ok(())
} else {
Err(format!("expected `{symbol}`"))
}
}
fn at_ident(&self, ident: &str) -> bool {
matches!(
self.peek().map(|token| &token.kind),
Some(ExprTokenKind::Ident(value)) if value == ident
)
}
fn consume_ident(&mut self, ident: &str) -> bool {
if self.at_ident(ident) {
self.pos += 1;
true
} else {
false
}
}
fn consume_op(&mut self, op: &'static str) -> bool {
if matches!(
self.peek().map(|token| &token.kind),
Some(ExprTokenKind::Op(value)) if *value == op
) {
self.pos += 1;
true
} else {
false
}
}
}
fn join_query_head(tokens: &[String]) -> String {
let mut head = String::new();
for token in tokens {
if token == "." {
head.push('.');
} else if head.ends_with('.') || head.is_empty() {
head.push_str(token);
} else {
head.push(' ');
head.push_str(token);
}
}
head
}
fn lex_expr(source: &str) -> Vec<ExprToken> {
let bytes = source.as_bytes();
let mut tokens = Vec::new();
let mut index = 0usize;
while index < bytes.len() {
let byte = bytes[index];
if byte.is_ascii_whitespace() {
index += 1;
continue;
}
if is_ident_start(byte) {
let start = index;
index += 1;
while index < bytes.len() && is_ident_continue(bytes[index]) {
index += 1;
}
tokens.push(ExprToken {
kind: ExprTokenKind::Ident(source[start..index].to_owned()),
});
continue;
}
if byte.is_ascii_digit() {
let start = index;
index += 1;
while index < bytes.len() && (bytes[index].is_ascii_digit() || bytes[index] == b'.') {
index += 1;
}
tokens.push(ExprToken {
kind: ExprTokenKind::Number(source[start..index].to_owned()),
});
continue;
}
if byte == b'"' {
let start = index + 1;
index += 1;
while index < bytes.len() && bytes[index] != b'"' {
index += 1;
}
let value = source[start..index.min(bytes.len())].to_owned();
index = (index + 1).min(bytes.len());
tokens.push(ExprToken {
kind: ExprTokenKind::String(value),
});
continue;
}
let rest = &source[index..];
if rest.starts_with("&&") {
tokens.push(ExprToken {
kind: ExprTokenKind::Op("&&"),
});
index += 2;
} else if rest.starts_with("||") {
tokens.push(ExprToken {
kind: ExprTokenKind::Op("||"),
});
index += 2;
} else if rest.starts_with("==") {
tokens.push(ExprToken {
kind: ExprTokenKind::Op("=="),
});
index += 2;
} else if rest.starts_with("!=") {
tokens.push(ExprToken {
kind: ExprTokenKind::Op("!="),
});
index += 2;
} else if rest.starts_with("<=") {
tokens.push(ExprToken {
kind: ExprTokenKind::Op("<="),
});
index += 2;
} else if rest.starts_with(">=") {
tokens.push(ExprToken {
kind: ExprTokenKind::Op(">="),
});
index += 2;
} else {
tokens.push(ExprToken {
kind: ExprTokenKind::Symbol(byte as char),
});
index += 1;
}
}
tokens
}
fn validate_primitive_literal(
rule: &RuleDecl,
record_schema: &str,
field: &str,
primitive: &str,
literal: &LiteralExpr<'_>,
diagnostics: &mut Vec<Diagnostic>,
) {
if primitive == "secret" {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!(
"field `{record_schema}.{field}` is `secret`: secrets have no literal form"
),
suggestion: Some(
"reference a declared credential; material lives with the custodian, never in \
source"
.to_owned(),
),
});
return;
}
let valid = matches!(
(primitive, literal),
("string", LiteralExpr::String(_))
| ("string", LiteralExpr::Ident(_))
| ("int", LiteralExpr::Number(_))
| ("float", LiteralExpr::Number(_))
| ("bool", LiteralExpr::Bool)
| ("null", LiteralExpr::Null)
| ("duration", LiteralExpr::String(_))
| ("time", LiteralExpr::String(_))
);
if !valid {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!("field `{record_schema}.{field}` expects `{primitive}`"),
suggestion: Some(format!("record a value compatible with `{primitive}`")),
});
return;
}
match (primitive, literal) {
("duration", LiteralExpr::String(value)) if parse_duration_seconds(value).is_none() => {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!("field `{record_schema}.{field}` has invalid duration literal"),
suggestion: Some("use an ISO-8601 duration such as `\"PT30M\"`".to_owned()),
});
}
("time", LiteralExpr::String(value)) if parse_time_epoch_seconds(value).is_none() => {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!("field `{record_schema}.{field}` has invalid time literal"),
suggestion: Some(
"use an RFC3339 timestamp such as `\"2026-05-29T10:00:00Z\"`".to_owned(),
),
});
}
_ => {}
}
}
fn validate_enum_literal(
rule: &RuleDecl,
record_schema: &str,
field: &str,
schema: &str,
literal: &LiteralExpr<'_>,
semantic: &SemanticContext,
diagnostics: &mut Vec<Diagnostic>,
) {
let Some(variants) = semantic.schemas.enums.get(schema) else {
return;
};
let LiteralExpr::Ident(variant) = literal else {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!("field `{record_schema}.{field}` expects enum `{schema}`"),
suggestion: Some(format!(
"use one of: {}",
variants.iter().cloned().collect::<Vec<_>>().join(", ")
)),
});
return;
};
if !variants.contains(*variant) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!("enum `{schema}` has no variant `{variant}`"),
suggestion: Some(format!(
"use one of: {}",
variants.iter().cloned().collect::<Vec<_>>().join(", ")
)),
});
}
}
fn validate_union_literal(
rule: &RuleDecl,
record_schema: &str,
field: &str,
variants: &[TypeSyntax],
literal: &LiteralExpr<'_>,
diagnostics: &mut Vec<Diagnostic>,
) {
let allowed = variants
.iter()
.filter_map(|variant| match variant {
TypeSyntax::LiteralString { value, .. } => Some(value.as_str()),
_ => None,
})
.collect::<Vec<_>>();
if allowed.is_empty() {
return;
}
let LiteralExpr::String(value) = literal else {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!("field `{record_schema}.{field}` expects one of its literal variants"),
suggestion: Some(format!("use one of: {}", allowed.join(", "))),
});
return;
};
if !allowed.contains(value) {
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!("field `{record_schema}.{field}` cannot be `{value}`"),
suggestion: Some(format!("use one of: {}", allowed.join(", "))),
});
}
}
fn parse_effect_line(line: &str) -> Option<(IrEffectKind, Option<String>)> {
let kind = if line.starts_with("tell ") {
IrEffectKind::AgentTell
} else if line.starts_with("coerce ") || line.starts_with("prompt ") {
IrEffectKind::SchemaCoerce
} else if line.starts_with("claim ") {
IrEffectKind::TrackerClaim
} else if line.starts_with("call ")
|| line.starts_with("recall ")
|| line.starts_with("learn ")
|| line.starts_with("curate ")
|| line.starts_with("promote ")
|| line.starts_with("undo ")
|| line.starts_with("transport ")
{
IrEffectKind::CapabilityCall
} else if line.starts_with("emit ") {
IrEffectKind::EventEmit
} else if line.starts_with("invoke ") {
IrEffectKind::WorkflowInvoke
} else if line.starts_with("read ") {
IrEffectKind::FileRead
} else if line.starts_with("write ") {
IrEffectKind::FileWrite
} else if line.starts_with("import ") {
IrEffectKind::FileImport
} else if line.starts_with("export ") {
IrEffectKind::FileExport
} else if line.starts_with("acquire ") {
IrEffectKind::LeaseAcquire
} else if line.starts_with("renew ") {
IrEffectKind::LeaseRenew
} else if line.starts_with("append ") {
IrEffectKind::LedgerAppend
} else if line.starts_with("consume ") && line.contains(" for ") {
IrEffectKind::CounterConsume
} else {
return None;
};
Some((kind, binding_after_as(line)))
}
fn parse_consume_line(line: &str) -> Option<String> {
let binding = line
.trim()
.trim_end_matches(';')
.strip_prefix("done ")?
.split("->")
.next()
.unwrap_or_default()
.trim();
let mut chars = binding.chars();
let first = chars.next()?;
if !(first.is_ascii_alphabetic() || first == '_') {
return None;
}
chars
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
.then(|| binding.to_owned())
}
fn binding_after_multiline_string_end(line: &str) -> Option<String> {
line.strip_prefix("\"\"\"")
.and_then(|rest| rest.trim().strip_prefix("as "))
.and_then(|rest| rest.split_whitespace().next())
.map(|binding| binding.trim_matches(|ch: char| !ch.is_alphanumeric() && ch != '_'))
.filter(|binding| !binding.is_empty())
.map(str::to_owned)
}
fn validate_rule_prompt_content_type_annotation(
rule: &RuleDecl,
line: &str,
diagnostics: &mut Vec<Diagnostic>,
) {
if !(line.starts_with("tell ") || line.starts_with("coerce ")) {
return;
}
let Some(annotation) = malformed_prompt_content_type_annotation(line) else {
return;
};
diagnostics.push(Diagnostic {
related: Vec::new(),
span: rule.body.span,
message: format!(
"rule `{}` has malformed multiline prompt content type `{annotation}`",
rule.name.name
),
suggestion: Some(
"write a supported token such as `\"\"\"markdown` or put prompt text on the next line"
.to_owned(),
),
});
}
fn validate_coerce_prompt_content_type_annotations(
coerce: &CoerceDecl,
diagnostics: &mut Vec<Diagnostic>,
) {
for line in coerce.body.text.lines().map(str::trim) {
if !line.starts_with("prompt ") {
continue;
}
let Some(annotation) = malformed_prompt_content_type_annotation(line) else {
continue;
};
diagnostics.push(Diagnostic { related: Vec::new(),
span: coerce.body.span,
message: format!(
"coerce `{}` has malformed multiline prompt content type `{annotation}`",
coerce.name.name
),
suggestion: Some(
"write a supported token such as `\"\"\"markdown` or put prompt text on the next line"
.to_owned(),
),
});
}
}
fn malformed_prompt_content_type_annotation(line: &str) -> Option<String> {
let (_, tail) = line.split_once("\"\"\"")?;
let candidate = tail.trim();
if candidate.is_empty() || candidate.contains("\"\"\"") {
return None;
}
let mut parts = candidate.split_whitespace();
let first = parts.next()?;
let has_extra_text = parts.next().is_some();
let first_is_supported = is_supported_prompt_content_type(first);
let first_is_annotation_shaped = first_is_supported || first.contains('/');
if has_extra_text && first_is_annotation_shaped {
return Some(candidate.to_owned());
}
if first.contains('/') && !first_is_supported {
return Some(first.to_owned());
}
None
}
fn is_supported_prompt_content_type(candidate: &str) -> bool {
if !is_prompt_content_type_token(candidate) {
return false;
}
let normalized = candidate.to_ascii_lowercase();
normalized.contains('/')
|| matches!(
normalized.as_str(),
"markdown" | "json" | "text" | "plain" | "html" | "xml" | "yaml" | "yml"
)
}
fn is_prompt_content_type_token(candidate: &str) -> bool {
let mut chars = candidate.chars();
let Some(first) = chars.next() else {
return false;
};
first.is_ascii_alphanumeric()
&& chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '+' | '-' | '_'))
}
pub(crate) fn binding_after_as(line: &str) -> Option<String> {
let mut tokens = line.split_whitespace();
while let Some(token) = tokens.next() {
if token == "as" {
return tokens
.next()
.map(|binding| binding.trim_matches(|ch: char| !ch.is_alphanumeric() && ch != '_'))
.filter(|binding| !binding.is_empty())
.map(str::to_owned);
}
}
None
}
fn parse_after_line(line: &str) -> Option<(String, DependencyPredicate)> {
let rest = line.strip_prefix("after ")?;
if rest.contains("=>") {
return None;
}
let before_body = rest.split('{').next().unwrap_or(rest).trim();
let mut parts = before_body.split_whitespace();
let binding = parts.next()?.to_owned();
let predicate = match parts.next()? {
"succeeds" => DependencyPredicate::Succeeds,
"fails" => DependencyPredicate::Fails,
"cancelled" => DependencyPredicate::Cancelled,
"times" => {
if parts.next()? != "out" {
return None;
}
DependencyPredicate::TimedOut
}
"completes" | "held" | "contended" | "ok" | "over" | "promoted" | "conflicted"
| "applied" | "stranded" => DependencyPredicate::Completes,
"reaches" => {
let rest = before_body.trim().strip_prefix(&binding)?.trim_start();
let after_kw = rest.strip_prefix("reaches")?.trim_start();
let quoted = after_kw.strip_prefix('"')?;
let close = quoted.find('"')?;
let tail = "ed[close + 1..];
let mut tail_parts = tail.split_whitespace();
match (tail_parts.next(), tail_parts.next(), tail_parts.next()) {
(None, None, None) => {}
(Some("as"), Some(alias), None) if is_identifier(alias) => {}
_ => return None,
}
return Some((binding, DependencyPredicate::Completes));
}
_ => return None,
};
match (parts.next(), parts.next(), parts.next()) {
(None, None, None) => {}
(Some("as"), Some(alias), None) if is_identifier(alias) => {}
_ => return None,
}
Some((binding, predicate))
}
pub(crate) fn is_identifier(value: &str) -> bool {
let mut chars = value.chars();
let Some(first) = chars.next() else {
return false;
};
(first.is_ascii_alphabetic() || first == '_')
&& chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
}
pub(crate) fn push_line(snapshot: &mut String, line: impl AsRef<str>) {
snapshot.push_str(line.as_ref());
snapshot.push('\n');
}
pub(crate) fn stable_hash(value: &str) -> String {
use sha2::Digest;
let digest = sha2::Sha256::digest(value.as_bytes());
let mut hex = String::with_capacity(32);
for byte in &digest[..16] {
hex.push_str(&format!("{byte:02x}"));
}
hex
}
pub fn parse_duration_seconds(value: &str) -> Option<f64> {
let value = value.strip_prefix('P')?;
let mut rest = value;
let mut seconds = 0.0;
let mut consumed = false;
let mut in_time = false;
while !rest.is_empty() {
if let Some(next) = rest.strip_prefix('T') {
if in_time {
return None;
}
in_time = true;
rest = next;
continue;
}
let number_len = rest
.char_indices()
.take_while(|(_, ch)| ch.is_ascii_digit() || *ch == '.')
.map(|(index, ch)| index + ch.len_utf8())
.last()?;
let number = rest[..number_len].parse::<f64>().ok()?;
if !number.is_finite() {
return None;
}
let unit = rest[number_len..].chars().next()?;
rest = &rest[number_len + unit.len_utf8()..];
let multiplier = match (in_time, unit) {
(false, 'D') => 86_400.0,
(true, 'H') => 3_600.0,
(true, 'M') => 60.0,
(true, 'S') => 1.0,
_ => return None,
};
seconds += number * multiplier;
consumed = true;
}
consumed.then_some(seconds)
}
pub fn parse_time_epoch_seconds(value: &str) -> Option<f64> {
if value.len() < 20 {
return None;
}
let year = parse_fixed_i32(value, 0, 4)?;
require_byte(value, 4, b'-')?;
let month = parse_fixed_u32(value, 5, 2)?;
require_byte(value, 7, b'-')?;
let day = parse_fixed_u32(value, 8, 2)?;
require_byte(value, 10, b'T')?;
let hour = parse_fixed_u32(value, 11, 2)?;
require_byte(value, 13, b':')?;
let minute = parse_fixed_u32(value, 14, 2)?;
require_byte(value, 16, b':')?;
let second = parse_fixed_u32(value, 17, 2)?;
let mut offset_start = 19;
let mut fractional_second = 0.0;
if value.as_bytes().get(offset_start).copied() == Some(b'.') {
let fraction_start = offset_start + 1;
let fraction_len = value[fraction_start..]
.char_indices()
.take_while(|(_, ch)| ch.is_ascii_digit())
.map(|(index, ch)| index + ch.len_utf8())
.last()?;
let fraction = &value[fraction_start..fraction_start + fraction_len];
let scale = 10_f64.powi(i32::try_from(fraction.len()).ok()?);
fractional_second = fraction.parse::<f64>().ok()? / scale;
offset_start = fraction_start + fraction_len;
}
if !(1..=12).contains(&month)
|| !(1..=days_in_month(year, month)).contains(&day)
|| hour > 23
|| minute > 59
|| second > 60
{
return None;
}
let offset_seconds = match value.as_bytes().get(offset_start).copied()? {
b'Z' if value.len() == offset_start + 1 => 0,
b'+' | b'-' if value.len() == offset_start + 6 => {
let sign = if value.as_bytes()[offset_start] == b'+' {
1
} else {
-1
};
let offset_hour = parse_fixed_i32(value, offset_start + 1, 2)?;
require_byte(value, offset_start + 3, b':')?;
let offset_minute = parse_fixed_i32(value, offset_start + 4, 2)?;
if offset_hour > 23 || offset_minute > 59 {
return None;
}
sign * (offset_hour * 3_600 + offset_minute * 60)
}
_ => return None,
};
let days = days_from_civil(year, month, day);
let local_seconds = days * 86_400 + i64::from(hour * 3_600 + minute * 60 + second.min(59));
Some((local_seconds - i64::from(offset_seconds)) as f64 + fractional_second)
}
fn parse_fixed_i32(value: &str, start: usize, len: usize) -> Option<i32> {
value.get(start..start + len)?.parse::<i32>().ok()
}
fn parse_fixed_u32(value: &str, start: usize, len: usize) -> Option<u32> {
value.get(start..start + len)?.parse::<u32>().ok()
}
fn require_byte(value: &str, index: usize, expected: u8) -> Option<()> {
(value.as_bytes().get(index).copied()? == expected).then_some(())
}
fn days_in_month(year: i32, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if is_leap_year(year) => 29,
2 => 28,
_ => 0,
}
}
fn is_leap_year(year: i32) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
fn days_from_civil(year: i32, month: u32, day: u32) -> i64 {
let year = year - i32::from(month <= 2);
let era = if year >= 0 { year } else { year - 399 } / 400;
let year_of_era = year - era * 400;
let month = month as i32;
let day = day as i32;
let day_of_year = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day - 1;
let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
i64::from(era * 146_097 + day_of_era - 719_468)
}
fn push_block_body(body: &str, formatted: &mut String) {
if body.is_empty() {
return;
}
for line in body.lines() {
if line.trim().is_empty() {
formatted.push('\n');
} else {
push_line(formatted, format!(" {}", line.trim_end()));
}
}
}
fn scan_braces(line: &str) -> (i32, bool) {
let bytes = line.as_bytes();
let mut index = 0;
let mut delta = 0i32;
let mut in_string = false;
while index < bytes.len() {
if in_string {
match bytes[index] {
b'\\' => index += 1,
b'"' => in_string = false,
_ => {}
}
index += 1;
continue;
}
if line[index..].starts_with("\"\"\"") {
match line[index + 3..].find("\"\"\"") {
Some(offset) => index += 3 + offset + 3,
None => return (delta, true),
}
continue;
}
match bytes[index] {
b'"' => in_string = true,
b'{' | b'[' | b'(' => delta += 1,
b'}' | b']' | b')' => delta -= 1,
_ => {}
}
index += 1;
}
(delta, false)
}
impl TypeSyntax {
fn to_source(&self) -> String {
match self {
Self::Primitive { name, .. } => name.clone(),
Self::LiteralString { value, .. } => format!("{value:?}"),
Self::Ref { name } => name.name.clone(),
Self::AgentRef { agents, .. } => {
let agents = agents
.iter()
.map(|agent| agent.name.as_str())
.collect::<Vec<_>>()
.join(" | ");
format!("AgentRef<{agents}>")
}
Self::Optional { inner, .. } => format!("{}?", inner.to_source()),
Self::Array { inner, .. } => format!("{}[]", inner.to_source()),
Self::Map { inner, .. } => format!("map<{}>", inner.to_source()),
Self::Union { variants, .. } => variants
.iter()
.map(Self::to_source)
.collect::<Vec<_>>()
.join(" | "),
}
}
}
#[cfg(test)]
#[path = "lib_tests/tests.rs"]
mod tests;