1mod action_expand;
8mod canonical;
9pub use canonical::{canonical_declarations, canonical_program_hash, DeclCanon};
10pub mod body;
11mod body_print;
12mod format;
13mod lowering;
14use format::*;
15pub use format::{format_program, format_program_preserving_comments, FormatOutput};
16use lowering::*;
17mod syntax;
18use syntax::*;
21pub use syntax::{lex_comments, parse_program, parser_stage, string_and_comment_spans};
22mod then_expand;
23
24use std::{
25 collections::{BTreeMap, BTreeSet, VecDeque},
26 fmt,
27};
28use whipplescript_core::{
29 ContractRegistry, EffectContract, LibraryRegistration, TypedOutputValidation,
30};
31
32#[derive(Clone, Copy, Debug, Eq, PartialEq)]
33pub struct SourceSpan {
34 pub start: usize,
35 pub end: usize,
36}
37
38impl SourceSpan {
39 fn join(self, other: Self) -> Self {
40 Self {
41 start: self.start,
42 end: other.end,
43 }
44 }
45}
46
47#[derive(Clone, Debug, Eq, PartialEq)]
48pub struct Diagnostic {
49 pub span: SourceSpan,
50 pub message: String,
51 pub suggestion: Option<String>,
52 pub related: Vec<RelatedInfo>,
58}
59
60#[derive(Clone, Debug, Eq, PartialEq)]
63pub struct RelatedInfo {
64 pub span: SourceSpan,
65 pub message: String,
66}
67
68impl Diagnostic {
69 pub fn with_related(mut self, span: SourceSpan, message: impl Into<String>) -> Self {
73 self.related.push(RelatedInfo {
74 span,
75 message: message.into(),
76 });
77 self
78 }
79}
80
81#[derive(Clone, Copy, Debug, Eq, PartialEq)]
84pub enum CommentMarker {
85 Hash,
87 Slash,
89}
90
91#[derive(Clone, Debug, Eq, PartialEq)]
96pub struct Comment {
97 pub marker: CommentMarker,
98 pub text: String,
99 pub span: SourceSpan,
100}
101
102#[derive(Clone, Debug, Eq, PartialEq)]
103pub struct Ident {
104 pub name: String,
105 pub span: SourceSpan,
106}
107
108#[derive(Clone, Debug, Eq, PartialEq)]
109pub struct StringLiteral {
110 pub value: String,
111 pub span: SourceSpan,
112}
113
114#[derive(Clone, Debug, Eq, PartialEq)]
115pub struct Program {
116 pub workflow: Option<Ident>,
117 pub workflow_tags: Vec<TagDecl>,
118 pub workflow_description: Option<StringLiteral>,
119 pub explicit_workflow_body: bool,
120 pub workflows: Vec<WorkflowDecl>,
121 pub patterns: Vec<PatternDecl>,
122 pub items: Vec<Item>,
123}
124
125#[derive(Clone, Debug, Eq, PartialEq)]
126pub struct WorkflowDecl {
127 pub name: Ident,
128 pub tags: Vec<TagDecl>,
129 pub description: Option<StringLiteral>,
130 pub items: Vec<Item>,
131 pub span: SourceSpan,
132}
133
134#[derive(Clone, Debug, Eq, PartialEq)]
135pub enum Item {
136 Include(IncludeDecl),
137 Use(UseDecl),
138 Pattern(PatternDecl),
139 Apply(ApplyDecl),
140 WorkflowContract(WorkflowContractDecl),
141 Harness(HarnessDecl),
142 Tracker(TrackerDecl),
143 Channel(ChannelDecl),
144 Credential(CredentialDecl),
145 Stream(StreamDecl),
146 Gauge(GaugeDecl),
147 Mark(MarkDecl),
148 Campaign(CampaignDecl),
149 FileStore(FileStoreDecl),
150 MemoryPool(MemoryPoolDecl),
151 Action(ActionDecl),
152 Agent(AgentDecl),
153 Enum(EnumDecl),
154 Event(EventDecl),
155 Source(Box<SourceDecl>),
158 Test(TestDecl),
159 Lease(LeaseDecl),
160 Ledger(LedgerDecl),
161 Counter(CounterDecl),
162 Class(ClassDecl),
163 Table(TableDecl),
164 Coerce(CoerceDecl),
165 Assert(AssertDecl),
166 Rule(RuleDecl),
167}
168
169impl Item {
170 fn span(&self) -> SourceSpan {
172 match self {
173 Self::Include(decl) => decl.path.span,
174 Self::Use(decl) => decl.name.span,
175 Self::Pattern(decl) => decl.span,
176 Self::Apply(decl) => decl.span,
177 Self::WorkflowContract(decl) => decl.span,
178 Self::Harness(decl) => decl.span,
179 Self::Tracker(decl) => decl.span,
180 Self::Channel(decl) => decl.span,
181 Self::Credential(decl) => decl.span,
182 Self::Stream(decl) => decl.span,
183 Self::Gauge(decl) => decl.span,
184 Self::Mark(decl) => decl.span,
185 Self::Campaign(decl) => decl.span,
186 Self::FileStore(decl) => decl.span,
187 Self::MemoryPool(decl) => decl.span,
188 Self::Action(decl) => decl.span,
189 Self::Agent(decl) => decl.span,
190 Self::Enum(decl) => decl.span,
191 Self::Event(decl) => decl.span,
192 Self::Source(decl) => decl.span,
193 Self::Test(decl) => decl.span,
194 Self::Lease(decl) => decl.span,
195 Self::Ledger(decl) => decl.span,
196 Self::Counter(decl) => decl.span,
197 Self::Class(decl) => decl.span,
198 Self::Table(decl) => decl.span,
199 Self::Coerce(decl) => decl.span,
200 Self::Assert(decl) => decl.span,
201 Self::Rule(decl) => decl.span,
202 }
203 }
204}
205
206#[derive(Clone, Debug, Eq, PartialEq)]
207pub struct PatternDecl {
208 pub name: Ident,
209 pub type_params: Vec<Ident>,
210 pub items: Vec<Item>,
211 pub span: SourceSpan,
212}
213
214#[derive(Clone, Debug, Eq, PartialEq)]
215pub struct ApplyDecl {
216 pub pattern: Ident,
217 pub type_args: Vec<TypeSyntax>,
218 pub alias: Ident,
219 pub body: BlockSource,
220 pub span: SourceSpan,
221}
222
223#[derive(Clone, Debug, Eq, PartialEq)]
224pub struct IncludeDecl {
225 pub path: StringLiteral,
226}
227
228#[derive(Clone, Debug, Eq, PartialEq)]
229pub struct WorkflowContractDecl {
230 pub kind: WorkflowContractKind,
231 pub name: Ident,
232 pub ty: TypeSyntax,
233 pub span: SourceSpan,
234}
235
236#[derive(Clone, Debug, Eq, PartialEq)]
237pub enum WorkflowContractKind {
238 Input,
239 Output,
240 Failure,
241}
242
243impl WorkflowContractKind {
244 fn as_str(&self) -> &'static str {
245 match self {
246 Self::Input => "input",
247 Self::Output => "output",
248 Self::Failure => "failure",
249 }
250 }
251}
252
253#[derive(Clone, Debug, Eq, PartialEq)]
254pub struct AssertDecl {
255 pub tags: Vec<TagDecl>,
256 pub description: Option<StringLiteral>,
257 pub expr: String,
258 pub span: SourceSpan,
259}
260
261#[derive(Clone, Debug, Eq, PartialEq)]
262pub struct TagDecl {
263 pub name: String,
264 pub span: SourceSpan,
265}
266
267#[derive(Clone, Debug, Eq, PartialEq)]
268pub struct UseDecl {
269 pub name: StringLiteral,
270}
271
272#[derive(Clone, Debug, Eq, PartialEq)]
273pub struct HarnessDecl {
274 pub name: Ident,
275 pub kind: Ident,
276 pub span: SourceSpan,
277}
278
279#[derive(Clone, Debug, Eq, PartialEq)]
280pub struct TrackerDecl {
281 pub name: Ident,
282 pub provider: Ident,
283 pub span: SourceSpan,
284}
285
286#[derive(Clone, Debug, Eq, PartialEq)]
293pub struct ChannelDecl {
294 pub name: Ident,
295 pub provider: Ident,
296 pub workspace: Option<Ident>,
297 pub destination: Option<StringLiteral>,
298 pub span: SourceSpan,
299}
300
301#[derive(Clone, Debug, Eq, PartialEq)]
310pub struct CredentialDecl {
311 pub name: Ident,
312 pub kind: Ident,
313 pub span: SourceSpan,
314}
315
316#[derive(Clone, Debug, Eq, PartialEq)]
325pub struct StreamDecl {
326 pub name: Ident,
327 pub members: Vec<Ident>,
328 pub staleness_seconds: Option<u64>,
329 pub span: SourceSpan,
330}
331
332#[derive(Clone, Debug, Eq, PartialEq)]
341pub struct MarkDecl {
342 pub name: StringLiteral,
343 pub site: String,
346 pub site_span: SourceSpan,
347 pub span: SourceSpan,
348}
349
350#[derive(Clone, Debug, Eq, PartialEq)]
359pub struct GaugeDecl {
360 pub name: Ident,
361 pub site: Option<String>,
365 pub site_span: Option<SourceSpan>,
366 pub judge: GaugeJudge,
367 pub expect: Option<GaugeBar>,
368 pub inputs: Vec<GaugeRef>,
372 pub span: SourceSpan,
373}
374
375#[derive(Clone, Debug, Eq, PartialEq)]
383pub enum GaugeJudge {
384 Coerce(Ident, Vec<String>),
385 Prompt(StringLiteral),
386 Exec(StringLiteral),
387 Labels(StringLiteral),
388}
389
390#[derive(Clone, Debug, Eq, PartialEq)]
396pub struct GaugeBar {
397 pub subject: GaugeBarSubject,
398 pub at_least: bool,
400 pub threshold: String,
401 pub span: SourceSpan,
402}
403
404#[derive(Clone, Debug, Eq, PartialEq)]
405pub enum GaugeBarSubject {
406 Chance { field: Ident },
408 Stat { stat: Ident },
410}
411
412#[derive(Clone, Debug, Eq, PartialEq)]
416pub struct GaugeRef {
417 pub name: String,
418 pub span: SourceSpan,
419}
420
421#[derive(Clone, Debug, Eq, PartialEq)]
427pub struct CampaignDecl {
428 pub name: Ident,
429 pub ascend: Vec<GaugeRef>,
430 pub reach: Vec<CampaignReach>,
431 pub guard: Vec<CampaignGuard>,
432 pub sacrifice: Vec<GaugeRef>,
433 pub proposer_redacted: bool,
437 pub span: SourceSpan,
438}
439
440#[derive(Clone, Debug, Eq, PartialEq)]
442pub struct CampaignReach {
443 pub gauge: GaugeRef,
444 pub at_least: bool,
445 pub threshold: String,
446 pub unit: Option<String>,
448 pub span: SourceSpan,
449}
450
451#[derive(Clone, Debug, Eq, PartialEq)]
453pub struct CampaignGuard {
454 pub gauge: GaugeRef,
455 pub band_percent: String,
456 pub span: SourceSpan,
457}
458
459#[derive(Clone, Debug, Eq, PartialEq)]
462pub struct FileStoreDecl {
463 pub name: Ident,
464 pub root: String,
465 pub read_globs: Vec<String>,
466 pub write_globs: Vec<String>,
467 pub provider: Option<Ident>,
471 pub root_span: Option<SourceSpan>,
475 pub read_span: Option<SourceSpan>,
476 pub write_span: Option<SourceSpan>,
477 pub provider_span: Option<SourceSpan>,
478 pub span: SourceSpan,
479}
480
481#[derive(Clone, Debug, Eq, PartialEq)]
487pub struct MemoryPoolDecl {
488 pub name: Ident,
489 pub context_limit: Option<u64>,
490 pub context_limit_span: Option<SourceSpan>,
493 pub span: SourceSpan,
494}
495
496#[derive(Clone, Debug, Eq, PartialEq)]
498pub struct ActionParam {
499 pub name: Ident,
500 pub ty: TypeSyntax,
501 pub span: SourceSpan,
502}
503
504#[derive(Clone, Debug, Eq, PartialEq)]
508pub struct ActionDecl {
509 pub name: Ident,
510 pub params: Vec<ActionParam>,
511 pub body: BlockSource,
512 pub span: SourceSpan,
513}
514
515#[derive(Clone, Debug, Eq, PartialEq)]
516pub struct AgentDecl {
517 pub name: Ident,
518 pub harness: Option<Ident>,
519 pub delegated_to: Option<Ident>,
523 pub fields: Vec<AgentField>,
524 pub span: SourceSpan,
525}
526
527#[derive(Clone, Debug, Eq, PartialEq)]
528pub enum AgentField {
529 Provider(Ident),
530 Profile(StringLiteral),
531 Capacity(u32, SourceSpan),
532 Skills(Vec<StringLiteral>, SourceSpan),
533 Capabilities(Vec<StringLiteral>, SourceSpan),
534 Requires(Vec<Ident>, SourceSpan),
539 Tools(Vec<Ident>, SourceSpan),
542 Compaction(Ident),
546 Thread(Ident),
551 Settings(Ident),
555 Unknown {
556 name: Ident,
557 span: SourceSpan,
558 },
559}
560
561#[derive(Clone, Debug, Eq, PartialEq)]
562pub struct EnumDecl {
563 pub name: Ident,
564 pub variants: Vec<EnumVariantDecl>,
565 pub span: SourceSpan,
566}
567
568#[derive(Clone, Debug, Eq, PartialEq)]
571pub struct EnumVariantDecl {
572 pub name: Ident,
573 pub fields: Vec<ClassField>,
574 pub span: SourceSpan,
575}
576
577#[derive(Clone, Debug, Eq, PartialEq)]
578pub struct ClassDecl {
579 pub name: Ident,
580 pub fields: Vec<ClassField>,
581 pub span: SourceSpan,
582}
583
584#[derive(Clone, Debug, Eq, PartialEq)]
588pub struct LeaseDecl {
589 pub name: Ident,
590 pub key_type: Ident,
591 pub slots: u32,
592 pub ttl_seconds: u64,
593 pub shared: bool,
594 pub span: SourceSpan,
595}
596
597#[derive(Clone, Debug, Eq, PartialEq)]
598pub struct LedgerDecl {
599 pub name: Ident,
600 pub entry_schema: Ident,
601 pub partition_field: Ident,
602 pub retain_seconds: u64,
603 pub shared: bool,
604 pub span: SourceSpan,
605}
606
607#[derive(Clone, Debug, Eq, PartialEq)]
608pub struct CounterDecl {
609 pub name: Ident,
610 pub key_type: Ident,
611 pub cap: i64,
612 pub reset: String,
613 pub timezone: Option<String>,
616 pub shared: bool,
617 pub span: SourceSpan,
618}
619
620#[derive(Clone, Debug, Eq, PartialEq)]
624pub struct EventDecl {
625 pub name: String,
627 pub name_span: SourceSpan,
628 pub fields: Vec<ClassField>,
629 pub span: SourceSpan,
630}
631
632#[derive(Clone, Debug, Eq, PartialEq)]
633pub struct ClassField {
634 pub name: Ident,
635 pub ty: TypeSyntax,
636 pub is_key: bool,
639 pub presence_condition: Option<(String, String)>,
643 pub span: SourceSpan,
644}
645
646#[derive(Clone, Debug, Eq, PartialEq)]
653pub struct SourceDecl {
654 pub name: Ident,
656 pub provider: Ident,
658 pub clock: Option<ClockPolicy>,
660 pub path: Option<StringLiteral>,
665 pub watch: Option<StringLiteral>,
671 pub url: Option<StringLiteral>,
675 pub dedup: Option<SourceValue>,
681 pub observe_binding: Ident,
683 pub emit: SourceEmit,
686 pub span: SourceSpan,
687}
688
689#[derive(Clone, Debug, Eq, PartialEq)]
690pub struct ClockPolicy {
691 pub recurrence: Recurrence,
692 pub timezone: Option<StringLiteral>,
693 pub missed: Option<MissedPolicy>,
694 pub span: SourceSpan,
695}
696
697#[derive(Clone, Debug, Eq, PartialEq)]
699pub enum Recurrence {
700 At { time: TimeOfDay, span: SourceSpan },
702 EveryDuration {
704 seconds: u64,
705 source: String,
706 span: SourceSpan,
707 },
708 EveryCalendar {
710 pattern: CalendarPattern,
711 time: TimeOfDay,
712 span: SourceSpan,
713 },
714}
715
716#[derive(Clone, Copy, Debug, Eq, PartialEq)]
717pub enum CalendarPattern {
718 Day,
719 Weekday,
720 Weekly(Weekday),
721}
722
723#[derive(Clone, Copy, Debug, Eq, PartialEq)]
724pub enum Weekday {
725 Monday,
726 Tuesday,
727 Wednesday,
728 Thursday,
729 Friday,
730 Saturday,
731 Sunday,
732}
733
734#[derive(Clone, Copy, Debug, Eq, PartialEq)]
735pub struct TimeOfDay {
736 pub hour: u8,
737 pub minute: u8,
738 pub span: SourceSpan,
739}
740
741#[derive(Clone, Copy, Debug, Eq, PartialEq)]
744pub enum MissedPolicy {
745 Skip,
746 Coalesce,
747 CatchUp { limit: u32 },
748}
749
750#[derive(Clone, Debug, Eq, PartialEq)]
751pub struct SourceEmit {
752 pub signal: String,
754 pub signal_span: SourceSpan,
755 pub from: Option<Ident>,
759 pub fields: Vec<SourceEmitField>,
760 pub span: SourceSpan,
761}
762
763#[derive(Clone, Debug, Eq, PartialEq)]
764pub struct SourceEmitField {
765 pub name: Ident,
766 pub value: SourceValue,
767 pub span: SourceSpan,
768}
769
770#[derive(Clone, Debug, Eq, PartialEq)]
773pub enum SourceValue {
774 Path {
775 binding: Ident,
776 segments: Vec<Ident>,
777 span: SourceSpan,
778 },
779 String(StringLiteral),
780 Number(String, SourceSpan),
781}
782
783#[derive(Clone, Debug, Eq, PartialEq)]
786pub struct TestDecl {
787 pub name: StringLiteral,
788 pub workflow: Option<Ident>,
792 pub clauses: Vec<TestClause>,
793 pub span: SourceSpan,
794}
795
796#[derive(Clone, Debug, Eq, PartialEq)]
797pub enum TestClause {
798 Given(GivenClause),
799 Stub(StubClause),
800 Run(RunClause),
801 Expect(ExpectClause),
802}
803
804#[derive(Clone, Debug, Eq, PartialEq)]
808pub struct TestField {
809 pub name: Ident,
810 pub value: String,
811 pub span: SourceSpan,
812}
813
814#[derive(Clone, Debug, Eq, PartialEq)]
815pub enum GivenClause {
816 Input {
817 fields: Vec<TestField>,
818 span: SourceSpan,
819 },
820 Fact {
821 ty: Ident,
822 fields: Vec<TestField>,
823 span: SourceSpan,
824 },
825 Signal {
826 name: String,
827 fields: Vec<TestField>,
828 span: SourceSpan,
829 },
830 Clock {
831 at: StringLiteral,
832 span: SourceSpan,
833 },
834 Tracker {
835 tracker: String,
836 fields: Vec<TestField>,
837 span: SourceSpan,
838 },
839 File {
843 store: String,
844 path: StringLiteral,
845 content: StringLiteral,
846 span: SourceSpan,
847 },
848}
849
850#[derive(Clone, Debug, Eq, PartialEq)]
853pub struct StubClause {
854 pub surface: Vec<String>,
857 pub outcome: String,
858 pub payload: Option<StubPayload>,
859 pub span: SourceSpan,
860}
861
862#[derive(Clone, Debug, Eq, PartialEq)]
863pub enum StubPayload {
864 Record(Vec<TestField>),
865 Message(StringLiteral),
866}
867
868#[derive(Clone, Debug, Eq, PartialEq)]
869pub struct RunClause {
870 pub kind: RunKind,
871 pub span: SourceSpan,
872}
873
874#[derive(Clone, Debug, Eq, PartialEq)]
875pub enum RunKind {
876 UntilIdle,
877 UntilWorkflowCompleted,
878 UntilWorkflowFailed,
879 ForSteps(u32),
880}
881
882#[derive(Clone, Debug, Eq, PartialEq)]
883pub struct ExpectClause {
884 pub target: ExpectTarget,
885 pub span: SourceSpan,
886}
887
888#[derive(Clone, Debug, Eq, PartialEq)]
889pub enum ExpectTarget {
890 WorkflowCompleted,
891 WorkflowFailed { failure: Option<Ident> },
892 Rule { name: Ident, status: RuleStatus },
893 Effect { name: String, status: EffectStatus },
894 Diagnostic { code: String },
895 NoEffect { name: String },
896 Projection(ProjQuery),
897}
898
899#[derive(Clone, Debug, Eq, PartialEq)]
900pub enum RuleStatus {
901 Fired,
902 FiredTimes(u32),
903 DidNotFire,
904}
905
906#[derive(Clone, Debug, Eq, PartialEq)]
907pub enum EffectStatus {
908 Requested,
909 Completed,
910 Failed,
911}
912
913#[derive(Clone, Debug, Eq, PartialEq)]
918pub struct ProjQuery {
919 pub noun: String,
920 pub kind: ProjQueryKind,
921 pub span: SourceSpan,
922}
923
924#[derive(Clone, Debug, Eq, PartialEq)]
925pub enum ProjQueryKind {
926 Exists,
927 Count { predicate: String, count: u32 },
928 Where { predicate: String },
929}
930
931#[derive(Clone, Debug, Eq, PartialEq)]
932pub struct TableDecl {
933 pub name: Ident,
934 pub tags: Vec<TagDecl>,
935 pub description: Option<StringLiteral>,
936 pub schema: Ident,
937 pub rows: Vec<TableRow>,
938 pub span: SourceSpan,
939}
940
941#[derive(Clone, Debug, Eq, PartialEq)]
942pub struct TableRow {
943 pub body: BlockSource,
944 pub span: SourceSpan,
945}
946
947#[derive(Clone, Debug, Eq, PartialEq)]
948pub struct CoerceDecl {
949 pub name: Ident,
950 pub params: Vec<ParamDecl>,
951 pub output: TypeSyntax,
952 pub body: BlockSource,
953 pub span: SourceSpan,
954}
955
956#[derive(Clone, Debug, Eq, PartialEq)]
957pub struct ParamDecl {
958 pub name: Ident,
959 pub ty: TypeSyntax,
960 pub span: SourceSpan,
961}
962
963#[derive(Clone, Debug, Eq, PartialEq)]
964pub enum TypeSyntax {
965 Primitive {
966 name: String,
967 span: SourceSpan,
968 },
969 LiteralString {
970 value: String,
971 span: SourceSpan,
972 },
973 Ref {
974 name: Ident,
975 },
976 AgentRef {
977 agents: Vec<Ident>,
978 span: SourceSpan,
979 },
980 Optional {
981 inner: Box<TypeSyntax>,
982 span: SourceSpan,
983 },
984 Array {
985 inner: Box<TypeSyntax>,
986 span: SourceSpan,
987 },
988 Map {
989 inner: Box<TypeSyntax>,
990 span: SourceSpan,
991 },
992 Union {
993 variants: Vec<TypeSyntax>,
994 span: SourceSpan,
995 },
996}
997
998impl TypeSyntax {
999 fn span(&self) -> SourceSpan {
1000 match self {
1001 Self::Primitive { span, .. }
1002 | Self::LiteralString { span, .. }
1003 | Self::Optional { span, .. }
1004 | Self::Array { span, .. }
1005 | Self::Map { span, .. }
1006 | Self::Union { span, .. }
1007 | Self::AgentRef { span, .. } => *span,
1008 Self::Ref { name } => name.span,
1009 }
1010 }
1011}
1012
1013#[derive(Clone, Debug, Eq, PartialEq)]
1014pub struct RuleDecl {
1015 pub name: Ident,
1016 pub tags: Vec<TagDecl>,
1017 pub description: Option<StringLiteral>,
1018 pub whens: Vec<WhenClause>,
1019 pub body: BlockSource,
1020 pub span: SourceSpan,
1021}
1022
1023#[derive(Clone, Debug, Eq, PartialEq)]
1024pub struct WhenClause {
1025 pub text: String,
1026 pub span: SourceSpan,
1027}
1028
1029#[derive(Clone, Debug, Eq, PartialEq)]
1030pub struct BlockSource {
1031 pub text: String,
1032 pub span: SourceSpan,
1033}
1034
1035#[derive(Clone, Debug, Eq, PartialEq)]
1036pub struct ParseOutput {
1037 pub program: Program,
1038 pub diagnostics: Vec<Diagnostic>,
1039}
1040
1041#[derive(Clone, Debug, Eq, PartialEq)]
1042pub struct CompileOutput {
1043 pub ir: Option<IrProgram>,
1044 pub diagnostics: Vec<Diagnostic>,
1045 pub warnings: Vec<Diagnostic>,
1047}
1048
1049#[derive(Clone, Debug, Eq, PartialEq)]
1050pub struct IrProgram {
1051 pub workflow: String,
1052 pub source_tags: Vec<IrSourceTag>,
1053 pub source_descriptions: Vec<IrSourceDescription>,
1054 pub includes: Vec<IrInclude>,
1055 pub pattern_applications: Vec<IrPatternApplication>,
1056 pub workflow_contracts: Vec<IrWorkflowContract>,
1057 pub uses: Vec<IrUse>,
1058 pub harnesses: Vec<IrHarness>,
1059 pub trackers: Vec<IrTracker>,
1060 pub streams: Vec<IrStream>,
1061 pub channels: Vec<IrChannel>,
1062 pub credentials: Vec<IrCredential>,
1063 pub gauges: Vec<IrGauge>,
1064 pub marks: Vec<IrMark>,
1065 pub campaigns: Vec<IrCampaign>,
1066 pub file_stores: Vec<IrFileStore>,
1067 pub memory_pools: Vec<IrMemoryPool>,
1068 pub events: Vec<IrEvent>,
1069 pub sources: Vec<IrSource>,
1070 pub tests: Vec<IrTest>,
1071 pub leases: Vec<IrLease>,
1072 pub ledgers: Vec<IrLedger>,
1073 pub counters: Vec<IrCounter>,
1074 pub shared_coordination_usage: Vec<IrSharedCoordinationUsage>,
1075 pub schemas: Vec<IrSchema>,
1076 pub agents: Vec<IrAgent>,
1077 pub coerces: Vec<IrCoerce>,
1078 pub assertions: Vec<IrAssertion>,
1079 pub rules: Vec<IrRule>,
1080 pub rule_dependencies: Vec<IrRuleDependency>,
1081}
1082
1083#[derive(Clone, Debug, Eq, PartialEq)]
1084pub struct IrSharedCoordinationUsage {
1085 pub resource: String,
1086 pub workflow_principals: Vec<String>,
1087}
1088
1089#[derive(Clone, Debug, Eq, PartialEq)]
1090pub struct IrSourceTag {
1091 pub name: String,
1092 pub target_kind: String,
1093 pub target: String,
1094 pub span: SourceSpan,
1095}
1096
1097#[derive(Clone, Debug, Eq, PartialEq)]
1098pub struct IrSourceDescription {
1099 pub value: String,
1100 pub target_kind: String,
1101 pub target: String,
1102 pub span: SourceSpan,
1103}
1104
1105#[derive(Clone, Debug, Eq, PartialEq)]
1106pub struct IrPatternApplication {
1107 pub pattern: String,
1108 pub alias: String,
1109 pub type_args: Vec<IrType>,
1110 pub value_args: Vec<IrPatternArgument>,
1111 pub generated: Vec<String>,
1112 pub definition_span: SourceSpan,
1115 pub application_span: SourceSpan,
1117}
1118
1119#[derive(Clone, Debug, Eq, PartialEq)]
1120pub struct IrPatternArgument {
1121 pub name: String,
1122 pub value: String,
1123}
1124
1125#[derive(Clone, Debug, Eq, PartialEq)]
1126pub struct IrWorkflowContract {
1127 pub kind: IrWorkflowContractKind,
1128 pub name: String,
1129 pub ty: IrType,
1130 pub span: SourceSpan,
1131}
1132
1133#[derive(Clone, Debug, Eq, PartialEq)]
1134pub enum IrWorkflowContractKind {
1135 Input,
1136 Output,
1137 Failure,
1138}
1139
1140impl IrWorkflowContractKind {
1141 fn as_str(&self) -> &'static str {
1142 match self {
1143 Self::Input => "input",
1144 Self::Output => "output",
1145 Self::Failure => "failure",
1146 }
1147 }
1148}
1149
1150#[derive(Clone, Debug, Eq, PartialEq)]
1151pub struct IrInclude {
1152 pub path: String,
1153 pub source_hash: Option<String>,
1154}
1155
1156#[derive(Clone, Debug, Eq, PartialEq)]
1157pub struct IrAssertion {
1158 pub expr: IrExpression,
1159 pub projection_reads: Vec<IrProjectionRead>,
1160}
1161
1162#[derive(Clone, Debug, Eq, PartialEq)]
1163pub struct IrExpression {
1164 pub source: String,
1165 pub expr: Expr,
1166 pub span: SourceSpan,
1167}
1168
1169#[derive(Clone, Debug, Eq, PartialEq)]
1170pub struct IrUse {
1171 pub kind: IrUseKind,
1172 pub name: String,
1173}
1174
1175#[derive(Clone, Debug, Eq, PartialEq)]
1176pub enum IrUseKind {
1177 Package,
1178}
1179
1180#[derive(Clone, Debug, Eq, PartialEq)]
1183pub struct IrStream {
1184 pub name: String,
1185 pub members: Vec<String>,
1186 pub member_spans: Vec<SourceSpan>,
1187 pub staleness_seconds: Option<u64>,
1188 pub span: SourceSpan,
1189}
1190
1191#[derive(Clone, Debug, Eq, PartialEq)]
1192pub struct IrTracker {
1193 pub name: String,
1194 pub provider: String,
1195 pub span: SourceSpan,
1196}
1197
1198#[derive(Clone, Debug, Eq, PartialEq)]
1202pub struct IrChannel {
1203 pub name: String,
1204 pub provider: String,
1205 pub workspace: Option<String>,
1206 pub destination: Option<String>,
1207 pub span: SourceSpan,
1208}
1209
1210#[derive(Clone, Debug, Eq, PartialEq)]
1215pub struct IrCredential {
1216 pub name: String,
1217 pub kind: String,
1219 pub span: SourceSpan,
1220}
1221
1222#[derive(Clone, Debug, Eq, PartialEq)]
1226pub struct IrMark {
1227 pub name: String,
1228 pub site: String,
1229 pub span: SourceSpan,
1230}
1231
1232#[derive(Clone, Debug, Eq, PartialEq)]
1237pub struct IrGauge {
1238 pub name: String,
1239 pub site: Option<String>,
1240 pub judge_kind: String,
1242 pub judge_target: String,
1245 pub judge_args: Vec<String>,
1250 pub expect: Option<IrGaugeBar>,
1251 pub inputs: Vec<String>,
1252 pub span: SourceSpan,
1253}
1254
1255#[derive(Clone, Debug, Eq, PartialEq)]
1259pub struct IrGaugeBar {
1260 pub form: String,
1261 pub subject: String,
1262 pub op: String,
1263 pub threshold: String,
1264}
1265
1266#[derive(Clone, Debug, Eq, PartialEq)]
1270pub struct IrCampaign {
1271 pub name: String,
1272 pub ascend: Vec<String>,
1273 pub reach: Vec<IrCampaignReach>,
1274 pub guard: Vec<IrCampaignGuard>,
1275 pub sacrifice: Vec<String>,
1276 pub proposer_redacted: bool,
1278 pub span: SourceSpan,
1279}
1280
1281#[derive(Clone, Debug, Eq, PartialEq)]
1282pub struct IrCampaignReach {
1283 pub gauge: String,
1284 pub op: String,
1285 pub threshold: String,
1286 pub unit: Option<String>,
1287}
1288
1289#[derive(Clone, Debug, Eq, PartialEq)]
1290pub struct IrCampaignGuard {
1291 pub gauge: String,
1292 pub band_percent: String,
1293}
1294
1295#[derive(Clone, Debug, Eq, PartialEq)]
1298pub struct IrFileStore {
1299 pub name: String,
1300 pub root: String,
1301 pub read_globs: Vec<String>,
1305 pub write_globs: Vec<String>,
1310 pub provider: Option<String>,
1314}
1315
1316#[derive(Clone, Debug, Eq, PartialEq)]
1320pub struct IrMemoryPool {
1321 pub name: String,
1322 pub context_limit: Option<u64>,
1325}
1326
1327#[derive(Clone, Debug, Eq, PartialEq)]
1328pub struct IrHarness {
1329 pub name: String,
1330 pub kind: String,
1331 pub span: SourceSpan,
1332}
1333
1334#[derive(Clone, Debug, Eq, PartialEq)]
1335pub enum IrSchema {
1336 Enum(IrEnum),
1337 Class(IrClass),
1338}
1339
1340#[derive(Clone, Debug, Eq, PartialEq)]
1341pub struct IrEnum {
1342 pub name: String,
1343 pub variants: Vec<String>,
1344 pub span: SourceSpan,
1345}
1346
1347#[derive(Clone, Debug, Eq, PartialEq)]
1348pub struct IrClass {
1349 pub name: String,
1350 pub fields: Vec<IrClassField>,
1351 pub span: SourceSpan,
1352}
1353
1354#[derive(Clone, Debug, Eq, PartialEq)]
1357pub struct IrEvent {
1358 pub name: String,
1359 pub fields: Vec<IrClassField>,
1360 pub span: SourceSpan,
1361}
1362
1363#[derive(Clone, Debug, Eq, PartialEq)]
1367pub struct IrSource {
1368 pub name: String,
1369 pub provider: String,
1370 pub is_clock: bool,
1371 pub is_file: bool,
1374 pub is_http: bool,
1378 pub recurrence: Option<Recurrence>,
1379 pub timezone: Option<String>,
1380 pub missed: Option<MissedPolicy>,
1381 pub path: Option<String>,
1384 pub watch: Option<String>,
1387 pub url: Option<String>,
1389 pub dedup_field: Option<String>,
1393 pub observe_binding: String,
1394 pub emit_signal: String,
1395 pub emit_from: Option<String>,
1399 pub emit_fields: Vec<IrSourceEmitField>,
1400 pub span: SourceSpan,
1401}
1402
1403#[derive(Clone, Debug, Eq, PartialEq)]
1404pub struct IrSourceEmitField {
1405 pub name: String,
1406 pub value: SourceValue,
1407 pub span: SourceSpan,
1408}
1409
1410#[derive(Clone, Debug, Eq, PartialEq)]
1414pub struct IrTest {
1415 pub name: String,
1416 pub workflow: Option<String>,
1417 pub clauses: Vec<TestClause>,
1418 pub span: SourceSpan,
1419}
1420
1421#[derive(Clone, Debug, Eq, PartialEq)]
1423pub struct IrLease {
1424 pub name: String,
1425 pub key_type: String,
1426 pub slots: u32,
1427 pub ttl_seconds: u64,
1428 pub shared: bool,
1429 pub span: SourceSpan,
1430}
1431
1432#[derive(Clone, Debug, Eq, PartialEq)]
1433pub struct IrLedger {
1434 pub name: String,
1435 pub entry_schema: String,
1436 pub partition_field: String,
1437 pub retain_seconds: u64,
1438 pub shared: bool,
1439 pub span: SourceSpan,
1440}
1441
1442#[derive(Clone, Debug, Eq, PartialEq)]
1443pub struct IrCounter {
1444 pub name: String,
1445 pub key_type: String,
1446 pub cap: i64,
1447 pub reset: String,
1448 pub timezone: Option<String>,
1450 pub shared: bool,
1451 pub span: SourceSpan,
1452}
1453
1454#[derive(Clone, Debug, Eq, PartialEq)]
1455pub struct IrClassField {
1456 pub name: String,
1457 pub ty: IrType,
1458 pub is_key: bool,
1460 pub presence_condition: Option<(String, String)>,
1464 pub span: SourceSpan,
1465}
1466
1467#[derive(Clone, Debug, Eq, PartialEq)]
1468pub enum IrType {
1469 Primitive(IrPrimitiveType),
1470 LiteralString(String),
1471 Ref(String),
1472 AgentRef(Vec<String>),
1473 Object(Vec<IrClassField>),
1474 Optional(Box<IrType>),
1475 Array(Box<IrType>),
1476 Map(Box<IrType>),
1477 Union(Vec<IrType>),
1478}
1479
1480#[derive(Clone, Debug, Eq, PartialEq)]
1481pub enum IrPrimitiveType {
1482 String,
1483 Int,
1484 Float,
1485 Bool,
1486 Null,
1487 Duration,
1488 Time,
1489 Image,
1490 Audio,
1491 Pdf,
1492 Video,
1493 Secret,
1498}
1499
1500#[derive(Clone, Debug, Eq, PartialEq)]
1501pub struct IrAgent {
1502 pub name: String,
1503 pub span: SourceSpan,
1511 pub harness: Option<String>,
1512 pub provider: Option<String>,
1513 pub profile: Option<String>,
1514 pub capacity: Option<u32>,
1515 pub skills: Vec<String>,
1516 pub capabilities: Vec<String>,
1517 pub requires: Vec<String>,
1521 pub tools: Vec<String>,
1523 pub compaction: Option<String>,
1527 pub thread: Option<String>,
1530 pub settings: Option<String>,
1534 pub harness_class: HarnessClass,
1538}
1539
1540#[derive(Clone, Debug, Eq, PartialEq)]
1541pub struct IrCoerce {
1542 pub name: String,
1543 pub span: SourceSpan,
1547 pub params: Vec<IrParam>,
1548 pub output: IrType,
1549 pub body: String,
1550 pub provider: Option<String>,
1559}
1560
1561#[derive(Clone, Debug, Eq, PartialEq)]
1562pub struct IrParam {
1563 pub name: String,
1564 pub ty: IrType,
1565}
1566
1567#[derive(Clone, Debug, Eq, PartialEq)]
1568pub struct IrRule {
1569 pub name: String,
1570 pub whens: Vec<IrWhen>,
1571 pub body: String,
1572 pub metadata: IrRuleMetadata,
1573}
1574
1575#[derive(Clone, Debug, Eq, PartialEq)]
1576pub struct IrWhen {
1577 pub source: String,
1578 pub pattern: String,
1579 pub guard: Option<IrExpression>,
1580 pub span: SourceSpan,
1581}
1582
1583#[derive(Clone, Debug, Eq, PartialEq)]
1584pub struct IrRuleDependency {
1585 pub producer: String,
1586 pub consumer: String,
1587 pub fact: String,
1588}
1589
1590#[derive(Clone, Debug, Eq, PartialEq)]
1593pub struct IrRegionEffect {
1594 pub binding: String,
1595 pub scope: Option<(String, String)>,
1596}
1597
1598#[derive(Clone, Debug, Eq, PartialEq)]
1606pub struct IrRegion {
1607 pub until: bool,
1608 pub condition: String,
1611 pub lapse_binding: Option<String>,
1612 pub effects: Vec<IrRegionEffect>,
1613 pub body_removed: String,
1614 pub body_lapsed: String,
1615 pub arm_content: String,
1620 pub arm_case_arms: Vec<(String, String)>,
1624}
1625
1626#[derive(Clone, Debug, Default, Eq, PartialEq)]
1627pub struct IrRuleMetadata {
1628 pub fact_reads: Vec<String>,
1629 pub projection_reads: Vec<IrProjectionRead>,
1630 pub fact_writes: Vec<String>,
1631 pub record_sources: Vec<IrRecordSource>,
1632 pub fact_consumes: Vec<String>,
1633 pub effects: Vec<IrEffectNode>,
1634 pub dependencies: Vec<IrEffectDependency>,
1635 pub region: Option<IrRegion>,
1637 pub case_branches: Vec<IrRuleCaseBranch>,
1638 pub terminal_outputs: Vec<IrTerminalOutput>,
1639 pub terminal_branches: Vec<IrTerminalCaseBranch>,
1640 pub terminal_completes: Vec<String>,
1647 pub redactions: Vec<IrRedaction>,
1655 pub egress_payload_reads: BTreeMap<String, BTreeSet<String>>,
1664 pub declassified_roots: BTreeSet<String>,
1672 pub endorsed_roots: BTreeSet<String>,
1677 pub endorsed_claim_items: BTreeSet<String>,
1687 pub record_field_reads: BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
1697 pub coerce_input_roots: BTreeMap<String, BTreeSet<String>>,
1705 pub after_aliases: BTreeMap<String, String>,
1710 pub egress_case_influence: BTreeMap<String, BTreeSet<String>>,
1716 pub complete_field_reads: BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
1725 pub milestone_field_reads: BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
1731 pub bounded_egresses: Vec<IrBoundedEgress>,
1735 pub max_after_depth: usize,
1739}
1740
1741#[derive(Clone, Debug, Eq, PartialEq)]
1748pub struct IrBoundedEgress {
1749 pub sink: String,
1751 pub source_schema: String,
1754 pub keep: Vec<String>,
1756}
1757
1758#[derive(Clone, Debug, Eq, PartialEq)]
1762pub struct IrRedaction {
1763 pub source: String,
1764 pub keep: Vec<String>,
1765 pub binding: String,
1766 pub source_schema: Option<String>,
1774}
1775
1776#[derive(Clone, Debug, Eq, PartialEq)]
1777pub struct IrRecordSource {
1778 pub schema: String,
1779 pub construct: String,
1780 pub span: SourceSpan,
1781}
1782
1783#[derive(Clone, Debug, Eq, PartialEq)]
1784pub struct IrProjectionRead {
1785 pub kind: QueryKind,
1786 pub head: String,
1787 pub guard: Option<String>,
1788}
1789
1790impl IrProjectionRead {
1791 fn to_snapshot(&self) -> String {
1792 let prefix = match self.kind {
1793 QueryKind::Fact => format!("fact:{}", self.head),
1794 QueryKind::Effect => format!("effect:{}", self.head),
1795 };
1796 match &self.guard {
1797 Some(guard) => format!("{prefix} where {guard}"),
1798 None => prefix,
1799 }
1800 }
1801}
1802
1803#[derive(Clone, Debug, Eq, PartialEq)]
1804pub struct IrEffectNode {
1805 pub id: String,
1806 pub kind: IrEffectKind,
1807 pub binding: Option<String>,
1808 pub required_capabilities: Vec<String>,
1809 pub construct_use: Option<IrConstructUse>,
1810 pub idempotency_key: String,
1811 pub span: SourceSpan,
1812 pub timeout_seconds: Option<u64>,
1814 pub access_grants: Vec<IrAccessGrant>,
1817 pub turn_skills: Vec<String>,
1821 pub on_stream: Option<String>,
1824 pub selection_source: Option<String>,
1828 pub transport_onto: Option<String>,
1831 pub resource: Option<String>,
1836 pub agent: Option<String>,
1840 pub coerce_target: Option<String>,
1846 pub workflow_target: Option<String>,
1850 pub endorsed: bool,
1854 pub declassified: bool,
1858 pub selected_by: Option<(String, String)>,
1864 pub exec_target: Option<IrExecTarget>,
1870}
1871
1872#[derive(Clone, Debug, Eq, PartialEq)]
1876pub enum IrExecTarget {
1877 Raw,
1878 Capability { name: String },
1879}
1880
1881#[derive(Clone, Debug, Eq, PartialEq)]
1884pub struct IrAccessGrant {
1885 pub resource: String,
1886 pub operations: Vec<IrAccessGrantOp>,
1887}
1888
1889#[derive(Clone, Debug, Eq, PartialEq)]
1890pub struct IrAccessGrantOp {
1891 pub operation: String,
1892 pub target: Option<String>,
1893 pub globs: Vec<String>,
1894}
1895
1896#[derive(Clone, Debug, Eq, PartialEq)]
1897pub struct IrConstructUse {
1898 pub keyword: String,
1899 pub scope: String,
1900 pub construct_family: String,
1901 pub lowering_target: String,
1902 pub target_capability: String,
1903}
1904
1905#[derive(Clone, Debug, Eq, PartialEq)]
1906pub enum IrEffectKind {
1907 AgentTell,
1908 SchemaCoerce,
1909 CapabilityCall,
1910 EventEmit,
1911 WorkflowInvoke,
1912 TimerWait,
1913 ExecCommand,
1914 TrackerFile,
1915 TrackerClaim,
1916 TrackerRenew,
1917 TrackerRelease,
1918 TrackerFinish,
1919 LeaseAcquire,
1920 LeaseRenew,
1921 LedgerAppend,
1922 CounterConsume,
1923 SignalEmit,
1924 FileRead,
1925 FileWrite,
1926 FileImport,
1927 FileExport,
1928}
1929
1930#[derive(Clone, Debug, Eq, PartialEq)]
1931pub struct IrEffectDependency {
1932 pub upstream: String,
1933 pub predicate: DependencyPredicate,
1934 pub downstream: String,
1935}
1936
1937#[derive(Clone, Debug, Eq, PartialEq)]
1938pub struct IrRuleCaseBranch {
1939 pub scrutinee: String,
1940 pub scrutinee_type: IrType,
1941 pub pattern: IrCasePattern,
1942 pub guard: Option<IrExpression>,
1943 pub body_hash: String,
1944 pub pattern_span: SourceSpan,
1945}
1946
1947#[derive(Clone, Debug, Eq, PartialEq)]
1948pub enum IrCasePattern {
1949 EnumVariant(String),
1950 LiteralString(String),
1951 Agent(String),
1952 OptionalSome { binding: String },
1953 OptionalNone,
1954 Wildcard,
1955}
1956
1957impl IrCasePattern {
1958 fn to_snapshot(&self) -> String {
1959 match self {
1960 IrCasePattern::EnumVariant(value) => format!("enum:{value}"),
1961 IrCasePattern::LiteralString(value) => format!("literal:\"{value}\""),
1962 IrCasePattern::Agent(value) => format!("agent:{value}"),
1963 IrCasePattern::OptionalSome { binding } => format!("some:{binding}"),
1964 IrCasePattern::OptionalNone => "none".to_owned(),
1965 IrCasePattern::Wildcard => "_".to_owned(),
1966 }
1967 }
1968}
1969
1970#[derive(Clone, Debug, Eq, PartialEq)]
1971pub struct IrTerminalOutput {
1972 pub binding: String,
1973 pub alternatives: Vec<IrTerminalAlternative>,
1974 pub span: SourceSpan,
1975}
1976
1977#[derive(Clone, Debug, Eq, PartialEq)]
1978pub struct IrTerminalAlternative {
1979 pub tag: String,
1980 pub payload_type: IrType,
1981 pub source_span: SourceSpan,
1982}
1983
1984#[derive(Clone, Debug, Eq, PartialEq)]
1985pub struct IrTerminalCaseBranch {
1986 pub scrutinee: String,
1987 pub tag: Option<String>,
1988 pub binding: Option<String>,
1989 pub guard: Option<IrExpression>,
1990 pub body_hash: String,
1991 pub pattern_span: SourceSpan,
1992}
1993
1994#[derive(Clone, Debug, Eq, PartialEq)]
1995pub enum DependencyPredicate {
1996 Succeeds,
1997 Fails,
1998 TimedOut,
1999 Cancelled,
2000 Completes,
2001}
2002
2003#[derive(Clone, Debug)]
2004struct SemanticContext {
2005 workflow: Option<String>,
2006 schemas: SchemaIndex,
2007 agents: BTreeSet<String>,
2008 agent_capabilities: BTreeMap<String, BTreeSet<String>>,
2009 coerce_outputs: BTreeMap<String, TypeSyntax>,
2010 coerce_params: BTreeMap<String, Vec<ParamDecl>>,
2011 workflow_inputs: BTreeMap<String, WorkflowInputSurface>,
2012 leases: BTreeSet<String>,
2014 ledgers: BTreeSet<String>,
2015 counters: BTreeSet<String>,
2016 channels: BTreeSet<String>,
2018 channel_providers: BTreeMap<String, String>,
2023 #[allow(dead_code)]
2030 credentials: BTreeMap<String, String>,
2031 memory_pools: BTreeSet<String>,
2034 regions: BTreeMap<String, IrRegion>,
2039}
2040
2041#[derive(Clone, Debug, Default)]
2042struct WorkflowInputSurface {
2043 inputs: BTreeMap<String, TypeSyntax>,
2044 outputs: BTreeMap<String, TypeSyntax>,
2049 failures: BTreeMap<String, TypeSyntax>,
2056 schemas: SchemaIndex,
2057 milestones: BTreeMap<String, String>,
2063}
2064
2065#[derive(Clone, Debug, Default)]
2066struct SchemaIndex {
2067 classes: BTreeMap<String, BTreeMap<String, TypeSyntax>>,
2068 enums: BTreeMap<String, BTreeSet<String>>,
2069 events: BTreeSet<String>,
2072 presence: BTreeMap<String, BTreeMap<String, (String, String)>>,
2076}
2077
2078#[derive(Clone, Debug, Eq, PartialEq)]
2079enum BlockFrame {
2080 After {
2081 binding: String,
2082 predicate: DependencyPredicate,
2083 },
2084}
2085
2086#[derive(Clone, Debug, Eq, PartialEq)]
2087enum LiteralExpr<'a> {
2088 String(&'a str),
2089 Number(&'a str),
2090 Bool,
2091 Null,
2092 Ident(&'a str),
2093}
2094
2095#[derive(Clone, Debug, Eq, PartialEq)]
2096enum ExprType {
2097 Bool,
2098 Int,
2099 Float,
2100 String,
2101 Duration,
2102 Time,
2103 Secret,
2108 Null,
2109 Object,
2110 Optional(Box<ExprType>),
2111 Array(Box<ExprType>),
2112 Map(Box<ExprType>),
2113 Finite {
2114 label: String,
2115 values: Vec<String>,
2116 },
2117 Collection,
2118 Unknown,
2119}
2120
2121#[derive(Clone, Debug, Eq, PartialEq)]
2122pub enum Expr {
2123 Literal(ExprLiteral),
2124 Path(Vec<String>),
2125 Index {
2126 target: Box<Expr>,
2127 key: Box<Expr>,
2128 },
2129 Array(Vec<Expr>),
2130 Object(Vec<ExprObjectField>),
2131 Unary {
2132 op: UnaryOp,
2133 expr: Box<Expr>,
2134 },
2135 Binary {
2136 op: BinaryOp,
2137 left: Box<Expr>,
2138 right: Box<Expr>,
2139 },
2140 Call {
2141 name: String,
2142 args: Vec<Expr>,
2143 },
2144 Query {
2145 kind: QueryKind,
2146 head: String,
2147 guard: Option<Box<Expr>>,
2148 },
2149}
2150
2151#[derive(Clone, Debug, Eq, PartialEq)]
2152pub struct ExprObjectField {
2153 pub key: String,
2154 pub value: Expr,
2155}
2156
2157#[derive(Clone, Debug, Eq, PartialEq)]
2158pub enum ExprLiteral {
2159 String(String),
2160 Number(String),
2161 Bool(bool),
2162 Null,
2163 Ident(String),
2164}
2165
2166#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2167pub enum UnaryOp {
2168 Not,
2169}
2170
2171#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2172pub enum BinaryOp {
2173 Or,
2174 And,
2175 Eq,
2176 Ne,
2177 Lt,
2178 Le,
2179 Gt,
2180 Ge,
2181 In,
2182 NotIn,
2183 Add,
2184 Sub,
2185 Mul,
2186 Div,
2187}
2188
2189#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2190pub enum QueryKind {
2191 Fact,
2192 Effect,
2193}
2194
2195pub fn parse_expression(expr: &str) -> Result<Expr, String> {
2197 ExprParser::new(expr).parse()
2198}
2199
2200impl Expr {
2201 pub fn to_snapshot(&self) -> String {
2202 match self {
2203 Self::Literal(literal) => literal.to_snapshot(),
2204 Self::Path(path) => path.join("."),
2205 Self::Index { target, key } => {
2206 format!(
2207 "{}[{}]",
2208 target.to_snapshot_with_parentheses(),
2209 key.to_snapshot()
2210 )
2211 }
2212 Self::Array(items) => {
2213 let items = items
2214 .iter()
2215 .map(Self::to_snapshot)
2216 .collect::<Vec<_>>()
2217 .join(", ");
2218 format!("[{items}]")
2219 }
2220 Self::Object(fields) => {
2221 let fields = fields
2222 .iter()
2223 .map(|field| format!("{} {}", field.key, field.value.to_snapshot()))
2224 .collect::<Vec<_>>()
2225 .join(", ");
2226 format!("{{{fields}}}")
2227 }
2228 Self::Unary { op, expr } => match op {
2229 UnaryOp::Not => format!("!{}", expr.to_snapshot_with_parentheses()),
2230 },
2231 Self::Binary { op, left, right } => format!(
2232 "{} {} {}",
2233 left.to_snapshot_with_parentheses(),
2234 op.to_snapshot(),
2235 right.to_snapshot_with_parentheses()
2236 ),
2237 Self::Call { name, args } => {
2238 let args = args
2239 .iter()
2240 .map(Self::to_snapshot)
2241 .collect::<Vec<_>>()
2242 .join(", ");
2243 format!("{name}({args})")
2244 }
2245 Self::Query { kind, head, guard } => {
2246 let prefix = match kind {
2247 QueryKind::Fact => head.clone(),
2248 QueryKind::Effect => format!("effect {head}"),
2249 };
2250 match guard {
2251 Some(guard) => format!("{prefix} where {}", guard.to_snapshot()),
2252 None => prefix,
2253 }
2254 }
2255 }
2256 }
2257
2258 fn to_snapshot_with_parentheses(&self) -> String {
2259 match self {
2260 Self::Binary { .. } => format!("({})", self.to_snapshot()),
2261 _ => self.to_snapshot(),
2262 }
2263 }
2264}
2265
2266impl ExprLiteral {
2267 fn to_snapshot(&self) -> String {
2268 match self {
2269 Self::String(value) => format!("{value:?}"),
2270 Self::Number(value) | Self::Ident(value) => value.clone(),
2271 Self::Bool(value) => value.to_string(),
2272 Self::Null => "null".to_owned(),
2273 }
2274 }
2275}
2276
2277impl BinaryOp {
2278 fn to_snapshot(self) -> &'static str {
2279 match self {
2280 Self::Or => "||",
2281 Self::And => "&&",
2282 Self::Eq => "==",
2283 Self::Ne => "!=",
2284 Self::Lt => "<",
2285 Self::Le => "<=",
2286 Self::Gt => ">",
2287 Self::Ge => ">=",
2288 Self::In => "in",
2289 Self::NotIn => "not in",
2290 Self::Add => "+",
2291 Self::Sub => "-",
2292 Self::Mul => "*",
2293 Self::Div => "/",
2294 }
2295 }
2296}
2297
2298pub fn compile_program(source: &str) -> CompileOutput {
2300 compile_program_with_root(source, None)
2301}
2302
2303pub fn compile_program_with_root(source: &str, root: Option<&str>) -> CompileOutput {
2306 let parsed = parse_program(source);
2307 if !parsed.diagnostics.is_empty() {
2308 return CompileOutput {
2309 ir: None,
2310 diagnostics: parsed.diagnostics,
2311 warnings: Vec::new(),
2312 };
2313 }
2314
2315 let mut invoke_recursion_diagnostics = Vec::new();
2320 detect_workflow_invoke_recursion(&parsed.program, &mut invoke_recursion_diagnostics);
2321 detect_private_workflow_invocations(&parsed.program, &mut invoke_recursion_diagnostics);
2322 if !invoke_recursion_diagnostics.is_empty() {
2323 return CompileOutput {
2324 ir: None,
2325 diagnostics: invoke_recursion_diagnostics,
2326 warnings: Vec::new(),
2327 };
2328 }
2329
2330 let workflow_inputs = collect_workflow_input_surfaces(&parsed.program);
2331 let shared_coordination_usage = collect_shared_coordination_usage(&parsed.program);
2332
2333 if parsed.program.workflows.len() > 1 {
2344 let global_names: BTreeSet<String> = parsed
2351 .program
2352 .items
2353 .iter()
2354 .filter_map(|item| referenced_decl_name(item).map(|(name, _)| name))
2355 .collect();
2356 let mut sibling_locals: BTreeMap<String, Vec<(String, SourceSpan)>> = BTreeMap::new();
2357 for workflow in &parsed.program.workflows {
2358 for item in &workflow.items {
2359 if let Some((name, span)) = referenced_decl_name(item) {
2360 sibling_locals
2361 .entry(name)
2362 .or_default()
2363 .push((workflow.name.name.clone(), span));
2364 }
2365 }
2366 }
2367
2368 let mut aggregated = Vec::new();
2369 for workflow in &parsed.program.workflows {
2370 let name = workflow.name.name.clone();
2371 let own_locals: BTreeSet<String> = workflow
2372 .items
2373 .iter()
2374 .filter_map(|item| referenced_decl_name(item).map(|(name, _)| name))
2375 .collect();
2376 let mut diagnostics = match select_root_workflow(parsed.program.clone(), Some(&name)) {
2377 Ok(scoped) => {
2378 lower_program(
2379 scoped,
2380 workflow_inputs.clone(),
2381 shared_coordination_usage.clone(),
2382 )
2383 .diagnostics
2384 }
2385 Err(diagnostics) => diagnostics,
2386 };
2387 for diagnostic in &mut diagnostics {
2388 annotate_cross_workflow_leak(
2389 diagnostic,
2390 &name,
2391 &own_locals,
2392 &global_names,
2393 &sibling_locals,
2394 );
2395 }
2396 aggregated.extend(diagnostics);
2397 }
2398 if !aggregated.is_empty() {
2399 return CompileOutput {
2400 ir: None,
2401 diagnostics: aggregated,
2402 warnings: Vec::new(),
2403 };
2404 }
2405 }
2406
2407 match select_root_workflow(parsed.program, root) {
2408 Ok(program) => lower_program(program, workflow_inputs, shared_coordination_usage),
2409 Err(diagnostics) => CompileOutput {
2410 ir: None,
2411 diagnostics,
2412 warnings: Vec::new(),
2413 },
2414 }
2415}
2416
2417#[derive(Clone, Debug, Eq, PartialEq)]
2420pub struct DeclSymbol {
2421 pub name: String,
2422 pub kind: &'static str,
2423 pub span: SourceSpan,
2424}
2425
2426pub fn document_symbols(source: &str) -> Vec<DeclSymbol> {
2429 let program = parse_program(source).program;
2430 let mut symbols = Vec::new();
2431 if let Some(workflow) = &program.workflow {
2432 symbols.push(DeclSymbol {
2433 name: workflow.name.clone(),
2434 kind: "workflow",
2435 span: workflow.span,
2436 });
2437 }
2438 for workflow in &program.workflows {
2439 symbols.push(DeclSymbol {
2440 name: workflow.name.name.clone(),
2441 kind: "workflow",
2442 span: workflow.span,
2443 });
2444 }
2445 for pattern in &program.patterns {
2446 symbols.push(DeclSymbol {
2447 name: pattern.name.name.clone(),
2448 kind: "pattern",
2449 span: pattern.span,
2450 });
2451 }
2452 for item in &program.items {
2453 let symbol = match item {
2454 Item::Class(decl) => ("class", decl.name.name.clone(), decl.span),
2455 Item::Enum(decl) => ("enum", decl.name.name.clone(), decl.span),
2456 Item::Agent(decl) => ("agent", decl.name.name.clone(), decl.span),
2457 Item::Rule(decl) => ("rule", decl.name.name.clone(), decl.span),
2458 Item::Coerce(decl) => ("coerce", decl.name.name.clone(), decl.span),
2459 Item::Action(decl) => ("action", decl.name.name.clone(), decl.span),
2460 Item::Lease(decl) => ("lease", decl.name.name.clone(), decl.span),
2461 Item::Ledger(decl) => ("ledger", decl.name.name.clone(), decl.span),
2462 Item::Counter(decl) => ("counter", decl.name.name.clone(), decl.span),
2463 Item::Tracker(decl) => ("tracker", decl.name.name.clone(), decl.span),
2464 Item::Channel(decl) => ("channel", decl.name.name.clone(), decl.span),
2465 Item::Credential(decl) => ("credential", decl.name.name.clone(), decl.span),
2466 Item::FileStore(decl) => ("file store", decl.name.name.clone(), decl.span),
2467 Item::MemoryPool(decl) => ("memory pool", decl.name.name.clone(), decl.span),
2468 Item::Event(decl) => ("signal", decl.name.clone(), decl.span),
2469 Item::Table(decl) => ("table", decl.name.name.clone(), decl.span),
2470 Item::Gauge(decl) => ("gauge", decl.name.name.clone(), decl.span),
2471 Item::Campaign(decl) => ("campaign", decl.name.name.clone(), decl.span),
2472 Item::Mark(decl) => ("mark", decl.name.value.clone(), decl.span),
2473 _ => continue,
2474 };
2475 symbols.push(DeclSymbol {
2476 name: symbol.1,
2477 kind: symbol.0,
2478 span: symbol.2,
2479 });
2480 }
2481 symbols
2482}
2483
2484fn line_index(source: &str, offset: usize) -> usize {
2486 source.as_bytes()[..offset]
2487 .iter()
2488 .filter(|&&byte| byte == b'\n')
2489 .count()
2490}
2491
2492fn classify_body_comments<'a>(
2501 source: &str,
2502 body: SourceSpan,
2503 members: &[(SourceSpan, Vec<String>)],
2504 comments: &'a [Comment],
2505) -> Option<(Vec<&'a Comment>, Vec<Option<&'a Comment>>)> {
2506 let mut own_line: Vec<&Comment> = Vec::new();
2507 let mut trailing: Vec<Option<&Comment>> = vec![None; members.len()];
2508 for comment in comments {
2509 if comment.span.start <= body.start || comment.span.start >= body.end {
2510 continue;
2511 }
2512 if members.iter().any(|(span, lines)| {
2515 lines.len() > 1 && span.start < comment.span.start && comment.span.start < span.end
2516 }) {
2517 return None;
2518 }
2519 let line_start = source[..comment.span.start]
2520 .rfind('\n')
2521 .map(|index| index + 1)
2522 .unwrap_or(0);
2523 if source[line_start..comment.span.start].trim().is_empty() {
2524 own_line.push(comment);
2525 continue;
2526 }
2527 let comment_line = line_index(source, comment.span.start);
2529 let mut placed = false;
2530 for (index, (span, lines)) in members.iter().enumerate() {
2531 if lines.len() == 1 && line_index(source, span.start) == comment_line {
2532 if trailing[index].is_some() {
2533 return None;
2534 }
2535 trailing[index] = Some(comment);
2536 placed = true;
2537 break;
2538 }
2539 }
2540 if !placed {
2541 return None;
2542 }
2543 }
2544 Some((own_line, trailing))
2545}
2546
2547fn emit_members_with_comments(
2552 members: &[(SourceSpan, Vec<String>)],
2553 own_line: &[&Comment],
2554 trailing: &[Option<&Comment>],
2555 indent: &str,
2556 formatted: &mut String,
2557) {
2558 let mut next = 0;
2559 for (index, (span, lines)) in members.iter().enumerate() {
2560 while next < own_line.len() && own_line[next].span.start < span.start {
2561 push_line(
2562 formatted,
2563 format!("{indent}{}", format_comment(own_line[next])),
2564 );
2565 next += 1;
2566 }
2567 let last = lines.len().saturating_sub(1);
2568 for (offset, line) in lines.iter().enumerate() {
2569 match trailing[index] {
2570 Some(comment) if offset == last => {
2571 push_line(formatted, format!("{line} {}", format_comment(comment)));
2572 }
2573 _ => push_line(formatted, line.clone()),
2574 }
2575 }
2576 }
2577 while next < own_line.len() {
2578 push_line(
2579 formatted,
2580 format!("{indent}{}", format_comment(own_line[next])),
2581 );
2582 next += 1;
2583 }
2584}
2585
2586fn try_format_class_with_comments(
2590 class_decl: &ClassDecl,
2591 source: &str,
2592 comments: &[Comment],
2593 formatted: &mut String,
2594) -> bool {
2595 let members: Vec<(SourceSpan, Vec<String>)> = class_decl
2596 .fields
2597 .iter()
2598 .map(|field| {
2599 let key = if field.is_key { " @key" } else { "" };
2600 (
2601 field.span,
2602 vec![format!(
2603 " {} {}{key}",
2604 field.name.name,
2605 field.ty.to_source()
2606 )],
2607 )
2608 })
2609 .collect();
2610 let Some((own_line, trailing)) =
2611 classify_body_comments(source, class_decl.span, &members, comments)
2612 else {
2613 return false;
2614 };
2615 push_line(formatted, format!("class {} {{", class_decl.name.name));
2616 emit_members_with_comments(&members, &own_line, &trailing, " ", formatted);
2617 push_line(formatted, "}");
2618 true
2619}
2620
2621fn try_format_tracker_with_comments(
2625 queue: &TrackerDecl,
2626 source: &str,
2627 comments: &[Comment],
2628 formatted: &mut String,
2629) -> bool {
2630 let members: Vec<(SourceSpan, Vec<String>)> = vec![(
2631 queue.provider.span,
2632 vec![format!(" provider {}", queue.provider.name)],
2633 )];
2634 let Some((own_line, trailing)) = classify_body_comments(source, queue.span, &members, comments)
2635 else {
2636 return false;
2637 };
2638 push_line(formatted, format!("tracker {} {{", queue.name.name));
2639 emit_members_with_comments(&members, &own_line, &trailing, " ", formatted);
2640 push_line(formatted, "}");
2641 true
2642}
2643
2644fn try_format_filestore_with_comments(
2649 file_store: &FileStoreDecl,
2650 source: &str,
2651 comments: &[Comment],
2652 formatted: &mut String,
2653) -> bool {
2654 let render = |globs: &[String]| {
2655 globs
2656 .iter()
2657 .map(|glob| format!("{glob:?}"))
2658 .collect::<Vec<_>>()
2659 .join(", ")
2660 };
2661 let mut members: Vec<(SourceSpan, Vec<String>)> = Vec::new();
2662 if let Some(span) = file_store.root_span {
2663 members.push((span, vec![format!(" root {:?}", file_store.root)]));
2664 }
2665 if !file_store.read_globs.is_empty() {
2666 if let Some(span) = file_store.read_span {
2667 members.push((
2668 span,
2669 vec![format!(" allow read [{}]", render(&file_store.read_globs))],
2670 ));
2671 }
2672 }
2673 if !file_store.write_globs.is_empty() {
2674 if let Some(span) = file_store.write_span {
2675 members.push((
2676 span,
2677 vec![format!(
2678 " allow write [{}]",
2679 render(&file_store.write_globs)
2680 )],
2681 ));
2682 }
2683 }
2684 if let Some(provider) = &file_store.provider {
2685 if let Some(span) = file_store.provider_span {
2686 members.push((span, vec![format!(" provider {}", provider.name)]));
2687 }
2688 }
2689 members.sort_by_key(|(span, _)| span.start);
2690 let Some((own_line, trailing)) =
2691 classify_body_comments(source, file_store.span, &members, comments)
2692 else {
2693 return false;
2694 };
2695 push_line(formatted, format!("file store {} {{", file_store.name.name));
2696 emit_members_with_comments(&members, &own_line, &trailing, " ", formatted);
2697 push_line(formatted, "}");
2698 true
2699}
2700
2701fn try_format_event_with_comments(
2705 event: &EventDecl,
2706 source: &str,
2707 comments: &[Comment],
2708 formatted: &mut String,
2709) -> bool {
2710 let members: Vec<(SourceSpan, Vec<String>)> = event
2711 .fields
2712 .iter()
2713 .map(|field| {
2714 (
2715 field.span,
2716 vec![format!(" {} {}", field.name.name, field.ty.to_source())],
2717 )
2718 })
2719 .collect();
2720 let Some((own_line, trailing)) = classify_body_comments(source, event.span, &members, comments)
2721 else {
2722 return false;
2723 };
2724 push_line(formatted, format!("signal {} {{", event.name));
2725 emit_members_with_comments(&members, &own_line, &trailing, " ", formatted);
2726 push_line(formatted, "}");
2727 true
2728}
2729
2730fn agent_field_span(field: &AgentField) -> SourceSpan {
2731 match field {
2732 AgentField::Provider(ident) => ident.span,
2733 AgentField::Profile(profile) => profile.span,
2734 AgentField::Capacity(_, span)
2735 | AgentField::Skills(_, span)
2736 | AgentField::Capabilities(_, span)
2737 | AgentField::Requires(_, span)
2738 | AgentField::Tools(_, span) => *span,
2739 AgentField::Compaction(strategy) => strategy.span,
2740 AgentField::Thread(mode) => mode.span,
2741 AgentField::Settings(sources) => sources.span,
2742 AgentField::Unknown { span, .. } => *span,
2743 }
2744}
2745
2746fn agent_field_line(field: &AgentField) -> String {
2747 match field {
2748 AgentField::Provider(provider) => format!(" provider {}", provider.name),
2749 AgentField::Profile(profile) => format!(" profile {:?}", profile.value),
2750 AgentField::Capacity(capacity, _) => format!(" capacity {capacity}"),
2751 AgentField::Skills(skills, _) => {
2752 let skills = skills
2753 .iter()
2754 .map(|skill| format!("{:?}", skill.value))
2755 .collect::<Vec<_>>()
2756 .join(", ");
2757 format!(" skills [{skills}]")
2758 }
2759 AgentField::Capabilities(capabilities, _) => {
2760 let capabilities = capabilities
2761 .iter()
2762 .map(|capability| format!("{:?}", capability.value))
2763 .collect::<Vec<_>>()
2764 .join(", ");
2765 format!(" capabilities [{capabilities}]")
2766 }
2767 AgentField::Requires(classes, _) => {
2768 let classes = classes
2769 .iter()
2770 .map(|class| class.name.as_str())
2771 .collect::<Vec<_>>()
2772 .join(", ");
2773 format!(" requires [{classes}]")
2774 }
2775 AgentField::Tools(tools, _) => {
2776 let tools = tools
2777 .iter()
2778 .map(|tool| tool.name.as_str())
2779 .collect::<Vec<_>>()
2780 .join(", ");
2781 format!(" tools [{tools}]")
2782 }
2783 AgentField::Compaction(strategy) => format!(" compaction {}", strategy.name),
2784 AgentField::Thread(mode) => format!(" thread {}", mode.name),
2785 AgentField::Settings(sources) => format!(" settings {}", sources.name),
2786 AgentField::Unknown { name, .. } => format!(" {}", name.name),
2787 }
2788}
2789
2790fn try_format_agent_with_comments(
2794 agent: &AgentDecl,
2795 source: &str,
2796 comments: &[Comment],
2797 formatted: &mut String,
2798) -> bool {
2799 let members: Vec<(SourceSpan, Vec<String>)> = agent
2800 .fields
2801 .iter()
2802 .map(|field| (agent_field_span(field), vec![agent_field_line(field)]))
2803 .collect();
2804 let Some((own_line, trailing)) = classify_body_comments(source, agent.span, &members, comments)
2805 else {
2806 return false;
2807 };
2808 let harness = agent
2809 .harness
2810 .as_ref()
2811 .map(|harness| format!(" using {}", harness.name))
2812 .or_else(|| {
2813 agent
2814 .delegated_to
2815 .as_ref()
2816 .map(|delegate| format!(" delegated to {}", delegate.name))
2817 })
2818 .unwrap_or_default();
2819 push_line(
2820 formatted,
2821 format!("agent {}{} {{", agent.name.name, harness),
2822 );
2823 emit_members_with_comments(&members, &own_line, &trailing, " ", formatted);
2824 push_line(formatted, "}");
2825 true
2826}
2827
2828fn enum_variant_lines_with_comments(
2833 variant: &EnumVariantDecl,
2834 source: &str,
2835 comments: &[Comment],
2836) -> Option<Vec<String>> {
2837 if variant.fields.is_empty() {
2838 return Some(vec![format!(" {}", variant.name.name)]);
2839 }
2840 let members: Vec<(SourceSpan, Vec<String>)> = variant
2841 .fields
2842 .iter()
2843 .map(|field| {
2844 (
2845 field.span,
2846 vec![format!(" {} {}", field.name.name, field.ty.to_source())],
2847 )
2848 })
2849 .collect();
2850 let (own_line, trailing) = classify_body_comments(source, variant.span, &members, comments)?;
2852 let mut block = String::new();
2853 emit_members_with_comments(&members, &own_line, &trailing, " ", &mut block);
2854 let mut lines = vec![format!(" {} {{", variant.name.name)];
2855 lines.extend(block.lines().map(str::to_owned));
2856 lines.push(" }".to_owned());
2857 Some(lines)
2858}
2859
2860fn try_format_enum_with_comments(
2866 enum_decl: &EnumDecl,
2867 source: &str,
2868 comments: &[Comment],
2869 formatted: &mut String,
2870) -> bool {
2871 let mut members: Vec<(SourceSpan, Vec<String>)> = Vec::with_capacity(enum_decl.variants.len());
2872 for variant in &enum_decl.variants {
2873 let Some(lines) = enum_variant_lines_with_comments(variant, source, comments) else {
2874 return false;
2875 };
2876 members.push((variant.span, lines));
2877 }
2878 let body_level: Vec<Comment> = comments
2882 .iter()
2883 .filter(|comment| {
2884 !enum_decl.variants.iter().any(|variant| {
2885 !variant.fields.is_empty()
2886 && variant.span.start < comment.span.start
2887 && comment.span.start < variant.span.end
2888 })
2889 })
2890 .cloned()
2891 .collect();
2892 let Some((own_line, trailing)) =
2893 classify_body_comments(source, enum_decl.span, &members, &body_level)
2894 else {
2895 return false;
2896 };
2897 push_line(formatted, format!("enum {} {{", enum_decl.name.name));
2898 emit_members_with_comments(&members, &own_line, &trailing, " ", formatted);
2899 push_line(formatted, "}");
2900 true
2901}
2902
2903fn referenced_decl_name(item: &Item) -> Option<(String, SourceSpan)> {
2910 match item {
2911 Item::Class(decl) => Some((decl.name.name.clone(), decl.span)),
2912 Item::Enum(decl) => Some((decl.name.name.clone(), decl.span)),
2913 Item::Agent(decl) => Some((decl.name.name.clone(), decl.span)),
2914 Item::Coerce(decl) => Some((decl.name.name.clone(), decl.span)),
2915 Item::Lease(decl) => Some((decl.name.name.clone(), decl.span)),
2916 Item::Ledger(decl) => Some((decl.name.name.clone(), decl.span)),
2917 Item::Counter(decl) => Some((decl.name.name.clone(), decl.span)),
2918 Item::Tracker(decl) => Some((decl.name.name.clone(), decl.span)),
2919 Item::Channel(decl) => Some((decl.name.name.clone(), decl.span)),
2920 Item::FileStore(decl) => Some((decl.name.name.clone(), decl.span)),
2921 Item::MemoryPool(decl) => Some((decl.name.name.clone(), decl.span)),
2922 Item::Event(decl) => Some((decl.name.clone(), decl.span)),
2923 Item::Table(decl) => Some((decl.name.name.clone(), decl.span)),
2924 Item::Gauge(decl) => Some((decl.name.name.clone(), decl.span)),
2925 Item::Campaign(decl) => Some((decl.name.name.clone(), decl.span)),
2926 Item::Mark(decl) => Some((decl.name.value.clone(), decl.span)),
2927 _ => None,
2928 }
2929}
2930
2931fn annotate_cross_workflow_leak(
2938 diagnostic: &mut Diagnostic,
2939 current: &str,
2940 own_locals: &BTreeSet<String>,
2941 global_names: &BTreeSet<String>,
2942 sibling_locals: &BTreeMap<String, Vec<(String, SourceSpan)>>,
2943) {
2944 for (name, owners) in sibling_locals {
2945 if global_names.contains(name) || own_locals.contains(name) {
2946 continue;
2947 }
2948 if !diagnostic.message.contains(&format!("`{name}`")) {
2951 continue;
2952 }
2953 let Some((owner, span)) = owners.iter().find(|(owner, _)| owner != current) else {
2954 continue;
2955 };
2956 diagnostic.related.push(RelatedInfo {
2957 span: *span,
2958 message: format!(
2959 "`{name}` is declared inside workflow `{owner}`, which makes it \
2960 private to that workflow; move it to a top-level declaration to \
2961 share it across workflows"
2962 ),
2963 });
2964 return;
2965 }
2966}
2967
2968fn select_root_workflow(
2969 mut program: Program,
2970 root: Option<&str>,
2971) -> Result<Program, Vec<Diagnostic>> {
2972 if program.workflow.is_none() && program.workflows.is_empty() {
2978 return Err(vec![Diagnostic {
2979 related: Vec::new(),
2980 span: SourceSpan { start: 0, end: 0 },
2981 message: "program declares no `workflow`".to_owned(),
2982 suggestion: Some(
2983 "add an explicit `workflow Name { ... }` declaration; a runnable \
2984 program requires at least one workflow (files that only declare \
2985 shared types or patterns are libraries, meant to be `include`d)"
2986 .to_owned(),
2987 ),
2988 }]);
2989 }
2990
2991 if program.workflows.is_empty() {
2992 if let Some(root) = root {
2993 match program.workflow.as_ref() {
2994 Some(workflow) if workflow.name == root => {}
2995 Some(workflow) => {
2996 return Err(vec![Diagnostic {
2997 related: Vec::new(),
2998 span: workflow.span,
2999 message: format!("root workflow `{root}` was not found"),
3000 suggestion: Some(format!("available workflow: `{}`", workflow.name)),
3001 }]);
3002 }
3003 None => {
3004 return Err(vec![Diagnostic {
3005 related: Vec::new(),
3006 span: SourceSpan { start: 0, end: 0 },
3007 message: format!("root workflow `{root}` was not found"),
3008 suggestion: Some(
3009 "add an explicit `workflow Name { ... }` declaration".to_owned(),
3010 ),
3011 }]);
3012 }
3013 }
3014 }
3015 return Ok(program);
3016 }
3017
3018 let selected_index = match root {
3019 Some(root) => match program
3020 .workflows
3021 .iter()
3022 .position(|workflow| workflow.name.name == root)
3023 {
3024 Some(index) => index,
3025 None => {
3026 let names = program
3027 .workflows
3028 .iter()
3029 .map(|workflow| format!("`{}`", workflow.name.name))
3030 .collect::<Vec<_>>()
3031 .join(", ");
3032 return Err(vec![Diagnostic {
3033 related: Vec::new(),
3034 span: SourceSpan { start: 0, end: 0 },
3035 message: format!("root workflow `{root}` was not found"),
3036 suggestion: Some(format!("available workflows: {names}")),
3037 }]);
3038 }
3039 },
3040 None if program.workflows.len() == 1 => 0,
3041 None => {
3042 let names = program
3043 .workflows
3044 .iter()
3045 .map(|workflow| format!("`{}`", workflow.name.name))
3046 .collect::<Vec<_>>()
3047 .join(", ");
3048 return Err(vec![Diagnostic {
3049 related: Vec::new(),
3050 span: SourceSpan { start: 0, end: 0 },
3051 message: "multiple workflow declarations require an explicit root".to_owned(),
3052 suggestion: Some(format!(
3053 "pass `--root <name>`; available workflows: {names}"
3054 )),
3055 }]);
3056 }
3057 };
3058
3059 let selected = program.workflows.remove(selected_index);
3060 let mut items = program.items;
3061 let workflow_tags = selected.tags;
3062 let workflow_description = selected.description;
3063 items.extend(selected.items);
3064 Ok(Program {
3065 workflow: Some(selected.name),
3066 workflow_tags,
3067 workflow_description,
3068 explicit_workflow_body: true,
3069 workflows: Vec::new(),
3070 patterns: program.patterns,
3071 items,
3072 })
3073}
3074
3075impl IrProgram {
3076 pub fn construct_uses(&self) -> Vec<&IrConstructUse> {
3077 self.rules
3078 .iter()
3079 .flat_map(|rule| rule.metadata.effects.iter())
3080 .filter_map(|effect| effect.construct_use.as_ref())
3081 .collect()
3082 }
3083
3084 pub fn contract_registry(&self) -> ContractRegistry {
3085 let mut libraries = BTreeMap::<String, LibraryRegistration>::new();
3086 let mut contracts = BTreeMap::<(String, String), EffectContract>::new();
3087
3088 for use_decl in &self.uses {
3089 libraries
3090 .entry(use_decl.name.clone())
3091 .or_insert_with(|| LibraryRegistration {
3092 id: use_decl.name.clone(),
3093 version: "unlocked".to_owned(),
3094 standard: false,
3095 });
3096 }
3097
3098 if !self.harnesses.is_empty() || !self.agents.is_empty() {
3099 register_standard_library(&mut libraries, "std.agent");
3100 }
3101 if !self.trackers.is_empty() {
3102 register_standard_library(&mut libraries, "std.tracker");
3103 }
3104 if !self.events.is_empty() {
3105 register_standard_library(&mut libraries, "std.ingress");
3106 }
3107 if !self.leases.is_empty() || !self.ledgers.is_empty() || !self.counters.is_empty() {
3108 register_standard_library(&mut libraries, "std.coord");
3109 }
3110 if !self.channels.is_empty() {
3111 register_standard_library(&mut libraries, "std.messaging");
3112 }
3113 if !self.credentials.is_empty() {
3114 register_standard_library(&mut libraries, "std.custody");
3115 }
3116 if !self.file_stores.is_empty() {
3120 register_standard_library(&mut libraries, "std.files");
3121 }
3122 if self.sources.iter().any(|source| source.is_clock) {
3123 register_standard_library(&mut libraries, "std.time");
3124 }
3125 if !self.coerces.is_empty() {
3126 register_standard_library(&mut libraries, "std.coercion");
3127 register_effect_contract(
3128 &mut libraries,
3129 &mut contracts,
3130 IrEffectKind::SchemaCoerce,
3131 Vec::new(),
3132 );
3133 }
3134
3135 for rule in &self.rules {
3136 for effect in &rule.metadata.effects {
3137 register_effect_contract(
3138 &mut libraries,
3139 &mut contracts,
3140 effect.kind.clone(),
3141 effect.required_capabilities.clone(),
3142 );
3143 }
3144 }
3145
3146 ContractRegistry {
3152 libraries: libraries.into_values().collect(),
3153 constructs: Vec::new(),
3154 effect_contracts: contracts.into_values().collect(),
3155 }
3156 }
3157
3158 pub fn to_snapshot(&self) -> String {
3159 let mut snapshot = String::new();
3160 push_line(&mut snapshot, format!("workflow {}", self.workflow));
3161
3162 if !self.source_tags.is_empty() {
3163 push_line(&mut snapshot, "source_tags");
3164 for tag in &self.source_tags {
3165 push_line(
3166 &mut snapshot,
3167 format!("@{} {} {}", tag.name, tag.target_kind, tag.target),
3168 );
3169 }
3170 }
3171
3172 if !self.source_descriptions.is_empty() {
3173 push_line(&mut snapshot, "source_descriptions");
3174 for description in &self.source_descriptions {
3175 push_line(
3176 &mut snapshot,
3177 format!(
3178 "{:?} {} {}",
3179 description.value, description.target_kind, description.target
3180 ),
3181 );
3182 }
3183 }
3184
3185 if !self.shared_coordination_usage.is_empty() {
3186 push_line(&mut snapshot, "shared_coordination_usage");
3187 for usage in &self.shared_coordination_usage {
3188 push_line(
3189 &mut snapshot,
3190 format!(
3191 "{} <- {}",
3192 usage.resource,
3193 usage.workflow_principals.join(",")
3194 ),
3195 );
3196 }
3197 }
3198
3199 if !self.includes.is_empty() {
3200 push_line(&mut snapshot, "includes");
3201 for include in &self.includes {
3202 match &include.source_hash {
3203 Some(source_hash) => {
3204 push_line(
3205 &mut snapshot,
3206 format!(" {} hash {}", include.path, source_hash),
3207 );
3208 }
3209 None => push_line(&mut snapshot, format!(" {}", include.path)),
3210 }
3211 }
3212 }
3213
3214 if !self.pattern_applications.is_empty() {
3215 push_line(&mut snapshot, "pattern_applications");
3216 for application in &self.pattern_applications {
3217 let type_args = application
3218 .type_args
3219 .iter()
3220 .map(IrType::to_snapshot)
3221 .collect::<Vec<_>>()
3222 .join(", ");
3223 push_line(
3224 &mut snapshot,
3225 format!(
3226 " {} as {}<{}>",
3227 application.pattern, application.alias, type_args
3228 ),
3229 );
3230 push_line(
3231 &mut snapshot,
3232 format!(
3233 " defined-at {}..{}",
3234 application.definition_span.start, application.definition_span.end
3235 ),
3236 );
3237 push_line(
3238 &mut snapshot,
3239 format!(
3240 " applied-at {}..{}",
3241 application.application_span.start, application.application_span.end
3242 ),
3243 );
3244 for argument in &application.value_args {
3245 push_line(
3246 &mut snapshot,
3247 format!(" arg {} {}", argument.name, argument.value),
3248 );
3249 }
3250 for generated in &application.generated {
3251 push_line(&mut snapshot, format!(" generated {generated}"));
3252 }
3253 }
3254 }
3255
3256 if !self.workflow_contracts.is_empty() {
3257 push_line(&mut snapshot, "workflow_contracts");
3258 for contract in &self.workflow_contracts {
3259 push_line(
3260 &mut snapshot,
3261 format!(
3262 " {} {} {}",
3263 contract.kind.as_str(),
3264 contract.name,
3265 contract.ty.to_snapshot()
3266 ),
3267 );
3268 }
3269 }
3270
3271 if !self.uses.is_empty() {
3272 push_line(&mut snapshot, "uses");
3273 for use_decl in &self.uses {
3274 push_line(
3275 &mut snapshot,
3276 format!(" {} {}", use_decl.kind.as_str(), use_decl.name),
3277 );
3278 }
3279 }
3280
3281 if !self.schemas.is_empty() {
3282 push_line(&mut snapshot, "schemas");
3283 for schema in &self.schemas {
3284 match schema {
3285 IrSchema::Enum(enum_decl) => {
3286 push_line(
3287 &mut snapshot,
3288 format!(
3289 " enum {} {{ {} }}",
3290 enum_decl.name,
3291 enum_decl.variants.join(", ")
3292 ),
3293 );
3294 }
3295 IrSchema::Class(class_decl) => {
3296 push_line(&mut snapshot, format!(" class {}", class_decl.name));
3297 for field in &class_decl.fields {
3298 let key = if field.is_key { " @key" } else { "" };
3301 push_line(
3302 &mut snapshot,
3303 format!(" {} {}{key}", field.name, field.ty.to_snapshot()),
3304 );
3305 }
3306 }
3307 }
3308 }
3309 }
3310
3311 if !self.harnesses.is_empty() {
3312 push_line(&mut snapshot, "harnesses");
3313 for harness in &self.harnesses {
3314 push_line(
3315 &mut snapshot,
3316 format!(" harness {} kind={}", harness.name, harness.kind),
3317 );
3318 }
3319 }
3320 if !self.trackers.is_empty() {
3321 push_line(&mut snapshot, "trackers");
3322 for queue in &self.trackers {
3323 push_line(
3324 &mut snapshot,
3325 format!(" tracker {} provider={}", queue.name, queue.provider),
3326 );
3327 }
3328 }
3329 if !self.streams.is_empty() {
3330 push_line(&mut snapshot, "streams");
3331 for stream in &self.streams {
3332 push_line(
3333 &mut snapshot,
3334 format!(
3335 " stream {} members=[{}]{}",
3336 stream.name,
3337 stream.members.join(","),
3338 stream
3339 .staleness_seconds
3340 .map(|seconds| format!(" staleness={seconds}s"))
3341 .unwrap_or_default()
3342 ),
3343 );
3344 }
3345 }
3346
3347 if !self.channels.is_empty() {
3348 push_line(&mut snapshot, "channels");
3349 for channel in &self.channels {
3350 let mut line = format!(" channel {} provider={}", channel.name, channel.provider);
3351 if let Some(workspace) = &channel.workspace {
3352 line.push_str(&format!(" workspace={workspace}"));
3353 }
3354 if let Some(destination) = &channel.destination {
3355 line.push_str(&format!(" destination={destination:?}"));
3356 }
3357 push_line(&mut snapshot, line);
3358 }
3359 }
3360
3361 if !self.credentials.is_empty() {
3362 push_line(&mut snapshot, "credentials");
3363 for credential in &self.credentials {
3364 push_line(
3365 &mut snapshot,
3366 format!(" credential {} kind={}", credential.name, credential.kind),
3367 );
3368 }
3369 }
3370
3371 if !self.gauges.is_empty() {
3372 push_line(&mut snapshot, "gauges");
3373 for gauge in &self.gauges {
3374 let mut line = format!(
3375 " gauge {} judge={}:{}",
3376 gauge.name, gauge.judge_kind, gauge.judge_target
3377 );
3378 if !gauge.judge_args.is_empty() {
3379 line.push_str(&format!(" args=({})", gauge.judge_args.join(",")));
3380 }
3381 if let Some(site) = &gauge.site {
3382 line.push_str(&format!(" site={site}"));
3383 }
3384 if let Some(bar) = &gauge.expect {
3385 line.push_str(&format!(
3386 " expect={}:{}{}{}",
3387 bar.form, bar.subject, bar.op, bar.threshold
3388 ));
3389 }
3390 if !gauge.inputs.is_empty() {
3391 line.push_str(&format!(" inputs={}", gauge.inputs.join(",")));
3392 }
3393 push_line(&mut snapshot, line);
3394 }
3395 }
3396
3397 if !self.marks.is_empty() {
3398 push_line(&mut snapshot, "marks");
3399 for mark in &self.marks {
3400 push_line(
3401 &mut snapshot,
3402 format!(" mark {:?} after {}", mark.name, mark.site),
3403 );
3404 }
3405 }
3406
3407 if !self.campaigns.is_empty() {
3408 push_line(&mut snapshot, "campaigns");
3409 for campaign in &self.campaigns {
3410 let mut line = format!(" campaign {}", campaign.name);
3411 if !campaign.ascend.is_empty() {
3412 line.push_str(&format!(" ascend={}", campaign.ascend.join(",")));
3413 }
3414 for reach in &campaign.reach {
3415 line.push_str(&format!(
3416 " reach={}{}{}{}",
3417 reach.gauge,
3418 reach.op,
3419 reach.threshold,
3420 reach.unit.as_deref().unwrap_or("")
3421 ));
3422 }
3423 for guard in &campaign.guard {
3424 line.push_str(&format!(
3425 " guard={}:within:{}%",
3426 guard.gauge, guard.band_percent
3427 ));
3428 }
3429 if !campaign.sacrifice.is_empty() {
3430 line.push_str(&format!(" sacrifice={}", campaign.sacrifice.join(",")));
3431 }
3432 if campaign.proposer_redacted {
3433 line.push_str(" proposer=redacted");
3434 }
3435 push_line(&mut snapshot, line);
3436 }
3437 }
3438
3439 if !self.file_stores.is_empty() {
3440 push_line(&mut snapshot, "file_stores");
3441 for file_store in &self.file_stores {
3442 push_line(
3443 &mut snapshot,
3444 format!(
3445 " file store {} root={:?}",
3446 file_store.name, file_store.root
3447 ),
3448 );
3449 if !file_store.read_globs.is_empty() {
3452 push_line(
3453 &mut snapshot,
3454 format!(" allow read {:?}", file_store.read_globs),
3455 );
3456 }
3457 if !file_store.write_globs.is_empty() {
3458 push_line(
3459 &mut snapshot,
3460 format!(" allow write {:?}", file_store.write_globs),
3461 );
3462 }
3463 if let Some(provider) = &file_store.provider {
3467 push_line(&mut snapshot, format!(" provider {provider}"));
3468 }
3469 }
3470 }
3471
3472 if !self.memory_pools.is_empty() {
3473 push_line(&mut snapshot, "memory_pools");
3474 for pool in &self.memory_pools {
3475 push_line(&mut snapshot, format!(" memory pool {}", pool.name));
3476 if let Some(limit) = pool.context_limit {
3479 push_line(&mut snapshot, format!(" context limit {limit}"));
3480 }
3481 }
3482 }
3483
3484 if !self.agents.is_empty() {
3485 push_line(&mut snapshot, "agents");
3486 for agent in &self.agents {
3487 let profile = agent.profile.as_deref().unwrap_or("<missing>");
3488 let harness = agent.harness.as_deref().unwrap_or("<fallback>");
3489 let provider = agent.provider.as_deref().unwrap_or("<fallback>");
3490 let capacity = agent
3491 .capacity
3492 .map(|capacity| capacity.to_string())
3493 .unwrap_or_else(|| "<missing>".to_owned());
3494 let skills = if agent.skills.is_empty() {
3495 "[]".to_owned()
3496 } else {
3497 format!("[{}]", agent.skills.join(", "))
3498 };
3499 let capabilities = if agent.capabilities.is_empty() {
3500 "[]".to_owned()
3501 } else {
3502 format!("[{}]", agent.capabilities.join(", "))
3503 };
3504 let tools = if agent.tools.is_empty() {
3505 "[]".to_owned()
3506 } else {
3507 format!("[{}]", agent.tools.join(", "))
3508 };
3509 let requires = if agent.requires.is_empty() {
3512 String::new()
3513 } else {
3514 format!(" requires=[{}]", agent.requires.join(", "))
3515 };
3516 let compaction = agent
3519 .compaction
3520 .as_deref()
3521 .map(|strategy| format!(" compaction={strategy}"))
3522 .unwrap_or_default();
3523 let settings = agent
3525 .settings
3526 .as_deref()
3527 .map(|sources| format!(" settings={sources}"))
3528 .unwrap_or_default();
3529 let thread = agent
3531 .thread
3532 .as_deref()
3533 .map(|mode| format!(" thread={mode}"))
3534 .unwrap_or_default();
3535 let class = match agent.harness_class {
3538 HarnessClass::Delegated => " class=delegated",
3539 HarnessClass::Managed => "",
3540 };
3541 push_line(
3542 &mut snapshot,
3543 format!(
3544 " agent {} harness={} provider={} profile={} capacity={} skills={} capabilities={} tools={}{}{}{}{}{}",
3545 agent.name, harness, provider, profile, capacity, skills, capabilities, tools, requires, compaction, settings, thread, class
3546 ),
3547 );
3548 }
3549 }
3550
3551 if !self.coerces.is_empty() {
3552 push_line(&mut snapshot, "coerces");
3553 for coerce in &self.coerces {
3554 let params = coerce
3555 .params
3556 .iter()
3557 .map(|param| format!("{} {}", param.name, param.ty.to_snapshot()))
3558 .collect::<Vec<_>>()
3559 .join(", ");
3560 push_line(
3561 &mut snapshot,
3562 format!(
3563 " coerce {}({}) -> {}",
3564 coerce.name,
3565 params,
3566 coerce.output.to_snapshot()
3567 ),
3568 );
3569 }
3570 }
3571
3572 if !self.assertions.is_empty() {
3573 push_line(&mut snapshot, "assertions");
3574 for assertion in &self.assertions {
3575 push_line(
3576 &mut snapshot,
3577 format!(" assert {}", assertion.expr.expr.to_snapshot()),
3578 );
3579 if !assertion.projection_reads.is_empty() {
3580 push_line(&mut snapshot, " reads");
3581 for read in &assertion.projection_reads {
3582 push_line(&mut snapshot, format!(" {}", read.to_snapshot()));
3583 }
3584 }
3585 }
3586 }
3587
3588 if !self.rules.is_empty() {
3589 push_line(&mut snapshot, "rules");
3590 for rule in &self.rules {
3591 push_line(&mut snapshot, format!(" rule {}", rule.name));
3592 for when in &rule.whens {
3593 match &when.guard {
3594 Some(guard) => push_line(
3595 &mut snapshot,
3596 format!(
3597 " when {} where {}",
3598 when.pattern,
3599 guard.expr.to_snapshot()
3600 ),
3601 ),
3602 None => push_line(&mut snapshot, format!(" when {}", when.pattern)),
3603 }
3604 }
3605 if !rule.metadata.fact_reads.is_empty() {
3606 push_line(&mut snapshot, " reads");
3607 for read in &rule.metadata.fact_reads {
3608 push_line(&mut snapshot, format!(" {}", read));
3609 }
3610 }
3611 if !rule.metadata.projection_reads.is_empty() {
3612 push_line(&mut snapshot, " projection_reads");
3613 for read in &rule.metadata.projection_reads {
3614 push_line(&mut snapshot, format!(" {}", read.to_snapshot()));
3615 }
3616 }
3617 if !rule.metadata.fact_writes.is_empty() {
3618 push_line(&mut snapshot, " writes");
3619 for write in &rule.metadata.fact_writes {
3620 push_line(&mut snapshot, format!(" {}", write));
3621 }
3622 }
3623 if !rule.metadata.record_sources.is_empty() {
3624 push_line(&mut snapshot, " record_sources");
3625 for source in &rule.metadata.record_sources {
3626 push_line(
3627 &mut snapshot,
3628 format!(
3629 " schema:{} construct={} span={}..{}",
3630 source.schema, source.construct, source.span.start, source.span.end
3631 ),
3632 );
3633 }
3634 }
3635 if !rule.metadata.fact_consumes.is_empty() {
3636 push_line(&mut snapshot, " consumes");
3637 for consumed in &rule.metadata.fact_consumes {
3638 push_line(&mut snapshot, format!(" {}", consumed));
3639 }
3640 }
3641 if !rule.metadata.effects.is_empty() {
3642 push_line(&mut snapshot, " effects");
3643 for effect in &rule.metadata.effects {
3644 let binding = effect.binding.as_deref().unwrap_or("-");
3645 let construct = effect
3646 .construct_use
3647 .as_ref()
3648 .map(|form| {
3649 format!(" construct={}->{}", form.keyword, form.target_capability)
3650 })
3651 .unwrap_or_default();
3652 let grants = if effect.access_grants.is_empty() {
3655 String::new()
3656 } else {
3657 let rendered = effect
3658 .access_grants
3659 .iter()
3660 .map(|grant| {
3661 let ops = grant
3662 .operations
3663 .iter()
3664 .map(|op| op.operation.as_str())
3665 .collect::<Vec<_>>()
3666 .join(",");
3667 format!("{}[{ops}]", grant.resource)
3668 })
3669 .collect::<Vec<_>>()
3670 .join(";");
3671 format!(" grants={rendered}")
3672 };
3673 let homing = effect
3676 .on_stream
3677 .as_ref()
3678 .map(|stream| format!(" on_stream={stream}"))
3679 .unwrap_or_default();
3680 let skills = if effect.turn_skills.is_empty() {
3681 String::new()
3682 } else {
3683 format!(" skills={}", effect.turn_skills.join(","))
3684 };
3685 push_line(
3686 &mut snapshot,
3687 format!(
3688 " {} kind={} binding={}{} key={}{}{}{}",
3689 effect.id,
3690 effect.kind.as_str(),
3691 binding,
3692 construct,
3693 effect.idempotency_key,
3694 grants,
3695 skills,
3696 homing
3697 ),
3698 );
3699 }
3700 }
3701 if !rule.metadata.dependencies.is_empty() {
3702 push_line(&mut snapshot, " dependencies");
3703 for dependency in &rule.metadata.dependencies {
3704 push_line(
3705 &mut snapshot,
3706 format!(
3707 " {} --{}--> {}",
3708 dependency.upstream,
3709 dependency.predicate.as_str(),
3710 dependency.downstream
3711 ),
3712 );
3713 }
3714 }
3715 if !rule.metadata.case_branches.is_empty() {
3716 push_line(&mut snapshot, " case_branches");
3717 for branch in &rule.metadata.case_branches {
3718 let guard = branch
3719 .guard
3720 .as_ref()
3721 .map(|guard| guard.expr.to_snapshot())
3722 .unwrap_or_else(|| "-".to_owned());
3723 push_line(
3724 &mut snapshot,
3725 format!(
3726 " case {} type={} pattern={} guard={} body_hash={} span={}..{}",
3727 branch.scrutinee,
3728 branch.scrutinee_type.to_snapshot(),
3729 branch.pattern.to_snapshot(),
3730 guard,
3731 branch.body_hash,
3732 branch.pattern_span.start,
3733 branch.pattern_span.end
3734 ),
3735 );
3736 }
3737 }
3738 if !rule.metadata.terminal_outputs.is_empty() {
3739 push_line(&mut snapshot, " terminal_outputs");
3740 for output in &rule.metadata.terminal_outputs {
3741 push_line(
3742 &mut snapshot,
3743 format!(
3744 " {} span={}..{}",
3745 output.binding, output.span.start, output.span.end
3746 ),
3747 );
3748 for alternative in &output.alternatives {
3749 push_line(
3750 &mut snapshot,
3751 format!(
3752 " {} payload={} span={}..{}",
3753 alternative.tag,
3754 alternative.payload_type.to_snapshot(),
3755 alternative.source_span.start,
3756 alternative.source_span.end
3757 ),
3758 );
3759 }
3760 }
3761 }
3762 if !rule.metadata.terminal_branches.is_empty() {
3763 push_line(&mut snapshot, " terminal_branches");
3764 for branch in &rule.metadata.terminal_branches {
3765 let tag = branch.tag.as_deref().unwrap_or("_");
3766 let binding = branch.binding.as_deref().unwrap_or("-");
3767 let guard = branch
3768 .guard
3769 .as_ref()
3770 .map(|guard| guard.expr.to_snapshot())
3771 .unwrap_or_else(|| "-".to_owned());
3772 push_line(
3773 &mut snapshot,
3774 format!(
3775 " case {} {} binding={} guard={} body_hash={} span={}..{}",
3776 branch.scrutinee,
3777 tag,
3778 binding,
3779 guard,
3780 branch.body_hash,
3781 branch.pattern_span.start,
3782 branch.pattern_span.end
3783 ),
3784 );
3785 }
3786 }
3787 push_line(
3788 &mut snapshot,
3789 format!(" body_hash {}", stable_hash(&rule.body)),
3790 );
3791 }
3792 }
3793
3794 if !self.rule_dependencies.is_empty() {
3795 push_line(&mut snapshot, "rule_dependencies");
3796 for dependency in &self.rule_dependencies {
3797 push_line(
3798 &mut snapshot,
3799 format!(
3800 " {} --{}--> {}",
3801 dependency.producer, dependency.fact, dependency.consumer
3802 ),
3803 );
3804 }
3805 }
3806
3807 snapshot
3808 }
3809}
3810
3811fn register_standard_library(libraries: &mut BTreeMap<String, LibraryRegistration>, id: &str) {
3812 libraries
3813 .entry(id.to_owned())
3814 .or_insert_with(|| LibraryRegistration {
3815 id: id.to_owned(),
3816 version: "0.1.0".to_owned(),
3817 standard: true,
3818 });
3819}
3820
3821fn register_effect_contract(
3822 libraries: &mut BTreeMap<String, LibraryRegistration>,
3823 contracts: &mut BTreeMap<(String, String), EffectContract>,
3824 kind: IrEffectKind,
3825 required_capabilities: Vec<String>,
3826) {
3827 let contract = effect_contract_for_kind(kind, required_capabilities);
3828 register_standard_library(libraries, contract.library_id.as_str());
3829 contracts
3830 .entry((contract.id.clone(), contract.version.clone()))
3831 .and_modify(|existing| {
3832 merge_unique(
3833 &mut existing.required_capabilities,
3834 &contract.required_capabilities,
3835 );
3836 merge_unique(&mut existing.provider_kinds, &contract.provider_kinds);
3837 merge_unique(&mut existing.source_forms, &contract.source_forms);
3838 merge_unique(&mut existing.projected_facts, &contract.projected_facts);
3839 })
3840 .or_insert(contract);
3841}
3842
3843fn merge_unique(target: &mut Vec<String>, values: &[String]) {
3844 for value in values {
3845 if !target.contains(value) {
3846 target.push(value.clone());
3847 }
3848 }
3849 target.sort();
3850}
3851
3852fn strings(values: &[&str]) -> Vec<String> {
3853 values.iter().map(|value| (*value).to_owned()).collect()
3854}
3855
3856fn effect_contract_for_kind(
3857 kind: IrEffectKind,
3858 required_capabilities: Vec<String>,
3859) -> EffectContract {
3860 let mut required_capabilities = required_capabilities;
3861 required_capabilities.sort();
3862 required_capabilities.dedup();
3863 let effect_kind = kind.as_str().to_owned();
3864
3865 let (
3866 library_id,
3867 source_forms,
3868 input_schema,
3869 output_schema,
3870 default_capabilities,
3871 provider_kinds,
3872 projected_facts,
3873 validation,
3874 ) = match kind {
3875 IrEffectKind::AgentTell => (
3876 "std.agent",
3877 strings(&["tell"]),
3878 Some("agent.turn.request"),
3879 Some("AgentTurn"),
3880 strings(&["agent.turn"]),
3881 strings(&["agent"]),
3882 strings(&["effect.output"]),
3883 TypedOutputValidation::RuntimeBoundary,
3884 ),
3885 IrEffectKind::SchemaCoerce => (
3886 "std.coercion",
3887 strings(&["coerce", "decide", "prompt"]),
3888 Some("schema.coerce.input"),
3889 Some("typed-provider-output"),
3890 strings(&["schema.coerce"]),
3896 strings(&["schema_coercer"]),
3897 strings(&["effect.output"]),
3898 TypedOutputValidation::RuntimeBoundary,
3899 ),
3900 IrEffectKind::CapabilityCall => (
3901 "std.script",
3902 strings(&["call"]),
3903 Some("capability.call.input"),
3904 Some("capability.call.output"),
3905 Vec::new(),
3906 strings(&["capability"]),
3907 strings(&["effect.output"]),
3908 TypedOutputValidation::RuntimeBoundary,
3909 ),
3910 IrEffectKind::EventEmit => (
3911 "std.ingress",
3912 strings(&["emit"]),
3913 Some("event.emit.input"),
3914 None,
3915 Vec::new(),
3916 Vec::new(),
3917 Vec::new(),
3918 TypedOutputValidation::None,
3919 ),
3920 IrEffectKind::WorkflowInvoke => (
3921 "std.workflow",
3922 strings(&["invoke"]),
3923 Some("workflow.invoke.input"),
3924 Some("workflow.terminal"),
3925 Vec::new(),
3926 Vec::new(),
3927 strings(&["effect.output"]),
3928 TypedOutputValidation::RuntimeBoundary,
3929 ),
3930 IrEffectKind::TimerWait => (
3931 "std.time",
3932 strings(&["timer"]),
3933 Some("timer.wait.input"),
3934 Some("TimerElapsed"),
3935 Vec::new(),
3936 Vec::new(),
3937 strings(&["effect.output"]),
3938 TypedOutputValidation::None,
3939 ),
3940 IrEffectKind::ExecCommand => (
3941 "std.script",
3942 strings(&["exec"]),
3943 Some("exec.command.input"),
3944 Some("exec.command.output"),
3945 strings(&["exec.run"]),
3946 strings(&["script", "command"]),
3947 strings(&["effect.output"]),
3948 TypedOutputValidation::RuntimeBoundary,
3949 ),
3950 IrEffectKind::TrackerFile => (
3951 "std.tracker",
3952 strings(&["file"]),
3953 Some("tracker.file.input"),
3954 None,
3955 strings(&["tracker.file"]),
3956 Vec::new(),
3957 Vec::new(),
3958 TypedOutputValidation::None,
3959 ),
3960 IrEffectKind::TrackerClaim => (
3961 "std.tracker",
3962 strings(&["claim"]),
3963 Some("tracker.claim.input"),
3964 Some("TrackerClaim"),
3965 strings(&["tracker.claim"]),
3966 Vec::new(),
3967 strings(&["effect.output"]),
3968 TypedOutputValidation::None,
3969 ),
3970 IrEffectKind::TrackerRenew => (
3975 "std.tracker",
3976 strings(&["renew"]),
3977 Some("tracker.renew.input"),
3978 None,
3979 strings(&["tracker.renew"]),
3980 Vec::new(),
3981 Vec::new(),
3982 TypedOutputValidation::None,
3983 ),
3984 IrEffectKind::TrackerRelease => (
3985 "std.tracker",
3986 strings(&["release"]),
3987 Some("tracker.release.input"),
3988 None,
3989 strings(&["tracker.release"]),
3990 Vec::new(),
3991 Vec::new(),
3992 TypedOutputValidation::None,
3993 ),
3994 IrEffectKind::TrackerFinish => (
3995 "std.tracker",
3996 strings(&["finish"]),
3997 Some("tracker.finish.input"),
3998 None,
3999 strings(&["tracker.finish"]),
4000 Vec::new(),
4001 Vec::new(),
4002 TypedOutputValidation::None,
4003 ),
4004 IrEffectKind::LeaseAcquire => (
4005 "std.coord",
4006 strings(&["acquire"]),
4007 Some("lease.acquire.input"),
4008 Some("LeaseAcquireOutcome"),
4009 Vec::new(),
4010 Vec::new(),
4011 strings(&["effect.output"]),
4012 TypedOutputValidation::None,
4013 ),
4014 IrEffectKind::LeaseRenew => (
4015 "std.coord",
4016 strings(&["renew"]),
4017 Some("lease.renew.input"),
4018 Some("LeaseRenewOutcome"),
4019 Vec::new(),
4020 Vec::new(),
4021 strings(&["effect.output"]),
4022 TypedOutputValidation::None,
4023 ),
4024 IrEffectKind::LedgerAppend => (
4025 "std.coord",
4026 strings(&["append"]),
4027 Some("ledger.append.input"),
4028 None,
4029 Vec::new(),
4030 Vec::new(),
4031 Vec::new(),
4032 TypedOutputValidation::None,
4033 ),
4034 IrEffectKind::CounterConsume => (
4035 "std.coord",
4036 strings(&["consume"]),
4037 Some("counter.consume.input"),
4038 Some("CounterConsumeOutcome"),
4039 Vec::new(),
4040 Vec::new(),
4041 strings(&["effect.output"]),
4042 TypedOutputValidation::None,
4043 ),
4044 IrEffectKind::SignalEmit => (
4045 "std.ingress",
4046 strings(&["emit", "signal"]),
4047 Some("signal.emit.input"),
4048 None,
4049 Vec::new(),
4050 Vec::new(),
4051 Vec::new(),
4052 TypedOutputValidation::None,
4053 ),
4054 IrEffectKind::FileRead => (
4059 "std.files",
4060 strings(&["read"]),
4061 Some("file.read.input"),
4062 Some("FileReadResult"),
4063 strings(&["file.read"]),
4064 Vec::new(),
4065 strings(&["effect.output"]),
4066 TypedOutputValidation::RuntimeBoundary,
4067 ),
4068 IrEffectKind::FileWrite => (
4069 "std.files",
4070 strings(&["write"]),
4071 Some("file.write.input"),
4072 Some("FileWriteResult"),
4073 strings(&["file.write"]),
4074 Vec::new(),
4075 strings(&["effect.output"]),
4076 TypedOutputValidation::RuntimeBoundary,
4077 ),
4078 IrEffectKind::FileImport => (
4079 "std.files",
4080 strings(&["import"]),
4081 Some("file.import.input"),
4082 Some("FileImportResult"),
4083 strings(&["file.import"]),
4084 Vec::new(),
4085 strings(&["effect.output"]),
4086 TypedOutputValidation::RuntimeBoundary,
4087 ),
4088 IrEffectKind::FileExport => (
4089 "std.files",
4090 strings(&["export"]),
4091 Some("file.export.input"),
4092 Some("FileExportResult"),
4093 strings(&["file.export"]),
4094 Vec::new(),
4095 strings(&["effect.output"]),
4096 TypedOutputValidation::RuntimeBoundary,
4097 ),
4098 };
4099
4100 merge_unique(&mut required_capabilities, &default_capabilities);
4101
4102 EffectContract {
4103 id: effect_kind.clone(),
4104 library_id: library_id.to_owned(),
4105 version: "0.1.0".to_owned(),
4106 effect_kind,
4107 source_forms,
4108 input_schema: input_schema.map(str::to_owned),
4109 output_schema: output_schema.map(str::to_owned),
4110 required_capabilities,
4111 provider_kinds,
4112 projected_facts,
4113 validation,
4114 }
4115}
4116
4117impl IrEffectKind {
4118 pub fn as_str(&self) -> &'static str {
4121 match self {
4122 Self::AgentTell => "agent.tell",
4123 Self::SchemaCoerce => "schema.coerce",
4124 Self::CapabilityCall => "capability.call",
4125 Self::EventEmit => "event.emit",
4126 Self::WorkflowInvoke => "workflow.invoke",
4127 Self::TimerWait => "timer.wait",
4128 Self::ExecCommand => "exec.command",
4129 Self::TrackerFile => "tracker.file",
4130 Self::TrackerClaim => "tracker.claim",
4131 Self::TrackerRenew => "tracker.renew",
4132 Self::TrackerRelease => "tracker.release",
4133 Self::TrackerFinish => "tracker.finish",
4134 Self::LeaseAcquire => "lease.acquire",
4135 Self::LeaseRenew => "lease.renew",
4136 Self::LedgerAppend => "ledger.append",
4137 Self::CounterConsume => "counter.consume",
4138 Self::SignalEmit => "signal.emit",
4139 Self::FileRead => "file.read",
4140 Self::FileWrite => "file.write",
4141 Self::FileImport => "file.import",
4142 Self::FileExport => "file.export",
4143 }
4144 }
4145}
4146
4147impl DependencyPredicate {
4148 fn as_str(&self) -> &'static str {
4149 match self {
4150 Self::Succeeds => "succeeds",
4151 Self::Fails => "fails",
4152 Self::TimedOut => "timed_out",
4153 Self::Cancelled => "cancelled",
4154 Self::Completes => "completes",
4155 }
4156 }
4157}
4158
4159impl IrUseKind {
4160 fn as_str(&self) -> &'static str {
4161 match self {
4162 Self::Package => "package",
4163 }
4164 }
4165}
4166
4167impl IrType {
4168 pub fn display_label(&self) -> String {
4171 self.to_snapshot()
4172 }
4173
4174 fn to_snapshot(&self) -> String {
4175 match self {
4176 Self::Primitive(primitive) => primitive.as_str().to_owned(),
4177 Self::LiteralString(value) => format!("literal<{value:?}>"),
4178 Self::Ref(name) => format!("ref<{name}>"),
4179 Self::AgentRef(agents) => format!("agentref<{}>", agents.join(" | ")),
4180 Self::Object(fields) => {
4181 let fields = fields
4182 .iter()
4183 .map(|field| format!("{} {}", field.name, field.ty.to_snapshot()))
4184 .collect::<Vec<_>>()
4185 .join(", ");
4186 format!("object<{{{fields}}}>")
4187 }
4188 Self::Optional(inner) => format!("optional<{}>", inner.to_snapshot()),
4189 Self::Array(inner) => format!("array<{}>", inner.to_snapshot()),
4190 Self::Map(inner) => format!("map<{}>", inner.to_snapshot()),
4191 Self::Union(variants) => {
4192 let variants = variants
4193 .iter()
4194 .map(Self::to_snapshot)
4195 .collect::<Vec<_>>()
4196 .join(" | ");
4197 format!("union<{variants}>")
4198 }
4199 }
4200 }
4201}
4202
4203impl IrPrimitiveType {
4204 fn as_str(&self) -> &'static str {
4205 match self {
4206 Self::String => "string",
4207 Self::Int => "int",
4208 Self::Float => "float",
4209 Self::Bool => "bool",
4210 Self::Null => "null",
4211 Self::Duration => "duration",
4212 Self::Time => "time",
4213 Self::Image => "image",
4214 Self::Audio => "audio",
4215 Self::Pdf => "pdf",
4216 Self::Video => "video",
4217 Self::Secret => "secret",
4218 }
4219 }
4220}
4221
4222fn validate_turn_access_grant_file_operations(ir: &IrProgram, diagnostics: &mut Vec<Diagnostic>) {
4230 const FILE_OPERATIONS: [&str; 4] = ["read", "write", "import", "export"];
4231 let file_stores: BTreeSet<&str> = ir
4232 .file_stores
4233 .iter()
4234 .map(|store| store.name.as_str())
4235 .collect();
4236 for rule in &ir.rules {
4237 for effect in &rule.metadata.effects {
4238 for grant in &effect.access_grants {
4239 if !file_stores.contains(grant.resource.as_str()) {
4240 continue;
4241 }
4242 for op in &grant.operations {
4243 if !FILE_OPERATIONS.contains(&op.operation.as_str()) {
4244 diagnostics.push(Diagnostic { related: Vec::new(),
4245 span: effect.span,
4246 message: format!(
4247 "rule `{}` grants `{}` on file store `{}`, which is not a file operation",
4248 rule.name, op.operation, grant.resource
4249 ),
4250 suggestion: Some(
4251 "file-store grants allow `read`, `write`, `import`, or `export`"
4252 .to_owned(),
4253 ),
4254 });
4255 }
4256 }
4257 }
4258 }
4259 }
4260}
4261
4262fn validate_turn_access_grant_memory_operations(ir: &IrProgram, diagnostics: &mut Vec<Diagnostic>) {
4271 const MEMORY_OPERATIONS: [&str; 3] = ["recall", "learn", "curate"];
4272 let memory_pools: BTreeSet<&str> = ir
4273 .memory_pools
4274 .iter()
4275 .map(|pool| pool.name.as_str())
4276 .collect();
4277 for rule in &ir.rules {
4278 for effect in &rule.metadata.effects {
4279 for grant in &effect.access_grants {
4280 if !memory_pools.contains(grant.resource.as_str()) {
4281 continue;
4282 }
4283 for op in &grant.operations {
4284 if !MEMORY_OPERATIONS.contains(&op.operation.as_str()) {
4285 diagnostics.push(Diagnostic {
4286 related: Vec::new(),
4287 span: effect.span,
4288 message: format!(
4289 "rule `{}` grants `{}` on memory pool `{}`, which is not a memory operation",
4290 rule.name, op.operation, grant.resource
4291 ),
4292 suggestion: Some(
4293 "memory-pool grants allow `recall`, `learn`, or `curate`".to_owned(),
4294 ),
4295 });
4296 }
4297 }
4298 }
4299 }
4300 }
4301}
4302
4303fn validate_file_store_write_policy(ir: &IrProgram, diagnostics: &mut Vec<Diagnostic>) {
4312 let read_only: BTreeSet<&str> = ir
4313 .file_stores
4314 .iter()
4315 .filter(|store| store.write_globs.is_empty())
4316 .map(|store| store.name.as_str())
4317 .collect();
4318 if read_only.is_empty() {
4319 return;
4320 }
4321 fn walk(
4322 statements: &[body::BodyStmt],
4323 rule_name: &str,
4324 read_only: &BTreeSet<&str>,
4325 diagnostics: &mut Vec<Diagnostic>,
4326 ) {
4327 for statement in statements {
4328 match statement {
4329 body::BodyStmt::Effect(effect) => {
4330 let store = match &effect.kind {
4331 body::BodyEffectKind::FileWrite { store, .. }
4332 | body::BodyEffectKind::FileExport { store, .. } => Some(store),
4333 _ => None,
4334 };
4335 if let Some(store) = store {
4336 if read_only.contains(store.as_str()) {
4337 diagnostics.push(Diagnostic {
4338 related: Vec::new(),
4339 span: effect.span,
4340 message: format!(
4341 "rule `{rule_name}` writes to store `{store}`, which permits \
4342 no writes — stores are read-only by default"
4343 ),
4344 suggestion: Some(format!(
4345 "declare `allow write [\"<glob>\", …]` on `file store {store}` \
4346 to permit (and bound) writes"
4347 )),
4348 });
4349 }
4350 }
4351 }
4352 body::BodyStmt::After(after) => {
4353 walk(&after.body, rule_name, read_only, diagnostics)
4354 }
4355 body::BodyStmt::Case(case) => {
4356 for branch in &case.branches {
4357 walk(&branch.body, rule_name, read_only, diagnostics);
4358 }
4359 }
4360 _ => {}
4361 }
4362 }
4363 }
4364 for rule in &ir.rules {
4365 let (ast, _) = body::parse_rule_body(&rule.body, 0);
4366 walk(&ast.statements, &rule.name, &read_only, diagnostics);
4367 }
4368}
4369
4370fn expand_source_emit_from(ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
4377 let events: BTreeMap<String, Vec<String>> = ir
4378 .events
4379 .iter()
4380 .map(|event| {
4381 (
4382 event.name.clone(),
4383 event
4384 .fields
4385 .iter()
4386 .map(|field| field.name.clone())
4387 .collect(),
4388 )
4389 })
4390 .collect();
4391 for source in &mut ir.sources {
4392 let Some(from) = source.emit_from.clone() else {
4393 continue;
4394 };
4395 if from != source.observe_binding {
4396 diagnostics.push(Diagnostic {
4397 related: Vec::new(),
4398 span: source.span,
4399 message: format!(
4400 "source `{}` emits `from {from}`, but the only binding in scope is the observe binding `{}`",
4401 source.name, source.observe_binding
4402 ),
4403 suggestion: Some(format!("write `emit {} from {}`", source.emit_signal, source.observe_binding)),
4404 });
4405 continue;
4406 }
4407 let Some(signal_fields) = events.get(&source.emit_signal) else {
4408 continue;
4410 };
4411 for field in signal_fields {
4412 if source
4413 .emit_fields
4414 .iter()
4415 .any(|existing| &existing.name == field)
4416 {
4417 continue;
4418 }
4419 source.emit_fields.push(IrSourceEmitField {
4420 name: field.clone(),
4421 value: SourceValue::Path {
4422 binding: Ident {
4423 name: from.clone(),
4424 span: source.span,
4425 },
4426 segments: vec![Ident {
4427 name: field.clone(),
4428 span: source.span,
4429 }],
4430 span: source.span,
4431 },
4432 span: source.span,
4433 });
4434 }
4435 }
4436}
4437
4438fn warn_unhandled_effect_failures(ir: &IrProgram, warnings: &mut Vec<Diagnostic>) {
4450 let service = ir
4451 .source_tags
4452 .iter()
4453 .any(|tag| tag.target_kind == "workflow" && tag.name == "service");
4454 if service {
4455 return;
4456 }
4457 for rule in &ir.rules {
4458 for effect in &rule.metadata.effects {
4459 let Some(binding) = effect.binding.as_deref() else {
4460 continue;
4461 };
4462 if effect.kind == IrEffectKind::TimerWait {
4463 continue;
4464 }
4465 if binding.starts_with(then_expand::THEN_BINDING_PREFIX) {
4468 continue;
4469 }
4470 let observed = rule.body.lines().any(|line| {
4471 let Some(rest) = line.trim().strip_prefix("after ") else {
4472 return false;
4473 };
4474 let mut parts = rest.split_whitespace();
4475 if parts.next() != Some(binding) {
4476 return false;
4477 }
4478 matches!(
4480 parts.next().map(|token| token.trim_end_matches('{')),
4481 Some(
4482 "fails"
4483 | "times"
4484 | "completes"
4485 | "held"
4486 | "contended"
4487 | "ok"
4488 | "over"
4489 | "promoted"
4490 | "conflicted"
4491 )
4492 )
4493 });
4494 if observed {
4495 continue;
4496 }
4497 warnings.push(Diagnostic {
4498 related: Vec::new(),
4499 span: effect.span,
4500 message: format!(
4501 "effect `{binding}`'s failure is unhandled in rule `{}`; if it fails or \
4502 times out, the instance will auto-fail with a generic reason",
4503 rule.name
4504 ),
4505 suggestion: Some(format!(
4506 "handle it with `after {binding} fails {{ … }}` (typed failure or recovery) \
4507 or observe every outcome with `after {binding} completes`"
4508 )),
4509 });
4510 }
4511 }
4512}
4513
4514fn warn_counter_without_timezone(ir: &IrProgram, warnings: &mut Vec<Diagnostic>) {
4515 for counter in &ir.counters {
4516 if counter.timezone.is_none() {
4517 warnings.push(Diagnostic {
4518 related: Vec::new(),
4519 span: counter.span,
4520 message: format!(
4521 "counter `{}` declares no `timezone`; its `{}` reset boundary anchors to UTC",
4522 counter.name, counter.reset
4523 ),
4524 suggestion: Some(
4525 "declare `timezone \"<IANA zone>\"` (e.g. `timezone \"America/New_York\"`) to anchor the period locally"
4526 .to_owned(),
4527 ),
4528 });
4529 }
4530 }
4531}
4532
4533fn warn_inert_memory_grant_on_native_adapter(ir: &IrProgram, warnings: &mut Vec<Diagnostic>) {
4538 let memory_pools: BTreeSet<&str> = ir
4539 .memory_pools
4540 .iter()
4541 .map(|pool| pool.name.as_str())
4542 .collect();
4543 if memory_pools.is_empty() {
4544 return;
4545 }
4546 let harness_kind_of: BTreeMap<&str, &str> = ir
4547 .harnesses
4548 .iter()
4549 .map(|harness| (harness.name.as_str(), harness.kind.as_str()))
4550 .collect();
4551 let agent_harness_kind: BTreeMap<&str, &str> = ir
4552 .agents
4553 .iter()
4554 .filter_map(|agent| {
4555 let harness = agent.harness.as_deref()?;
4556 Some((agent.name.as_str(), *harness_kind_of.get(harness)?))
4557 })
4558 .collect();
4559 for rule in &ir.rules {
4560 for effect in &rule.metadata.effects {
4561 let Some(agent) = effect.agent.as_deref() else {
4562 continue;
4563 };
4564 let Some(kind) = agent_harness_kind.get(agent) else {
4565 continue;
4566 };
4567 if !matches!(*kind, "codex" | "claude" | "command") {
4568 continue;
4569 }
4570 for grant in &effect.access_grants {
4571 if memory_pools.contains(grant.resource.as_str()) {
4572 warnings.push(Diagnostic {
4573 related: Vec::new(),
4574 span: effect.span,
4575 message: format!(
4576 "rule `{}` grants memory pool `{}` on a tell to `{agent}`, whose \
4577 harness kind `{kind}` is a native adapter — memory grants only \
4578 take effect on the owned harness, so this grant is inert",
4579 rule.name, grant.resource
4580 ),
4581 suggestion: Some(
4582 "target an owned-harness agent, or drop the memory grant".to_owned(),
4583 ),
4584 });
4585 }
4586 }
4587 }
4588 }
4589}
4590
4591fn detect_pattern_recursion(
4601 patterns: &BTreeMap<String, PatternDecl>,
4602 diagnostics: &mut Vec<Diagnostic>,
4603) -> BTreeSet<String> {
4604 let mut edges: BTreeMap<&str, Vec<(&str, SourceSpan)>> = BTreeMap::new();
4606 for pattern in patterns.values() {
4607 let mut applied = Vec::new();
4608 for item in &pattern.items {
4609 if let Item::Apply(apply) = item {
4610 applied.push((apply.pattern.name.as_str(), apply.span));
4611 }
4612 }
4613 edges.insert(pattern.name.name.as_str(), applied);
4614 }
4615
4616 let find_cycle = |start: &str| -> Option<(Vec<String>, SourceSpan)> {
4619 let mut queue: VecDeque<&str> = VecDeque::new();
4620 let mut predecessor: BTreeMap<&str, (&str, SourceSpan)> = BTreeMap::new();
4622 for &(target, span) in edges.get(start).into_iter().flatten() {
4623 if target == start {
4624 return Some((vec![start.to_owned(), start.to_owned()], span));
4626 }
4627 if predecessor.insert(target, (start, span)).is_none() {
4628 queue.push_back(target);
4629 }
4630 }
4631 while let Some(node) = queue.pop_front() {
4632 for &(target, span) in edges.get(node).into_iter().flatten() {
4633 if target == start {
4634 let mut path = vec![node.to_owned()];
4636 let mut cursor = node;
4637 while cursor != start {
4638 let (from, _) = predecessor[cursor];
4639 path.push(from.to_owned());
4640 cursor = from;
4641 }
4642 path.reverse();
4643 path.push(start.to_owned());
4644 let first = &path[1];
4646 let entry_span = edges
4647 .get(start)
4648 .into_iter()
4649 .flatten()
4650 .find(|(target, _)| target == first)
4651 .map(|(_, span)| *span)
4652 .unwrap_or(span);
4653 return Some((path, entry_span));
4654 }
4655 if predecessor.insert(target, (node, span)).is_none() {
4656 queue.push_back(target);
4657 }
4658 }
4659 }
4660 None
4661 };
4662
4663 let mut recursive = BTreeSet::new();
4664 for name in patterns.keys() {
4667 if recursive.contains(name) {
4668 continue;
4669 }
4670 if let Some((cycle, span)) = find_cycle(name) {
4671 for member in &cycle {
4672 recursive.insert(member.clone());
4673 }
4674 diagnostics.push(Diagnostic { related: Vec::new(),
4675 span,
4676 message: format!(
4677 "recursive pattern application is not allowed (graph.unbounded_pattern_recursion): expansion cycle {}",
4678 cycle.join(" -> ")
4679 ),
4680 suggestion: Some(
4681 "break the cycle: pattern expansion must elaborate into a finite program"
4682 .to_owned(),
4683 ),
4684 });
4685 }
4686 }
4687 recursive
4688}
4689
4690fn detect_workflow_invoke_recursion(program: &Program, diagnostics: &mut Vec<Diagnostic>) {
4701 let mut edges: BTreeMap<String, Vec<(String, SourceSpan)>> = BTreeMap::new();
4706 let record_invokes =
4707 |name: &str, items: &[Item], edges: &mut BTreeMap<String, Vec<(String, SourceSpan)>>| {
4708 let entry = edges.entry(name.to_owned()).or_default();
4709 for item in items {
4710 let Item::Rule(rule) = item else {
4711 continue;
4712 };
4713 for statement in workflow_invoke_statements(&rule.body.text) {
4714 if let Some((target, _)) = invoke_statement_parts(&statement) {
4715 if target != name {
4716 entry.push((target.to_owned(), rule.body.span));
4717 }
4718 }
4719 }
4720 }
4721 };
4722 if let Some(root) = &program.workflow {
4723 record_invokes(&root.name, &program.items, &mut edges);
4724 }
4725 for workflow in &program.workflows {
4726 record_invokes(&workflow.name.name, &workflow.items, &mut edges);
4727 }
4728
4729 let find_cycle = |start: &str| -> Option<(Vec<String>, SourceSpan)> {
4732 let mut queue: VecDeque<&str> = VecDeque::new();
4733 let mut predecessor: BTreeMap<&str, (&str, SourceSpan)> = BTreeMap::new();
4734 for (target, span) in edges.get(start).into_iter().flatten() {
4735 if predecessor
4736 .insert(target.as_str(), (start, *span))
4737 .is_none()
4738 {
4739 queue.push_back(target.as_str());
4740 }
4741 }
4742 while let Some(node) = queue.pop_front() {
4743 for (target, span) in edges.get(node).into_iter().flatten() {
4744 if target == start {
4745 let mut path = vec![node.to_owned()];
4746 let mut cursor = node;
4747 while cursor != start {
4748 let (from, _) = predecessor[cursor];
4749 path.push(from.to_owned());
4750 cursor = from;
4751 }
4752 path.reverse();
4753 path.push(start.to_owned());
4754 let first = &path[1];
4755 let entry_span = edges
4756 .get(start)
4757 .into_iter()
4758 .flatten()
4759 .find(|(target, _)| target == first)
4760 .map(|(_, span)| *span)
4761 .unwrap_or(*span);
4762 return Some((path, entry_span));
4763 }
4764 if predecessor.insert(target.as_str(), (node, *span)).is_none() {
4765 queue.push_back(target.as_str());
4766 }
4767 }
4768 }
4769 None
4770 };
4771
4772 let mut flagged: BTreeSet<String> = BTreeSet::new();
4773 for name in edges.keys() {
4774 if flagged.contains(name) {
4775 continue;
4776 }
4777 if let Some((cycle, span)) = find_cycle(name) {
4778 for member in &cycle {
4779 flagged.insert(member.clone());
4780 }
4781 diagnostics.push(Diagnostic {
4782 related: Vec::new(),
4783 span,
4784 message: format!(
4785 "recursive workflow invocation is not allowed (graph.unbounded_workflow_invocation_recursion): invocation cycle {}",
4786 cycle.join(" -> ")
4787 ),
4788 suggestion: Some(
4789 "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"
4790 .to_owned(),
4791 ),
4792 });
4793 }
4794 }
4795}
4796
4797fn detect_private_workflow_invocations(program: &Program, diagnostics: &mut Vec<Diagnostic>) {
4798 let private_workflows = program
4799 .workflows
4800 .iter()
4801 .filter(|workflow| workflow.tags.iter().any(|tag| tag.name == "private"))
4802 .map(|workflow| workflow.name.name.as_str())
4803 .collect::<BTreeSet<_>>();
4804 if private_workflows.is_empty() {
4805 return;
4806 }
4807
4808 let mut record_private_invokes = |caller: &str, items: &[Item]| {
4809 for item in items {
4810 let Item::Rule(rule) = item else {
4811 continue;
4812 };
4813 for statement in workflow_invoke_statements(&rule.body.text) {
4814 let Some((target, _)) = invoke_statement_parts(&statement) else {
4815 continue;
4816 };
4817 if caller == target || !private_workflows.contains(target) {
4818 continue;
4819 }
4820 diagnostics.push(Diagnostic {
4821 related: Vec::new(),
4822 span: rule.body.span,
4823 message: format!(
4824 "rule `{}` invokes private workflow `{target}`",
4825 rule.name.name
4826 ),
4827 suggestion: Some(
4828 "remove `@private` from the target workflow or expose a public wrapper workflow"
4829 .to_owned(),
4830 ),
4831 });
4832 }
4833 }
4834 };
4835
4836 if let Some(root) = &program.workflow {
4837 record_private_invokes(&root.name, &program.items);
4838 }
4839 for workflow in &program.workflows {
4840 record_private_invokes(&workflow.name.name, &workflow.items);
4841 }
4842}
4843
4844fn expand_pattern_applications(
4845 mut program: Program,
4846 diagnostics: &mut Vec<Diagnostic>,
4847) -> (Program, Vec<IrPatternApplication>) {
4848 let mut patterns = BTreeMap::new();
4849 for pattern in &program.patterns {
4850 if patterns
4851 .insert(pattern.name.name.clone(), pattern.clone())
4852 .is_some()
4853 {
4854 diagnostics.push(Diagnostic {
4855 related: Vec::new(),
4856 span: pattern.name.span,
4857 message: format!("pattern `{}` is declared more than once", pattern.name.name),
4858 suggestion: Some("rename one pattern declaration".to_owned()),
4859 });
4860 }
4861 }
4862
4863 let recursive_patterns = detect_pattern_recursion(&patterns, diagnostics);
4870
4871 let mut expanded_items = Vec::new();
4872 let mut applications = Vec::new();
4873 for item in program.items {
4874 let Item::Apply(apply) = item else {
4875 expanded_items.push(item);
4876 continue;
4877 };
4878 let Some(pattern) = patterns.get(&apply.pattern.name) else {
4879 diagnostics.push(Diagnostic {
4880 related: Vec::new(),
4881 span: apply.pattern.span,
4882 message: format!("pattern `{}` was not found", apply.pattern.name),
4883 suggestion: Some("declare the pattern before applying it".to_owned()),
4884 });
4885 continue;
4886 };
4887 if pattern.type_params.len() != apply.type_args.len() {
4888 diagnostics.push(Diagnostic {
4889 related: Vec::new(),
4890 span: apply.span,
4891 message: format!(
4892 "pattern `{}` expects {} type arguments but got {}",
4893 pattern.name.name,
4894 pattern.type_params.len(),
4895 apply.type_args.len()
4896 ),
4897 suggestion: Some("match the pattern type parameter list".to_owned()),
4898 });
4899 continue;
4900 }
4901 let type_substitutions = pattern
4902 .type_params
4903 .iter()
4904 .map(|param| param.name.clone())
4905 .zip(apply.type_args.iter().cloned())
4906 .collect::<BTreeMap<_, _>>();
4907 let value_substitutions = parse_pattern_value_arguments(&apply, diagnostics);
4908 let local_names = pattern_local_names(pattern, &apply.alias.name);
4909 let definition_span = pattern.span;
4910 let application_span = apply.span;
4911 let mut generated = Vec::new();
4912 for pattern_item in pattern.items.iter().cloned() {
4913 if let Some(diagnostic) = pattern_body_admission(&pattern_item, &recursive_patterns) {
4917 diagnostics.push(diagnostic);
4918 continue;
4919 }
4920 if let Some((generated_name, item)) = expand_pattern_item(
4921 pattern_item,
4922 &apply.alias.name,
4923 &type_substitutions,
4924 &value_substitutions,
4925 &local_names,
4926 ) {
4927 generated.push(generated_name);
4928 expanded_items.push(item);
4929 }
4930 }
4931 applications.push(IrPatternApplication {
4932 pattern: pattern.name.name.clone(),
4933 alias: apply.alias.name,
4934 type_args: apply.type_args.into_iter().map(lower_type).collect(),
4935 value_args: value_substitutions
4936 .into_iter()
4937 .map(|(name, value)| IrPatternArgument { name, value })
4938 .collect(),
4939 generated,
4940 definition_span,
4941 application_span,
4942 });
4943 }
4944 program.items = expanded_items;
4945 (program, applications)
4946}
4947
4948fn pattern_local_names(pattern: &PatternDecl, alias: &str) -> BTreeMap<String, String> {
4949 let mut names = BTreeMap::new();
4950 for item in &pattern.items {
4951 match item {
4952 Item::Harness(harness) => {
4953 names.insert(
4954 harness.name.name.clone(),
4955 generated_pattern_name(alias, &harness.name.name),
4956 );
4957 }
4958 Item::Agent(agent) => {
4959 names.insert(
4960 agent.name.name.clone(),
4961 generated_pattern_name(alias, &agent.name.name),
4962 );
4963 }
4964 Item::Enum(enum_decl) => {
4965 names.insert(
4966 enum_decl.name.name.clone(),
4967 generated_pattern_name(alias, &enum_decl.name.name),
4968 );
4969 }
4970 Item::Class(class_decl) => {
4971 names.insert(
4972 class_decl.name.name.clone(),
4973 generated_pattern_name(alias, &class_decl.name.name),
4974 );
4975 }
4976 Item::Coerce(coerce) => {
4977 names.insert(
4978 coerce.name.name.clone(),
4979 generated_pattern_name(alias, &coerce.name.name),
4980 );
4981 }
4982 Item::Rule(rule) => {
4983 names.insert(
4984 rule.name.name.clone(),
4985 generated_pattern_name(alias, &rule.name.name),
4986 );
4987 }
4988 _ => {}
4989 }
4990 }
4991 names
4992}
4993
4994fn generated_pattern_name(alias: &str, name: &str) -> String {
4995 format!("{alias}_{name}")
4996}
4997
4998fn parse_pattern_value_arguments(
4999 apply: &ApplyDecl,
5000 diagnostics: &mut Vec<Diagnostic>,
5001) -> BTreeMap<String, String> {
5002 let mut args = BTreeMap::new();
5003 for line in apply
5004 .body
5005 .text
5006 .lines()
5007 .map(str::trim)
5008 .filter(|line| !line.is_empty())
5009 {
5010 let mut parts = line.splitn(2, char::is_whitespace);
5011 let Some(name) = parts.next().filter(|name| is_identifier(name)) else {
5012 diagnostics.push(Diagnostic {
5013 related: Vec::new(),
5014 span: apply.body.span,
5015 message: format!(
5016 "pattern application `{}` has malformed argument `{line}`",
5017 apply.alias.name
5018 ),
5019 suggestion: Some("write pattern arguments as `name value`".to_owned()),
5020 });
5021 continue;
5022 };
5023 let Some(value) = parts
5024 .next()
5025 .map(str::trim)
5026 .filter(|value| !value.is_empty())
5027 else {
5028 diagnostics.push(Diagnostic {
5029 related: Vec::new(),
5030 span: apply.body.span,
5031 message: format!(
5032 "pattern application `{}` argument `{name}` is missing a value",
5033 apply.alias.name
5034 ),
5035 suggestion: Some("write pattern arguments as `name value`".to_owned()),
5036 });
5037 continue;
5038 };
5039 if args.insert(name.to_owned(), value.to_owned()).is_some() {
5040 diagnostics.push(Diagnostic {
5041 related: Vec::new(),
5042 span: apply.body.span,
5043 message: format!(
5044 "pattern application `{}` passes argument `{name}` more than once",
5045 apply.alias.name
5046 ),
5047 suggestion: Some("remove the duplicate pattern argument".to_owned()),
5048 });
5049 }
5050 }
5051 args
5052}
5053
5054fn pattern_body_admission(
5069 item: &Item,
5070 recursive_patterns: &BTreeSet<String>,
5071) -> Option<Diagnostic> {
5072 match item {
5073 Item::WorkflowContract(contract) => Some(Diagnostic {
5074 related: Vec::new(),
5075 span: contract.span,
5076 message: "workflow contracts are not allowed in pattern bodies".to_owned(),
5077 suggestion: Some(
5078 "declare workflow inputs, outputs, and failures on the workflow".to_owned(),
5079 ),
5080 }),
5081 Item::Pattern(pattern) => Some(Diagnostic {
5082 related: Vec::new(),
5083 span: pattern.span,
5084 message: "nested pattern declarations are not supported in pattern bodies".to_owned(),
5085 suggestion: Some("declare reusable patterns at source top level".to_owned()),
5086 }),
5087 Item::Apply(apply) if !recursive_patterns.contains(&apply.pattern.name) => Some(Diagnostic {
5091 related: Vec::new(),
5092 span: apply.span,
5093 message: "pattern applications inside pattern bodies are not supported yet".to_owned(),
5094 suggestion: Some(
5095 "apply patterns from workflow bodies only in this implementation slice".to_owned(),
5096 ),
5097 }),
5098 Item::Gauge(gauge) => Some(Diagnostic {
5102 related: Vec::new(),
5103 span: gauge.span,
5104 message: "gauge declarations are not allowed in pattern bodies".to_owned(),
5105 suggestion: Some("declare gauges at source top level".to_owned()),
5106 }),
5107 Item::Campaign(campaign) => Some(Diagnostic {
5108 related: Vec::new(),
5109 span: campaign.span,
5110 message: "campaign declarations are not allowed in pattern bodies".to_owned(),
5111 suggestion: Some("declare campaigns at source top level".to_owned()),
5112 }),
5113 Item::Mark(mark) => Some(Diagnostic {
5114 related: Vec::new(),
5115 span: mark.span,
5116 message: "mark declarations are not allowed in pattern bodies".to_owned(),
5117 suggestion: Some("declare marks at source top level".to_owned()),
5118 }),
5119 Item::Rule(rule) => pattern_rule_terminal_span(rule).map(|span| Diagnostic {
5120 related: Vec::new(),
5121 span,
5122 message: format!(
5123 "rule `{}` in a pattern body cannot reach a workflow terminal (`complete`/`fail`)",
5124 rule.name.name
5125 ),
5126 suggestion: Some(
5127 "record a fact in the pattern rule and let a workflow rule decide the terminal outcome"
5128 .to_owned(),
5129 ),
5130 }),
5131 _ => None,
5132 }
5133}
5134
5135fn pattern_rule_terminal_span(rule: &RuleDecl) -> Option<SourceSpan> {
5138 let mut offset = 0usize;
5139 for line in rule.body.text.split_inclusive('\n') {
5140 let trimmed_start = line.trim_start();
5141 let leading = line.len() - trimmed_start.len();
5142 let statement = trimmed_start.trim_end();
5143 if is_pattern_terminal_statement(statement) {
5144 let start = rule.body.span.start + offset + leading;
5145 return Some(SourceSpan {
5146 start,
5147 end: start + statement.len(),
5148 });
5149 }
5150 offset += line.len();
5151 }
5152 None
5153}
5154
5155fn is_pattern_terminal_statement(line: &str) -> bool {
5158 for keyword in ["complete", "fail"] {
5159 if let Some(rest) = line.strip_prefix(keyword) {
5160 if rest.is_empty() || rest.starts_with('{') || rest.starts_with(char::is_whitespace) {
5161 return true;
5162 }
5163 }
5164 }
5165 false
5166}
5167
5168fn expand_pattern_item(
5169 item: Item,
5170 alias: &str,
5171 type_substitutions: &BTreeMap<String, TypeSyntax>,
5172 value_substitutions: &BTreeMap<String, String>,
5173 local_names: &BTreeMap<String, String>,
5174) -> Option<(String, Item)> {
5175 match item {
5176 Item::Include(include) => Some((
5177 format!("include:{}", include.path.value),
5178 Item::Include(include),
5179 )),
5180 Item::Use(use_decl) => Some((format!("use:{}", use_decl.name.value), Item::Use(use_decl))),
5181 Item::Tracker(queue) => {
5182 Some((format!("tracker:{}", queue.name.name), Item::Tracker(queue)))
5183 }
5184 Item::Stream(stream) => {
5185 Some((format!("stream:{}", stream.name.name), Item::Stream(stream)))
5186 }
5187 Item::Channel(channel) => Some((
5188 format!("channel:{}", channel.name.name),
5189 Item::Channel(channel),
5190 )),
5191 Item::Credential(credential) => Some((
5192 format!("credential:{}", credential.name.name),
5193 Item::Credential(credential),
5194 )),
5195 Item::Gauge(_) | Item::Campaign(_) | Item::Mark(_) => None,
5199 Item::FileStore(file_store) => Some((
5200 format!("file-store:{}", file_store.name.name),
5201 Item::FileStore(file_store),
5202 )),
5203 Item::MemoryPool(pool) => Some((
5204 format!("memory-pool:{}", pool.name.name),
5205 Item::MemoryPool(pool),
5206 )),
5207 Item::Event(event) => Some((format!("event:{}", event.name), Item::Event(event))),
5208 Item::Source(source) => {
5209 Some((format!("source:{}", source.name.name), Item::Source(source)))
5210 }
5211 Item::Test(test) => Some((format!("test:{}", test.name.value), Item::Test(test))),
5212 Item::Lease(lease) => Some((format!("lease:{}", lease.name.name), Item::Lease(lease))),
5213 Item::Ledger(ledger) => {
5214 Some((format!("ledger:{}", ledger.name.name), Item::Ledger(ledger)))
5215 }
5216 Item::Counter(counter) => Some((
5217 format!("counter:{}", counter.name.name),
5218 Item::Counter(counter),
5219 )),
5220 Item::Action(action) => {
5221 Some((format!("action:{}", action.name.name), Item::Action(action)))
5222 }
5223 Item::Harness(mut harness) => {
5224 let name = rename_ident(harness.name, alias, local_names);
5225 let generated = format!("harness:{}", name.name);
5226 harness.name = name;
5227 Some((generated, Item::Harness(harness)))
5228 }
5229 Item::WorkflowContract(_) | Item::Pattern(_) | Item::Apply(_) => None,
5233 Item::Agent(mut agent) => {
5234 let name = rename_ident(agent.name, alias, local_names);
5235 let generated = format!("agent:{}", name.name);
5236 agent.name = name;
5237 if let Some(harness) = agent.harness {
5238 agent.harness = Some(Ident {
5239 name: local_names
5240 .get(&harness.name)
5241 .cloned()
5242 .unwrap_or(harness.name),
5243 span: harness.span,
5244 });
5245 }
5246 Some((generated, Item::Agent(agent)))
5247 }
5248 Item::Enum(mut enum_decl) => {
5249 let name = rename_ident(enum_decl.name, alias, local_names);
5250 let generated = format!("enum:{}", name.name);
5251 enum_decl.name = name;
5252 Some((generated, Item::Enum(enum_decl)))
5253 }
5254 Item::Class(mut class_decl) => {
5255 let name = rename_ident(class_decl.name, alias, local_names);
5256 let generated = format!("class:{}", name.name);
5257 class_decl.name = name;
5258 for field in &mut class_decl.fields {
5259 field.ty =
5260 substitute_pattern_type(field.ty.clone(), type_substitutions, local_names);
5261 }
5262 Some((generated, Item::Class(class_decl)))
5263 }
5264 Item::Table(mut table) => {
5265 let name = rename_ident(table.name, alias, local_names);
5266 let generated = format!("table:{}", name.name);
5267 table.name = name;
5268 for row in &mut table.rows {
5269 row.body.text = substitute_pattern_text(
5270 &row.body.text,
5271 type_substitutions,
5272 value_substitutions,
5273 local_names,
5274 );
5275 }
5276 Some((generated, Item::Table(table)))
5277 }
5278 Item::Coerce(mut coerce) => {
5279 let name = rename_ident(coerce.name, alias, local_names);
5280 let generated = format!("coerce:{}", name.name);
5281 coerce.name = name;
5282 for param in &mut coerce.params {
5283 param.ty =
5284 substitute_pattern_type(param.ty.clone(), type_substitutions, local_names);
5285 }
5286 coerce.output =
5287 substitute_pattern_type(coerce.output.clone(), type_substitutions, local_names);
5288 coerce.body.text = substitute_pattern_text(
5289 &coerce.body.text,
5290 type_substitutions,
5291 value_substitutions,
5292 local_names,
5293 );
5294 Some((generated, Item::Coerce(coerce)))
5295 }
5296 Item::Assert(mut assertion) => {
5297 assertion.expr = substitute_pattern_text(
5298 &assertion.expr,
5299 type_substitutions,
5300 value_substitutions,
5301 local_names,
5302 );
5303 Some((format!("assert:{alias}"), Item::Assert(assertion)))
5304 }
5305 Item::Rule(mut rule) => {
5306 let name = rename_ident(rule.name, alias, local_names);
5307 let generated = format!("rule:{}", name.name);
5308 rule.name = name;
5309 for when in &mut rule.whens {
5310 when.text = substitute_pattern_text(
5311 &when.text,
5312 type_substitutions,
5313 value_substitutions,
5314 local_names,
5315 );
5316 }
5317 rule.body.text = substitute_pattern_text(
5318 &rule.body.text,
5319 type_substitutions,
5320 value_substitutions,
5321 local_names,
5322 );
5323 Some((generated, Item::Rule(rule)))
5324 }
5325 }
5326}
5327
5328fn rename_ident(ident: Ident, alias: &str, local_names: &BTreeMap<String, String>) -> Ident {
5329 Ident {
5330 name: local_names
5331 .get(&ident.name)
5332 .cloned()
5333 .unwrap_or_else(|| generated_pattern_name(alias, &ident.name)),
5334 span: ident.span,
5335 }
5336}
5337
5338fn substitute_pattern_type(
5339 ty: TypeSyntax,
5340 type_substitutions: &BTreeMap<String, TypeSyntax>,
5341 local_names: &BTreeMap<String, String>,
5342) -> TypeSyntax {
5343 match ty {
5344 TypeSyntax::Ref { name } => {
5345 if let Some(replacement) = type_substitutions.get(&name.name) {
5346 return replacement.clone();
5347 }
5348 TypeSyntax::Ref {
5349 name: Ident {
5350 name: local_names.get(&name.name).cloned().unwrap_or(name.name),
5351 span: name.span,
5352 },
5353 }
5354 }
5355 TypeSyntax::AgentRef { agents, span } => TypeSyntax::AgentRef {
5356 agents: agents
5357 .into_iter()
5358 .map(|agent| Ident {
5359 name: local_names.get(&agent.name).cloned().unwrap_or(agent.name),
5360 span: agent.span,
5361 })
5362 .collect(),
5363 span,
5364 },
5365 TypeSyntax::Optional { inner, span } => TypeSyntax::Optional {
5366 inner: Box::new(substitute_pattern_type(
5367 *inner,
5368 type_substitutions,
5369 local_names,
5370 )),
5371 span,
5372 },
5373 TypeSyntax::Array { inner, span } => TypeSyntax::Array {
5374 inner: Box::new(substitute_pattern_type(
5375 *inner,
5376 type_substitutions,
5377 local_names,
5378 )),
5379 span,
5380 },
5381 TypeSyntax::Map { inner, span } => TypeSyntax::Map {
5382 inner: Box::new(substitute_pattern_type(
5383 *inner,
5384 type_substitutions,
5385 local_names,
5386 )),
5387 span,
5388 },
5389 TypeSyntax::Union { variants, span } => TypeSyntax::Union {
5390 variants: variants
5391 .into_iter()
5392 .map(|variant| substitute_pattern_type(variant, type_substitutions, local_names))
5393 .collect(),
5394 span,
5395 },
5396 other => other,
5397 }
5398}
5399
5400fn substitute_pattern_text(
5420 text: &str,
5421 type_substitutions: &BTreeMap<String, TypeSyntax>,
5422 value_substitutions: &BTreeMap<String, String>,
5423 local_names: &BTreeMap<String, String>,
5424) -> String {
5425 let mut output = String::with_capacity(text.len());
5426 let mut rest = text;
5427 while let Some(start) = rest.find(is_identifier_char) {
5428 output.push_str(&rest[..start]);
5429 let after = &rest[start..];
5430 let end = after
5431 .find(|ch| !is_identifier_char(ch))
5432 .unwrap_or(after.len());
5433 let token = &after[..end];
5434 match resolve_pattern_token(token, type_substitutions, value_substitutions, local_names) {
5435 Some(replacement) => output.push_str(&replacement),
5436 None => output.push_str(token),
5437 }
5438 rest = &after[end..];
5439 }
5440 output.push_str(rest);
5441 output
5442}
5443
5444fn resolve_pattern_token(
5449 token: &str,
5450 type_substitutions: &BTreeMap<String, TypeSyntax>,
5451 value_substitutions: &BTreeMap<String, String>,
5452 local_names: &BTreeMap<String, String>,
5453) -> Option<String> {
5454 if let Some(ty) = type_substitutions.get(token) {
5455 return Some(ty.to_source());
5456 }
5457 if let Some(local) = local_names.get(token) {
5458 return Some(local.clone());
5459 }
5460 value_substitutions.get(token).cloned()
5461}
5462
5463fn is_identifier_char(ch: char) -> bool {
5464 ch.is_ascii_alphanumeric() || ch == '_' || ch == '-'
5465}
5466
5467fn collect_projection_reads(expr: &Expr) -> Vec<IrProjectionRead> {
5468 let mut reads = Vec::new();
5469 collect_projection_reads_into(expr, &mut reads);
5470 reads
5471}
5472
5473fn collect_projection_reads_into(expr: &Expr, reads: &mut Vec<IrProjectionRead>) {
5474 match expr {
5475 Expr::Literal(_) | Expr::Path(_) => {}
5476 Expr::Index { target, key } => {
5477 collect_projection_reads_into(target, reads);
5478 collect_projection_reads_into(key, reads);
5479 }
5480 Expr::Array(items) => {
5481 for item in items {
5482 collect_projection_reads_into(item, reads);
5483 }
5484 }
5485 Expr::Object(fields) => {
5486 for field in fields {
5487 collect_projection_reads_into(&field.value, reads);
5488 }
5489 }
5490 Expr::Unary { expr, .. } => collect_projection_reads_into(expr, reads),
5491 Expr::Binary { left, right, .. } => {
5492 collect_projection_reads_into(left, reads);
5493 collect_projection_reads_into(right, reads);
5494 }
5495 Expr::Call { args, .. } => {
5496 for arg in args {
5497 collect_projection_reads_into(arg, reads);
5498 }
5499 }
5500 Expr::Query { kind, head, guard } => {
5501 reads.push(IrProjectionRead {
5502 kind: *kind,
5503 head: head.clone(),
5504 guard: guard.as_ref().map(|guard| guard.to_snapshot()),
5505 });
5506 if let Some(guard) = guard {
5507 collect_projection_reads_into(guard, reads);
5508 }
5509 }
5510 }
5511}
5512
5513fn sort_projection_reads(reads: &mut Vec<IrProjectionRead>) {
5514 reads.sort_by_key(IrProjectionRead::to_snapshot);
5515 reads.dedup();
5516}
5517
5518fn collect_schema_names(program: &Program, diagnostics: &mut Vec<Diagnostic>) -> BTreeSet<String> {
5519 let mut names = BTreeSet::new();
5520 let mut first_spans: BTreeMap<String, SourceSpan> = BTreeMap::new();
5523 for item in &program.items {
5524 let name = match item {
5525 Item::Enum(enum_decl) => &enum_decl.name,
5526 Item::Class(class_decl) => &class_decl.name,
5527 _ => continue,
5528 };
5529
5530 if !names.insert(name.name.clone()) {
5531 let mut diagnostic = Diagnostic {
5532 related: Vec::new(),
5533 span: name.span,
5534 message: format!("schema `{}` is declared more than once", name.name),
5535 suggestion: Some("rename one declaration or merge the schemas".to_owned()),
5536 };
5537 if let Some(first) = first_spans.get(&name.name) {
5538 diagnostic = diagnostic.with_related(*first, "first declared here");
5539 }
5540 diagnostics.push(diagnostic);
5541 } else {
5542 first_spans.insert(name.name.clone(), name.span);
5543 }
5544 }
5545
5546 names
5547}
5548
5549fn collect_harness_kinds(
5550 program: &Program,
5551 diagnostics: &mut Vec<Diagnostic>,
5552) -> BTreeMap<String, String> {
5553 let mut kinds: BTreeMap<String, String> = BTreeMap::new();
5554 for item in &program.items {
5555 let Item::Harness(harness) = item else {
5556 continue;
5557 };
5558 if kinds
5559 .insert(harness.name.name.clone(), harness.kind.name.clone())
5560 .is_some()
5561 {
5562 diagnostics.push(Diagnostic {
5563 related: Vec::new(),
5564 span: harness.name.span,
5565 message: format!("harness `{}` is declared more than once", harness.name.name),
5566 suggestion: Some(
5567 "rename one harness declaration or merge the harness settings".to_owned(),
5568 ),
5569 });
5570 }
5571 }
5572 kinds
5573}
5574
5575fn collect_agent_names(program: &Program, diagnostics: &mut Vec<Diagnostic>) -> BTreeSet<String> {
5576 let mut names = BTreeSet::new();
5577 for item in &program.items {
5578 let Item::Agent(agent) = item else {
5579 continue;
5580 };
5581 if !names.insert(agent.name.name.clone()) {
5582 diagnostics.push(Diagnostic {
5583 related: Vec::new(),
5584 span: agent.name.span,
5585 message: format!("agent `{}` is declared more than once", agent.name.name),
5586 suggestion: Some("rename one agent declaration or merge the settings".to_owned()),
5587 });
5588 }
5589 }
5590 names
5591}
5592
5593#[derive(Clone, Debug, Default, Eq, PartialEq)]
5594struct WorkflowContractNames {
5595 inputs: BTreeMap<String, TypeSyntax>,
5596 outputs: BTreeMap<String, TypeSyntax>,
5597 failures: BTreeMap<String, TypeSyntax>,
5598}
5599
5600fn collect_workflow_contract_names(
5601 program: &Program,
5602 diagnostics: &mut Vec<Diagnostic>,
5603) -> WorkflowContractNames {
5604 let mut names = WorkflowContractNames::default();
5605 for item in &program.items {
5606 let Item::WorkflowContract(contract) = item else {
5607 continue;
5608 };
5609 let set = match contract.kind {
5610 WorkflowContractKind::Input => &mut names.inputs,
5611 WorkflowContractKind::Output => &mut names.outputs,
5612 WorkflowContractKind::Failure => &mut names.failures,
5613 };
5614 if set
5615 .insert(contract.name.name.clone(), contract.ty.clone())
5616 .is_some()
5617 {
5618 diagnostics.push(Diagnostic {
5619 related: Vec::new(),
5620 span: contract.name.span,
5621 message: format!(
5622 "workflow declares {} `{}` more than once",
5623 contract.kind.as_str(),
5624 contract.name.name
5625 ),
5626 suggestion: Some("remove the duplicate workflow contract".to_owned()),
5627 });
5628 }
5629 }
5630 names
5631}
5632
5633impl SemanticContext {
5634 fn from_program(
5635 program: &Program,
5636 workflow_inputs: BTreeMap<String, WorkflowInputSurface>,
5637 ) -> Self {
5638 let mut schemas = SchemaIndex::with_builtins();
5639 let mut agents = BTreeSet::new();
5640 let mut agent_capabilities = BTreeMap::new();
5641 let mut coerce_outputs = BTreeMap::new();
5642 let mut coerce_params = BTreeMap::new();
5643 let mut leases = BTreeSet::new();
5644 let mut ledgers = BTreeSet::new();
5645 let mut counters = BTreeSet::new();
5646 let mut channels = BTreeSet::new();
5647 let mut channel_providers = BTreeMap::new();
5648 let mut credentials = BTreeMap::new();
5649 let mut memory_pools = BTreeSet::new();
5650
5651 for item in &program.items {
5652 schemas.insert_item(item);
5653 match item {
5654 Item::Agent(agent) => {
5655 agents.insert(agent.name.name.clone());
5656 let capabilities = agent
5657 .fields
5658 .iter()
5659 .find_map(|field| match field {
5660 AgentField::Capabilities(capabilities, _) => Some(
5661 capabilities
5662 .iter()
5663 .map(|capability| capability.value.clone())
5664 .collect::<BTreeSet<_>>(),
5665 ),
5666 _ => None,
5667 })
5668 .unwrap_or_default();
5669 agent_capabilities.insert(agent.name.name.clone(), capabilities);
5670 }
5671 Item::Coerce(coerce) => {
5672 coerce_outputs.insert(coerce.name.name.clone(), coerce.output.clone());
5673 coerce_params.insert(coerce.name.name.clone(), coerce.params.clone());
5674 }
5675 Item::Lease(lease) => {
5676 leases.insert(lease.name.name.clone());
5677 }
5678 Item::Ledger(ledger) => {
5679 ledgers.insert(ledger.name.name.clone());
5680 }
5681 Item::Counter(counter) => {
5682 counters.insert(counter.name.name.clone());
5683 }
5684 Item::Channel(channel) => {
5685 channels.insert(channel.name.name.clone());
5686 channel_providers
5687 .insert(channel.name.name.clone(), channel.provider.name.clone());
5688 }
5689 Item::Credential(credential) => {
5690 credentials.insert(
5691 credential.name.name.clone(),
5692 credential.kind.name.replace('_', "-"),
5693 );
5694 }
5695 Item::MemoryPool(pool) => {
5696 memory_pools.insert(pool.name.name.clone());
5697 }
5698 _ => {}
5699 }
5700 }
5701
5702 Self {
5703 workflow: program
5704 .workflow
5705 .as_ref()
5706 .map(|workflow| workflow.name.clone()),
5707 schemas,
5708 agents,
5709 agent_capabilities,
5710 coerce_outputs,
5711 coerce_params,
5712 workflow_inputs,
5713 leases,
5714 ledgers,
5715 counters,
5716 channels,
5717 channel_providers,
5718 credentials,
5719 memory_pools,
5720 regions: BTreeMap::new(),
5721 }
5722 }
5723}
5724
5725fn collect_workflow_input_surfaces(program: &Program) -> BTreeMap<String, WorkflowInputSurface> {
5726 let mut surfaces = BTreeMap::new();
5727 let top_level_schemas = schema_index_for_items(&program.items);
5728
5729 if let Some(workflow) = &program.workflow {
5730 let inputs = workflow_inputs_for_items(&program.items);
5731 surfaces.insert(
5732 workflow.name.clone(),
5733 WorkflowInputSurface {
5734 inputs,
5735 outputs: workflow_outputs_for_items(&program.items),
5736 failures: workflow_failures_for_items(&program.items),
5737 schemas: top_level_schemas.clone(),
5738 milestones: collect_milestone_declarations(&program.items),
5739 },
5740 );
5741 }
5742
5743 for workflow in &program.workflows {
5744 let mut schemas = top_level_schemas.clone();
5745 schemas.merge(schema_index_for_items(&workflow.items));
5746 surfaces.insert(
5747 workflow.name.name.clone(),
5748 WorkflowInputSurface {
5749 inputs: workflow_inputs_for_items(&workflow.items),
5750 outputs: workflow_outputs_for_items(&workflow.items),
5751 failures: workflow_failures_for_items(&workflow.items),
5752 schemas,
5753 milestones: collect_milestone_declarations(&workflow.items),
5754 },
5755 );
5756 }
5757
5758 surfaces
5759}
5760
5761fn collect_shared_coordination_usage(program: &Program) -> Vec<IrSharedCoordinationUsage> {
5762 let global_shared = shared_coordination_declarations(&program.items);
5763 let mut usage: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
5764
5765 let mut record_workflow = |workflow_name: &str, local_items: &[Item]| {
5766 let mut shared = global_shared.clone();
5767 shared.extend(shared_coordination_declarations(local_items));
5768 if shared.is_empty() {
5769 return;
5770 }
5771 let principal = format!("workflow:local/{workflow_name}");
5772 for resource in coordination_resources_used_by_items(&program.items)
5773 .into_iter()
5774 .chain(coordination_resources_used_by_items(local_items))
5775 {
5776 if shared.contains(&resource) {
5777 usage.entry(resource).or_default().insert(principal.clone());
5778 }
5779 }
5780 };
5781
5782 if let Some(workflow) = &program.workflow {
5783 record_workflow(&workflow.name, &[]);
5784 }
5785 for workflow in &program.workflows {
5786 record_workflow(&workflow.name.name, &workflow.items);
5787 }
5788
5789 usage
5790 .into_iter()
5791 .map(|(resource, principals)| IrSharedCoordinationUsage {
5792 resource: format!("resource:{resource}"),
5793 workflow_principals: principals.into_iter().collect(),
5794 })
5795 .collect()
5796}
5797
5798fn shared_coordination_declarations(items: &[Item]) -> BTreeSet<String> {
5799 items
5800 .iter()
5801 .filter_map(|item| match item {
5802 Item::Lease(lease) if lease.shared => Some(lease.name.name.clone()),
5803 Item::Ledger(ledger) if ledger.shared => Some(ledger.name.name.clone()),
5804 Item::Counter(counter) if counter.shared => Some(counter.name.name.clone()),
5805 _ => None,
5806 })
5807 .collect()
5808}
5809
5810fn coordination_resources_used_by_items(items: &[Item]) -> BTreeSet<String> {
5811 let mut resources = BTreeSet::new();
5812 for item in items {
5813 let Item::Rule(rule) = item else {
5814 continue;
5815 };
5816 let (body, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
5817 collect_coordination_resources_from_statements(&body.statements, &mut resources);
5818 }
5819 resources
5820}
5821
5822fn collect_coordination_resources_from_statements(
5823 statements: &[body::BodyStmt],
5824 resources: &mut BTreeSet<String>,
5825) {
5826 for statement in statements {
5827 match statement {
5828 body::BodyStmt::Effect(effect) => match &effect.kind {
5829 body::BodyEffectKind::LeaseAcquire { resource, .. } => {
5830 resources.insert(resource.clone());
5831 }
5832 body::BodyEffectKind::LedgerAppend { ledger, .. } => {
5833 resources.insert(ledger.clone());
5834 }
5835 body::BodyEffectKind::CounterConsume { counter, .. } => {
5836 resources.insert(counter.clone());
5837 }
5838 _ => {}
5839 },
5840 body::BodyStmt::After(after) => {
5841 collect_coordination_resources_from_statements(&after.body, resources);
5842 }
5843 body::BodyStmt::Region(region) => {
5844 collect_coordination_resources_from_statements(®ion.body, resources);
5845 collect_coordination_resources_from_statements(®ion.lapse_body, resources);
5846 }
5847 body::BodyStmt::Case(case_stmt) => {
5848 for branch in &case_stmt.branches {
5849 collect_coordination_resources_from_statements(&branch.body, resources);
5850 }
5851 }
5852 body::BodyStmt::Record(_)
5853 | body::BodyStmt::Done { .. }
5854 | body::BodyStmt::Terminal(_)
5855 | body::BodyStmt::Cancel { .. }
5856 | body::BodyStmt::Milestone { .. }
5857 | body::BodyStmt::Redact { .. } => {}
5858 }
5859 }
5860}
5861
5862fn collect_milestone_declarations(items: &[Item]) -> BTreeMap<String, String> {
5868 let mut milestones = BTreeMap::new();
5869 for item in items {
5870 let Item::Rule(rule) = item else {
5871 continue;
5872 };
5873 for (name, class) in milestone_emissions_in_body(&rule.body.text) {
5874 milestones.entry(name).or_insert(class);
5875 }
5876 }
5877 milestones
5878}
5879
5880fn validate_milestone_statements(
5890 rule: &RuleDecl,
5891 semantic: &SemanticContext,
5892 diagnostics: &mut Vec<Diagnostic>,
5893) {
5894 for (name, class) in milestone_emissions_in_body(&rule.body.text) {
5897 if !class.is_empty() && !semantic.schemas.class_exists(&class) {
5898 diagnostics.push(Diagnostic {
5899 related: Vec::new(),
5900 span: rule.body.span,
5901 message: format!(
5902 "rule `{}` emits milestone `{name}` with unknown payload class `{class}`",
5903 rule.name.name
5904 ),
5905 suggestion: Some(format!("declare `class {class}` before projecting it")),
5906 });
5907 }
5908 }
5909
5910 for (binding, milestone) in milestone_reaches_in_body(&rule.body.text) {
5913 let Some(workflow) = invoke_binding_workflow(rule, &binding) else {
5914 diagnostics.push(Diagnostic {
5915 related: Vec::new(),
5916 span: rule.body.span,
5917 message: format!(
5918 "rule `{}` has `after {binding} reaches \"{milestone}\"` for `{binding}`, which is not a workflow-invoke binding in this rule",
5919 rule.name.name
5920 ),
5921 suggestion: Some(
5922 "`reaches` observes a child workflow milestone; bind the child with `invoke W { ... } as <binding>` first"
5923 .to_owned(),
5924 ),
5925 });
5926 continue;
5927 };
5928 let declared = semantic
5929 .workflow_inputs
5930 .get(&workflow)
5931 .map(|surface| surface.milestones.contains_key(&milestone))
5932 .unwrap_or(false);
5933 if !declared {
5934 let available = semantic
5935 .workflow_inputs
5936 .get(&workflow)
5937 .map(|surface| {
5938 surface
5939 .milestones
5940 .keys()
5941 .map(|name| format!("\"{name}\""))
5942 .collect::<Vec<_>>()
5943 .join(", ")
5944 })
5945 .unwrap_or_default();
5946 let suggestion = if available.is_empty() {
5947 format!("workflow `{workflow}` declares no milestones; add `emit milestone \"{milestone}\" ...` to it")
5948 } else {
5949 format!("workflow `{workflow}` declares: {available}")
5950 };
5951 diagnostics.push(Diagnostic {
5952 related: Vec::new(),
5953 span: rule.body.span,
5954 message: format!(
5955 "rule `{}` reaches milestone `{milestone}` that workflow `{workflow}` does not declare",
5956 rule.name.name
5957 ),
5958 suggestion: Some(suggestion),
5959 });
5960 }
5961 }
5962}
5963
5964fn milestone_reaches_in_body(body: &str) -> Vec<(String, String)> {
5967 let mut out = Vec::new();
5968 for raw in body.lines() {
5969 let trimmed = raw.trim();
5970 let Some(rest) = trimmed.strip_prefix("after ") else {
5971 continue;
5972 };
5973 let mut words = rest.split_whitespace();
5974 let Some(binding) = words.next() else {
5975 continue;
5976 };
5977 if words.next() != Some("reaches") {
5978 continue;
5979 }
5980 let Some(quoted) = words.next() else {
5981 continue;
5982 };
5983 if !(quoted.starts_with('"') && quoted.ends_with('"') && quoted.len() >= 2) {
5984 continue;
5985 }
5986 out.push((binding.to_owned(), quoted.trim_matches('"').to_owned()));
5987 }
5988 out
5989}
5990
5991fn invoke_binding_workflow(rule: &RuleDecl, binding: &str) -> Option<String> {
5995 for statement in workflow_invoke_statements(&rule.body.text) {
5996 let (target, _) = invoke_statement_parts(&statement)?;
5997 if let Some(as_binding) = binding_after_as(&statement) {
5998 if as_binding == binding {
5999 return Some(target.to_owned());
6000 }
6001 }
6002 }
6003 None
6004}
6005
6006fn milestone_payload_class(
6014 rule: &RuleDecl,
6015 binding: &str,
6016 milestone: &str,
6017 semantic: &SemanticContext,
6018) -> Option<(String, String)> {
6019 let workflow = invoke_binding_workflow(rule, binding)?;
6020 let surface = semantic.workflow_inputs.get(&workflow)?;
6021 let class = surface.milestones.get(milestone).cloned()?;
6022 Some((workflow, class))
6023}
6024
6025fn invoke_output_class(
6038 rule: &RuleDecl,
6039 binding: &str,
6040 semantic: &SemanticContext,
6041) -> Option<(String, String)> {
6042 let workflow = invoke_binding_workflow(rule, binding)?;
6043 let surface = semantic.workflow_inputs.get(&workflow)?;
6044 if surface.outputs.len() != 1 {
6045 return None;
6046 }
6047 match surface.outputs.values().next()? {
6048 TypeSyntax::Ref { name } if surface.schemas.class_exists(&name.name) => {
6049 Some((workflow, name.name.clone()))
6050 }
6051 _ => None,
6052 }
6053}
6054
6055fn invoke_failure_class(
6067 rule: &RuleDecl,
6068 binding: &str,
6069 semantic: &SemanticContext,
6070) -> Option<(String, String)> {
6071 let workflow = invoke_binding_workflow(rule, binding)?;
6072 let surface = semantic.workflow_inputs.get(&workflow)?;
6073 if surface.failures.len() != 1 {
6074 return None;
6075 }
6076 match surface.failures.values().next()? {
6077 TypeSyntax::Ref { name } if surface.schemas.class_exists(&name.name) => {
6078 Some((workflow, name.name.clone()))
6079 }
6080 _ => None,
6081 }
6082}
6083
6084fn milestone_emissions_in_body(body: &str) -> Vec<(String, String)> {
6089 let mut out = Vec::new();
6090 for raw in body.lines() {
6091 let trimmed = raw.trim();
6092 let Some(rest) = trimmed.strip_prefix("emit milestone ") else {
6093 continue;
6094 };
6095 let rest = rest.trim_start();
6098 if !rest.starts_with('"') {
6099 continue;
6100 }
6101 let Some(close) = rest[1..].find('"') else {
6102 continue;
6103 };
6104 let name = rest[1..=close].to_owned();
6105 let after_name = rest[close + 2..].trim_start();
6106 let class = after_name
6107 .strip_prefix("of ")
6108 .map(|tail| {
6109 tail.trim_start()
6110 .split(|c: char| c.is_whitespace() || c == '{')
6111 .next()
6112 .unwrap_or("")
6113 .to_owned()
6114 })
6115 .unwrap_or_default();
6116 out.push((name, class));
6117 }
6118 out
6119}
6120
6121fn schema_index_for_items(items: &[Item]) -> SchemaIndex {
6122 let mut schemas = SchemaIndex::with_builtins();
6123 for item in items {
6124 schemas.insert_item(item);
6125 }
6126 schemas
6127}
6128
6129fn workflow_inputs_for_items(items: &[Item]) -> BTreeMap<String, TypeSyntax> {
6130 items
6131 .iter()
6132 .filter_map(|item| match item {
6133 Item::WorkflowContract(contract) if contract.kind == WorkflowContractKind::Input => {
6134 Some((contract.name.name.clone(), contract.ty.clone()))
6135 }
6136 _ => None,
6137 })
6138 .collect()
6139}
6140
6141fn workflow_outputs_for_items(items: &[Item]) -> BTreeMap<String, TypeSyntax> {
6142 items
6143 .iter()
6144 .filter_map(|item| match item {
6145 Item::WorkflowContract(contract) if contract.kind == WorkflowContractKind::Output => {
6146 Some((contract.name.name.clone(), contract.ty.clone()))
6147 }
6148 _ => None,
6149 })
6150 .collect()
6151}
6152
6153fn workflow_failures_for_items(items: &[Item]) -> BTreeMap<String, TypeSyntax> {
6154 items
6155 .iter()
6156 .filter_map(|item| match item {
6157 Item::WorkflowContract(contract) if contract.kind == WorkflowContractKind::Failure => {
6158 Some((contract.name.name.clone(), contract.ty.clone()))
6159 }
6160 _ => None,
6161 })
6162 .collect()
6163}
6164
6165impl SchemaIndex {
6166 fn with_builtins() -> Self {
6167 let mut index = Self::default();
6168 index.insert_class(
6169 "AgentTurn",
6170 [
6171 ("id", string_ty()),
6172 ("summary", string_ty()),
6173 ("agent", string_ty()),
6174 ("provider", string_ty()),
6175 ("status", string_ty()),
6176 ("run_id", string_ty()),
6177 ("effect_id", string_ty()),
6178 ],
6179 );
6180 index.insert_class(
6181 "WorkItem",
6182 [
6183 ("id", string_ty()),
6184 ("title", string_ty()),
6185 ("body", string_ty()),
6186 ("queue", string_ty()),
6187 ("status", string_ty()),
6188 ("labels", array_ty(string_ty())),
6189 ],
6190 );
6191 index.insert_class(
6195 "VcsChange",
6196 [
6197 ("branch", string_ty()),
6198 ("cut", string_ty()),
6199 ("path", string_ty()),
6200 ("origin", string_ty()),
6201 ("by", string_ty()),
6202 ("intent", string_ty()),
6203 ("at", string_ty()),
6204 ],
6205 );
6206 index.insert_class(
6207 "VcsContention",
6208 [
6209 ("branch", string_ty()),
6210 ("with", string_ty()),
6211 ("stream", string_ty()),
6212 ("slice", array_ty(string_ty())),
6213 ("at", string_ty()),
6214 ],
6215 );
6216 index.insert_class(
6217 "VcsPromotion",
6218 [
6219 ("branch", string_ty()),
6220 ("stream", string_ty()),
6221 ("cut", string_ty()),
6222 ("at", string_ty()),
6223 ],
6224 );
6225 index.insert_class(
6226 "VcsStall",
6227 [
6228 ("branch", string_ty()),
6229 ("stream", string_ty()),
6230 ("boundary", string_ty()),
6231 ("paths", array_ty(string_ty())),
6232 ("at", string_ty()),
6233 ],
6234 );
6235 index.insert_class(
6236 "Evidence",
6237 [
6238 ("title", string_ty()),
6239 ("path", string_ty()),
6240 ("summary", string_ty()),
6241 ],
6242 );
6243 index.insert_class(
6244 "TerminalFailed",
6245 [
6246 ("reason", string_ty()),
6247 ("summary", string_ty()),
6248 ("effect_id", string_ty()),
6249 ("run_id", string_ty()),
6250 ("kind", string_ty()),
6254 ],
6255 );
6256 index.insert_class(
6263 "TerminalFailedExec",
6264 [
6265 ("reason", string_ty()),
6266 ("summary", string_ty()),
6267 ("effect_id", string_ty()),
6268 ("run_id", string_ty()),
6269 ("kind", string_ty()),
6270 ("exit_code", optional_ty(int_ty())),
6274 ],
6275 );
6276 index.insert_class(
6277 "TerminalFailedCoerce",
6278 [
6279 ("reason", string_ty()),
6280 ("summary", string_ty()),
6281 ("effect_id", string_ty()),
6282 ("run_id", string_ty()),
6283 ("kind", string_ty()),
6284 ("error_class", string_ty()),
6285 ("http_status", optional_ty(int_ty())),
6286 ],
6287 );
6288 index.insert_class(
6289 "TerminalFailedTell",
6290 [
6291 ("reason", string_ty()),
6292 ("summary", string_ty()),
6293 ("effect_id", string_ty()),
6294 ("run_id", string_ty()),
6295 ("kind", string_ty()),
6296 ("error_class", string_ty()),
6297 ],
6298 );
6299 index.insert_class(
6300 "TerminalTimedOut",
6301 [
6302 ("summary", string_ty()),
6303 ("effect_id", string_ty()),
6304 ("run_id", string_ty()),
6305 ],
6306 );
6307 index.insert_class(
6308 "TerminalCancelled",
6309 [
6310 ("summary", string_ty()),
6311 ("effect_id", string_ty()),
6312 ("run_id", string_ty()),
6313 ],
6314 );
6315 index.insert_class(
6322 "TerminalOutcome",
6323 [
6324 ("tag", string_ty()),
6325 ("status", string_ty()),
6326 ("summary", string_ty()),
6327 ("effect_id", string_ty()),
6328 ("run_id", string_ty()),
6329 ],
6330 );
6331 index.insert_class(
6337 "Message",
6338 [
6339 ("message_id", string_ty()),
6340 ("channel", string_ty()),
6341 ("provider", string_ty()),
6342 ("received_at", string_ty()),
6343 ("sender", string_ty()),
6344 ("sender_claims", string_ty()),
6345 ("thread_id", string_ty()),
6346 ("text", string_ty()),
6347 ("markdown", string_ty()),
6348 ("attachments", array_ty(string_ty())),
6349 ("interaction", string_ty()),
6350 ("raw_ref", string_ty()),
6351 ("correlation", string_ty()),
6352 ],
6353 );
6354 index.insert_class(
6365 "MessageSendReceipt",
6366 [
6367 ("message_id", string_ty()),
6368 ("channel", string_ty()),
6369 ("provider", string_ty()),
6370 ("status", string_ty()),
6371 ("provider_message_id", string_ty()),
6372 ("thread_id", string_ty()),
6373 ("destination", string_ty()),
6374 ("accepted_at", string_ty()),
6375 ],
6376 );
6377 index
6378 }
6379
6380 fn insert_class<const N: usize>(&mut self, name: &str, fields: [(&str, TypeSyntax); N]) {
6381 self.classes.insert(
6382 name.to_owned(),
6383 fields
6384 .into_iter()
6385 .map(|(field, ty)| (field.to_owned(), ty))
6386 .collect(),
6387 );
6388 }
6389
6390 fn insert_item(&mut self, item: &Item) {
6391 match item {
6392 Item::Enum(enum_decl) => {
6393 self.enums.insert(
6394 enum_decl.name.name.clone(),
6395 enum_decl
6396 .variants
6397 .iter()
6398 .map(|variant| variant.name.name.clone())
6399 .collect(),
6400 );
6401 for variant in &enum_decl.variants {
6405 if variant.fields.is_empty() {
6406 continue;
6407 }
6408 let mut fields = BTreeMap::new();
6409 fields.insert(
6410 "variant".to_owned(),
6411 TypeSyntax::LiteralString {
6412 value: variant.name.name.clone(),
6413 span: variant.name.span,
6414 },
6415 );
6416 for field in &variant.fields {
6417 fields.insert(field.name.name.clone(), field.ty.clone());
6418 }
6419 self.classes.insert(
6420 format!("{}.{}", enum_decl.name.name, variant.name.name),
6421 fields,
6422 );
6423 }
6424 }
6425 Item::Class(class_decl) => {
6426 self.classes.insert(
6427 class_decl.name.name.clone(),
6428 class_decl
6429 .fields
6430 .iter()
6431 .map(|field| (field.name.name.clone(), field.ty.clone()))
6432 .collect(),
6433 );
6434 self.insert_presence(&class_decl.name.name, &class_decl.fields);
6435 }
6436 Item::Event(event) => {
6437 self.events.insert(event.name.clone());
6438 self.classes.insert(
6442 event.name.clone(),
6443 event
6444 .fields
6445 .iter()
6446 .map(|field| (field.name.name.clone(), field.ty.clone()))
6447 .collect(),
6448 );
6449 self.insert_presence(&event.name, &event.fields);
6450 }
6451 _ => {}
6452 }
6453 }
6454
6455 fn insert_presence(&mut self, schema: &str, fields: &[ClassField]) {
6457 let conditions: BTreeMap<String, (String, String)> = fields
6458 .iter()
6459 .filter_map(|field| {
6460 field
6461 .presence_condition
6462 .clone()
6463 .map(|condition| (field.name.name.clone(), condition))
6464 })
6465 .collect();
6466 if !conditions.is_empty() {
6467 self.presence.insert(schema.to_owned(), conditions);
6468 }
6469 }
6470
6471 fn field_presence(&self, schema: &str, field: &str) -> Option<&(String, String)> {
6473 self.presence
6474 .get(schema)
6475 .and_then(|fields| fields.get(field))
6476 }
6477
6478 fn merge(&mut self, other: SchemaIndex) {
6479 self.classes.extend(other.classes);
6480 self.enums.extend(other.enums);
6481 self.presence.extend(other.presence);
6482 }
6483
6484 fn class_exists(&self, name: &str) -> bool {
6485 self.classes.contains_key(name)
6486 }
6487
6488 fn resolve_field_path(&self, root_schema: &str, path: &[String]) -> Result<TypeSyntax, String> {
6489 if root_schema.contains('.') && !self.classes.contains_key(root_schema) {
6494 return Ok(TypeSyntax::Ref {
6495 name: Ident {
6496 name: root_schema.to_owned(),
6497 span: zero_span(),
6498 },
6499 });
6500 }
6501 let mut schema = root_schema.to_owned();
6502 let mut current = TypeSyntax::Ref {
6503 name: Ident {
6504 name: schema.clone(),
6505 span: zero_span(),
6506 },
6507 };
6508
6509 for field in path {
6510 let Some(fields) = self.classes.get(&schema) else {
6511 return Err(format!("schema `{schema}` has no declared fields"));
6512 };
6513 let Some(field_ty) = fields.get(field) else {
6514 return Err(format!("schema `{schema}` has no field `{field}`"));
6515 };
6516
6517 current = field_ty.clone();
6518 match schema_name_for_path(¤t) {
6519 Some(next_schema) => schema = next_schema,
6520 None if field != path.last().expect("path is non-empty") => {
6521 return Err(format!("field `{field}` is not a schema value"));
6522 }
6523 None => {}
6524 }
6525 }
6526
6527 Ok(current)
6528 }
6529}
6530
6531fn zero_span() -> SourceSpan {
6532 SourceSpan { start: 0, end: 0 }
6533}
6534
6535fn string_ty() -> TypeSyntax {
6536 TypeSyntax::Primitive {
6537 name: "string".to_owned(),
6538 span: zero_span(),
6539 }
6540}
6541
6542fn int_ty() -> TypeSyntax {
6543 TypeSyntax::Primitive {
6544 name: "int".to_owned(),
6545 span: zero_span(),
6546 }
6547}
6548
6549fn optional_ty(inner: TypeSyntax) -> TypeSyntax {
6550 TypeSyntax::Optional {
6551 inner: Box::new(inner),
6552 span: zero_span(),
6553 }
6554}
6555
6556fn array_ty(inner: TypeSyntax) -> TypeSyntax {
6557 TypeSyntax::Array {
6558 inner: Box::new(inner),
6559 span: zero_span(),
6560 }
6561}
6562
6563fn schema_name_for_path(ty: &TypeSyntax) -> Option<String> {
6564 match ty {
6565 TypeSyntax::Ref { name } => Some(name.name.clone()),
6566 TypeSyntax::Optional { inner, .. } => schema_name_for_path(inner),
6567 _ => None,
6568 }
6569}
6570
6571pub const STD_PACKAGE_IDS: &[&str] = &[
6577 "std.agent",
6578 "std.vcs",
6579 "std.coercion",
6580 "std.coord",
6581 "std.files",
6582 "std.human",
6583 "std.ingress",
6584 "std.memory",
6585 "std.messaging",
6586 "std.script",
6587 "std.telemetry",
6588 "std.time",
6589 "std.tracker",
6590 "std.workflow",
6591];
6592
6593fn validate_streams(ir: &IrProgram, diagnostics: &mut Vec<Diagnostic>) {
6598 let mut memberships: Vec<(&str, &str)> = Vec::new(); for stream in &ir.streams {
6600 for (member, span) in stream.members.iter().zip(&stream.member_spans) {
6601 if !ir.agents.iter().any(|agent| agent.name == *member) {
6602 diagnostics.push(Diagnostic {
6603 related: Vec::new(),
6604 span: *span,
6605 message: format!(
6606 "stream `{}` member `{}` is not a declared agent",
6607 stream.name, member
6608 ),
6609 suggestion: Some(
6610 "stream members are agent declarations; declare the agent \
6611 or remove it from the stream"
6612 .to_owned(),
6613 ),
6614 });
6615 continue;
6616 }
6617 if let Some((_, holder)) = memberships.iter().find(|(agent, _)| agent == member) {
6618 diagnostics.push(Diagnostic {
6619 related: Vec::new(),
6620 span: *span,
6621 message: format!("agent `{member}` is already a member of stream `{holder}`",),
6622 suggestion: Some(
6623 "membership is single-valued (the sync topology stays a \
6624 tree): an agent homes to exactly one stream"
6625 .to_owned(),
6626 ),
6627 });
6628 continue;
6629 }
6630 memberships.push((member, &stream.name));
6631 }
6632 }
6633 for rule in &ir.rules {
6636 for effect in &rule.metadata.effects {
6637 if let Some(target) = &effect.on_stream {
6638 if !ir.streams.iter().any(|stream| stream.name == *target) {
6639 diagnostics.push(Diagnostic {
6640 related: Vec::new(),
6641 span: effect.span,
6642 message: format!("`on stream {target}` names an undeclared stream"),
6643 suggestion: Some(
6644 "declare the stream at top level: `stream <name> { members [...] }`"
6645 .to_owned(),
6646 ),
6647 });
6648 }
6649 }
6650 if let Some(source) = &effect.selection_source {
6655 let trimmed = source.trim();
6656 if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') {
6657 let literal = &trimmed[1..trimmed.len() - 1];
6658 if let Err(error) = whipplescript_core::selection::parse(literal) {
6659 diagnostics.push(Diagnostic {
6660 related: Vec::new(),
6661 span: effect.span,
6662 message: format!("the selection does not parse: {error}"),
6663 suggestion: Some(
6664 "selections compose atoms like `path(<glob>)`, `by(<prefix>)`, \
6665 `intent(<prefix>)`, `cut(<id>)` with `|`, `&`, `~`, and \
6666 `dependents-of(...)`"
6667 .to_owned(),
6668 ),
6669 });
6670 }
6671 }
6672 }
6673 for grant in &effect.access_grants {
6679 if grant.resource != "vcs" {
6680 continue;
6681 }
6682 if effect.kind != IrEffectKind::WorkflowInvoke {
6683 diagnostics.push(Diagnostic {
6684 related: Vec::new(),
6685 span: effect.span,
6686 message: "a `vcs` access grant rides an `invoke` only".to_owned(),
6687 suggestion: Some(
6688 "repair authority is orchestration: grant it to a repair \
6689 workflow via `invoke ... with access to vcs { repair for \
6690 <binding> }`; agents never receive it"
6691 .to_owned(),
6692 ),
6693 });
6694 continue;
6695 }
6696 for op in &grant.operations {
6697 if op.operation != "repair" {
6698 diagnostics.push(Diagnostic {
6699 related: Vec::new(),
6700 span: effect.span,
6701 message: format!("unknown `vcs` grant operation `{}`", op.operation),
6702 suggestion: Some(
6703 "the vcs resource grants `repair for <binding>`".to_owned(),
6704 ),
6705 });
6706 continue;
6707 }
6708 let Some(target) = &op.target else {
6709 diagnostics.push(Diagnostic {
6710 related: Vec::new(),
6711 span: effect.span,
6712 message: "`repair` names no binding".to_owned(),
6713 suggestion: Some(
6714 "write `repair for <binding>` where the binding is a \
6715 vcs arming fact this rule matched (e.g. `when reconcile \
6716 stalled as r`)"
6717 .to_owned(),
6718 ),
6719 });
6720 continue;
6721 };
6722 let bound = rule.whens.iter().any(|when| {
6723 binding_after_as(when.pattern.as_str()).as_deref() == Some(target)
6724 });
6725 if !bound {
6726 diagnostics.push(Diagnostic {
6727 related: Vec::new(),
6728 span: effect.span,
6729 message: format!("`repair for {target}` names no binding of this rule"),
6730 suggestion: Some(
6731 "bind the arming fact first: `when reconcile stalled as \
6732 <binding>` (or the dotted `when fact vcs.* as <binding>` \
6733 form)"
6734 .to_owned(),
6735 ),
6736 });
6737 }
6738 }
6739 }
6740 if let Some(target) = &effect.transport_onto {
6743 if target != "mainline" && !ir.streams.iter().any(|stream| stream.name == *target) {
6744 diagnostics.push(Diagnostic {
6745 related: Vec::new(),
6746 span: effect.span,
6747 message: format!(
6748 "`onto {target}` names neither `mainline` nor a declared stream"
6749 ),
6750 suggestion: Some(
6751 "transport targets are the nameable tiers: `onto mainline`, or \
6752 `onto <stream>` for a declared stream's line"
6753 .to_owned(),
6754 ),
6755 });
6756 }
6757 }
6758 }
6759 }
6760}
6761
6762#[derive(Clone, Copy, Debug, Eq, PartialEq)]
6773pub struct ChannelProviderReport {
6774 pub short_name: &'static str,
6776 pub provider_id: &'static str,
6778 pub direction: &'static str,
6780 pub identity: &'static str,
6782 pub interactions: &'static [&'static str],
6784 pub content: &'static [&'static str],
6786 pub delivery_receipts: &'static [&'static str],
6788}
6789
6790pub const CHANNEL_PROVIDER_REPORTS: &[ChannelProviderReport] = &[
6792 ChannelProviderReport {
6793 short_name: "fixture",
6794 provider_id: "fixture",
6795 direction: "bidirectional",
6796 identity: "claimed_actor",
6797 interactions: &["buttons", "reactions"],
6798 content: &["text", "markdown"],
6799 delivery_receipts: &["accepted", "failed"],
6800 },
6801 ChannelProviderReport {
6802 short_name: "local",
6803 provider_id: "std.messaging.local",
6804 direction: "bidirectional",
6805 identity: "claimed_actor",
6806 interactions: &["buttons", "reactions"],
6807 content: &["text", "markdown"],
6808 delivery_receipts: &["accepted", "failed"],
6809 },
6810 ChannelProviderReport {
6811 short_name: "desktop",
6812 provider_id: "std.messaging.desktop",
6813 direction: "outbound_only",
6814 identity: "anonymous",
6815 interactions: &[],
6816 content: &["text"],
6817 delivery_receipts: &["accepted", "failed"],
6818 },
6819 ChannelProviderReport {
6820 short_name: "stdio",
6821 provider_id: "std.messaging.stdio",
6822 direction: "bidirectional",
6823 identity: "claimed_actor",
6824 interactions: &["buttons"],
6825 content: &["text", "markdown"],
6826 delivery_receipts: &["accepted", "failed"],
6827 },
6828];
6829
6830pub fn channel_provider_report(provider: &str) -> Option<&'static ChannelProviderReport> {
6836 CHANNEL_PROVIDER_REPORTS
6837 .iter()
6838 .find(|report| report.short_name == provider || report.provider_id == provider)
6839}
6840
6841pub const BUILTIN_GAUGES: &[&str] = &["std.spend", "std.latency", "std.tokens", "std.cache_hit"];
6847
6848pub const FILE_STORE_PROVIDERS: &[&str] = &["local"];
6854
6855fn validate_improve_declarations(ir: &IrProgram, diagnostics: &mut Vec<Diagnostic>) {
6861 for mark in &ir.marks {
6864 if !ir.rules.iter().any(|rule| rule.name == mark.site) {
6865 diagnostics.push(Diagnostic {
6866 related: Vec::new(),
6867 span: mark.span,
6868 message: format!("mark `{}` rides unknown site `{}`", mark.name, mark.site),
6869 suggestion: Some(format!(
6870 "declared rules: {}",
6871 ir.rules
6872 .iter()
6873 .map(|rule| rule.name.as_str())
6874 .collect::<Vec<_>>()
6875 .join(", ")
6876 )),
6877 });
6878 }
6879 }
6880 let gauge_names: BTreeSet<&str> = ir.gauges.iter().map(|gauge| gauge.name.as_str()).collect();
6881 let resolves = |name: &str| gauge_names.contains(name) || BUILTIN_GAUGES.contains(&name);
6882 let unknown = |name: &str, span: SourceSpan, diagnostics: &mut Vec<Diagnostic>| {
6883 diagnostics.push(Diagnostic {
6884 related: Vec::new(),
6885 span,
6886 message: format!("unknown gauge `{name}`"),
6887 suggestion: Some(format!(
6888 "declare `gauge {name} {{ ... }}` or use a built-in gauge ({})",
6889 BUILTIN_GAUGES.join(", ")
6890 )),
6891 });
6892 };
6893 for gauge in &ir.gauges {
6894 if gauge.judge_kind == "coerce" {
6895 match ir
6896 .coerces
6897 .iter()
6898 .find(|coerce| coerce.name == gauge.judge_target)
6899 {
6900 None => {
6901 diagnostics.push(Diagnostic {
6902 related: Vec::new(),
6903 span: gauge.span,
6904 message: format!(
6905 "gauge `{}` judges via undeclared coerce `{}`",
6906 gauge.name, gauge.judge_target
6907 ),
6908 suggestion: Some("declare the coerce this gauge judges with".to_owned()),
6909 });
6910 }
6911 Some(coerce) if !gauge.judge_args.is_empty() => {
6920 if gauge.judge_args.len() == 1 && gauge.judge_args[0] == "record" {
6921 if coerce.params.len() != 1 {
6922 diagnostics.push(Diagnostic {
6923 related: Vec::new(),
6924 span: gauge.span,
6925 message: format!(
6926 "gauge `{}`: the reserved `(record)` form needs a \
6927 single-parameter coerce; `{}` takes {}",
6928 gauge.name,
6929 gauge.judge_target,
6930 coerce.params.len()
6931 ),
6932 suggestion: Some(
6933 "give the coerce one record-shaped parameter, or bind each \
6934 parameter to an explicit path"
6935 .to_owned(),
6936 ),
6937 });
6938 }
6939 } else {
6940 for arg in &gauge.judge_args {
6941 let head = arg.split('.').next().unwrap_or_default();
6942 let valid = match head {
6943 "record" => false, "input" => true,
6945 "facts" => arg.splitn(3, '.').count() == 3,
6946 _ => false,
6947 };
6948 if !valid {
6949 diagnostics.push(Diagnostic {
6950 related: Vec::new(),
6951 span: gauge.span,
6952 message: format!(
6953 "gauge `{}`: judge argument `{arg}` is not a record \
6954 path",
6955 gauge.name
6956 ),
6957 suggestion: Some(
6958 "arguments are `input.<path>`, \
6959 `facts.<Class>.<field...>`, or the single reserved \
6960 `record`"
6961 .to_owned(),
6962 ),
6963 });
6964 }
6965 }
6966 if gauge.judge_args.len() != coerce.params.len() {
6967 diagnostics.push(Diagnostic {
6968 related: Vec::new(),
6969 span: gauge.span,
6970 message: format!(
6971 "gauge `{}`: judge passes {} argument{} but coerce `{}` \
6972 takes {}",
6973 gauge.name,
6974 gauge.judge_args.len(),
6975 if gauge.judge_args.len() == 1 { "" } else { "s" },
6976 gauge.judge_target,
6977 coerce.params.len()
6978 ),
6979 suggestion: Some(
6980 "bind one path per coerce parameter, in order".to_owned(),
6981 ),
6982 });
6983 }
6984 }
6985 }
6986 Some(_) => {}
6987 }
6988 }
6989 if !gauge.inputs.is_empty() && gauge.judge_kind != "exec" {
6990 diagnostics.push(Diagnostic {
6991 related: Vec::new(),
6992 span: gauge.span,
6993 message: format!(
6994 "derived gauge `{}` must judge via exec (its judge receives the input score vector)",
6995 gauge.name
6996 ),
6997 suggestion: Some("use `judge via exec \"<validator>\"`".to_owned()),
6998 });
6999 }
7000 for input in &gauge.inputs {
7001 if input == &gauge.name {
7002 diagnostics.push(Diagnostic {
7003 related: Vec::new(),
7004 span: gauge.span,
7005 message: format!("derived gauge `{}` cannot input itself", gauge.name),
7006 suggestion: None,
7007 });
7008 } else if !resolves(input) {
7009 unknown(input, gauge.span, diagnostics);
7010 }
7011 }
7012 }
7013 for campaign in &ir.campaigns {
7014 let mut named: Vec<(&str, &'static str)> = Vec::new();
7015 for name in &campaign.ascend {
7016 named.push((name, "ascend"));
7017 }
7018 for reach in &campaign.reach {
7019 named.push((&reach.gauge, "reach"));
7020 }
7021 for guard in &campaign.guard {
7022 named.push((&guard.gauge, "guard"));
7023 }
7024 for name in &campaign.sacrifice {
7025 named.push((name, "sacrifice"));
7026 }
7027 let mut seen: BTreeMap<&str, &'static str> = BTreeMap::new();
7028 for (name, role) in named {
7029 if !resolves(name) {
7030 unknown(name, campaign.span, diagnostics);
7031 }
7032 if let Some(previous) = seen.insert(name, role) {
7033 let message = if previous == role {
7034 format!(
7035 "campaign `{}` names gauge `{name}` twice in {role}",
7036 campaign.name
7037 )
7038 } else {
7039 format!(
7040 "campaign `{}` names gauge `{name}` as both {previous} and {role}",
7041 campaign.name
7042 )
7043 };
7044 diagnostics.push(Diagnostic {
7045 related: Vec::new(),
7046 span: campaign.span,
7047 message,
7048 suggestion: Some("name each gauge once, in at most one clause".to_owned()),
7049 });
7050 }
7051 }
7052 }
7053}
7054
7055#[derive(Clone, Copy, Debug, Eq, PartialEq)]
7060pub enum HarnessClass {
7061 Managed,
7062 Delegated,
7063}
7064
7065impl HarnessClass {
7066 pub fn as_str(self) -> &'static str {
7067 match self {
7068 HarnessClass::Managed => "managed",
7069 HarnessClass::Delegated => "delegated",
7070 }
7071 }
7072}
7073
7074pub fn harness_class(kind: &str) -> HarnessClass {
7081 match kind {
7082 "owned" | "fixture" => HarnessClass::Managed,
7083 _ => HarnessClass::Delegated,
7084 }
7085}
7086
7087fn validate_test_expr_source(
7088 label: &str,
7089 source: &str,
7090 span: SourceSpan,
7091 diagnostics: &mut Vec<Diagnostic>,
7092) {
7093 if source.trim().is_empty() {
7094 diagnostics.push(Diagnostic {
7095 related: Vec::new(),
7096 span,
7097 message: format!("{label} is empty"),
7098 suggestion: Some("provide an expression".to_owned()),
7099 });
7100 return;
7101 }
7102 if let Err(error) = parse_expression(source) {
7103 diagnostics.push(Diagnostic {
7104 related: Vec::new(),
7105 span,
7106 message: format!("{label} is not a valid expression: {error}"),
7107 suggestion: None,
7108 });
7109 }
7110}
7111
7112fn literal_union_values(ty: &TypeSyntax) -> Option<Vec<String>> {
7116 match ty {
7117 TypeSyntax::LiteralString { value, .. } => Some(vec![value.clone()]),
7118 TypeSyntax::Union { variants, .. } => {
7119 let values = variants
7120 .iter()
7121 .filter_map(|variant| match variant {
7122 TypeSyntax::LiteralString { value, .. } => Some(value.clone()),
7123 _ => None,
7124 })
7125 .collect::<Vec<_>>();
7126 (!values.is_empty() && values.len() == variants.len()).then_some(values)
7127 }
7128 _ => None,
7129 }
7130}
7131
7132fn validate_presence_conditions(
7136 container: &str,
7137 fields: &[ClassField],
7138 diagnostics: &mut Vec<Diagnostic>,
7139) {
7140 for field in fields {
7141 let Some((disc, literal)) = &field.presence_condition else {
7142 continue;
7143 };
7144 let Some(disc_field) = fields.iter().find(|candidate| &candidate.name.name == disc) else {
7145 diagnostics.push(Diagnostic {
7146 related: Vec::new(),
7147 span: field.span,
7148 message: format!(
7149 "`{container}` field `{}` is conditioned on unknown discriminant `{disc}`",
7150 field.name.name
7151 ),
7152 suggestion: Some(
7153 "`when <field> is \"...\"` must name a literal-union field of the same schema"
7154 .to_owned(),
7155 ),
7156 });
7157 continue;
7158 };
7159 match literal_union_values(&disc_field.ty) {
7160 Some(values) if values.iter().any(|value| value == literal) => {}
7161 Some(values) => diagnostics.push(Diagnostic {
7162 related: Vec::new(),
7163 span: field.span,
7164 message: format!(
7165 "`{container}` field `{}` is conditioned on `{disc} is \"{literal}\"`, which is not a value of `{disc}`",
7166 field.name.name
7167 ),
7168 suggestion: Some(format!("use one of: {}", values.join(", "))),
7169 }),
7170 None => diagnostics.push(Diagnostic {
7171 related: Vec::new(),
7172 span: field.span,
7173 message: format!(
7174 "`{container}` field `{}` is conditioned on `{disc}`, which is not a string-literal discriminant",
7175 field.name.name
7176 ),
7177 suggestion: Some(
7178 "the discriminant must be a string-literal union, e.g. `kind \"a\" | \"b\"`"
7179 .to_owned(),
7180 ),
7181 }),
7182 }
7183 }
7184}
7185
7186fn coerce_declared_provider(body: &str) -> Option<String> {
7197 let mut in_prompt = false;
7198 let mut awaiting_opener = false;
7199 for line in body.lines() {
7200 let trimmed = line.trim();
7201 if in_prompt {
7202 if trimmed.matches('"').count() >= 3 && trimmed.matches("\"\"\"").count() % 2 == 1 {
7203 in_prompt = false;
7204 }
7205 continue;
7206 }
7207 if awaiting_opener {
7208 if trimmed.is_empty() {
7209 continue;
7210 }
7211 awaiting_opener = false;
7212 if let Some(after_opener) = trimmed.strip_prefix("\"\"\"") {
7213 if after_opener.matches("\"\"\"").count() % 2 == 0 {
7214 in_prompt = true;
7215 }
7216 continue;
7217 }
7218 }
7219 if trimmed.is_empty() || trimmed.starts_with('#') {
7220 continue;
7221 }
7222 if trimmed == "prompt" {
7223 awaiting_opener = true;
7224 continue;
7225 }
7226 if let Some(rest) = trimmed.strip_prefix("prompt ") {
7227 if let Some(after_opener) = rest.strip_prefix("\"\"\"") {
7228 if after_opener.matches("\"\"\"").count() % 2 == 0 {
7229 in_prompt = true;
7230 }
7231 }
7232 continue;
7233 }
7234 if let Some(rest) = trimmed.strip_prefix("provider ") {
7235 let mut tokens = rest.split_whitespace();
7236 if let (Some(name), None) = (tokens.next(), tokens.next()) {
7237 return Some(name.to_owned());
7238 }
7239 }
7240 }
7241 None
7242}
7243
7244#[cfg(test)]
7245#[path = "lib_tests/coerce_provider.rs"]
7246mod coerce_provider_tests;
7247
7248fn validate_coerce_body_fields(coerce: &CoerceDecl, diagnostics: &mut Vec<Diagnostic>) {
7249 let mut in_prompt = false;
7250 let mut awaiting_opener = false;
7251 for line in coerce.body.text.lines() {
7252 let trimmed = line.trim();
7253 if in_prompt {
7254 if trimmed.matches("\"\"\"").count() % 2 == 1 {
7256 in_prompt = false;
7257 }
7258 continue;
7259 }
7260 if awaiting_opener {
7261 if trimmed.is_empty() {
7264 continue;
7265 }
7266 awaiting_opener = false;
7267 if let Some(after_opener) = trimmed.strip_prefix("\"\"\"") {
7268 if after_opener.matches("\"\"\"").count() % 2 == 0 {
7269 in_prompt = true;
7270 }
7271 continue;
7272 }
7273 }
7275 if trimmed.is_empty() || trimmed.starts_with('#') {
7276 continue;
7277 }
7278 if trimmed == "prompt" {
7279 awaiting_opener = true;
7280 continue;
7281 }
7282 if let Some(rest) = trimmed.strip_prefix("prompt ") {
7283 let rest = rest.trim_start();
7284 if let Some(after_opener) = rest.strip_prefix("\"\"\"") {
7285 if after_opener.matches("\"\"\"").count() % 2 == 0 {
7288 in_prompt = true;
7289 }
7290 }
7291 continue;
7293 }
7294 if let Some(rest) = trimmed.strip_prefix("provider ") {
7295 if rest.split_whitespace().count() != 1 {
7296 diagnostics.push(Diagnostic {
7297 related: Vec::new(),
7298 span: coerce.name.span,
7299 message: format!(
7300 "coerce `{}` has a malformed `provider` clause: `{trimmed}`",
7301 coerce.name.name
7302 ),
7303 suggestion: Some("write `provider <name>`".to_owned()),
7304 });
7305 }
7306 continue;
7307 }
7308 let field = trimmed.split_whitespace().next().unwrap_or(trimmed);
7309 diagnostics.push(Diagnostic {
7310 related: Vec::new(),
7311 span: coerce.name.span,
7312 message: format!(
7313 "unknown coerce field `{field}` on coerce `{}`",
7314 coerce.name.name
7315 ),
7316 suggestion: Some("supported coerce fields are `prompt` and `provider`".to_owned()),
7317 });
7318 }
7319}
7320
7321fn validate_type_refs(
7322 ty: &TypeSyntax,
7323 schema_names: &BTreeSet<String>,
7324 agent_names: &BTreeSet<String>,
7325 diagnostics: &mut Vec<Diagnostic>,
7326) {
7327 match ty {
7328 TypeSyntax::Primitive { .. } | TypeSyntax::LiteralString { .. } => {}
7329 TypeSyntax::Ref { name } => {
7330 if !schema_names.contains(&name.name) && !is_builtin_schema_ref(&name.name) {
7331 diagnostics.push(Diagnostic {
7332 related: Vec::new(),
7333 span: name.span,
7334 message: format!("unknown schema reference `{}`", name.name),
7335 suggestion: Some(format!(
7336 "declare `class {}` or `enum {}` before using it",
7337 name.name, name.name
7338 )),
7339 });
7340 }
7341 }
7342 TypeSyntax::AgentRef { agents, .. } => {
7343 let mut seen = BTreeSet::new();
7344 for agent in agents {
7345 if !seen.insert(agent.name.clone()) {
7346 diagnostics.push(Diagnostic {
7347 related: Vec::new(),
7348 span: agent.span,
7349 message: format!("AgentRef lists agent `{}` more than once", agent.name),
7350 suggestion: Some(
7351 "remove the duplicate agent from the AgentRef domain".to_owned(),
7352 ),
7353 });
7354 }
7355 if !agent_names.contains(&agent.name) {
7356 diagnostics.push(Diagnostic {
7357 related: Vec::new(),
7358 span: agent.span,
7359 message: format!("AgentRef references unknown agent `{}`", agent.name),
7360 suggestion: Some(format!(
7361 "declare `agent {}` before using it in AgentRef",
7362 agent.name
7363 )),
7364 });
7365 }
7366 }
7367 }
7368 TypeSyntax::Optional { inner, .. }
7369 | TypeSyntax::Array { inner, .. }
7370 | TypeSyntax::Map { inner, .. } => {
7371 validate_type_refs(inner, schema_names, agent_names, diagnostics)
7372 }
7373 TypeSyntax::Union { variants, .. } => {
7374 for variant in variants {
7375 validate_type_refs(variant, schema_names, agent_names, diagnostics);
7376 }
7377 }
7378 }
7379}
7380
7381fn is_builtin_schema_ref(name: &str) -> bool {
7382 matches!(
7383 name,
7384 "AgentTurn"
7385 | "WorkItem"
7386 | "Evidence"
7387 | "VcsChange"
7388 | "VcsContention"
7389 | "VcsPromotion"
7390 | "VcsStall"
7391 | "TerminalFailed"
7392 | "TerminalTimedOut"
7393 | "TerminalCancelled"
7394 | "TerminalOutcome"
7395 )
7396}
7397
7398fn is_observer_only_schema(name: &str) -> bool {
7405 matches!(
7406 name,
7407 "TerminalFailed"
7408 | "TerminalTimedOut"
7409 | "TerminalCancelled"
7410 | "TerminalOutcome"
7411 | "VcsChange"
7414 | "VcsContention"
7415 | "VcsPromotion"
7416 | "VcsStall"
7417 )
7418}
7419
7420fn validate_canonical_rule_body_syntax(rule: &RuleDecl, diagnostics: &mut Vec<Diagnostic>) {
7421 for line in rule.body.text.lines().map(str::trim) {
7422 if line.starts_with("then ") {
7423 diagnostics.push(Diagnostic {
7424 related: Vec::new(),
7425 span: rule.body.span,
7426 message: format!(
7427 "rule `{}` uses unsupported `then` sequencing",
7428 rule.name.name
7429 ),
7430 suggestion: Some(
7431 "use `after <effect> succeeds { ... }` blocks for effect sequencing".to_owned(),
7432 ),
7433 });
7434 }
7435 if line.starts_with("after ") && line.contains("=>") {
7436 diagnostics.push(Diagnostic {
7437 related: Vec::new(),
7438 span: rule.body.span,
7439 message: format!(
7440 "rule `{}` uses unsupported `after ... =>` sequencing",
7441 rule.name.name
7442 ),
7443 suggestion: Some("write `after <effect> succeeds { ... }`".to_owned()),
7444 });
7445 }
7446 }
7447}
7448
7449fn build_rule_dependencies(rules: &[IrRule]) -> Vec<IrRuleDependency> {
7450 let mut dependencies = Vec::new();
7451 for producer in rules {
7452 for produced_fact in &producer.metadata.fact_writes {
7453 for consumer in rules {
7454 if consumer.metadata.fact_reads.contains(produced_fact) {
7455 dependencies.push(IrRuleDependency {
7456 producer: producer.name.clone(),
7457 consumer: consumer.name.clone(),
7458 fact: produced_fact.clone(),
7459 });
7460 }
7461 }
7462 }
7463 }
7464 dependencies.sort_by(|left, right| {
7465 (&left.producer, &left.consumer, &left.fact).cmp(&(
7466 &right.producer,
7467 &right.consumer,
7468 &right.fact,
7469 ))
7470 });
7471 dependencies
7472}
7473
7474fn validate_message_from_channels(
7481 rule: &RuleDecl,
7482 semantic: &SemanticContext,
7483 diagnostics: &mut Vec<Diagnostic>,
7484) {
7485 for when in &rule.whens {
7486 let (pattern, _) = split_when_guard(&when.text);
7487 let Some(rest) = pattern.trim_start().strip_prefix("message from ") else {
7488 continue;
7489 };
7490 let Some(channel) = rest.split_whitespace().next() else {
7491 continue;
7492 };
7493 if !semantic.channels.iter().any(|c| c.as_str() == channel) {
7494 diagnostics.push(Diagnostic {
7495 related: Vec::new(),
7496 span: when.span,
7497 message: format!("`when message from {channel}` names an unknown channel"),
7498 suggestion: Some(
7499 "declare it with `channel <name> { provider … }`, or correct the channel name"
7500 .to_owned(),
7501 ),
7502 });
7503 continue;
7504 }
7505 if let Some(report) = semantic
7512 .channel_providers
7513 .get(channel)
7514 .and_then(|provider| channel_provider_report(provider))
7515 {
7516 if report.direction == "outbound_only" {
7517 diagnostics.push(Diagnostic {
7518 related: Vec::new(),
7519 span: when.span,
7520 message: format!(
7521 "`when message from {channel}` observes a channel whose provider `{}` is outbound-only (its capability report cannot deliver inbound messages)",
7522 report.short_name
7523 ),
7524 suggestion: Some(
7525 "route inbound observation through an inbound-capable provider (`local`, `stdio`, `fixture`)"
7526 .to_owned(),
7527 ),
7528 });
7529 }
7530 }
7531 }
7532}
7533
7534fn validate_send_channels(
7535 rule: &RuleDecl,
7536 semantic: &SemanticContext,
7537 diagnostics: &mut Vec<Diagnostic>,
7538) {
7539 let (ast, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
7540 fn walk(
7541 statements: &[body::BodyStmt],
7542 semantic: &SemanticContext,
7543 diagnostics: &mut Vec<Diagnostic>,
7544 ) {
7545 for statement in statements {
7546 match statement {
7547 body::BodyStmt::Effect(effect) => {
7548 if let body::BodyEffectKind::ConstructCapabilityCall {
7549 keyword, fields, ..
7550 } = &effect.kind
7551 {
7552 if keyword == "send" {
7553 if let Some(channel) =
7554 fields.iter().find(|field| field.name == "channel")
7555 {
7556 if !semantic.channels.contains(&channel.source) {
7557 diagnostics.push(Diagnostic {
7558 related: Vec::new(),
7559 span: effect.span,
7560 message: format!(
7561 "`send via {}` names an unknown channel",
7562 channel.source
7563 ),
7564 suggestion: Some(
7565 "declare it with `channel <name> { provider … }`, or correct the channel name"
7566 .to_owned(),
7567 ),
7568 });
7569 } else if let Some(report) = semantic
7570 .channel_providers
7571 .get(&channel.source)
7572 .and_then(|provider| channel_provider_report(provider))
7573 {
7574 if report.direction == "inbound_only" {
7584 diagnostics.push(Diagnostic {
7585 related: Vec::new(),
7586 span: effect.span,
7587 message: format!(
7588 "`send via {}` targets a channel whose provider `{}` is inbound-only (its capability report cannot accept outbound sends)",
7589 channel.source, report.short_name
7590 ),
7591 suggestion: Some(
7592 "send through an outbound-capable provider (`local`, `desktop`, `stdio`, `fixture`)"
7593 .to_owned(),
7594 ),
7595 });
7596 }
7597 }
7598 }
7599 }
7600 if matches!(keyword.as_str(), "recall" | "learn" | "curate") {
7603 if let Some(pool) = fields.iter().find(|field| field.name == "pool") {
7604 if !semantic.memory_pools.contains(&pool.source) {
7605 diagnostics.push(Diagnostic {
7606 related: Vec::new(),
7607 span: effect.span,
7608 message: format!(
7609 "`{keyword}` names unknown memory pool `{}`",
7610 pool.source
7611 ),
7612 suggestion: Some(
7613 "declare it with `memory pool <name> { … }`, or correct the pool name"
7614 .to_owned(),
7615 ),
7616 });
7617 }
7618 }
7619 }
7620 }
7621 }
7622 body::BodyStmt::After(after) => walk(&after.body, semantic, diagnostics),
7623 body::BodyStmt::Case(case) => {
7624 for branch in &case.branches {
7625 walk(&branch.body, semantic, diagnostics);
7626 }
7627 }
7628 _ => {}
7629 }
7630 }
7631 }
7632 walk(&ast.statements, semantic, diagnostics);
7633}
7634
7635const EVIDENCE_ONLY_TURN_FACTS: [&str; 3] = [
7642 "agent.turn.streamed",
7643 "agent.turn.tool_requested",
7644 "agent.turn.artifact_captured",
7645];
7646
7647fn validate_turn_access_grants(
7654 rule: &RuleDecl,
7655 metadata: &IrRuleMetadata,
7656 diagnostics: &mut Vec<Diagnostic>,
7657) {
7658 for effect in &metadata.effects {
7659 if effect.access_grants.is_empty() {
7660 continue;
7661 }
7662 let mut seen = BTreeSet::new();
7663 for grant in &effect.access_grants {
7664 if grant.operations.is_empty() {
7665 diagnostics.push(Diagnostic {
7666 related: Vec::new(),
7667 span: effect.span,
7668 message: format!(
7669 "rule `{}` has a `with access to {}` grant that grants no operations",
7670 rule.name.name, grant.resource
7671 ),
7672 suggestion: Some(
7673 "list at least one operation in the grant block, or drop the grant"
7674 .to_owned(),
7675 ),
7676 });
7677 }
7678 if !seen.insert(grant.resource.clone()) {
7679 diagnostics.push(Diagnostic {
7680 related: Vec::new(),
7681 span: effect.span,
7682 message: format!(
7683 "rule `{}` lists access resource `{}` more than once on one effect",
7684 rule.name.name, grant.resource
7685 ),
7686 suggestion: Some(
7687 "merge the grant clauses for a resource into a single block".to_owned(),
7688 ),
7689 });
7690 }
7691 }
7692 }
7693}
7694
7695fn validate_evidence_fact_not_matched(rule: &RuleDecl, diagnostics: &mut Vec<Diagnostic>) {
7696 for when in &rule.whens {
7697 let (pattern, _) = split_when_guard(&when.text);
7698 let Some(name) = runtime_fact_name_for_pattern(pattern) else {
7699 continue;
7700 };
7701 if EVIDENCE_ONLY_TURN_FACTS.contains(&name.as_str()) {
7702 diagnostics.push(Diagnostic { related: Vec::new(),
7703 span: when.span,
7704 message: format!(
7705 "rule `{}` matches evidence-only fact `{name}`: in-turn observations are evidence, not rule-matchable facts",
7706 rule.name.name
7707 ),
7708 suggestion: Some(
7709 "match a lifecycle fact (`agent.turn.completed`/`failed`/`timed_out`/`cancelled`) and read in-turn detail from its evidence".to_owned(),
7710 ),
7711 });
7712 }
7713 }
7714}
7715
7716fn extract_rule_regions(
7723 items: &mut [Item],
7724 diagnostics: &mut Vec<Diagnostic>,
7725) -> BTreeMap<String, IrRegion> {
7726 let mut pending = BTreeMap::new();
7727 for item in items.iter_mut() {
7728 let Item::Rule(rule) = item else {
7729 continue;
7730 };
7731 let (ast, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
7732 let mut regions = Vec::new();
7733 collect_region_blocks(&ast.statements, &[], &mut regions);
7734 if regions.is_empty() {
7735 continue;
7736 }
7737 if regions.len() > 1 {
7738 diagnostics.push(Diagnostic {
7739 related: Vec::new(),
7740 span: regions[1].0.span,
7741 message: format!(
7742 "rule `{}` declares more than one `during`/`until` region",
7743 rule.name.name
7744 ),
7745 suggestion: Some(
7746 "v1 supports one region per rule (including nested regions); split the \
7747 rule or merge the conditions"
7748 .to_owned(),
7749 ),
7750 });
7751 continue;
7752 }
7753 let (region, region_case_arms) = regions[0].clone();
7754 if count_effect_statements(®ion.body) == 0 {
7755 diagnostics.push(Diagnostic {
7756 related: Vec::new(),
7757 span: region.span,
7758 message: format!(
7759 "the `{}` region in rule `{}` contains no progression",
7760 if region.until { "until" } else { "during" },
7761 rule.name.name
7762 ),
7763 suggestion: Some(
7764 "a region around purely-atomic actions commits with admission and can \
7765 never lapse between steps; it needs at least one effect with a \
7766 continuation"
7767 .to_owned(),
7768 ),
7769 });
7770 continue;
7771 }
7772 let mut region_bindings = BTreeSet::new();
7777 collect_all_binding_names(®ion.body, &mut region_bindings);
7778 if let Some(view) = ®ion.lapse_binding {
7779 region_bindings.remove(view);
7780 }
7781 let mut arm_roots = BTreeSet::new();
7782 collect_statement_roots(®ion.lapse_body, &mut arm_roots);
7783 for root in &arm_roots {
7784 if region_bindings.contains(root) {
7785 diagnostics.push(Diagnostic {
7786 related: Vec::new(),
7787 span: region.span,
7788 message: format!(
7789 "the `on lapse` arm of rule `{}` references `{root}`, a binding the \
7790 region introduces — it may not exist when the arm runs",
7791 rule.name.name
7792 ),
7793 suggestion: Some(
7794 "reference only bindings from before the region, or bind the \
7795 progress view (`on lapse as got`) and read `got.<binding>` — its \
7796 fields are present exactly if that step settled"
7797 .to_owned(),
7798 ),
7799 });
7800 }
7801 }
7802 let base = rule.body.span.start;
7804 let text = rule.body.text.clone();
7805 let clamp = |offset: usize| offset.saturating_sub(base).min(text.len());
7806 let (r_start, r_end) = (clamp(region.span.start), clamp(region.span.end));
7807 let (b_start, b_end) = (clamp(region.body_span.start), clamp(region.body_span.end));
7808 let (l_start, l_end) = (clamp(region.lapse_span.start), clamp(region.lapse_span.end));
7809 if !(r_start <= b_start
7810 && b_start <= b_end
7811 && b_end <= l_start
7812 && l_start <= l_end
7813 && l_end <= r_end)
7814 {
7815 diagnostics.push(Diagnostic {
7816 related: Vec::new(),
7817 span: region.span,
7818 message: format!(
7819 "internal: region span reconstruction failed for rule `{}`",
7820 rule.name.name
7821 ),
7822 suggestion: None,
7823 });
7824 continue;
7825 }
7826 let body_content = &text[b_start..b_end];
7827 let arm_content = &text[l_start..l_end];
7828 let variant_holds = format!("{}{}{}", &text[..r_start], body_content, &text[r_end..]);
7829 let variant_removed = format!("{}{}", &text[..r_start], &text[r_end..]);
7830 let variant_lapsed = format!("{}{}{}", &text[..r_start], arm_content, &text[r_end..]);
7831 let mut effect_bindings = BTreeSet::new();
7835 collect_effect_binding_names(®ion.body, &mut effect_bindings);
7836 let (holds_ast, _) = body::parse_rule_body(&variant_holds, 0);
7837 let mut region_effects = Vec::new();
7838 assign_region_effect_scopes(
7839 &holds_ast.statements,
7840 None,
7841 &effect_bindings,
7842 &mut region_effects,
7843 );
7844 pending.insert(
7845 rule.name.name.clone(),
7846 IrRegion {
7847 until: region.until,
7848 condition: region.condition.clone(),
7849 lapse_binding: region.lapse_binding.clone(),
7850 effects: region_effects,
7851 body_removed: variant_removed,
7852 body_lapsed: variant_lapsed,
7853 arm_content: arm_content.to_owned(),
7854 arm_case_arms: region_case_arms,
7855 },
7856 );
7857 rule.body.text = variant_holds;
7858 }
7859 pending
7860}
7861
7862fn collect_region_blocks(
7868 statements: &[body::BodyStmt],
7869 case_arms: &[(String, String)],
7870 out: &mut Vec<(body::RegionBlock, Vec<(String, String)>)>,
7871) {
7872 for statement in statements {
7873 match statement {
7874 body::BodyStmt::Region(region) => {
7875 out.push((region.clone(), case_arms.to_vec()));
7876 collect_region_blocks(®ion.body, case_arms, out);
7877 collect_region_blocks(®ion.lapse_body, case_arms, out);
7878 }
7879 body::BodyStmt::After(after) => collect_region_blocks(&after.body, case_arms, out),
7880 body::BodyStmt::Case(case) => {
7881 for branch in &case.branches {
7882 let mut nested = case_arms.to_vec();
7883 nested.push((case.scrutinee.clone(), branch.pattern.clone()));
7884 collect_region_blocks(&branch.body, &nested, out);
7885 }
7886 }
7887 _ => {}
7888 }
7889 }
7890}
7891
7892fn count_effect_statements(statements: &[body::BodyStmt]) -> usize {
7893 let mut count = 0;
7894 for statement in statements {
7895 match statement {
7896 body::BodyStmt::Effect(_) => count += 1,
7897 body::BodyStmt::After(after) => count += count_effect_statements(&after.body),
7898 body::BodyStmt::Case(case) => {
7899 for branch in &case.branches {
7900 count += count_effect_statements(&branch.body);
7901 }
7902 }
7903 body::BodyStmt::Region(region) => {
7904 count += count_effect_statements(®ion.body);
7905 }
7906 _ => {}
7907 }
7908 }
7909 count
7910}
7911
7912fn collect_effect_binding_names(statements: &[body::BodyStmt], out: &mut BTreeSet<String>) {
7913 for statement in statements {
7914 match statement {
7915 body::BodyStmt::Effect(effect) => {
7916 if let Some(binding) = &effect.binding {
7917 out.insert(binding.clone());
7918 }
7919 }
7920 body::BodyStmt::After(after) => collect_effect_binding_names(&after.body, out),
7921 body::BodyStmt::Case(case) => {
7922 for branch in &case.branches {
7923 collect_effect_binding_names(&branch.body, out);
7924 }
7925 }
7926 body::BodyStmt::Region(region) => {
7927 collect_effect_binding_names(®ion.body, out);
7928 }
7929 _ => {}
7930 }
7931 }
7932}
7933
7934fn assign_region_effect_scopes(
7938 statements: &[body::BodyStmt],
7939 level1: Option<&(String, String)>,
7940 region_bindings: &BTreeSet<String>,
7941 out: &mut Vec<IrRegionEffect>,
7942) {
7943 for statement in statements {
7944 match statement {
7945 body::BodyStmt::Effect(effect) => {
7946 if let Some(binding) = &effect.binding {
7947 if region_bindings.contains(binding)
7948 && !out.iter().any(|known| &known.binding == binding)
7949 {
7950 out.push(IrRegionEffect {
7951 binding: binding.clone(),
7952 scope: level1.cloned(),
7953 });
7954 }
7955 }
7956 }
7957 body::BodyStmt::After(after) => {
7958 let own = (
7959 after.binding.clone(),
7960 after.predicate.kernel_str().to_owned(),
7961 );
7962 let next = level1.cloned().unwrap_or(own);
7963 assign_region_effect_scopes(&after.body, Some(&next), region_bindings, out);
7964 }
7965 body::BodyStmt::Case(case) => {
7966 for branch in &case.branches {
7967 assign_region_effect_scopes(&branch.body, level1, region_bindings, out);
7968 }
7969 }
7970 body::BodyStmt::Region(region) => {
7971 assign_region_effect_scopes(®ion.body, level1, region_bindings, out);
7972 }
7973 _ => {}
7974 }
7975 }
7976}
7977
7978fn collect_statement_roots(statements: &[body::BodyStmt], out: &mut BTreeSet<String>) {
7982 fn roots_in_expr(source: &str, out: &mut BTreeSet<String>) {
7983 let bytes = source.as_bytes();
7984 let mut i = 0;
7985 let mut in_string = false;
7986 while i < bytes.len() {
7987 let c = bytes[i] as char;
7988 if c == '"' {
7989 in_string = !in_string;
7990 i += 1;
7991 continue;
7992 }
7993 if in_string {
7994 i += 1;
7995 continue;
7996 }
7997 if c.is_ascii_alphabetic() || c == '_' {
7998 let start = i;
7999 while i < bytes.len() {
8000 let cj = bytes[i] as char;
8001 if cj.is_ascii_alphanumeric() || cj == '_' {
8002 i += 1;
8003 } else {
8004 break;
8005 }
8006 }
8007 let preceded_by_dot = start > 0 && bytes[start - 1] as char == '.';
8008 if !preceded_by_dot {
8009 out.insert(source[start..i].to_owned());
8010 }
8011 continue;
8012 }
8013 i += 1;
8014 }
8015 }
8016 fn roots_in_fields(fields: &[body::FieldAssign], out: &mut BTreeSet<String>) {
8017 for field in fields {
8018 match &field.value {
8019 body::FieldValue::Expr { source, .. } => roots_in_expr(source, out),
8020 body::FieldValue::Nested { fields, .. } => roots_in_fields(fields, out),
8021 body::FieldValue::Shorthand => {
8022 out.insert(field.name.clone());
8023 }
8024 }
8025 }
8026 }
8027 fn roots_in_prompt(text: &str, out: &mut BTreeSet<String>) {
8028 let mut rest = text;
8029 while let Some(open) = rest.find("{{") {
8030 let tail = &rest[open + 2..];
8031 let Some(close) = tail.find("}}") else {
8032 break;
8033 };
8034 roots_in_expr(&tail[..close], out);
8035 rest = &tail[close + 2..];
8036 }
8037 }
8038 for statement in statements {
8039 match statement {
8040 body::BodyStmt::Record(record) => roots_in_fields(&record.fields, out),
8041 body::BodyStmt::Done {
8042 binding,
8043 replacement,
8044 ..
8045 } => {
8046 out.insert(binding.clone());
8047 if let Some(record) = replacement {
8048 roots_in_fields(&record.fields, out);
8049 }
8050 }
8051 body::BodyStmt::Cancel { binding, .. } => {
8052 out.insert(binding.clone());
8053 }
8054 body::BodyStmt::Effect(effect) => {
8055 if let Some(prompt) = &effect.prompt {
8056 roots_in_prompt(&prompt.text, out);
8057 }
8058 match &effect.kind {
8059 body::BodyEffectKind::Coerce { args, .. } => {
8060 for arg in args {
8061 roots_in_expr(arg, out);
8062 }
8063 }
8064 body::BodyEffectKind::TrackerFinish { item, fields } => {
8065 out.insert(item.clone());
8066 roots_in_fields(fields, out);
8067 }
8068 body::BodyEffectKind::TrackerRelease { item } => {
8069 out.insert(item.clone());
8070 }
8071 _ => {}
8072 }
8073 }
8074 body::BodyStmt::Terminal(terminal) => {
8075 roots_in_fields(&terminal.fields, out);
8076 if let Some(body::FieldValue::Expr { source, .. }) = &terminal.scalar {
8077 roots_in_expr(source, out);
8078 }
8079 }
8080 body::BodyStmt::Milestone { fields, .. } => roots_in_fields(fields, out),
8081 body::BodyStmt::After(after) => collect_statement_roots(&after.body, out),
8082 body::BodyStmt::Case(case) => {
8083 roots_in_expr(&case.scrutinee, out);
8084 for branch in &case.branches {
8085 collect_statement_roots(&branch.body, out);
8086 }
8087 }
8088 body::BodyStmt::Region(region) => {
8089 collect_statement_roots(®ion.body, out);
8090 collect_statement_roots(®ion.lapse_body, out);
8091 }
8092 body::BodyStmt::Redact { source, .. } => {
8093 out.insert(source.clone());
8094 }
8095 }
8096 }
8097}
8098
8099fn validate_effectful_self_trigger(
8100 rule: &RuleDecl,
8101 metadata: &IrRuleMetadata,
8102 diagnostics: &mut Vec<Diagnostic>,
8103) {
8104 if metadata.effects.is_empty() {
8105 return;
8106 }
8107
8108 for written_fact in &metadata.fact_writes {
8109 if metadata.fact_reads.contains(written_fact)
8110 && !metadata.fact_consumes.contains(written_fact)
8111 {
8112 diagnostics.push(Diagnostic { related: Vec::new(),
8113 span: rule.body.span,
8114 message: format!(
8115 "effectful rule `{}` preserves trigger fact `{written_fact}`",
8116 rule.name.name
8117 ),
8118 suggestion: Some(
8119 "consume or advance the triggering fact, or move the next effect behind an external completion event"
8120 .to_owned(),
8121 ),
8122 });
8123 }
8124 }
8125}
8126
8127fn binding_types_for_rule(rule: &RuleDecl) -> BTreeMap<String, String> {
8128 let mut binding_types = BTreeMap::new();
8129 for when in &rule.whens {
8130 if let Some((binding, schema)) = binding_from_when(&when.text) {
8131 binding_types.insert(binding, schema);
8132 }
8133 }
8134 binding_types
8135}
8136
8137fn validate_workflow_terminal_actions(
8138 rule: &RuleDecl,
8139 semantic: &SemanticContext,
8140 binding_types: &BTreeMap<String, String>,
8141 known_roots: &BTreeSet<String>,
8142 contracts: &WorkflowContractNames,
8143 diagnostics: &mut Vec<Diagnostic>,
8144) {
8145 for line in rule.body.text.lines().map(str::trim) {
8146 let terminal = line
8147 .strip_prefix("complete ")
8148 .map(|rest| ("complete", rest, &contracts.outputs))
8149 .or_else(|| {
8150 line.strip_prefix("fail ")
8151 .map(|rest| ("fail", rest, &contracts.failures))
8152 });
8153 let Some((action, rest, declared)) = terminal else {
8154 continue;
8155 };
8156 if !rest.contains('{') {
8161 let tokens: Vec<&str> = rest.split_whitespace().collect();
8162 let is_from = matches!(tokens.as_slice(), [_, "from", ..]) && action == "complete";
8163 if tokens.len() >= 2 && !is_from {
8164 let name = tokens[0];
8165 let value = rest.trim().get(name.len()..).unwrap_or("").trim();
8166 if !declared.contains_key(name) {
8167 diagnostics.push(Diagnostic {
8168 related: Vec::new(),
8169 span: rule.body.span,
8170 message: format!(
8171 "rule `{}` {action}s unknown workflow terminal `{name}`",
8172 rule.name.name
8173 ),
8174 suggestion: Some(format!(
8175 "declare `{kind} {name} Type` on the workflow first",
8176 kind = if action == "complete" {
8177 "output"
8178 } else {
8179 "failure"
8180 }
8181 )),
8182 });
8183 continue;
8184 }
8185 if let Some(contract_ty) = declared.get(name) {
8186 validate_scalar_terminal_payload(
8187 rule,
8188 action,
8189 name,
8190 value,
8191 contract_ty,
8192 semantic,
8193 binding_types,
8194 known_roots,
8195 diagnostics,
8196 );
8197 }
8198 continue;
8199 }
8200 }
8201 let Some(name) = rest.split('{').next().and_then(|header| {
8204 let mut parts = header.split_whitespace();
8205 match (parts.next(), parts.next(), parts.next()) {
8206 (Some(name), None, _) => Some(name),
8207 (Some(name), Some("from"), Some(binding))
8208 if action == "complete" && is_identifier(binding) =>
8209 {
8210 Some(name)
8211 }
8212 _ => None,
8213 }
8214 }) else {
8215 diagnostics.push(Diagnostic {
8216 related: Vec::new(),
8217 span: rule.body.span,
8218 message: format!("rule `{}` has malformed `{action}` action", rule.name.name),
8219 suggestion: Some(format!(
8220 "{action} a declared workflow terminal with a payload block"
8221 )),
8222 });
8223 continue;
8224 };
8225 if !declared.contains_key(name) {
8226 diagnostics.push(Diagnostic {
8227 related: Vec::new(),
8228 span: rule.body.span,
8229 message: format!(
8230 "rule `{}` {action}s unknown workflow terminal `{name}`",
8231 rule.name.name
8232 ),
8233 suggestion: Some(format!(
8234 "declare `{kind} {name} Type` on the workflow first",
8235 kind = if action == "complete" {
8236 "output"
8237 } else {
8238 "failure"
8239 }
8240 )),
8241 });
8242 continue;
8243 }
8244 let Some(contract_ty) = declared.get(name) else {
8245 continue;
8246 };
8247 validate_workflow_terminal_payload(
8248 rule,
8249 action,
8250 name,
8251 contract_ty,
8252 semantic,
8253 binding_types,
8254 known_roots,
8255 diagnostics,
8256 );
8257 }
8258}
8259
8260#[allow(clippy::too_many_arguments)]
8261fn validate_workflow_terminal_payload(
8262 rule: &RuleDecl,
8263 action: &str,
8264 terminal_name: &str,
8265 contract_ty: &TypeSyntax,
8266 semantic: &SemanticContext,
8267 binding_types: &BTreeMap<String, String>,
8268 known_roots: &BTreeSet<String>,
8269 diagnostics: &mut Vec<Diagnostic>,
8270) {
8271 let Some((_, _, body)) = workflow_terminal_blocks(&rule.body.text).into_iter().find(
8272 |(candidate_action, candidate_name, _)| {
8273 candidate_action == action && candidate_name == terminal_name
8274 },
8275 ) else {
8276 return;
8277 };
8278 let schema = match contract_ty {
8279 TypeSyntax::Ref { name } if semantic.schemas.class_exists(&name.name) => &name.name,
8280 TypeSyntax::Primitive { .. }
8281 | TypeSyntax::LiteralString { .. }
8282 | TypeSyntax::Union { .. } => {
8283 diagnostics.push(Diagnostic {
8286 related: Vec::new(),
8287 span: rule.body.span,
8288 message: format!(
8289 "workflow terminal `{terminal_name}` has a scalar payload contract but is given a field block"
8290 ),
8291 suggestion: Some(format!(
8292 "write a bare scalar value: `{action} {terminal_name} <value>`"
8293 )),
8294 });
8295 return;
8296 }
8297 _ => {
8298 diagnostics.push(Diagnostic { related: Vec::new(),
8299 span: rule.body.span,
8300 message: format!(
8301 "workflow terminal `{terminal_name}` uses an unsupported payload contract type"
8302 ),
8303 suggestion: Some(
8304 "declare the terminal payload as a class (field block) or a scalar type (number/string/bool)"
8305 .to_owned(),
8306 ),
8307 });
8308 return;
8309 }
8310 };
8311 for assignment in collect_field_assignments(&body) {
8312 let (field, value) = match assignment {
8313 RecordFieldAssignment::Value { field, value } => (field, value),
8314 RecordFieldAssignment::Shorthand { field } => (field.clone(), field),
8315 };
8316 let line = format!("{field} {value}");
8317 validate_record_field(
8318 rule,
8319 &line,
8320 schema,
8321 semantic,
8322 binding_types,
8323 known_roots,
8324 diagnostics,
8325 );
8326 }
8327 validate_required_terminal_fields(rule, schema, terminal_name, &body, semantic, diagnostics);
8328}
8329
8330#[allow(clippy::too_many_arguments)]
8336fn validate_scalar_terminal_payload(
8337 rule: &RuleDecl,
8338 action: &str,
8339 terminal_name: &str,
8340 value: &str,
8341 contract_ty: &TypeSyntax,
8342 semantic: &SemanticContext,
8343 binding_types: &BTreeMap<String, String>,
8344 known_roots: &BTreeSet<String>,
8345 diagnostics: &mut Vec<Diagnostic>,
8346) {
8347 if let TypeSyntax::Ref { name } = contract_ty {
8348 if semantic.schemas.class_exists(&name.name) {
8349 diagnostics.push(Diagnostic {
8350 related: Vec::new(),
8351 span: rule.body.span,
8352 message: format!(
8353 "workflow terminal `{terminal_name}` has a class payload contract `{}` but is given a bare scalar value",
8354 name.name
8355 ),
8356 suggestion: Some(format!("write a field block: `{action} {terminal_name} {{ … }}`")),
8357 });
8358 return;
8359 }
8360 }
8361 if value.is_empty() {
8362 diagnostics.push(Diagnostic {
8363 related: Vec::new(),
8364 span: rule.body.span,
8365 message: format!("workflow terminal `{terminal_name}` is missing its scalar value"),
8366 suggestion: Some(format!("write `{action} {terminal_name} <value>`")),
8367 });
8368 return;
8369 }
8370 validate_literal_assignment(
8373 rule,
8374 terminal_name,
8375 "value",
8376 contract_ty,
8377 value,
8378 semantic,
8379 diagnostics,
8380 );
8381 if let Some(root) = dangling_value_root(value, known_roots) {
8382 diagnostics.push(Diagnostic {
8383 related: Vec::new(),
8384 span: rule.body.span,
8385 message: format!(
8386 "rule `{}` has unknown binding `{root}` in `{action} {terminal_name}` value",
8387 rule.name.name
8388 ),
8389 suggestion: Some(
8390 "reference a binding from a `when ... as name` clause, an effect `as` binding, or a `case` pattern"
8391 .to_owned(),
8392 ),
8393 });
8394 } else if let Some((root, path)) = expression_path(value) {
8395 check_field_path(
8400 rule,
8401 &root,
8402 &path,
8403 rule.body.span,
8404 SchemaScopes::local(&semantic.schemas),
8405 binding_types,
8406 diagnostics,
8407 );
8408 }
8409}
8410
8411fn validate_required_terminal_fields(
8412 rule: &RuleDecl,
8413 schema: &str,
8414 terminal_name: &str,
8415 body: &str,
8416 semantic: &SemanticContext,
8417 diagnostics: &mut Vec<Diagnostic>,
8418) {
8419 let Some(schema_fields) = semantic.schemas.classes.get(schema) else {
8420 return;
8421 };
8422 let seen = collect_field_assignments(body)
8423 .into_iter()
8424 .map(|assignment| match assignment {
8425 RecordFieldAssignment::Value { field, .. }
8426 | RecordFieldAssignment::Shorthand { field } => field,
8427 })
8428 .collect::<BTreeSet<_>>();
8429 for (required, ty) in schema_fields {
8430 if seen.contains(required) || matches!(ty, TypeSyntax::Optional { .. }) {
8431 continue;
8432 }
8433 diagnostics.push(Diagnostic { related: Vec::new(),
8434 span: rule.body.span,
8435 message: format!(
8436 "workflow terminal `{terminal_name}` is missing required field `{schema}.{required}`"
8437 ),
8438 suggestion: Some(format!("add `{required}` to the `{terminal_name}` payload")),
8439 });
8440 }
8441}
8442
8443fn max_after_depth(statements: &[body::BodyStmt]) -> usize {
8449 use body::BodyStmt;
8450 statements
8451 .iter()
8452 .map(|statement| match statement {
8453 BodyStmt::After(after) => 1 + max_after_depth(&after.body),
8454 BodyStmt::Case(case) => case
8455 .branches
8456 .iter()
8457 .map(|branch| max_after_depth(&branch.body))
8458 .max()
8459 .unwrap_or(0),
8460 _ => 0,
8461 })
8462 .max()
8463 .unwrap_or(0)
8464}
8465
8466fn analyze_rule(
8467 rule: &RuleDecl,
8468 semantic: &SemanticContext,
8469 diagnostics: &mut Vec<Diagnostic>,
8470) -> IrRuleMetadata {
8471 let (body_ast, body_diagnostics) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
8475 diagnostics.extend(body_diagnostics);
8476 let mut metadata = IrRuleMetadata {
8477 fact_reads: rule
8478 .whens
8479 .iter()
8480 .map(|when| fact_read_from_when(&when.text))
8481 .collect(),
8482 max_after_depth: max_after_depth(&body_ast.statements),
8483 ..IrRuleMetadata::default()
8484 };
8485 let mut seen_bindings = BTreeSet::new();
8486 let mut binding_types = BTreeMap::new();
8487 let mut foreign_schemas: BTreeMap<String, String> = BTreeMap::new();
8491 for when in &rule.whens {
8492 let (pattern_text, _) = split_when_guard(&when.text);
8495 if binding_after_as(pattern_text).is_some()
8496 && binding_from_when(&when.text).is_none()
8497 && !pattern_text.ends_with(" is available")
8498 {
8499 diagnostics.push(Diagnostic {
8500 related: Vec::new(),
8501 span: when.span,
8502 message: format!(
8503 "rule `{}` has unknown readiness pattern `{pattern_text}`",
8504 rule.name.name
8505 ),
8506 suggestion: Some(
8507 "match a class (`when Class as x`) or a runtime fact (`when fact <name> as x`)"
8508 .to_owned(),
8509 ),
8510 });
8511 }
8512 if let Some((binding, schema)) = binding_from_when(&when.text) {
8513 validate_binding_name(rule, &binding, when.span, diagnostics);
8514 if !schema.contains('.') && !semantic.schemas.class_exists(&schema) {
8515 let suggestion = match closest_name(&schema, semantic.schemas.classes.keys()) {
8516 Some(candidate) => {
8517 format!("did you mean `{candidate}`? otherwise declare `class {schema}`")
8518 }
8519 None => format!("declare `class {schema}` before matching it"),
8520 };
8521 diagnostics.push(Diagnostic {
8522 related: Vec::new(),
8523 span: when.span,
8524 message: format!("rule `{}` matches unknown class `{schema}`", rule.name.name),
8525 suggestion: Some(suggestion),
8526 });
8527 }
8528 if schema.contains('.')
8532 && !pattern_text.trim_start().starts_with("fact ")
8533 && !semantic.schemas.events.contains(&schema)
8534 {
8535 diagnostics.push(Diagnostic { related: Vec::new(),
8536 span: when.span,
8537 message: format!(
8538 "rule `{}` reacts to undeclared signal `{schema}`",
8539 rule.name.name
8540 ),
8541 suggestion: Some(format!(
8542 "declare `signal {schema} {{ ... }}` for a typed reaction, or use `when fact {schema} as ...` for an untyped one"
8543 )),
8544 });
8545 }
8546 binding_types.insert(binding, schema);
8547 }
8548 }
8549 let mut effect_payload_types = collect_effect_payload_types(rule, semantic, diagnostics);
8550 collect_exec_payload_types(&body_ast.statements, semantic, &mut effect_payload_types);
8554 collect_decide_payload_types(
8558 &body_ast.statements,
8559 &rule.name.name,
8560 &mut effect_payload_types,
8561 );
8562 collect_prompt_payload_types(&body_ast.statements, &mut effect_payload_types);
8563 collect_redact_payload_types(
8567 &body_ast.statements,
8568 &rule.name.name,
8569 &mut effect_payload_types,
8570 );
8571 for (binding, payload_type) in &effect_payload_types {
8572 if let IrType::Ref(schema) = payload_type {
8573 binding_types.insert(binding.clone(), schema.clone());
8574 }
8575 }
8576 let vcs_verb_bindings: BTreeMap<String, (&'static str, &'static str)> = rule
8585 .body
8586 .text
8587 .lines()
8588 .filter_map(|line| {
8589 let line = line.trim();
8590 let (verb, negative) = if line.starts_with("promote ") {
8591 ("promote", "Conflicted")
8592 } else if line.starts_with("undo ") {
8593 ("undo", "Stranded")
8594 } else if line.starts_with("transport ") {
8595 ("transport", "Conflicted")
8596 } else {
8597 return None;
8598 };
8599 Some((binding_after_as(line)?, (verb, negative)))
8600 })
8601 .collect();
8602 let mut effect_binding_kinds: BTreeMap<String, IrEffectKind> = rule
8603 .body
8604 .text
8605 .lines()
8606 .filter_map(|line| {
8607 let line = line.trim();
8608 if line.starts_with("exec ") {
8611 return Some((binding_after_as(line)?, IrEffectKind::ExecCommand));
8612 }
8613 let (kind, binding) = parse_effect_line(line)?;
8614 Some((binding?, kind))
8615 })
8616 .collect();
8617 for statement in effect_payload_statements(&rule.body.text) {
8618 if let Some((kind, Some(binding))) = parse_effect_line(statement.trim()) {
8619 effect_binding_kinds.insert(binding, kind);
8620 }
8621 }
8622 for line in rule.body.text.lines() {
8626 let Some(rest) = line.trim().strip_prefix("after ") else {
8627 continue;
8628 };
8629 let mut words = rest.split_whitespace();
8630 let Some(binding) = words.next() else {
8631 continue;
8632 };
8633 let Some(predicate) = words.next() else {
8634 continue;
8635 };
8636 if predicate == "succeeds" {
8643 if let Some((verb, negative)) = vcs_verb_bindings.get(binding) {
8644 let positive = if *verb == "promote" {
8650 "promoted"
8651 } else {
8652 "applied"
8653 };
8654 let negative_arm = negative.to_lowercase();
8655 diagnostics.push(Diagnostic {
8656 related: Vec::new(),
8657 span: rule.body.span,
8658 message: format!(
8659 "rule `{}` observes {verb} `{binding}` with `succeeds`, which also \
8660 matches a {negative} outcome (the op completes either way)",
8661 rule.name.name
8662 ),
8663 suggestion: Some(format!(
8664 "use `after {binding} {positive}` / `after {binding} {negative_arm}` \
8665 for the outcome variants, or `after {binding} completes` for any \
8666 settled outcome"
8667 )),
8668 });
8669 }
8670 }
8671 if predicate == "succeeds" {
8672 match effect_binding_kinds.get(binding) {
8673 Some(IrEffectKind::LeaseAcquire) => {
8674 diagnostics.push(Diagnostic {
8675 related: Vec::new(),
8676 span: rule.body.span,
8677 message: format!(
8678 "rule `{}` observes acquire `{binding}` with `succeeds`, which also \
8679 matches a Contended outcome (the acquire op completes either way)",
8680 rule.name.name
8681 ),
8682 suggestion: Some(format!(
8683 "use `after {binding} held` / `after {binding} contended` for the \
8684 outcome variants, or `after {binding} completes` for any settled \
8685 outcome"
8686 )),
8687 });
8688 }
8689 Some(IrEffectKind::CounterConsume) => {
8690 diagnostics.push(Diagnostic {
8691 related: Vec::new(),
8692 span: rule.body.span,
8693 message: format!(
8694 "rule `{}` observes counter consume `{binding}` with `succeeds`, \
8695 which also matches an Over outcome (the consume op completes \
8696 either way)",
8697 rule.name.name
8698 ),
8699 suggestion: Some(format!(
8700 "use `after {binding} ok` / `after {binding} over` for the outcome \
8701 variants, or `after {binding} completes` for any settled outcome"
8702 )),
8703 });
8704 }
8705 _ => {}
8706 }
8707 }
8708 if predicate == "reaches" {
8712 let Some(quoted) = words.next() else {
8713 continue;
8714 };
8715 let milestone = quoted.trim_matches('"');
8716 let (Some("as"), Some(alias)) = (words.next(), words.next()) else {
8717 continue;
8718 };
8719 let alias = alias.trim_end_matches('{').trim();
8720 if alias.is_empty() {
8721 continue;
8722 }
8723 if let Some((workflow, class)) =
8724 milestone_payload_class(rule, binding, milestone, semantic)
8725 {
8726 if !class.is_empty() {
8727 binding_types.insert(alias.to_owned(), class);
8728 foreign_schemas.insert(alias.to_owned(), workflow);
8729 }
8730 }
8731 continue;
8732 }
8733 if predicate == "times" && words.next() != Some("out") {
8736 continue;
8737 }
8738 let (Some(keyword), Some(alias)) = (words.next(), words.next()) else {
8739 continue;
8740 };
8741 if keyword != "as" {
8742 continue;
8743 }
8744 let alias = alias.trim_end_matches('{').trim();
8745 if alias.is_empty() {
8746 continue;
8747 }
8748 match predicate {
8754 "times" => {
8755 binding_types.insert(alias.to_owned(), "TerminalTimedOut".to_owned());
8756 }
8757 "cancelled" => {
8758 binding_types.insert(alias.to_owned(), "TerminalCancelled".to_owned());
8759 }
8760 "completes" => {
8766 binding_types.insert(alias.to_owned(), "TerminalOutcome".to_owned());
8767 }
8768 "fails" => {
8782 if let Some((workflow, class)) = invoke_failure_class(rule, binding, semantic) {
8783 binding_types.insert(alias.to_owned(), class);
8784 foreign_schemas.insert(alias.to_owned(), workflow);
8785 } else {
8786 let schema = match effect_binding_kinds.get(binding) {
8787 Some(IrEffectKind::ExecCommand) => "TerminalFailedExec",
8788 Some(IrEffectKind::SchemaCoerce) => "TerminalFailedCoerce",
8789 Some(IrEffectKind::AgentTell) => "TerminalFailedTell",
8790 _ => "TerminalFailed",
8791 };
8792 binding_types.insert(alias.to_owned(), schema.to_owned());
8793 }
8794 }
8795 _ => {
8796 if let Some(IrType::Ref(schema)) = effect_payload_types.get(binding) {
8797 binding_types.insert(alias.to_owned(), schema.clone());
8798 } else if let Some((workflow, class)) = invoke_output_class(rule, binding, semantic)
8799 {
8800 binding_types.insert(alias.to_owned(), class);
8806 foreign_schemas.insert(alias.to_owned(), workflow);
8807 }
8808 }
8809 }
8810 }
8811 for when in &rule.whens {
8812 if let (_, Some(guard)) = split_when_guard(&when.text) {
8813 validate_expression(rule, guard, semantic, &binding_types, "guard", diagnostics);
8814 validate_known_field_paths(rule, guard, semantic, &binding_types, diagnostics);
8815 if let Some(expr) = lower_expression(guard, when.span) {
8816 metadata
8817 .projection_reads
8818 .extend(collect_projection_reads(&expr.expr));
8819 }
8820 }
8821 validate_availability_when(rule, &when.text, semantic, &binding_types, diagnostics);
8822 }
8823 validate_case_blocks(rule, semantic, &binding_types, diagnostics);
8824 metadata.case_branches =
8825 collect_rule_case_metadata(rule, semantic, &binding_types, diagnostics);
8826 let terminal_metadata = collect_terminal_case_metadata(
8827 rule,
8828 semantic,
8829 &binding_types,
8830 &effect_payload_types,
8831 diagnostics,
8832 );
8833 let mut known_roots: BTreeSet<String> = binding_types.keys().cloned().collect();
8837 collect_all_binding_names(&body_ast.statements, &mut known_roots);
8838 validate_record_blocks(rule, semantic, &binding_types, &known_roots, diagnostics);
8839 validate_effect_payloads(rule, semantic, &binding_types, &known_roots, diagnostics);
8840 validate_effect_field_roots(rule, &body_ast.statements, &known_roots, diagnostics);
8841 validate_emit_signal_declarations(
8842 rule,
8843 &body_ast.statements,
8844 &semantic.schemas.events,
8845 diagnostics,
8846 );
8847 validate_workflow_invocations(rule, semantic, &binding_types, &known_roots, diagnostics);
8848 validate_milestone_statements(rule, semantic, diagnostics);
8849 let mut block_stack: Vec<BlockFrame> = Vec::new();
8850 let mut misplaced_effect_bindings = BTreeSet::new();
8851 seed_ast_only_effect_bindings(&body_ast.statements, &mut seen_bindings, &mut binding_types);
8852 validate_body_effect_operands(
8853 rule,
8854 &body_ast.statements,
8855 semantic,
8856 &binding_types,
8857 diagnostics,
8858 );
8859 validate_coordination_discipline(rule, &body_ast.statements, diagnostics);
8860 validate_redactions(
8863 rule,
8864 &body_ast.statements,
8865 semantic,
8866 &binding_types,
8867 diagnostics,
8868 );
8869 validate_conditioned_field_reads(
8872 rule,
8873 &body_ast.statements,
8874 semantic,
8875 &binding_types,
8876 &BTreeSet::new(),
8877 diagnostics,
8878 );
8879 let mut anonymous_effects = 0usize;
8880 let mut record_depth = 0i32;
8881
8882 for raw_line in rule.body.text.lines() {
8883 let line = raw_line.trim();
8884 if line.is_empty() {
8885 continue;
8886 }
8887
8888 if record_depth > 0 {
8889 record_depth += brace_delta(line);
8890 continue;
8891 }
8892
8893 if let Some(binding) = binding_after_multiline_string_end(line) {
8894 misplaced_effect_bindings.insert(binding.clone());
8895 diagnostics.push(Diagnostic { related: Vec::new(),
8896 span: rule.body.span,
8897 message: format!(
8898 "rule `{}` places effect binding `{binding}` after a multiline string delimiter",
8899 rule.name.name
8900 ),
8901 suggestion: Some(format!(
8902 "move `as {binding}` onto the effect line, before the multiline string body"
8903 )),
8904 });
8905 continue;
8906 }
8907 validate_rule_prompt_content_type_annotation(rule, line, diagnostics);
8908
8909 if line.starts_with('}') {
8910 block_stack.pop();
8911 continue;
8912 }
8913
8914 if line.starts_with("case ") || (!line.starts_with("after ") && is_case_branch_start(line))
8915 {
8916 validate_known_field_paths_scoped(
8917 rule,
8918 line,
8919 semantic,
8920 &binding_types,
8921 &foreign_schemas,
8922 diagnostics,
8923 );
8924 continue;
8925 }
8926
8927 let active_afters = after_scopes(&block_stack);
8928 validate_binding_uses(rule, line, &seen_bindings, &active_afters, diagnostics);
8929 validate_known_field_paths_scoped(
8930 rule,
8931 line,
8932 semantic,
8933 &binding_types,
8934 &foreign_schemas,
8935 diagnostics,
8936 );
8937
8938 if let Some(binding) = parse_consume_line(line) {
8939 match binding_types.get(&binding) {
8940 Some(schema) => metadata.fact_consumes.push(format!("schema:{schema}")),
8941 None => diagnostics.push(Diagnostic {
8942 related: Vec::new(),
8943 span: rule.body.span,
8944 message: format!(
8945 "rule `{}` consumes unknown fact binding `{binding}`",
8946 rule.name.name
8947 ),
8948 suggestion: Some(
8949 "consume a binding introduced by a `when Class as binding` clause"
8950 .to_owned(),
8951 ),
8952 }),
8953 }
8954 if !line.contains("->") {
8955 continue;
8956 }
8957 }
8958
8959 if line.starts_with("after ") {
8960 if let Some(alias) = binding_after_as(line) {
8961 validate_binding_name(rule, &alias, rule.body.span, diagnostics);
8962 }
8963 match parse_after_line(line) {
8964 Some((binding, predicate)) => {
8965 if !seen_bindings.contains(&binding) {
8966 let suggestion = if misplaced_effect_bindings.contains(&binding) {
8967 format!(
8968 "move `as {binding}` onto the effect line before the multiline string"
8969 )
8970 } else {
8971 format!("create an effect with `as {binding}` before the `after` block")
8972 };
8973 diagnostics.push(Diagnostic { related: Vec::new(),
8974 span: rule.body.span,
8975 message: format!(
8976 "rule `{}` has `after` block for unknown effect binding `{binding}`",
8977 rule.name.name
8978 ),
8979 suggestion: Some(suggestion),
8980 });
8981 }
8982 block_stack.push(BlockFrame::After { binding, predicate });
8983 }
8984 None => {
8985 diagnostics.push(Diagnostic { related: Vec::new(),
8986 span: rule.body.span,
8987 message: format!(
8988 "rule `{}` has unsupported `after` dependency predicate",
8989 rule.name.name
8990 ),
8991 suggestion: Some(
8992 "use `after name succeeds`, `after name fails`, `after name completes`, `after name times out`, or `after name cancelled`"
8993 .to_owned(),
8994 ),
8995 });
8996 }
8997 }
8998 continue;
8999 }
9000
9001 if let Some((schema, _)) = parse_record_start(line) {
9002 if is_observer_only_schema(&schema) {
9003 diagnostics.push(Diagnostic {
9004 related: Vec::new(),
9005 span: rule.body.span,
9006 message: format!(
9007 "rule `{}` cannot record kernel-owned terminal schema `{schema}`",
9008 rule.name.name
9009 ),
9010 suggestion: Some(
9011 "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`"
9012 .to_owned(),
9013 ),
9014 });
9015 } else if !semantic.schemas.class_exists(&schema) {
9016 diagnostics.push(Diagnostic {
9017 related: Vec::new(),
9018 span: rule.body.span,
9019 message: format!("rule `{}` records unknown class `{schema}`", rule.name.name),
9020 suggestion: Some(format!("declare `class {schema}` before recording it")),
9021 });
9022 }
9023 metadata.fact_writes.push(format!("schema:{schema}"));
9024 record_depth = brace_delta(line).max(1);
9025 continue;
9026 }
9027
9028 if let Some((kind, binding)) = parse_effect_line(line) {
9029 validate_agent_tell_target(
9030 rule,
9031 line,
9032 &kind,
9033 semantic,
9034 &binding_types,
9035 &known_roots,
9036 diagnostics,
9037 );
9038 anonymous_effects += 1;
9039 let id = binding
9040 .clone()
9041 .unwrap_or_else(|| format!("effect{anonymous_effects}"));
9042 if let Some(binding) = &binding {
9043 validate_binding_name(rule, binding, rule.body.span, diagnostics);
9044 seen_bindings.insert(binding.clone());
9045 if let Some(schema) = effect_binding_schema(line, &kind, semantic) {
9046 binding_types.insert(binding.clone(), schema);
9047 }
9048 }
9049 for (upstream, predicate) in after_scopes(&block_stack) {
9050 metadata.dependencies.push(IrEffectDependency {
9051 upstream,
9052 predicate,
9053 downstream: id.clone(),
9054 });
9055 }
9056 let idempotency_key = effect_idempotency_key(&rule.name.name, &id, &kind, &binding);
9057 metadata.effects.push(IrEffectNode {
9058 id,
9059 kind,
9060 binding,
9061 required_capabilities: parse_required_capabilities(line),
9062 construct_use: None,
9063 idempotency_key,
9064 span: rule.body.span,
9065 timeout_seconds: None,
9066 access_grants: Vec::new(),
9069 turn_skills: Vec::new(),
9070 on_stream: None,
9071 selection_source: None,
9072 transport_onto: None,
9073 resource: None,
9074 agent: None,
9075 coerce_target: None,
9076 workflow_target: None,
9077 endorsed: false,
9078 declassified: false,
9079 selected_by: None,
9080 exec_target: None,
9081 });
9082 }
9083 }
9084
9085 let (ast_effects, ast_dependencies) =
9086 collect_effects_from_ast(&body_ast.statements, &rule.name.name);
9087 metadata.effects = ast_effects;
9088 metadata.dependencies = ast_dependencies;
9089
9090 push_ingest_fact_writes(&body_ast.statements, &mut metadata.fact_writes);
9094
9095 metadata.fact_reads.sort();
9096 metadata.fact_reads.dedup();
9097 sort_projection_reads(&mut metadata.projection_reads);
9098 metadata.fact_writes.sort();
9099 metadata.fact_writes.dedup();
9100 metadata.fact_consumes.sort();
9101 metadata.fact_consumes.dedup();
9102 metadata.terminal_outputs = terminal_metadata.outputs;
9103 metadata.terminal_branches = terminal_metadata.branches;
9104 for branch in &metadata.case_branches {
9111 if let Some(guard) = &branch.guard {
9112 metadata
9113 .projection_reads
9114 .extend(collect_projection_reads(&guard.expr));
9115 }
9116 }
9117 for branch in &metadata.terminal_branches {
9118 if let Some(guard) = &branch.guard {
9119 metadata
9120 .projection_reads
9121 .extend(collect_projection_reads(&guard.expr));
9122 }
9123 }
9124 sort_projection_reads(&mut metadata.projection_reads);
9125 if let Some(region) = semantic.regions.get(&rule.name.name) {
9129 validate_lapse_arm(
9130 rule,
9131 region,
9132 semantic,
9133 &binding_types,
9134 &foreign_schemas,
9135 &effect_payload_types,
9136 diagnostics,
9137 );
9138 }
9139 collect_terminal_complete_bindings(&body_ast.statements, &mut metadata.terminal_completes);
9140 metadata.terminal_completes.sort();
9141 metadata.terminal_completes.dedup();
9142 collect_redaction_metadata(
9143 &body_ast.statements,
9144 &binding_types,
9145 &mut metadata.redactions,
9146 );
9147 collect_bounded_egresses(
9148 &body_ast.statements,
9149 &binding_types,
9150 &mut metadata.bounded_egresses,
9151 );
9152 let mut egress_reads = Vec::new();
9153 collect_egress_payload_reads(&body_ast.statements, &mut egress_reads);
9154 for (sink, roots) in egress_reads {
9155 metadata
9156 .egress_payload_reads
9157 .entry(sink)
9158 .or_default()
9159 .extend(roots);
9160 }
9161 collect_complete_field_reads(&body_ast.statements, &mut metadata.complete_field_reads);
9162 collect_record_field_reads(&body_ast.statements, &mut metadata.record_field_reads);
9163 collect_milestone_field_reads(&body_ast.statements, &mut metadata.milestone_field_reads);
9164 collect_crossing_roots(
9165 &body_ast.statements,
9166 &mut metadata.declassified_roots,
9167 &mut metadata.endorsed_roots,
9168 &mut metadata.endorsed_claim_items,
9169 );
9170 collect_provenance_metadata(
9171 &body_ast.statements,
9172 &mut metadata.coerce_input_roots,
9173 &mut metadata.after_aliases,
9174 );
9175 collect_egress_case_influence(
9176 &body_ast.statements,
9177 &mut Vec::new(),
9178 &mut metadata.egress_case_influence,
9179 );
9180 loop {
9187 let mut changed = false;
9188 for redaction in &metadata.redactions {
9189 if metadata.declassified_roots.contains(&redaction.source)
9190 && metadata
9191 .declassified_roots
9192 .insert(redaction.binding.clone())
9193 {
9194 changed = true;
9195 }
9196 if metadata.endorsed_roots.contains(&redaction.source)
9197 && metadata.endorsed_roots.insert(redaction.binding.clone())
9198 {
9199 changed = true;
9200 }
9201 }
9202 if !changed {
9203 break;
9204 }
9205 }
9206 metadata
9207}
9208
9209fn collect_egress_case_influence(
9214 statements: &[body::BodyStmt],
9215 active: &mut Vec<BTreeSet<String>>,
9216 out: &mut BTreeMap<String, BTreeSet<String>>,
9217) {
9218 let record_sink = |sink: String,
9219 active: &[BTreeSet<String>],
9220 out: &mut BTreeMap<String, BTreeSet<String>>| {
9221 if active.is_empty() {
9222 return;
9223 }
9224 let entry = out.entry(sink).or_default();
9225 for roots in active {
9226 entry.extend(roots.iter().cloned());
9227 }
9228 };
9229 for statement in statements {
9230 match statement {
9231 body::BodyStmt::Terminal(terminal) if terminal.kind == body::TerminalKind::Complete => {
9232 record_sink(terminal.name.clone(), active, out);
9233 }
9234 body::BodyStmt::Record(record) => {
9235 record_sink(format!("fact:{}", record.schema), active, out);
9236 }
9237 body::BodyStmt::Done {
9238 replacement: Some(record),
9239 ..
9240 } => {
9241 record_sink(format!("fact:{}", record.schema), active, out);
9242 }
9243 body::BodyStmt::Milestone { name, .. } => {
9244 record_sink(format!("milestone:{name}"), active, out);
9245 }
9246 body::BodyStmt::Effect(effect) => match &effect.kind {
9247 body::BodyEffectKind::ConstructCapabilityCall {
9248 keyword, fields, ..
9249 } if keyword == "send" => {
9250 if let Some(channel) = fields
9251 .iter()
9252 .find(|field| field.name == "channel")
9253 .map(|field| field.source.clone())
9254 {
9255 record_sink(channel, active, out);
9256 }
9257 }
9258 body::BodyEffectKind::FileWrite { store, .. } => {
9259 record_sink(store.clone(), active, out);
9260 }
9261 _ => {}
9262 },
9263 body::BodyStmt::After(after) => {
9264 collect_egress_case_influence(&after.body, active, out);
9265 }
9266 body::BodyStmt::Case(case) => {
9267 let mut roots = BTreeSet::new();
9268 if let Ok(expr) = parse_expression(&case.scrutinee) {
9269 collect_expr_binding_roots(&expr, &mut roots);
9270 } else {
9271 collect_template_binding_roots(&case.scrutinee, &mut roots);
9272 }
9273 active.push(roots);
9274 for branch in &case.branches {
9275 collect_egress_case_influence(&branch.body, active, out);
9276 }
9277 active.pop();
9278 }
9279 _ => {}
9280 }
9281 }
9282}
9283
9284fn collect_provenance_metadata(
9289 statements: &[body::BodyStmt],
9290 coerce_input_roots: &mut BTreeMap<String, BTreeSet<String>>,
9291 after_aliases: &mut BTreeMap<String, String>,
9292) {
9293 for statement in statements {
9294 match statement {
9295 body::BodyStmt::Effect(effect) => {
9296 if let body::BodyEffectKind::Coerce { args, .. } = &effect.kind {
9297 if let Some(binding) = &effect.binding {
9298 let mut roots = BTreeSet::new();
9299 for arg in args {
9300 if let Ok(expr) = parse_expression(arg) {
9301 collect_expr_binding_roots(&expr, &mut roots);
9302 } else {
9303 collect_template_binding_roots(arg, &mut roots);
9304 }
9305 }
9306 coerce_input_roots
9307 .entry(binding.clone())
9308 .or_default()
9309 .extend(roots);
9310 }
9311 }
9312 }
9313 body::BodyStmt::After(after) => {
9314 if matches!(
9315 after.predicate,
9316 body::AfterPredicate::Succeeds | body::AfterPredicate::Completes
9317 ) {
9318 if let Some(alias) = &after.alias {
9319 after_aliases.insert(alias.clone(), after.binding.clone());
9320 }
9321 }
9322 collect_provenance_metadata(&after.body, coerce_input_roots, after_aliases);
9323 }
9324 body::BodyStmt::Case(case) => {
9325 for branch in &case.branches {
9326 collect_provenance_metadata(&branch.body, coerce_input_roots, after_aliases);
9327 }
9328 }
9329 _ => {}
9330 }
9331 }
9332}
9333
9334fn collect_crossing_roots(
9341 statements: &[body::BodyStmt],
9342 declassified: &mut BTreeSet<String>,
9343 endorsed: &mut BTreeSet<String>,
9344 claim_items: &mut BTreeSet<String>,
9345) {
9346 fn collect_marked(
9347 statements: &[body::BodyStmt],
9348 declassified: &mut BTreeSet<String>,
9349 endorsed: &mut BTreeSet<String>,
9350 claim_items: &mut BTreeSet<String>,
9351 ) {
9352 for statement in statements {
9353 match statement {
9354 body::BodyStmt::Effect(effect) => {
9355 if let body::BodyEffectKind::Coerce {
9356 declassified: is_declassified,
9357 endorsed: is_endorsed,
9358 ..
9359 } = &effect.kind
9360 {
9361 if let Some(binding) = &effect.binding {
9362 if *is_declassified {
9363 declassified.insert(binding.clone());
9364 }
9365 if *is_endorsed {
9366 endorsed.insert(binding.clone());
9367 }
9368 }
9369 }
9370 if let body::BodyEffectKind::TrackerClaim {
9375 endorsed: is_endorsed,
9376 item,
9377 ..
9378 } = &effect.kind
9379 {
9380 if *is_endorsed {
9381 endorsed.insert(item.clone());
9387 claim_items.insert(item.clone());
9388 }
9389 }
9390 }
9391 body::BodyStmt::After(after) => {
9392 collect_marked(&after.body, declassified, endorsed, claim_items)
9393 }
9394 body::BodyStmt::Case(case) => {
9395 for branch in &case.branches {
9396 collect_marked(&branch.body, declassified, endorsed, claim_items);
9397 }
9398 }
9399 _ => {}
9400 }
9401 }
9402 }
9403 fn collect_aliases(
9404 statements: &[body::BodyStmt],
9405 declassified: &mut BTreeSet<String>,
9406 endorsed: &mut BTreeSet<String>,
9407 ) {
9408 for statement in statements {
9409 match statement {
9410 body::BodyStmt::After(after) => {
9411 if matches!(
9412 after.predicate,
9413 body::AfterPredicate::Succeeds | body::AfterPredicate::Completes
9414 ) {
9415 if let Some(alias) = &after.alias {
9416 if declassified.contains(&after.binding) {
9417 declassified.insert(alias.clone());
9418 }
9419 if endorsed.contains(&after.binding) {
9420 endorsed.insert(alias.clone());
9421 }
9422 }
9423 }
9424 collect_aliases(&after.body, declassified, endorsed);
9425 }
9426 body::BodyStmt::Case(case) => {
9427 for branch in &case.branches {
9428 collect_aliases(&branch.body, declassified, endorsed);
9429 }
9430 }
9431 _ => {}
9432 }
9433 }
9434 }
9435 collect_marked(statements, declassified, endorsed, claim_items);
9436 loop {
9440 let before = (declassified.len(), endorsed.len());
9441 collect_aliases(statements, declassified, endorsed);
9442 if (declassified.len(), endorsed.len()) == before {
9443 break;
9444 }
9445 }
9446}
9447
9448fn collect_complete_field_reads(
9456 statements: &[body::BodyStmt],
9457 out: &mut BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
9458) {
9459 for statement in statements {
9460 match statement {
9461 body::BodyStmt::Terminal(terminal) if terminal.kind == body::TerminalKind::Complete => {
9462 let per_field = out.entry(terminal.name.clone()).or_default();
9463 for field in &terminal.fields {
9464 let mut roots = BTreeSet::new();
9465 match &field.value {
9466 body::FieldValue::Shorthand => {
9467 if let Some(root) = &terminal.from {
9468 roots.insert(root.clone());
9469 }
9470 }
9471 body::FieldValue::Expr { expr, .. } => {
9472 collect_expr_binding_roots(expr, &mut roots)
9473 }
9474 body::FieldValue::Nested { fields, .. } => collect_payload_field_roots(
9475 fields,
9476 terminal.from.as_deref(),
9477 &mut roots,
9478 ),
9479 }
9480 per_field
9481 .entry(field.name.clone())
9482 .or_default()
9483 .extend(roots);
9484 }
9485 }
9486 body::BodyStmt::After(after) => collect_complete_field_reads(&after.body, out),
9487 body::BodyStmt::Case(case) => {
9488 for branch in &case.branches {
9489 collect_complete_field_reads(&branch.body, out);
9490 }
9491 }
9492 _ => {}
9493 }
9494 }
9495}
9496
9497fn collect_milestone_field_reads(
9503 statements: &[body::BodyStmt],
9504 out: &mut BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
9505) {
9506 for statement in statements {
9507 match statement {
9508 body::BodyStmt::Milestone { name, fields, .. } => {
9509 let per_field = out.entry(name.clone()).or_default();
9510 for field in fields {
9511 let mut roots = BTreeSet::new();
9512 match &field.value {
9513 body::FieldValue::Shorthand => {}
9514 body::FieldValue::Expr { expr, .. } => {
9515 collect_expr_binding_roots(expr, &mut roots)
9516 }
9517 body::FieldValue::Nested { fields, .. } => {
9518 collect_payload_field_roots(fields, None, &mut roots)
9519 }
9520 }
9521 per_field
9522 .entry(field.name.clone())
9523 .or_default()
9524 .extend(roots);
9525 }
9526 }
9527 body::BodyStmt::After(after) => collect_milestone_field_reads(&after.body, out),
9528 body::BodyStmt::Case(case) => {
9529 for branch in &case.branches {
9530 collect_milestone_field_reads(&branch.body, out);
9531 }
9532 }
9533 _ => {}
9534 }
9535 }
9536}
9537
9538fn collect_redaction_metadata(
9545 statements: &[body::BodyStmt],
9546 binding_types: &BTreeMap<String, String>,
9547 out: &mut Vec<IrRedaction>,
9548) {
9549 let mut redacts = Vec::new();
9550 collect_redact_effects(statements, &mut redacts);
9551 for (source, keep, binding, _span) in redacts {
9552 out.push(IrRedaction {
9553 source: source.to_owned(),
9554 keep: keep.to_vec(),
9555 binding: binding.to_owned(),
9556 source_schema: binding_types.get(source).cloned(),
9557 });
9558 }
9559}
9560
9561fn push_bounded_projection(
9575 from: Option<&str>,
9576 fields: &[body::FieldAssign],
9577 sink: String,
9578 binding_types: &BTreeMap<String, String>,
9579 out: &mut Vec<IrBoundedEgress>,
9580) {
9581 let Some(source_schema) = from.and_then(|src| binding_types.get(src)) else {
9582 return;
9583 };
9584 if fields.is_empty()
9585 || !fields
9586 .iter()
9587 .all(|field| matches!(field.value, body::FieldValue::Shorthand))
9588 {
9589 return;
9590 }
9591 out.push(IrBoundedEgress {
9592 sink,
9593 source_schema: source_schema.clone(),
9594 keep: fields.iter().map(|field| field.name.clone()).collect(),
9595 });
9596}
9597
9598fn push_bounded_record(
9599 record: &body::RecordStmt,
9600 binding_types: &BTreeMap<String, String>,
9601 out: &mut Vec<IrBoundedEgress>,
9602) {
9603 push_bounded_projection(
9604 record.from.as_deref(),
9605 &record.fields,
9606 format!("fact:{}", record.schema),
9607 binding_types,
9608 out,
9609 );
9610}
9611
9612fn collect_bounded_egresses(
9613 statements: &[body::BodyStmt],
9614 binding_types: &BTreeMap<String, String>,
9615 out: &mut Vec<IrBoundedEgress>,
9616) {
9617 for statement in statements {
9618 match statement {
9619 body::BodyStmt::Record(record) => push_bounded_record(record, binding_types, out),
9620 body::BodyStmt::Done {
9621 replacement: Some(record),
9622 ..
9623 } => push_bounded_record(record, binding_types, out),
9624 body::BodyStmt::Terminal(terminal)
9627 if terminal.kind == body::TerminalKind::Complete && terminal.from.is_some() =>
9628 {
9629 push_bounded_projection(
9630 terminal.from.as_deref(),
9631 &terminal.fields,
9632 terminal.name.clone(),
9633 binding_types,
9634 out,
9635 );
9636 }
9637 body::BodyStmt::After(after) => {
9638 collect_bounded_egresses(&after.body, binding_types, out)
9639 }
9640 body::BodyStmt::Case(case) => {
9641 for branch in &case.branches {
9642 collect_bounded_egresses(&branch.body, binding_types, out);
9643 }
9644 }
9645 _ => {}
9646 }
9647 }
9648}
9649
9650fn collect_expr_binding_roots(expr: &Expr, out: &mut BTreeSet<String>) {
9658 match expr {
9659 Expr::Literal(ExprLiteral::String(text)) => collect_template_binding_roots(text, out),
9660 Expr::Literal(ExprLiteral::Ident(name)) => {
9661 out.insert(name.clone());
9662 }
9663 Expr::Literal(ExprLiteral::Number(_) | ExprLiteral::Bool(_) | ExprLiteral::Null) => {}
9664 Expr::Path(segments) => {
9665 if let Some(root) = segments.first() {
9666 out.insert(root.clone());
9667 }
9668 }
9669 Expr::Index { target, key } => {
9670 collect_expr_binding_roots(target, out);
9671 collect_expr_binding_roots(key, out);
9672 }
9673 Expr::Array(items) => {
9674 for item in items {
9675 collect_expr_binding_roots(item, out);
9676 }
9677 }
9678 Expr::Object(fields) => {
9679 for field in fields {
9680 collect_expr_binding_roots(&field.value, out);
9681 }
9682 }
9683 Expr::Unary { expr, .. } => collect_expr_binding_roots(expr, out),
9684 Expr::Binary { left, right, .. } => {
9685 collect_expr_binding_roots(left, out);
9686 collect_expr_binding_roots(right, out);
9687 }
9688 Expr::Call { args, .. } => {
9689 for arg in args {
9690 collect_expr_binding_roots(arg, out);
9691 }
9692 }
9693 Expr::Query { head, guard, .. } => {
9694 out.insert(head.clone());
9695 if let Some(guard) = guard {
9696 collect_expr_binding_roots(guard, out);
9697 }
9698 }
9699 }
9700}
9701
9702fn collect_template_binding_roots(text: &str, out: &mut BTreeSet<String>) {
9708 let mut rest = text;
9709 while let Some(open) = rest.find("{{") {
9710 let after_open = &rest[open + 2..];
9711 let Some(close) = after_open.find("}}") else {
9712 break;
9713 };
9714 let body = after_open[..close].trim();
9715 if let Ok(expr) = parse_expression(body) {
9716 collect_expr_binding_roots(&expr, out);
9717 } else {
9718 for token in body.split(|ch: char| !ch.is_alphanumeric() && ch != '_') {
9719 if token
9720 .as_bytes()
9721 .first()
9722 .is_some_and(|byte| is_ident_start(*byte))
9723 {
9724 out.insert(token.to_owned());
9725 }
9726 }
9727 }
9728 rest = &after_open[close + 2..];
9729 }
9730}
9731
9732fn collect_payload_field_roots(
9735 fields: &[body::FieldAssign],
9736 from_binding: Option<&str>,
9737 out: &mut BTreeSet<String>,
9738) {
9739 for field in fields {
9740 match &field.value {
9741 body::FieldValue::Shorthand => {
9742 if let Some(root) = from_binding {
9743 out.insert(root.to_owned());
9744 }
9745 }
9746 body::FieldValue::Expr { expr, .. } => collect_expr_binding_roots(expr, out),
9747 body::FieldValue::Nested { fields, .. } => {
9748 collect_payload_field_roots(fields, from_binding, out)
9749 }
9750 }
9751 }
9752}
9753
9754fn collect_egress_payload_reads(
9763 statements: &[body::BodyStmt],
9764 out: &mut Vec<(String, BTreeSet<String>)>,
9765) {
9766 for statement in statements {
9767 match statement {
9768 body::BodyStmt::Terminal(terminal) if terminal.kind == body::TerminalKind::Complete => {
9769 let mut roots = BTreeSet::new();
9770 collect_payload_field_roots(&terminal.fields, None, &mut roots);
9771 if let Some(body::FieldValue::Expr { expr, .. }) = &terminal.scalar {
9775 collect_expr_binding_roots(expr, &mut roots);
9776 }
9777 out.push((terminal.name.clone(), roots));
9778 }
9779 body::BodyStmt::Record(record) => out.push(record_payload_reads(record)),
9780 body::BodyStmt::Done {
9782 replacement: Some(record),
9783 ..
9784 } => out.push(record_payload_reads(record)),
9785 body::BodyStmt::Milestone { name, fields, .. } => {
9786 let mut roots = BTreeSet::new();
9787 collect_payload_field_roots(fields, None, &mut roots);
9788 out.push((format!("milestone:{name}"), roots));
9789 }
9790 body::BodyStmt::Effect(effect) => match &effect.kind {
9797 body::BodyEffectKind::ConstructCapabilityCall {
9798 keyword, fields, ..
9799 } if keyword == "send" => {
9800 if let Some(reads) = send_payload_reads(fields) {
9801 out.push(reads);
9802 }
9803 }
9804 body::BodyEffectKind::FileWrite {
9805 store, path, body, ..
9806 } => {
9807 let mut roots = BTreeSet::new();
9808 for source in [path, body] {
9809 if let Ok(expr) = parse_expression(source) {
9810 collect_expr_binding_roots(&expr, &mut roots);
9811 } else {
9812 collect_template_binding_roots(source, &mut roots);
9813 }
9814 }
9815 out.push((store.clone(), roots));
9816 }
9817 _ => {}
9818 },
9819 body::BodyStmt::After(after) => collect_egress_payload_reads(&after.body, out),
9820 body::BodyStmt::Case(case) => {
9821 for branch in &case.branches {
9822 collect_egress_payload_reads(&branch.body, out);
9823 }
9824 }
9825 _ => {}
9826 }
9827 }
9828}
9829
9830fn send_payload_reads(fields: &[body::ConstructUseField]) -> Option<(String, BTreeSet<String>)> {
9835 let channel = fields
9836 .iter()
9837 .find(|field| field.name == "channel")
9838 .map(|field| field.source.clone())?;
9839 let mut roots = BTreeSet::new();
9840 for field in fields.iter().filter(|field| field.name != "channel") {
9841 if let Ok(expr) = parse_expression(&field.source) {
9842 collect_expr_binding_roots(&expr, &mut roots);
9843 } else {
9844 collect_template_binding_roots(&field.source, &mut roots);
9846 }
9847 }
9848 Some((channel, roots))
9849}
9850
9851fn collect_record_field_reads(
9858 statements: &[body::BodyStmt],
9859 out: &mut BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
9860) {
9861 fn record_fields(
9862 record: &body::RecordStmt,
9863 out: &mut BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
9864 ) {
9865 let per_field = out.entry(format!("fact:{}", record.schema)).or_default();
9866 for field in &record.fields {
9867 let mut roots = BTreeSet::new();
9868 match &field.value {
9869 body::FieldValue::Shorthand => {
9870 if let Some(root) = &record.from {
9871 roots.insert(root.clone());
9872 }
9873 }
9874 body::FieldValue::Expr { expr, .. } => collect_expr_binding_roots(expr, &mut roots),
9875 body::FieldValue::Nested { fields, .. } => {
9876 collect_payload_field_roots(fields, record.from.as_deref(), &mut roots)
9877 }
9878 }
9879 per_field
9880 .entry(field.name.clone())
9881 .or_default()
9882 .extend(roots);
9883 }
9884 }
9885 for statement in statements {
9886 match statement {
9887 body::BodyStmt::Record(record) => record_fields(record, out),
9888 body::BodyStmt::Done {
9889 replacement: Some(record),
9890 ..
9891 } => record_fields(record, out),
9892 body::BodyStmt::After(after) => collect_record_field_reads(&after.body, out),
9893 body::BodyStmt::Case(case) => {
9894 for branch in &case.branches {
9895 collect_record_field_reads(&branch.body, out);
9896 }
9897 }
9898 _ => {}
9899 }
9900 }
9901}
9902
9903fn record_payload_reads(record: &body::RecordStmt) -> (String, BTreeSet<String>) {
9904 let mut roots = BTreeSet::new();
9905 if let Some(from) = &record.from {
9906 roots.insert(from.clone());
9907 }
9908 collect_payload_field_roots(&record.fields, record.from.as_deref(), &mut roots);
9909 (format!("fact:{}", record.schema), roots)
9910}
9911
9912#[derive(Clone, Debug, Default)]
9913struct TerminalMetadata {
9914 outputs: Vec<IrTerminalOutput>,
9915 branches: Vec<IrTerminalCaseBranch>,
9916}
9917
9918#[derive(Clone, Debug)]
9919struct TerminalBranchSource {
9920 scrutinee: String,
9921 pattern: String,
9922 guard: Option<String>,
9923 body: String,
9924 pattern_span: SourceSpan,
9925}
9926
9927#[derive(Clone, Debug)]
9928struct RuleCaseBranchSource {
9929 scrutinee: String,
9930 scrutinee_type: TypeSyntax,
9931 pattern: String,
9932 guard: Option<String>,
9933 body: String,
9934 pattern_span: SourceSpan,
9935}
9936
9937fn collect_effect_payload_types(
9938 rule: &RuleDecl,
9939 semantic: &SemanticContext,
9940 diagnostics: &mut Vec<Diagnostic>,
9941) -> BTreeMap<String, IrType> {
9942 let mut payloads = BTreeMap::new();
9943 for statement in effect_payload_statements(&rule.body.text) {
9944 let line = statement.trim();
9945 let Some((kind, Some(binding))) = parse_effect_line(line) else {
9946 continue;
9947 };
9948 let payload = terminal_completed_payload_type(line, &kind, semantic);
9949 match payloads.get(&binding) {
9954 Some(existing) if existing != &payload => {
9955 diagnostics.push(Diagnostic {
9956 related: Vec::new(),
9957 span: rule.body.span,
9958 message: format!(
9959 "rule `{}` reuses effect binding `{binding}` for effects with conflicting result types",
9960 rule.name.name
9961 ),
9962 suggestion: Some(format!(
9963 "give each effect a distinct binding — `as {binding}` is reused with a different result type, so `after {binding} …` is ambiguous"
9964 )),
9965 });
9966 }
9967 Some(_) => {}
9968 None => {
9969 payloads.insert(binding, payload);
9970 }
9971 }
9972 }
9973
9974 payloads
9975}
9976
9977fn terminal_completed_payload_type(
9978 line: &str,
9979 kind: &IrEffectKind,
9980 semantic: &SemanticContext,
9981) -> IrType {
9982 match kind {
9983 IrEffectKind::SchemaCoerce if line.starts_with("prompt ") => {
9984 IrType::Primitive(IrPrimitiveType::String)
9985 }
9986 IrEffectKind::SchemaCoerce => parse_coerce_call_name(line)
9987 .and_then(|name| semantic.coerce_outputs.get(name))
9988 .cloned()
9989 .map(lower_type)
9990 .unwrap_or_else(terminal_unknown_payload_type),
9991 IrEffectKind::AgentTell => IrType::Ref("AgentTurn".to_owned()),
9992 IrEffectKind::CapabilityCall
9993 | IrEffectKind::EventEmit
9994 | IrEffectKind::WorkflowInvoke
9995 | IrEffectKind::TimerWait
9996 | IrEffectKind::ExecCommand
9997 | IrEffectKind::TrackerFile
9998 | IrEffectKind::TrackerClaim
9999 | IrEffectKind::TrackerRenew
10000 | IrEffectKind::TrackerRelease
10001 | IrEffectKind::TrackerFinish
10002 | IrEffectKind::LeaseAcquire
10003 | IrEffectKind::LeaseRenew
10004 | IrEffectKind::LedgerAppend
10005 | IrEffectKind::CounterConsume
10006 | IrEffectKind::SignalEmit
10007 | IrEffectKind::FileRead
10008 | IrEffectKind::FileWrite
10009 | IrEffectKind::FileImport
10010 | IrEffectKind::FileExport => terminal_unknown_payload_type(),
10011 }
10012}
10013
10014fn collect_rule_case_metadata(
10015 rule: &RuleDecl,
10016 semantic: &SemanticContext,
10017 binding_types: &BTreeMap<String, String>,
10018 diagnostics: &mut Vec<Diagnostic>,
10019) -> Vec<IrRuleCaseBranch> {
10020 let mut branches = Vec::new();
10021 for branch in rule_case_branch_sources(rule, semantic, binding_types) {
10022 let mut branch_scope = binding_types.clone();
10023 if let Some((binding, schema)) =
10024 case_branch_payload_binding(&branch.pattern, &branch.scrutinee_type, semantic)
10025 {
10026 branch_scope.insert(binding, schema);
10027 }
10028 if let Some(guard) = &branch.guard {
10029 validate_expression(
10030 rule,
10031 guard,
10032 semantic,
10033 &branch_scope,
10034 "case guard",
10035 diagnostics,
10036 );
10037 validate_known_field_paths_at_span(
10038 rule,
10039 guard,
10040 branch.pattern_span,
10041 semantic,
10042 &branch_scope,
10043 diagnostics,
10044 );
10045 }
10046 validate_known_field_paths_at_span(
10047 rule,
10048 &branch.body,
10049 branch.pattern_span,
10050 semantic,
10051 &branch_scope,
10052 diagnostics,
10053 );
10054 if let Some(pattern) = lower_case_pattern(&branch.pattern, &branch.scrutinee_type, semantic)
10055 {
10056 branches.push(IrRuleCaseBranch {
10057 scrutinee: branch.scrutinee,
10058 scrutinee_type: lower_type(branch.scrutinee_type),
10059 pattern,
10060 guard: branch.guard.as_ref().and_then(|guard| {
10061 lower_expression(
10062 guard,
10063 SourceSpan {
10064 start: branch.pattern_span.start,
10065 end: branch.pattern_span.end,
10066 },
10067 )
10068 }),
10069 body_hash: stable_hash(&branch.body),
10070 pattern_span: branch.pattern_span,
10071 });
10072 }
10073 }
10074 branches.sort_by(|left, right| {
10075 (left.scrutinee.as_str(), left.pattern_span.start)
10076 .cmp(&(right.scrutinee.as_str(), right.pattern_span.start))
10077 });
10078 branches
10079}
10080
10081fn rule_case_branch_sources(
10082 rule: &RuleDecl,
10083 semantic: &SemanticContext,
10084 binding_types: &BTreeMap<String, String>,
10085) -> Vec<RuleCaseBranchSource> {
10086 let lines = rule
10087 .body
10088 .text
10089 .lines()
10090 .scan(0usize, |offset, line| {
10091 let current = *offset;
10092 *offset += line.len() + 1;
10093 Some((line, current))
10094 })
10095 .collect::<Vec<_>>();
10096 let text_lines = lines.iter().map(|(line, _)| *line).collect::<Vec<_>>();
10097 let mut branches = Vec::new();
10098 let mut index = 0usize;
10099 while index < lines.len() {
10100 let (line, _) = lines[index];
10101 let trimmed = line.trim();
10102 let Some(scrutinee) = case_scrutinee(trimmed) else {
10103 index += 1;
10104 continue;
10105 };
10106 if active_completes_binding_for_case(&text_lines, index, scrutinee) {
10107 index += 1;
10108 continue;
10109 }
10110 let Some(scrutinee_type) = expression_type(scrutinee, semantic, binding_types) else {
10111 index += 1;
10112 continue;
10113 };
10114 let mut depth = brace_delta(trimmed).max(1);
10115 index += 1;
10116 while index < lines.len() && depth > 0 {
10117 let (branch_line, branch_line_offset) = lines[index];
10118 let branch_trimmed = branch_line.trim();
10119 if depth == 1 {
10120 if let Some((pattern, guard, body_start)) = terminal_branch_header(branch_trimmed) {
10121 let pattern_column = case_pattern_column(branch_line, pattern);
10122 let pattern_span = SourceSpan {
10123 start: rule_body_text_start(rule) + branch_line_offset + pattern_column,
10124 end: rule_body_text_start(rule)
10125 + branch_line_offset
10126 + pattern_column
10127 + pattern.len(),
10128 };
10129 let mut body_lines = Vec::new();
10130 let mut branch_depth = brace_delta(body_start).max(1);
10131 index += 1;
10132 while index < lines.len() && branch_depth > 0 {
10133 let body_line = lines[index].0;
10134 let next_depth = branch_depth + brace_delta(body_line);
10135 if next_depth >= 1 {
10136 body_lines.push(body_line.to_owned());
10137 }
10138 branch_depth = next_depth;
10139 index += 1;
10140 }
10141 branches.push(RuleCaseBranchSource {
10142 scrutinee: scrutinee.to_owned(),
10143 scrutinee_type: scrutinee_type.clone(),
10144 pattern: pattern.to_owned(),
10145 guard,
10146 body: body_lines.join("\n"),
10147 pattern_span,
10148 });
10149 continue;
10150 }
10151 }
10152 depth += brace_delta(branch_trimmed);
10153 index += 1;
10154 }
10155 }
10156 branches
10157}
10158
10159fn case_branch_payload_binding(
10160 pattern: &str,
10161 scrutinee_type: &TypeSyntax,
10162 semantic: &SemanticContext,
10163) -> Option<(String, String)> {
10164 if let TypeSyntax::Ref { name } = scrutinee_type {
10167 if semantic.schemas.enums.contains_key(&name.name) {
10168 let (variant, binding) = sum_case_pattern_parts(pattern);
10169 let binding = binding?;
10170 let generated = format!("{}.{variant}", name.name);
10171 if binding.is_empty() || !semantic.schemas.class_exists(&generated) {
10172 return None;
10173 }
10174 return Some((binding.to_owned(), generated));
10175 }
10176 }
10177 let binding = pattern.strip_prefix("Some ").map(str::trim)?;
10178 if binding.is_empty() {
10179 return None;
10180 }
10181 let TypeSyntax::Optional { inner, .. } = scrutinee_type else {
10182 return None;
10183 };
10184 let schema = match inner.as_ref() {
10185 TypeSyntax::Ref { name } if semantic.schemas.class_exists(&name.name) => {
10186 Some(name.name.clone())
10187 }
10188 _ => None,
10189 }?;
10190 Some((binding.to_owned(), schema))
10191}
10192
10193fn collect_terminal_case_metadata(
10194 rule: &RuleDecl,
10195 semantic: &SemanticContext,
10196 binding_types: &BTreeMap<String, String>,
10197 effect_payload_types: &BTreeMap<String, IrType>,
10198 diagnostics: &mut Vec<Diagnostic>,
10199) -> TerminalMetadata {
10200 let mut metadata = TerminalMetadata::default();
10201 let mut output_bindings = BTreeSet::new();
10202
10203 for branch in terminal_case_branch_sources(rule) {
10204 if output_bindings.insert(branch.scrutinee.clone()) {
10205 let completed_payload = effect_payload_types
10206 .get(&branch.scrutinee)
10207 .cloned()
10208 .unwrap_or_else(terminal_unknown_payload_type);
10209 metadata.outputs.push(IrTerminalOutput {
10210 binding: branch.scrutinee.clone(),
10211 alternatives: terminal_alternatives(completed_payload, branch.pattern_span),
10212 span: branch.pattern_span,
10213 });
10214 }
10215
10216 let (tag, binding) = parse_terminal_pattern_parts(&branch.pattern);
10217 let mut branch_scope = binding_types.clone();
10218 if let (Some(tag), Some(binding)) = (&tag, &binding) {
10219 if let Some(schema) =
10220 terminal_payload_schema_for_tag(tag, &branch.scrutinee, effect_payload_types)
10221 {
10222 branch_scope.insert(binding.clone(), schema);
10223 }
10224 }
10225 if let Some(guard) = &branch.guard {
10226 validate_expression(
10227 rule,
10228 guard,
10229 semantic,
10230 &branch_scope,
10231 "case guard",
10232 diagnostics,
10233 );
10234 validate_known_field_paths(rule, guard, semantic, &branch_scope, diagnostics);
10235 }
10236 validate_known_field_paths(rule, &branch.body, semantic, &branch_scope, diagnostics);
10237 metadata.branches.push(IrTerminalCaseBranch {
10238 scrutinee: branch.scrutinee,
10239 tag,
10240 binding,
10241 guard: branch.guard.as_ref().and_then(|guard| {
10242 lower_expression(
10243 guard,
10244 SourceSpan {
10245 start: branch.pattern_span.start,
10246 end: branch.pattern_span.end,
10247 },
10248 )
10249 }),
10250 body_hash: stable_hash(&branch.body),
10251 pattern_span: branch.pattern_span,
10252 });
10253 }
10254
10255 metadata
10256 .outputs
10257 .sort_by(|left, right| left.binding.cmp(&right.binding));
10258 metadata.branches.sort_by(|left, right| {
10259 (left.scrutinee.as_str(), left.pattern_span.start)
10260 .cmp(&(right.scrutinee.as_str(), right.pattern_span.start))
10261 });
10262 metadata
10263}
10264
10265fn terminal_case_branch_sources(rule: &RuleDecl) -> Vec<TerminalBranchSource> {
10266 let lines = rule
10267 .body
10268 .text
10269 .lines()
10270 .scan(0usize, |offset, line| {
10271 let current = *offset;
10272 *offset += line.len() + 1;
10273 Some((line, current))
10274 })
10275 .collect::<Vec<_>>();
10276 let text_lines = lines.iter().map(|(line, _)| *line).collect::<Vec<_>>();
10277 let mut branches = Vec::new();
10278 let mut index = 0usize;
10279 while index < lines.len() {
10280 let (line, line_offset) = lines[index];
10281 let trimmed = line.trim();
10282 let Some(scrutinee) = case_scrutinee(trimmed) else {
10283 index += 1;
10284 continue;
10285 };
10286 if !active_completes_binding_for_case(&text_lines, index, scrutinee) {
10287 index += 1;
10288 continue;
10289 }
10290 let mut depth = brace_delta(trimmed).max(1);
10291 index += 1;
10292 while index < lines.len() && depth > 0 {
10293 let (branch_line, branch_line_offset) = lines[index];
10294 let branch_trimmed = branch_line.trim();
10295 if depth == 1 {
10296 if let Some((pattern, guard, body_start)) = terminal_branch_header(branch_trimmed) {
10297 let pattern_column = case_pattern_column(branch_line, pattern);
10298 let pattern_span = SourceSpan {
10299 start: rule_body_text_start(rule) + branch_line_offset + pattern_column,
10300 end: rule_body_text_start(rule)
10301 + branch_line_offset
10302 + pattern_column
10303 + pattern.len(),
10304 };
10305 let mut body_lines = Vec::new();
10306 let mut branch_depth = brace_delta(body_start).max(1);
10307 index += 1;
10308 while index < lines.len() && branch_depth > 0 {
10309 let body_line = lines[index].0;
10310 let next_depth = branch_depth + brace_delta(body_line);
10311 if next_depth >= 1 {
10312 body_lines.push(body_line.to_owned());
10313 }
10314 branch_depth = next_depth;
10315 index += 1;
10316 }
10317 branches.push(TerminalBranchSource {
10318 scrutinee: scrutinee.to_owned(),
10319 pattern: pattern.to_owned(),
10320 guard,
10321 body: body_lines.join("\n"),
10322 pattern_span,
10323 });
10324 continue;
10325 }
10326 }
10327 depth += brace_delta(branch_trimmed);
10328 index += 1;
10329 }
10330 let _ = line_offset;
10331 }
10332 branches
10333}
10334
10335fn rule_body_text_start(rule: &RuleDecl) -> usize {
10336 rule.body.span.end.saturating_sub(2 + rule.body.text.len())
10337}
10338
10339fn terminal_branch_header(line: &str) -> Option<(&str, Option<String>, &str)> {
10340 let (head, body_start) = line.split_once("=>")?;
10341 let body_start = body_start.trim();
10342 if !body_start.starts_with('{') {
10343 return None;
10344 }
10345 let head = head.trim();
10346 let (pattern, guard) = match head.split_once(" where ") {
10347 Some((pattern, guard)) => (pattern.trim(), Some(guard.trim().to_owned())),
10348 None => (head, None),
10349 };
10350 Some((pattern, guard, body_start))
10351}
10352
10353fn case_pattern_column(line: &str, pattern: &str) -> usize {
10354 line.find(pattern).unwrap_or_else(|| {
10355 let indent = line.len().saturating_sub(line.trim_start().len());
10356 indent + line.trim_start().find(pattern).unwrap_or(0)
10357 })
10358}
10359
10360fn parse_terminal_pattern_parts(pattern: &str) -> (Option<String>, Option<String>) {
10361 if is_fallback_pattern(pattern) {
10362 return (None, None);
10363 }
10364 let mut parts = pattern.split_whitespace();
10365 let tag = parts.next().map(str::to_owned);
10366 let second = parts.next();
10368 let binding = match second {
10369 Some("as") => parts.next().map(str::to_owned),
10370 Some(_) => return (tag, None),
10371 None => None,
10372 };
10373 if parts.next().is_some() {
10374 return (tag, None);
10375 }
10376 (tag, binding)
10377}
10378
10379fn terminal_payload_schema_for_tag(
10380 tag: &str,
10381 scrutinee: &str,
10382 effect_payload_types: &BTreeMap<String, IrType>,
10383) -> Option<String> {
10384 match tag {
10385 "Completed" => match effect_payload_types.get(scrutinee) {
10386 Some(IrType::Ref(schema)) => Some(schema.clone()),
10387 _ => None,
10388 },
10389 "Failed" => Some("TerminalFailed".to_owned()),
10390 "TimedOut" => Some("TerminalTimedOut".to_owned()),
10391 "Cancelled" => Some("TerminalCancelled".to_owned()),
10392 _ => None,
10393 }
10394}
10395
10396fn terminal_alternatives(
10397 completed_payload: IrType,
10398 span: SourceSpan,
10399) -> Vec<IrTerminalAlternative> {
10400 [
10401 ("Completed", completed_payload),
10402 ("Failed", terminal_failure_payload_type()),
10403 ("TimedOut", terminal_timeout_payload_type()),
10404 ("Cancelled", terminal_cancelled_payload_type()),
10405 ]
10406 .into_iter()
10407 .map(|(tag, payload_type)| IrTerminalAlternative {
10408 tag: tag.to_owned(),
10409 payload_type,
10410 source_span: span,
10411 })
10412 .collect()
10413}
10414
10415fn terminal_failure_payload_type() -> IrType {
10416 IrType::Object(vec![
10417 ir_field("reason", IrType::Primitive(IrPrimitiveType::String)),
10418 ir_field("summary", IrType::Primitive(IrPrimitiveType::String)),
10419 ir_field("effect_id", IrType::Primitive(IrPrimitiveType::String)),
10420 ir_field("run_id", IrType::Primitive(IrPrimitiveType::String)),
10421 ])
10422}
10423
10424fn terminal_timeout_payload_type() -> IrType {
10425 IrType::Object(vec![
10426 ir_field("summary", IrType::Primitive(IrPrimitiveType::String)),
10427 ir_field("effect_id", IrType::Primitive(IrPrimitiveType::String)),
10428 ir_field("run_id", IrType::Primitive(IrPrimitiveType::String)),
10429 ])
10430}
10431
10432fn terminal_cancelled_payload_type() -> IrType {
10433 IrType::Object(vec![
10434 ir_field("summary", IrType::Primitive(IrPrimitiveType::String)),
10435 ir_field("effect_id", IrType::Primitive(IrPrimitiveType::String)),
10436 ir_field("run_id", IrType::Primitive(IrPrimitiveType::String)),
10437 ])
10438}
10439
10440fn terminal_unknown_payload_type() -> IrType {
10441 IrType::Object(vec![
10442 ir_field("summary", IrType::Primitive(IrPrimitiveType::String)),
10443 ir_field("effect_id", IrType::Primitive(IrPrimitiveType::String)),
10444 ir_field("run_id", IrType::Primitive(IrPrimitiveType::String)),
10445 ])
10446}
10447
10448fn ir_field(name: &str, ty: IrType) -> IrClassField {
10449 IrClassField {
10450 name: name.to_owned(),
10451 ty,
10452 is_key: false,
10453 presence_condition: None,
10454 span: SourceSpan { start: 0, end: 0 },
10455 }
10456}
10457
10458fn ir_access_grants_for_body(kind: &body::BodyEffectKind) -> Vec<IrAccessGrant> {
10461 match kind {
10462 body::BodyEffectKind::Tell { access_grants, .. }
10463 | body::BodyEffectKind::Invoke { access_grants, .. } => access_grants
10464 .iter()
10465 .map(|grant| IrAccessGrant {
10466 resource: grant.resource.clone(),
10467 operations: grant
10468 .operations
10469 .iter()
10470 .map(|op| IrAccessGrantOp {
10471 operation: op.operation.clone(),
10472 target: op.target.clone(),
10473 globs: op.globs.clone(),
10474 })
10475 .collect(),
10476 })
10477 .collect(),
10478 _ => Vec::new(),
10479 }
10480}
10481
10482fn ir_effect_kind_for_body(kind: &body::BodyEffectKind) -> IrEffectKind {
10483 match kind {
10484 body::BodyEffectKind::Tell { .. } => IrEffectKind::AgentTell,
10485 body::BodyEffectKind::Coerce { .. }
10486 | body::BodyEffectKind::Prompt { .. }
10487 | body::BodyEffectKind::Decide { .. } => IrEffectKind::SchemaCoerce,
10488 body::BodyEffectKind::Call { .. }
10489 | body::BodyEffectKind::ConstructCapabilityCall { .. } => IrEffectKind::CapabilityCall,
10490 body::BodyEffectKind::Invoke { .. } => IrEffectKind::WorkflowInvoke,
10491 body::BodyEffectKind::Timer { .. } => IrEffectKind::TimerWait,
10492 body::BodyEffectKind::Exec { .. } => IrEffectKind::ExecCommand,
10493 body::BodyEffectKind::TrackerFile { .. } => IrEffectKind::TrackerFile,
10494 body::BodyEffectKind::TrackerClaim { .. } => IrEffectKind::TrackerClaim,
10495 body::BodyEffectKind::TrackerRelease { .. } => IrEffectKind::TrackerRelease,
10496 body::BodyEffectKind::TrackerFinish { .. } => IrEffectKind::TrackerFinish,
10497 body::BodyEffectKind::LeaseAcquire { .. } => IrEffectKind::LeaseAcquire,
10498 body::BodyEffectKind::LeaseRenew { .. } => IrEffectKind::LeaseRenew,
10499 body::BodyEffectKind::LedgerAppend { .. } => IrEffectKind::LedgerAppend,
10500 body::BodyEffectKind::CounterConsume { .. } => IrEffectKind::CounterConsume,
10501 body::BodyEffectKind::Notify { .. } => IrEffectKind::SignalEmit,
10502 body::BodyEffectKind::FileRead { .. } => IrEffectKind::FileRead,
10503 body::BodyEffectKind::FileWrite { .. } => IrEffectKind::FileWrite,
10504 body::BodyEffectKind::FileImport { .. } => IrEffectKind::FileImport,
10505 body::BodyEffectKind::FileExport { .. } => IrEffectKind::FileExport,
10506 }
10507}
10508
10509fn agent_for_body(kind: &body::BodyEffectKind) -> Option<String> {
10512 match kind {
10513 body::BodyEffectKind::Tell { target, .. } => Some(target.clone()),
10514 _ => None,
10515 }
10516}
10517
10518fn coerce_target_for_body(kind: &body::BodyEffectKind) -> Option<String> {
10522 match kind {
10523 body::BodyEffectKind::Coerce { name, .. } => Some(name.clone()),
10524 _ => None,
10525 }
10526}
10527
10528fn turn_skills_for_body(kind: &body::BodyEffectKind) -> Vec<String> {
10530 match kind {
10531 body::BodyEffectKind::Tell { skills, .. } => skills.clone(),
10532 _ => Vec::new(),
10533 }
10534}
10535
10536fn on_stream_for_body(kind: &body::BodyEffectKind) -> Option<String> {
10538 match kind {
10539 body::BodyEffectKind::Tell { on_stream, .. } => on_stream.clone(),
10540 _ => None,
10541 }
10542}
10543
10544fn vcs_selective_for_body(kind: &body::BodyEffectKind) -> (Option<String>, Option<String>) {
10547 let body::BodyEffectKind::ConstructCapabilityCall {
10548 keyword, fields, ..
10549 } = kind
10550 else {
10551 return (None, None);
10552 };
10553 if keyword != "undo" && keyword != "transport" {
10554 return (None, None);
10555 }
10556 let field = |name: &str| {
10557 fields
10558 .iter()
10559 .find(|field| field.name == name)
10560 .map(|field| field.source.clone())
10561 };
10562 (field("selection"), field("onto"))
10563}
10564
10565fn workflow_target_for_body(kind: &body::BodyEffectKind) -> Option<String> {
10567 match kind {
10568 body::BodyEffectKind::Invoke { workflow, .. } => Some(workflow.clone()),
10569 _ => None,
10570 }
10571}
10572
10573fn exec_target_for_body(kind: &body::BodyEffectKind) -> Option<IrExecTarget> {
10576 match kind {
10577 body::BodyEffectKind::Exec { target, .. } => Some(match target {
10578 body::ExecTarget::RawCommand(_) => IrExecTarget::Raw,
10579 body::ExecTarget::Capability { name, .. } => {
10580 IrExecTarget::Capability { name: name.clone() }
10581 }
10582 }),
10583 _ => None,
10584 }
10585}
10586
10587fn endorsed_for_body(kind: &body::BodyEffectKind) -> bool {
10590 matches!(kind, body::BodyEffectKind::Coerce { endorsed: true, .. })
10591}
10592
10593fn declassified_for_body(kind: &body::BodyEffectKind) -> bool {
10596 matches!(
10597 kind,
10598 body::BodyEffectKind::Coerce {
10599 declassified: true,
10600 ..
10601 }
10602 )
10603}
10604
10605fn resource_for_body(kind: &body::BodyEffectKind) -> Option<String> {
10608 match kind {
10609 body::BodyEffectKind::FileRead { store, .. }
10610 | body::BodyEffectKind::FileWrite { store, .. }
10611 | body::BodyEffectKind::FileImport { store, .. }
10612 | body::BodyEffectKind::FileExport { store, .. } => Some(store.clone()),
10613 body::BodyEffectKind::ConstructCapabilityCall {
10615 keyword, fields, ..
10616 } if keyword == "send" => fields
10617 .iter()
10618 .find(|field| field.name == "channel")
10619 .map(|field| field.source.clone()),
10620 body::BodyEffectKind::Notify { event, .. } => Some(format!("signal:{event}")),
10624 body::BodyEffectKind::LeaseAcquire { resource, .. } => Some(format!("resource:{resource}")),
10627 body::BodyEffectKind::LedgerAppend { ledger, .. } => Some(format!("resource:{ledger}")),
10628 body::BodyEffectKind::CounterConsume { counter, .. } => Some(format!("resource:{counter}")),
10629 _ => None,
10630 }
10631}
10632
10633fn construct_use_for_body(kind: &body::BodyEffectKind) -> Option<IrConstructUse> {
10634 match kind {
10635 body::BodyEffectKind::ConstructCapabilityCall {
10636 keyword,
10637 target_capability,
10638 ..
10639 } => Some(IrConstructUse {
10640 keyword: keyword.clone(),
10641 scope: "rule_body".to_owned(),
10642 construct_family: "effect_operation".to_owned(),
10643 lowering_target: "capability_call".to_owned(),
10644 target_capability: target_capability.clone(),
10645 }),
10646 _ => None,
10647 }
10648}
10649
10650fn is_ast_only_effect_kind(kind: &body::BodyEffectKind) -> bool {
10651 if let body::BodyEffectKind::ConstructCapabilityCall { keyword, .. } = kind {
10656 return keyword == "send";
10657 }
10658 matches!(
10659 kind,
10660 body::BodyEffectKind::Prompt { .. }
10661 | body::BodyEffectKind::Timer { .. }
10662 | body::BodyEffectKind::Exec { .. }
10663 | body::BodyEffectKind::Decide { .. }
10664 | body::BodyEffectKind::TrackerFile { .. }
10665 | body::BodyEffectKind::TrackerClaim { .. }
10666 | body::BodyEffectKind::TrackerRelease { .. }
10667 | body::BodyEffectKind::TrackerFinish { .. }
10668 | body::BodyEffectKind::LeaseAcquire { .. }
10669 | body::BodyEffectKind::LeaseRenew { .. }
10670 | body::BodyEffectKind::LedgerAppend { .. }
10671 | body::BodyEffectKind::CounterConsume { .. }
10672 | body::BodyEffectKind::Notify { .. }
10673 | body::BodyEffectKind::Invoke { .. }
10676 | body::BodyEffectKind::FileWrite { .. }
10680 | body::BodyEffectKind::FileExport { .. }
10681 )
10682}
10683
10684fn seed_ast_only_effect_bindings(
10688 statements: &[body::BodyStmt],
10689 seen_bindings: &mut BTreeSet<String>,
10690 binding_types: &mut BTreeMap<String, String>,
10691) {
10692 for statement in statements {
10693 match statement {
10694 body::BodyStmt::Effect(effect) if is_ast_only_effect_kind(&effect.kind) => {
10695 if let Some(binding) = &effect.binding {
10696 seen_bindings.insert(binding.clone());
10697 let _ = binding_types;
10698 }
10699 }
10700 body::BodyStmt::After(after) => {
10701 seed_ast_only_effect_bindings(&after.body, seen_bindings, binding_types)
10702 }
10703 body::BodyStmt::Case(case) => {
10704 for branch in &case.branches {
10705 seed_ast_only_effect_bindings(&branch.body, seen_bindings, binding_types);
10706 }
10707 }
10708 _ => {}
10709 }
10710 }
10711}
10712
10713fn collect_terminal_complete_bindings(statements: &[body::BodyStmt], out: &mut Vec<String>) {
10722 for statement in statements {
10723 match statement {
10724 body::BodyStmt::Terminal(terminal) if terminal.kind == body::TerminalKind::Complete => {
10725 out.push(terminal.name.clone());
10726 }
10727 body::BodyStmt::After(after) => collect_terminal_complete_bindings(&after.body, out),
10728 body::BodyStmt::Case(case) => {
10729 for branch in &case.branches {
10730 collect_terminal_complete_bindings(&branch.body, out);
10731 }
10732 }
10733 _ => {}
10734 }
10735 }
10736}
10737
10738fn collect_effects_from_ast(
10739 statements: &[body::BodyStmt],
10740 rule_name: &str,
10741) -> (Vec<IrEffectNode>, Vec<IrEffectDependency>) {
10742 let mut effects = Vec::new();
10743 let mut dependencies = Vec::new();
10744 let mut counter = 0usize;
10745 let mut after_stack: Vec<(String, DependencyPredicate)> = Vec::new();
10746 let mut case_stack: Vec<(String, String)> = Vec::new();
10747 let claim_bindings = collect_claim_bindings(statements);
10753 walk_effects(
10754 statements,
10755 rule_name,
10756 &claim_bindings,
10757 &mut counter,
10758 &mut after_stack,
10759 &mut case_stack,
10760 &mut effects,
10761 &mut dependencies,
10762 );
10763 (effects, dependencies)
10764}
10765
10766fn collect_claim_bindings(statements: &[body::BodyStmt]) -> BTreeSet<String> {
10770 let mut bindings = BTreeSet::new();
10771 for_each_body(statements, &mut |stmt| {
10772 if let body::BodyStmt::Effect(effect) = stmt {
10773 if matches!(effect.kind, body::BodyEffectKind::TrackerClaim { .. }) {
10774 if let Some(binding) = &effect.binding {
10775 bindings.insert(binding.clone());
10776 }
10777 }
10778 }
10779 });
10780 bindings
10781}
10782
10783#[allow(clippy::too_many_arguments)]
10784fn walk_effects(
10785 statements: &[body::BodyStmt],
10786 rule_name: &str,
10787 claim_bindings: &BTreeSet<String>,
10788 counter: &mut usize,
10789 after_stack: &mut Vec<(String, DependencyPredicate)>,
10790 case_stack: &mut Vec<(String, String)>,
10791 effects: &mut Vec<IrEffectNode>,
10792 dependencies: &mut Vec<IrEffectDependency>,
10793) {
10794 for statement in statements {
10795 match statement {
10796 body::BodyStmt::Effect(effect) => {
10797 *counter += 1;
10798 let id = effect
10799 .binding
10800 .clone()
10801 .unwrap_or_else(|| format!("effect{counter}"));
10802 let kind = match &effect.kind {
10805 body::BodyEffectKind::LeaseRenew {
10806 acquire_binding, ..
10807 } if claim_bindings.contains(acquire_binding) => IrEffectKind::TrackerRenew,
10808 other => ir_effect_kind_for_body(other),
10809 };
10810 for (upstream, predicate) in after_stack.iter() {
10811 dependencies.push(IrEffectDependency {
10812 upstream: upstream.clone(),
10813 predicate: predicate.clone(),
10814 downstream: id.clone(),
10815 });
10816 }
10817 let idempotency_key =
10818 effect_idempotency_key(rule_name, &id, &kind, &effect.binding);
10819 let mut required_capabilities = effect.requires.clone();
10820 match &effect.kind {
10821 body::BodyEffectKind::Call { capability, .. } => {
10822 required_capabilities.push(capability.clone());
10823 }
10824 body::BodyEffectKind::ConstructCapabilityCall {
10825 target_capability, ..
10826 } => {
10827 required_capabilities.push(target_capability.clone());
10828 }
10829 _ => {}
10830 }
10831 required_capabilities.sort();
10832 required_capabilities.dedup();
10833 let construct_use = construct_use_for_body(&effect.kind);
10834 let access_grants = ir_access_grants_for_body(&effect.kind);
10835 let turn_skills = turn_skills_for_body(&effect.kind);
10836 let on_stream = on_stream_for_body(&effect.kind);
10837 let (selection_source, transport_onto) = vcs_selective_for_body(&effect.kind);
10838 let resource = resource_for_body(&effect.kind);
10839 let agent = agent_for_body(&effect.kind);
10840 let coerce_target = coerce_target_for_body(&effect.kind);
10841 let workflow_target = workflow_target_for_body(&effect.kind);
10842 let endorsed = endorsed_for_body(&effect.kind);
10843 let declassified = declassified_for_body(&effect.kind);
10844 let exec_target = exec_target_for_body(&effect.kind);
10845 effects.push(IrEffectNode {
10846 id,
10847 kind,
10848 binding: effect.binding.clone(),
10849 required_capabilities,
10850 construct_use,
10851 idempotency_key,
10852 span: effect.span,
10853 timeout_seconds: effect.timeout_seconds,
10854 access_grants,
10855 turn_skills,
10856 on_stream,
10857 selection_source,
10858 transport_onto,
10859 resource,
10860 agent,
10861 coerce_target,
10862 workflow_target,
10863 endorsed,
10864 declassified,
10865 selected_by: case_stack.last().cloned(),
10866 exec_target,
10867 });
10868 }
10869 body::BodyStmt::After(after) => {
10870 let predicate = match after.predicate {
10871 body::AfterPredicate::Succeeds => DependencyPredicate::Succeeds,
10872 body::AfterPredicate::Fails => DependencyPredicate::Fails,
10873 body::AfterPredicate::TimedOut => DependencyPredicate::TimedOut,
10878 body::AfterPredicate::Cancelled => DependencyPredicate::Cancelled,
10879 body::AfterPredicate::Completes
10883 | body::AfterPredicate::Held
10884 | body::AfterPredicate::Contended
10885 | body::AfterPredicate::Ok
10886 | body::AfterPredicate::Over
10887 | body::AfterPredicate::Promoted
10888 | body::AfterPredicate::Conflicted
10889 | body::AfterPredicate::Applied
10890 | body::AfterPredicate::Stranded => DependencyPredicate::Completes,
10891 body::AfterPredicate::Reaches => DependencyPredicate::Completes,
10898 };
10899 after_stack.push((after.binding.clone(), predicate));
10900 walk_effects(
10901 &after.body,
10902 rule_name,
10903 claim_bindings,
10904 counter,
10905 after_stack,
10906 case_stack,
10907 effects,
10908 dependencies,
10909 );
10910 after_stack.pop();
10911 }
10912 body::BodyStmt::Case(case) => {
10913 for branch in &case.branches {
10914 case_stack.push((case.scrutinee.clone(), branch.pattern.clone()));
10917 walk_effects(
10918 &branch.body,
10919 rule_name,
10920 claim_bindings,
10921 counter,
10922 after_stack,
10923 case_stack,
10924 effects,
10925 dependencies,
10926 );
10927 case_stack.pop();
10928 }
10929 }
10930 _ => {}
10931 }
10932 }
10933}
10934
10935fn effect_idempotency_key(
10936 rule_name: &str,
10937 effect_id: &str,
10938 kind: &IrEffectKind,
10939 binding: &Option<String>,
10940) -> String {
10941 stable_hash(&format!(
10942 "rule={rule_name};effect={effect_id};kind={};binding={}",
10943 kind.as_str(),
10944 binding.as_deref().unwrap_or("-")
10945 ))
10946}
10947
10948fn validate_coerce_call(
10949 rule: &RuleDecl,
10950 line: &str,
10951 semantic: &SemanticContext,
10952 binding_types: &BTreeMap<String, String>,
10953 known_roots: &BTreeSet<String>,
10954 diagnostics: &mut Vec<Diagnostic>,
10955) {
10956 let Some((function_name, args)) = parse_coerce_call(line) else {
10957 diagnostics.push(Diagnostic {
10958 related: Vec::new(),
10959 span: rule.body.span,
10960 message: format!("rule `{}` has malformed coerce call", rule.name.name),
10961 suggestion: Some("write `coerce functionName(arg, ...) as name`".to_owned()),
10962 });
10963 return;
10964 };
10965 let Some(params) = semantic.coerce_params.get(function_name) else {
10966 diagnostics.push(Diagnostic {
10967 related: Vec::new(),
10968 span: rule.body.span,
10969 message: format!(
10970 "rule `{}` calls unknown coerce function `{function_name}`",
10971 rule.name.name
10972 ),
10973 suggestion: Some(format!(
10974 "declare `coerce {function_name}(...) -> Output {{ ... }}` before using it"
10975 )),
10976 });
10977 return;
10978 };
10979 if args.len() != params.len() {
10980 diagnostics.push(Diagnostic {
10981 related: Vec::new(),
10982 span: rule.body.span,
10983 message: format!(
10984 "rule `{}` calls coerce `{function_name}` with {} argument(s), expected {}",
10985 rule.name.name,
10986 args.len(),
10987 params.len()
10988 ),
10989 suggestion: Some("pass one argument for each declared coerce parameter".to_owned()),
10990 });
10991 return;
10992 }
10993 let scope = ExprScope::from_bindings(binding_types);
10994 for (arg, param) in args.iter().zip(params) {
10995 if let Some(root) = dangling_value_root(arg, known_roots) {
10999 diagnostics.push(Diagnostic { related: Vec::new(),
11000 span: rule.body.span,
11001 message: format!(
11002 "rule `{}` has unknown binding `{root}` in coerce `{function_name}` argument",
11003 rule.name.name
11004 ),
11005 suggestion: Some(
11006 "reference a binding from a `when ... as name` clause, an effect `as` binding, or a `case` pattern"
11007 .to_owned(),
11008 ),
11009 });
11010 }
11011 validate_expr_source_against_type(
11012 rule,
11013 &format!("coerce `{function_name}`"),
11014 ¶m.name.name,
11015 ¶m.ty,
11016 arg,
11017 semantic,
11018 &scope,
11019 diagnostics,
11020 );
11021 }
11022}
11023
11024fn validate_effect_payloads(
11025 rule: &RuleDecl,
11026 semantic: &SemanticContext,
11027 binding_types: &BTreeMap<String, String>,
11028 known_roots: &BTreeSet<String>,
11029 diagnostics: &mut Vec<Diagnostic>,
11030) {
11031 for statement in effect_payload_statements(&rule.body.text) {
11032 let trimmed = statement.trim();
11033 if trimmed.starts_with("coerce ") {
11034 validate_coerce_call(
11035 rule,
11036 trimmed,
11037 semantic,
11038 binding_types,
11039 known_roots,
11040 diagnostics,
11041 );
11042 }
11043 }
11044}
11045
11046fn validate_workflow_invocations(
11047 rule: &RuleDecl,
11048 semantic: &SemanticContext,
11049 binding_types: &BTreeMap<String, String>,
11050 known_roots: &BTreeSet<String>,
11051 diagnostics: &mut Vec<Diagnostic>,
11052) {
11053 for statement in workflow_invoke_statements(&rule.body.text) {
11054 let Some((target, body)) = invoke_statement_parts(&statement) else {
11055 diagnostics.push(Diagnostic {
11056 related: Vec::new(),
11057 span: rule.body.span,
11058 message: format!(
11059 "rule `{}` has malformed workflow invocation",
11060 rule.name.name
11061 ),
11062 suggestion: Some("write `invoke Workflow { input value } as binding`".to_owned()),
11063 });
11064 continue;
11065 };
11066 if semantic.workflow.as_deref() == Some(target) {
11067 diagnostics.push(Diagnostic {
11068 related: Vec::new(),
11069 span: rule.body.span,
11070 message: format!(
11071 "rule `{}` recursively invokes workflow `{target}`",
11072 rule.name.name
11073 ),
11074 suggestion: Some(
11075 "split recursive orchestration into an explicit bounded scheduler workflow"
11076 .to_owned(),
11077 ),
11078 });
11079 continue;
11080 }
11081 let Some(surface) = semantic.workflow_inputs.get(target) else {
11082 diagnostics.push(Diagnostic {
11083 related: Vec::new(),
11084 span: rule.body.span,
11085 message: format!(
11086 "rule `{}` invokes unknown workflow `{target}`",
11087 rule.name.name
11088 ),
11089 suggestion: Some("invoke a workflow declared in this source bundle".to_owned()),
11090 });
11091 continue;
11092 };
11093
11094 let mut invocation_semantic = semantic.clone();
11095 invocation_semantic.schemas.merge(surface.schemas.clone());
11096 let assignments = collect_field_assignments(body);
11097 let mut seen = BTreeSet::new();
11098 for assignment in assignments {
11099 let (field, value) = match assignment {
11100 RecordFieldAssignment::Value { field, value } => (field, value),
11101 RecordFieldAssignment::Shorthand { field } => (field.clone(), field),
11102 };
11103 if !seen.insert(field.clone()) {
11104 diagnostics.push(Diagnostic {
11105 related: Vec::new(),
11106 span: rule.body.span,
11107 message: format!("workflow invocation `{target}` repeats input `{field}`"),
11108 suggestion: Some("remove the duplicate invocation input".to_owned()),
11109 });
11110 continue;
11111 }
11112 let Some(input_ty) = surface.inputs.get(&field) else {
11113 let known = surface
11114 .inputs
11115 .keys()
11116 .map(|input| format!("`{input}`"))
11117 .collect::<Vec<_>>()
11118 .join(", ");
11119 diagnostics.push(Diagnostic {
11120 related: Vec::new(),
11121 span: rule.body.span,
11122 message: format!("workflow `{target}` has no input `{field}`"),
11123 suggestion: Some(if known.is_empty() {
11124 "remove the invocation payload; the target declares no inputs".to_owned()
11125 } else {
11126 format!("pass one of: {known}")
11127 }),
11128 });
11129 continue;
11130 };
11131 if let Some(root) = dangling_value_root(&value, known_roots) {
11132 diagnostics.push(Diagnostic { related: Vec::new(),
11133 span: rule.body.span,
11134 message: format!(
11135 "rule `{}` has unknown binding `{root}` in `invoke {target}` input `{field}`",
11136 rule.name.name
11137 ),
11138 suggestion: Some(
11139 "reference a binding from a `when ... as name` clause, an effect `as` binding, or a `case` pattern"
11140 .to_owned(),
11141 ),
11142 });
11143 }
11144 validate_expr_source_against_type(
11145 rule,
11146 target,
11147 &field,
11148 input_ty,
11149 &value,
11150 &invocation_semantic,
11151 &ExprScope::from_bindings(binding_types),
11152 diagnostics,
11153 );
11154 }
11155 for input in surface.inputs.keys() {
11156 if seen.contains(input) {
11157 continue;
11158 }
11159 diagnostics.push(Diagnostic {
11160 related: Vec::new(),
11161 span: rule.body.span,
11162 message: format!("workflow invocation `{target}` is missing input `{input}`"),
11163 suggestion: Some(format!(
11164 "add `{input}` to the `{target}` invocation payload"
11165 )),
11166 });
11167 }
11168 }
11169}
11170
11171fn validate_agent_tell_target(
11172 rule: &RuleDecl,
11173 line: &str,
11174 kind: &IrEffectKind,
11175 semantic: &SemanticContext,
11176 binding_types: &BTreeMap<String, String>,
11177 known_roots: &BTreeSet<String>,
11178 diagnostics: &mut Vec<Diagnostic>,
11179) {
11180 if kind != &IrEffectKind::AgentTell {
11181 return;
11182 }
11183 let Some(target) = parse_tell_target(line) else {
11184 diagnostics.push(Diagnostic {
11185 related: Vec::new(),
11186 span: rule.body.span,
11187 message: format!("rule `{}` has malformed tell target", rule.name.name),
11188 suggestion: Some("write `tell agentName ...` or `tell task.agentRef ...`".to_owned()),
11189 });
11190 return;
11191 };
11192 if target.starts_with('"') {
11193 diagnostics.push(Diagnostic {
11194 related: Vec::new(),
11195 span: rule.body.span,
11196 message: format!(
11197 "rule `{}` uses a string literal as a tell target",
11198 rule.name.name
11199 ),
11200 suggestion: Some("use a declared agent name or an AgentRef field".to_owned()),
11201 });
11202 return;
11203 }
11204 let required_capabilities = parse_required_capabilities(line);
11205 if target.contains('.') {
11206 let Some(ty) = expression_type(target, semantic, binding_types) else {
11207 if let Some(root) = dangling_value_root(target, known_roots) {
11211 diagnostics.push(Diagnostic { related: Vec::new(),
11212 span: rule.body.span,
11213 message: format!(
11214 "rule `{}` has unknown binding `{root}` in tell target `{target}`",
11215 rule.name.name
11216 ),
11217 suggestion: Some(
11218 "reference a binding from a `when ... as name` clause or an effect `as` binding"
11219 .to_owned(),
11220 ),
11221 });
11222 }
11223 return;
11224 };
11225 if let TypeSyntax::AgentRef { agents, .. } = ty {
11226 for agent in agents {
11227 validate_agent_capabilities(
11228 rule,
11229 &agent.name,
11230 &required_capabilities,
11231 semantic,
11232 diagnostics,
11233 );
11234 }
11235 } else {
11236 diagnostics.push(Diagnostic {
11237 related: Vec::new(),
11238 span: rule.body.span,
11239 message: format!(
11240 "rule `{}` uses non-AgentRef dynamic tell target `{target}`",
11241 rule.name.name
11242 ),
11243 suggestion: Some(
11244 "declare the field as `AgentRef<...>` before using it as a tell target"
11245 .to_owned(),
11246 ),
11247 });
11248 }
11249 return;
11250 }
11251 if !semantic.agents.contains(target) {
11252 diagnostics.push(Diagnostic {
11253 related: Vec::new(),
11254 span: rule.body.span,
11255 message: format!("rule `{}` tells unknown agent `{target}`", rule.name.name),
11256 suggestion: Some("declare the target agent before telling it".to_owned()),
11257 });
11258 return;
11259 }
11260 validate_agent_capabilities(rule, target, &required_capabilities, semantic, diagnostics);
11261}
11262
11263fn validate_agent_capabilities(
11264 rule: &RuleDecl,
11265 agent: &str,
11266 required_capabilities: &[String],
11267 semantic: &SemanticContext,
11268 diagnostics: &mut Vec<Diagnostic>,
11269) {
11270 if required_capabilities.is_empty() {
11271 return;
11272 }
11273 let declared = semantic
11274 .agent_capabilities
11275 .get(agent)
11276 .cloned()
11277 .unwrap_or_default();
11278 for capability in required_capabilities {
11279 if !declared.contains(capability) {
11280 diagnostics.push(Diagnostic { related: Vec::new(),
11281 span: rule.body.span,
11282 message: format!(
11283 "rule `{}` tells agent `{agent}` requiring undeclared capability `{capability}`",
11284 rule.name.name
11285 ),
11286 suggestion: Some(format!(
11287 "add `{capability}` to agent `{agent}` capabilities or choose another AgentRef target"
11288 )),
11289 });
11290 }
11291 }
11292}
11293
11294fn validate_availability_when(
11295 rule: &RuleDecl,
11296 when: &str,
11297 semantic: &SemanticContext,
11298 binding_types: &BTreeMap<String, String>,
11299 diagnostics: &mut Vec<Diagnostic>,
11300) {
11301 let (pattern, _) = split_when_guard(when);
11302 let Some(target) = pattern.strip_suffix(" is available").map(str::trim) else {
11303 return;
11304 };
11305 if target.contains('.') {
11306 let Some(ty) = expression_type(target, semantic, binding_types) else {
11307 return;
11308 };
11309 if !matches!(ty, TypeSyntax::AgentRef { .. }) {
11310 diagnostics.push(Diagnostic {
11311 related: Vec::new(),
11312 span: rule.body.span,
11313 message: format!(
11314 "rule `{}` checks availability for non-AgentRef `{target}`",
11315 rule.name.name
11316 ),
11317 suggestion: Some(
11318 "availability checks must name a declared agent or an AgentRef field"
11319 .to_owned(),
11320 ),
11321 });
11322 }
11323 return;
11324 }
11325 if !semantic.agents.contains(target) {
11326 diagnostics.push(Diagnostic {
11327 related: Vec::new(),
11328 span: rule.body.span,
11329 message: format!("rule `{}` checks unknown agent `{target}`", rule.name.name),
11330 suggestion: Some("declare the target agent before checking availability".to_owned()),
11331 });
11332 }
11333}
11334
11335#[derive(Clone, Debug, Default)]
11336struct ExprScope {
11337 binding_types: BTreeMap<String, String>,
11338 implicit_schema: Option<String>,
11339}
11340
11341impl ExprScope {
11342 fn from_bindings(binding_types: &BTreeMap<String, String>) -> Self {
11343 Self {
11344 binding_types: binding_types.clone(),
11345 implicit_schema: None,
11346 }
11347 }
11348
11349 fn with_implicit_schema(&self, schema: String) -> Self {
11350 let mut scope = self.clone();
11351 scope.implicit_schema = Some(schema);
11352 scope
11353 }
11354}
11355
11356#[derive(Clone, Debug)]
11357struct ExprValidationContext {
11358 subject: String,
11359 span: SourceSpan,
11360}
11361
11362impl ExprValidationContext {
11363 fn rule(rule: &RuleDecl) -> Self {
11364 Self {
11365 subject: format!("rule `{}`", rule.name.name),
11366 span: rule.body.span,
11367 }
11368 }
11369
11370 fn assertion(span: SourceSpan) -> Self {
11371 Self {
11372 subject: "assertion".to_owned(),
11373 span,
11374 }
11375 }
11376}
11377
11378fn validate_expression(
11379 rule: &RuleDecl,
11380 expr: &str,
11381 semantic: &SemanticContext,
11382 binding_types: &BTreeMap<String, String>,
11383 label: &str,
11384 diagnostics: &mut Vec<Diagnostic>,
11385) {
11386 match parse_expression(expr) {
11387 Ok(expr) => {
11388 validate_parsed_expression(
11389 &expr,
11390 semantic,
11391 &ExprScope::from_bindings(binding_types),
11392 &ExprValidationContext::rule(rule),
11393 label,
11394 diagnostics,
11395 );
11396 }
11397 Err(message) => diagnostics.push(Diagnostic { related: Vec::new(),
11398 span: rule.body.span,
11399 message: format!("rule `{}` has invalid {label} expression: {message}", rule.name.name),
11400 suggestion: Some("use deterministic field paths, literals, boolean operators, comparisons, membership, count, or exists".to_owned()),
11401 }),
11402 }
11403}
11404
11405fn validate_parsed_expression(
11406 expr: &Expr,
11407 semantic: &SemanticContext,
11408 scope: &ExprScope,
11409 context: &ExprValidationContext,
11410 label: &str,
11411 diagnostics: &mut Vec<Diagnostic>,
11412) {
11413 let presence_proofs = BTreeSet::new();
11414 validate_expr_node(
11415 expr,
11416 semantic,
11417 scope,
11418 context,
11419 &presence_proofs,
11420 diagnostics,
11421 );
11422 let ty = infer_expr_type(expr, semantic, scope, context, diagnostics);
11423 if ty != ExprType::Bool && ty != ExprType::Unknown {
11424 diagnostics.push(Diagnostic {
11425 related: Vec::new(),
11426 span: context.span,
11427 message: format!("{} has non-boolean {label} expression", context.subject),
11428 suggestion: Some(format!("{label} expressions must evaluate to bool")),
11429 });
11430 }
11431}
11432
11433fn validate_expr_node(
11434 expr: &Expr,
11435 semantic: &SemanticContext,
11436 scope: &ExprScope,
11437 context: &ExprValidationContext,
11438 presence_proofs: &BTreeSet<String>,
11439 diagnostics: &mut Vec<Diagnostic>,
11440) {
11441 match expr {
11442 Expr::Path(path) => {
11443 if path.len() < 2 {
11444 return;
11445 }
11446 let root = &path[0];
11447 let Some(schema) = scope.binding_types.get(root) else {
11448 if let Some(schema) = &scope.implicit_schema {
11449 if let Err(message) =
11450 validate_optional_path_access(schema, path, semantic, presence_proofs)
11451 {
11452 diagnostics.push(Diagnostic {
11453 related: Vec::new(),
11454 span: context.span,
11455 message: format!(
11456 "{} has unsafe optional path `{}`: {message}",
11457 context.subject,
11458 path.join(".")
11459 ),
11460 suggestion: Some(
11461 "prove the optional value is present before reading through it"
11462 .to_owned(),
11463 ),
11464 });
11465 return;
11466 }
11467 if let Err(message) = semantic.schemas.resolve_field_path(schema, path) {
11468 diagnostics.push(Diagnostic {
11469 related: Vec::new(),
11470 span: context.span,
11471 message: format!(
11472 "{} has invalid expression path `{}`: {message}",
11473 context.subject,
11474 path.join(".")
11475 ),
11476 suggestion: Some(
11477 "use a field declared on the queried schema".to_owned(),
11478 ),
11479 });
11480 }
11481 return;
11482 }
11483 diagnostics.push(Diagnostic {
11484 related: Vec::new(),
11485 span: context.span,
11486 message: format!("{} has unknown expression root `{root}`", context.subject),
11487 suggestion: Some(
11488 "use a binding introduced by a `when ... as name` clause".to_owned(),
11489 ),
11490 });
11491 return;
11492 };
11493 if let Err(message) =
11494 validate_optional_path_access(schema, &path[1..], semantic, presence_proofs)
11495 {
11496 diagnostics.push(Diagnostic {
11497 related: Vec::new(),
11498 span: context.span,
11499 message: format!(
11500 "{} has unsafe optional path `{}`: {message}",
11501 context.subject,
11502 path.join(".")
11503 ),
11504 suggestion: Some(
11505 "prove the optional value is present before reading through it".to_owned(),
11506 ),
11507 });
11508 return;
11509 }
11510 if let Err(message) = semantic.schemas.resolve_field_path(schema, &path[1..]) {
11511 diagnostics.push(Diagnostic {
11512 related: Vec::new(),
11513 span: context.span,
11514 message: format!(
11515 "{} has invalid expression path `{}`: {message}",
11516 context.subject,
11517 path.join(".")
11518 ),
11519 suggestion: Some("use a field declared on the bound schema".to_owned()),
11520 });
11521 }
11522 }
11523 Expr::Index { target, key } => {
11524 validate_expr_node(
11525 target,
11526 semantic,
11527 scope,
11528 context,
11529 presence_proofs,
11530 diagnostics,
11531 );
11532 validate_expr_node(key, semantic, scope, context, presence_proofs, diagnostics);
11533 let key_ty = infer_expr_type(key, semantic, scope, context, diagnostics);
11534 if !matches!(key_ty, ExprType::String | ExprType::Unknown) {
11535 diagnostics.push(Diagnostic {
11536 related: Vec::new(),
11537 span: context.span,
11538 message: format!("{} indexes a map with a non-string key", context.subject),
11539 suggestion: Some(
11540 "use a string literal or string expression as the map key".to_owned(),
11541 ),
11542 });
11543 }
11544 }
11545 Expr::Array(items) => {
11546 for item in items {
11547 validate_expr_node(item, semantic, scope, context, presence_proofs, diagnostics);
11548 }
11549 }
11550 Expr::Object(fields) => {
11551 diagnostics.push(Diagnostic {
11552 related: Vec::new(),
11553 span: context.span,
11554 message: format!(
11555 "{} uses an object literal without an expected object or map type",
11556 context.subject
11557 ),
11558 suggestion: Some(
11559 "use object literals only in typed record fields or typed effect arguments"
11560 .to_owned(),
11561 ),
11562 });
11563 for field in fields {
11564 validate_expr_node(
11565 &field.value,
11566 semantic,
11567 scope,
11568 context,
11569 presence_proofs,
11570 diagnostics,
11571 );
11572 }
11573 }
11574 Expr::Unary { expr, .. } => {
11575 validate_expr_node(expr, semantic, scope, context, presence_proofs, diagnostics)
11576 }
11577 Expr::Binary {
11578 op: BinaryOp::And,
11579 left,
11580 right,
11581 } => {
11582 validate_expr_node(left, semantic, scope, context, presence_proofs, diagnostics);
11583 let mut right_proofs = presence_proofs.clone();
11584 collect_presence_proofs(left, &mut right_proofs);
11585 validate_expr_node(right, semantic, scope, context, &right_proofs, diagnostics);
11586 }
11587 Expr::Binary { op, left, right } => {
11588 validate_expr_node(left, semantic, scope, context, presence_proofs, diagnostics);
11589 validate_expr_node(
11590 right,
11591 semantic,
11592 scope,
11593 context,
11594 presence_proofs,
11595 diagnostics,
11596 );
11597 validate_unknown_implicit_idents(
11598 *op,
11599 left,
11600 right,
11601 semantic,
11602 scope,
11603 context,
11604 diagnostics,
11605 );
11606 validate_finite_domain_expr(*op, left, right, semantic, scope, context, diagnostics);
11607 }
11608 Expr::Call { name, args } => {
11609 validate_function_call(name, args, semantic, scope, context, diagnostics);
11610 for arg in args {
11611 validate_expr_node(arg, semantic, scope, context, presence_proofs, diagnostics);
11612 }
11613 }
11614 Expr::Query { guard, .. } => {
11615 validate_query_expr(expr, semantic, scope, context, diagnostics);
11616 if let Some(guard) = guard {
11617 let guard_scope = query_guard_scope(expr, semantic, scope);
11618 validate_expr_node(
11619 guard,
11620 semantic,
11621 &guard_scope,
11622 context,
11623 presence_proofs,
11624 diagnostics,
11625 );
11626 }
11627 }
11628 Expr::Literal(_) => {}
11629 }
11630}
11631
11632fn validate_unknown_implicit_idents(
11633 op: BinaryOp,
11634 left: &Expr,
11635 right: &Expr,
11636 semantic: &SemanticContext,
11637 scope: &ExprScope,
11638 context: &ExprValidationContext,
11639 diagnostics: &mut Vec<Diagnostic>,
11640) {
11641 if !matches!(
11642 op,
11643 BinaryOp::Eq | BinaryOp::Ne | BinaryOp::Lt | BinaryOp::Le | BinaryOp::Gt | BinaryOp::Ge
11644 ) {
11645 return;
11646 }
11647 validate_unknown_implicit_ident(left, right, semantic, scope, context, diagnostics);
11648 validate_unknown_implicit_ident(right, left, semantic, scope, context, diagnostics);
11649}
11650
11651fn validate_unknown_implicit_ident(
11652 expr: &Expr,
11653 other: &Expr,
11654 semantic: &SemanticContext,
11655 scope: &ExprScope,
11656 context: &ExprValidationContext,
11657 diagnostics: &mut Vec<Diagnostic>,
11658) {
11659 let Expr::Literal(ExprLiteral::Ident(name)) = expr else {
11660 return;
11661 };
11662 let Some(schema) = &scope.implicit_schema else {
11663 return;
11664 };
11665 let field_exists = semantic
11666 .schemas
11667 .classes
11668 .get(schema)
11669 .is_some_and(|fields| fields.contains_key(name));
11670 if field_exists
11671 || expr_domain(other, semantic, scope).is_some()
11672 || implicit_ident_field_exists(other, semantic, scope)
11673 {
11674 return;
11675 }
11676 diagnostics.push(Diagnostic {
11677 related: Vec::new(),
11678 span: context.span,
11679 message: format!(
11680 "{} fact query `{schema}` has unknown field `{name}`",
11681 context.subject
11682 ),
11683 suggestion: Some(format!(
11684 "use a field declared on `{schema}` inside the query `where` expression"
11685 )),
11686 });
11687}
11688
11689fn implicit_ident_field_exists(expr: &Expr, semantic: &SemanticContext, scope: &ExprScope) -> bool {
11690 let Expr::Literal(ExprLiteral::Ident(name)) = expr else {
11691 return false;
11692 };
11693 let Some(schema) = &scope.implicit_schema else {
11694 return false;
11695 };
11696 semantic
11697 .schemas
11698 .classes
11699 .get(schema)
11700 .is_some_and(|fields| fields.contains_key(name))
11701}
11702
11703fn validate_function_call(
11704 name: &str,
11705 args: &[Expr],
11706 semantic: &SemanticContext,
11707 scope: &ExprScope,
11708 context: &ExprValidationContext,
11709 diagnostics: &mut Vec<Diagnostic>,
11710) {
11711 match name {
11712 "count" => {
11713 if args.len() != 1 {
11714 diagnostics.push(Diagnostic { related: Vec::new(),
11715 span: context.span,
11716 message: format!(
11717 "{} calls `count` with {} arguments, expected 1",
11718 context.subject,
11719 args.len()
11720 ),
11721 suggestion: Some(
11722 "call `count` with exactly one array, map, fact query, or effect query argument"
11723 .to_owned(),
11724 ),
11725 });
11726 return;
11727 }
11728 let ty = infer_expr_type(&args[0], semantic, scope, context, diagnostics);
11729 if !is_countable_type(&ty) {
11730 diagnostics.push(Diagnostic {
11731 related: Vec::new(),
11732 span: context.span,
11733 message: format!(
11734 "{} calls `count` with unsupported argument type `{}`",
11735 context.subject,
11736 expr_type_label(&ty)
11737 ),
11738 suggestion: Some(
11739 "use `count` only with arrays, maps, fact queries, or effect queries"
11740 .to_owned(),
11741 ),
11742 });
11743 }
11744 }
11745 "exists" => {
11746 if args.len() != 1 {
11747 diagnostics.push(Diagnostic {
11748 related: Vec::new(),
11749 span: context.span,
11750 message: format!(
11751 "{} calls `exists` with {} arguments, expected 1",
11752 context.subject,
11753 args.len()
11754 ),
11755 suggestion: Some("call `exists` with exactly one argument".to_owned()),
11756 });
11757 return;
11758 }
11759 let ty = infer_expr_type(&args[0], semantic, scope, context, diagnostics);
11760 if !matches!(args[0], Expr::Index { .. }) && !is_exists_type(&ty) {
11761 diagnostics.push(Diagnostic { related: Vec::new(),
11762 span: context.span,
11763 message: format!(
11764 "{} calls `exists` with unsupported argument type `{}`",
11765 context.subject,
11766 expr_type_label(&ty)
11767 ),
11768 suggestion: Some(
11769 "use `exists path` for optional/map presence checks or pass an array, map, fact query, or effect query"
11770 .to_owned(),
11771 ),
11772 });
11773 }
11774 }
11775 "empty" => {
11776 if args.len() != 1 {
11777 diagnostics.push(Diagnostic {
11778 related: Vec::new(),
11779 span: context.span,
11780 message: format!(
11781 "{} calls `empty` with {} arguments, expected 1",
11782 context.subject,
11783 args.len()
11784 ),
11785 suggestion: Some(
11786 "call `empty` with exactly one array, map, string, fact query, or effect query argument"
11787 .to_owned(),
11788 ),
11789 });
11790 return;
11791 }
11792 let ty = infer_expr_type(&args[0], semantic, scope, context, diagnostics);
11793 if !is_emptiable_type(&ty) {
11794 let optional = matches!(ty, ExprType::Optional(_));
11798 diagnostics.push(Diagnostic {
11799 related: Vec::new(),
11800 span: context.span,
11801 message: format!(
11802 "{} calls `empty` with unsupported {}argument type `{}`",
11803 context.subject,
11804 if optional { "optional " } else { "" },
11805 expr_type_label(&ty)
11806 ),
11807 suggestion: Some(
11808 "use `empty` only with arrays, maps, strings, fact queries, effect queries, null, or supported optional values"
11809 .to_owned(),
11810 ),
11811 });
11812 }
11813 }
11814 _ => {}
11815 }
11816}
11817
11818fn validate_query_expr(
11819 expr: &Expr,
11820 semantic: &SemanticContext,
11821 scope: &ExprScope,
11822 context: &ExprValidationContext,
11823 diagnostics: &mut Vec<Diagnostic>,
11824) {
11825 let Expr::Query { kind, head, guard } = expr else {
11826 return;
11827 };
11828 if *kind == QueryKind::Fact {
11829 let Some(schema) = query_head_schema(head, semantic) else {
11830 diagnostics.push(Diagnostic {
11831 related: Vec::new(),
11832 span: context.span,
11833 message: format!(
11834 "{} queries unknown fact schema `{}`",
11835 context.subject,
11836 head.trim()
11837 ),
11838 suggestion: Some("use a declared class name in fact queries".to_owned()),
11839 });
11840 return;
11841 };
11842 if let Some(guard) = guard {
11843 let guard_scope = scope.with_implicit_schema(schema);
11844 let ty = infer_expr_type(guard, semantic, &guard_scope, context, diagnostics);
11845 if !matches!(ty, ExprType::Bool | ExprType::Unknown) {
11846 diagnostics.push(Diagnostic {
11847 related: Vec::new(),
11848 span: context.span,
11849 message: format!(
11850 "{} fact query `{}` has non-boolean `where` expression",
11851 context.subject,
11852 head.trim()
11853 ),
11854 suggestion: Some("query `where` expressions must evaluate to bool".to_owned()),
11855 });
11856 }
11857 }
11858 }
11859}
11860
11861fn validate_optional_path_access(
11862 root_schema: &str,
11863 path: &[String],
11864 semantic: &SemanticContext,
11865 presence_proofs: &BTreeSet<String>,
11866) -> Result<(), String> {
11867 let mut schema = root_schema.to_owned();
11868 let mut prefix = Vec::new();
11869 for (index, field) in path.iter().enumerate() {
11870 let Some(fields) = semantic.schemas.classes.get(&schema) else {
11871 return Ok(());
11872 };
11873 let Some(field_ty) = fields.get(field) else {
11874 return Ok(());
11875 };
11876 prefix.push(field.clone());
11877 if let TypeSyntax::Optional { inner, .. } = field_ty {
11878 if index + 1 < path.len() && !presence_proofs.contains(&prefix.join(".")) {
11879 return Err(format!(
11880 "`{}` must be proven present before accessing `{}`",
11881 prefix.join("."),
11882 path[index + 1..].join(".")
11883 ));
11884 }
11885 if let Some(next_schema) = schema_name_for_path(inner) {
11886 schema = next_schema;
11887 }
11888 continue;
11889 }
11890 if let Some(next_schema) = schema_name_for_path(field_ty) {
11891 schema = next_schema;
11892 }
11893 }
11894 Ok(())
11895}
11896
11897fn collect_presence_proofs(expr: &Expr, proofs: &mut BTreeSet<String>) {
11898 match expr {
11899 Expr::Binary {
11900 op: BinaryOp::Ne,
11901 left,
11902 right,
11903 } => {
11904 if matches!(**right, Expr::Literal(ExprLiteral::Null)) {
11905 if let Some(path) = expr_path_key(left) {
11906 proofs.insert(path);
11907 }
11908 }
11909 if matches!(**left, Expr::Literal(ExprLiteral::Null)) {
11910 if let Some(path) = expr_path_key(right) {
11911 proofs.insert(path);
11912 }
11913 }
11914 }
11915 Expr::Unary {
11916 op: UnaryOp::Not,
11917 expr,
11918 } => {
11919 if let Expr::Binary {
11920 op: BinaryOp::Eq,
11921 left,
11922 right,
11923 } = expr.as_ref()
11924 {
11925 if matches!(**right, Expr::Literal(ExprLiteral::Null)) {
11926 if let Some(path) = expr_path_key(left) {
11927 proofs.insert(path);
11928 }
11929 }
11930 if matches!(**left, Expr::Literal(ExprLiteral::Null)) {
11931 if let Some(path) = expr_path_key(right) {
11932 proofs.insert(path);
11933 }
11934 }
11935 }
11936 }
11937 Expr::Call { name, args } if name == "exists" && args.len() == 1 => {
11938 if let Some(path) = expr_path_key(&args[0]) {
11939 proofs.insert(path);
11940 }
11941 }
11942 Expr::Binary {
11943 op: BinaryOp::And,
11944 left,
11945 right,
11946 } => {
11947 collect_presence_proofs(left, proofs);
11948 collect_presence_proofs(right, proofs);
11949 }
11950 _ => {}
11951 }
11952}
11953
11954fn expr_path_key(expr: &Expr) -> Option<String> {
11955 match expr {
11956 Expr::Literal(ExprLiteral::Ident(name)) => Some(name.clone()),
11957 Expr::Path(path) if path.len() >= 2 => Some(path[1..].join(".")),
11958 Expr::Index { target, key } => {
11959 let target = expr_path_key(target)?;
11960 let key = match key.as_ref() {
11961 Expr::Literal(ExprLiteral::String(value) | ExprLiteral::Ident(value)) => value,
11962 _ => return None,
11963 };
11964 Some(format!("{target}[{key:?}]"))
11965 }
11966 _ => None,
11967 }
11968}
11969
11970fn query_guard_scope(expr: &Expr, semantic: &SemanticContext, scope: &ExprScope) -> ExprScope {
11971 let Expr::Query {
11972 kind: QueryKind::Fact,
11973 head,
11974 ..
11975 } = expr
11976 else {
11977 return scope.clone();
11978 };
11979 query_head_schema(head, semantic)
11980 .map(|schema| scope.with_implicit_schema(schema))
11981 .unwrap_or_else(|| scope.clone())
11982}
11983
11984fn query_head_schema(head: &str, semantic: &SemanticContext) -> Option<String> {
11985 let mut parts = head.split_whitespace();
11986 let schema = parts.next()?;
11987 if parts.next().is_some() {
11988 return None;
11989 }
11990 semantic
11991 .schemas
11992 .class_exists(schema)
11993 .then(|| schema.to_owned())
11994}
11995
11996fn implicit_field_type(
11997 name: &str,
11998 semantic: &SemanticContext,
11999 scope: &ExprScope,
12000) -> Option<TypeSyntax> {
12001 let schema = scope.implicit_schema.as_ref()?;
12002 semantic
12003 .schemas
12004 .resolve_field_path(schema, &[name.to_owned()])
12005 .ok()
12006}
12007
12008fn infer_expr_type(
12009 expr: &Expr,
12010 semantic: &SemanticContext,
12011 scope: &ExprScope,
12012 context: &ExprValidationContext,
12013 diagnostics: &mut Vec<Diagnostic>,
12014) -> ExprType {
12015 match expr {
12016 Expr::Literal(ExprLiteral::Ident(name)) => implicit_field_type(name, semantic, scope)
12017 .map(|ty| expr_type_from_type_syntax(&ty, semantic))
12018 .unwrap_or_else(|| expr_literal_type(&ExprLiteral::Ident(name.clone()))),
12019 Expr::Literal(literal) => expr_literal_type(literal),
12020 Expr::Path(path) => expr_path_type(path, semantic, scope).unwrap_or(ExprType::Unknown),
12021 Expr::Index { target, key } => {
12022 let target_ty = infer_expr_type(target, semantic, scope, context, diagnostics);
12023 let key_ty = infer_expr_type(key, semantic, scope, context, diagnostics);
12024 if !matches!(key_ty, ExprType::String | ExprType::Unknown) {
12025 diagnostics.push(Diagnostic {
12026 related: Vec::new(),
12027 span: context.span,
12028 message: format!("{} indexes a map with a non-string key", context.subject),
12029 suggestion: Some(
12030 "use a string literal or string expression as the map key".to_owned(),
12031 ),
12032 });
12033 }
12034 match target_ty {
12035 ExprType::Map(inner) => *inner,
12036 ExprType::Unknown => ExprType::Unknown,
12037 _ => {
12038 diagnostics.push(Diagnostic {
12039 related: Vec::new(),
12040 span: context.span,
12041 message: format!("{} indexes a non-map expression", context.subject),
12042 suggestion: Some("use indexing only on map values".to_owned()),
12043 });
12044 ExprType::Unknown
12045 }
12046 }
12047 }
12048 Expr::Array(items) => infer_array_type(items, semantic, scope, context, diagnostics),
12049 Expr::Object(fields) => {
12050 for field in fields {
12051 infer_expr_type(&field.value, semantic, scope, context, diagnostics);
12052 }
12053 ExprType::Object
12054 }
12055 Expr::Unary {
12056 op: UnaryOp::Not,
12057 expr,
12058 } => {
12059 let inner = infer_expr_type(expr, semantic, scope, context, diagnostics);
12060 if !matches!(inner, ExprType::Bool | ExprType::Unknown) {
12061 diagnostics.push(Diagnostic {
12062 related: Vec::new(),
12063 span: context.span,
12064 message: format!(
12065 "{} applies `!` to a non-boolean expression",
12066 context.subject
12067 ),
12068 suggestion: Some("use `!` only with boolean expressions".to_owned()),
12069 });
12070 }
12071 ExprType::Bool
12072 }
12073 Expr::Binary { op, left, right } => {
12074 infer_binary_type(*op, left, right, semantic, scope, context, diagnostics)
12075 }
12076 Expr::Call { name, args } => match name.as_str() {
12077 "count" => ExprType::Int,
12078 "exists" => ExprType::Bool,
12079 "empty" => ExprType::Bool,
12080 _ => {
12081 diagnostics.push(Diagnostic {
12082 related: Vec::new(),
12083 span: context.span,
12084 message: format!(
12085 "{} calls unsupported expression function `{name}`",
12086 context.subject
12087 ),
12088 suggestion: Some("use `count`, `exists`, or `empty`".to_owned()),
12089 });
12090 for arg in args {
12091 infer_expr_type(arg, semantic, scope, context, diagnostics);
12092 }
12093 ExprType::Unknown
12094 }
12095 },
12096 Expr::Query { guard, .. } => {
12097 if let Some(guard) = guard {
12098 let guard_scope = query_guard_scope(expr, semantic, scope);
12099 infer_expr_type(guard, semantic, &guard_scope, context, diagnostics);
12100 }
12101 ExprType::Collection
12102 }
12103 }
12104}
12105
12106fn infer_binary_type(
12107 op: BinaryOp,
12108 left: &Expr,
12109 right: &Expr,
12110 semantic: &SemanticContext,
12111 scope: &ExprScope,
12112 context: &ExprValidationContext,
12113 diagnostics: &mut Vec<Diagnostic>,
12114) -> ExprType {
12115 let left_ty = infer_expr_type(left, semantic, scope, context, diagnostics);
12116 let right_ty = infer_expr_type(right, semantic, scope, context, diagnostics);
12117 match op {
12118 BinaryOp::And | BinaryOp::Or => {
12119 for ty in [&left_ty, &right_ty] {
12120 if !matches!(ty, ExprType::Bool | ExprType::Unknown) {
12121 diagnostics.push(Diagnostic {
12122 related: Vec::new(),
12123 span: context.span,
12124 message: format!(
12125 "{} uses boolean operator with non-boolean operand",
12126 context.subject
12127 ),
12128 suggestion: Some(
12129 "use `&&` and `||` only with boolean expressions".to_owned(),
12130 ),
12131 });
12132 break;
12133 }
12134 }
12135 ExprType::Bool
12136 }
12137 BinaryOp::Eq | BinaryOp::Ne => {
12138 if !types_comparable(&left_ty, &right_ty) {
12139 diagnostics.push(Diagnostic {
12140 related: Vec::new(),
12141 span: context.span,
12142 message: format!("{} compares incompatible expression types", context.subject),
12143 suggestion: Some(
12144 "compare values with compatible scalar or finite-domain types".to_owned(),
12145 ),
12146 });
12147 }
12148 ExprType::Bool
12149 }
12150 BinaryOp::Lt | BinaryOp::Le | BinaryOp::Gt | BinaryOp::Ge => {
12151 if !is_orderable_pair(&left_ty, &right_ty) {
12152 diagnostics.push(Diagnostic {
12153 related: Vec::new(),
12154 span: context.span,
12155 message: format!("{} orders non-orderable expression values", context.subject),
12156 suggestion: Some(
12157 "use ordering only with int, float, duration, or time values".to_owned(),
12158 ),
12159 });
12160 }
12161 ExprType::Bool
12162 }
12163 BinaryOp::In | BinaryOp::NotIn => {
12164 match &right_ty {
12165 ExprType::Array(item_ty) => {
12166 if !types_comparable(&left_ty, item_ty) {
12167 diagnostics.push(Diagnostic {
12168 related: Vec::new(),
12169 span: context.span,
12170 message: format!(
12171 "{} uses membership with incompatible item type",
12172 context.subject
12173 ),
12174 suggestion: Some(
12175 "make the left value compatible with the array item type"
12176 .to_owned(),
12177 ),
12178 });
12179 }
12180 }
12181 ExprType::Map(_) => {
12182 if !is_string_like_key_type(&left_ty) {
12183 diagnostics.push(Diagnostic {
12184 related: Vec::new(),
12185 span: context.span,
12186 message: format!(
12187 "{} uses map membership with a non-string key",
12188 context.subject
12189 ),
12190 suggestion: Some(
12191 "use a string value on the left side of map membership".to_owned(),
12192 ),
12193 });
12194 }
12195 }
12196 ExprType::Unknown => {}
12197 _ => diagnostics.push(Diagnostic {
12198 related: Vec::new(),
12199 span: context.span,
12200 message: format!(
12201 "{} uses membership against a non-array/non-map expression",
12202 context.subject
12203 ),
12204 suggestion: Some(
12205 "use `in` with an array literal, array value, or map value".to_owned(),
12206 ),
12207 }),
12208 }
12209 ExprType::Bool
12210 }
12211 BinaryOp::Add | BinaryOp::Sub | BinaryOp::Mul | BinaryOp::Div => {
12212 for ty in [&left_ty, &right_ty] {
12213 if !matches!(ty, ExprType::Int | ExprType::Float | ExprType::Unknown) {
12214 diagnostics.push(Diagnostic {
12215 related: Vec::new(),
12216 span: context.span,
12217 message: format!(
12218 "{} uses arithmetic with a non-numeric operand",
12219 context.subject
12220 ),
12221 suggestion: Some("use `+ - * /` only with int or float values".to_owned()),
12222 });
12223 break;
12224 }
12225 }
12226 if matches!(left_ty, ExprType::Float) || matches!(right_ty, ExprType::Float) {
12227 ExprType::Float
12228 } else if matches!(left_ty, ExprType::Int) && matches!(right_ty, ExprType::Int) {
12229 ExprType::Int
12230 } else {
12231 ExprType::Unknown
12232 }
12233 }
12234 }
12235}
12236
12237fn infer_array_type(
12238 items: &[Expr],
12239 semantic: &SemanticContext,
12240 scope: &ExprScope,
12241 context: &ExprValidationContext,
12242 diagnostics: &mut Vec<Diagnostic>,
12243) -> ExprType {
12244 let mut item_ty: Option<ExprType> = None;
12245 for item in items {
12246 let ty = infer_expr_type(item, semantic, scope, context, diagnostics);
12247 if matches!(ty, ExprType::Unknown) {
12248 continue;
12249 }
12250 match &item_ty {
12251 None => item_ty = Some(ty),
12252 Some(existing) if types_comparable(existing, &ty) => {}
12253 Some(_) => {
12254 diagnostics.push(Diagnostic {
12255 related: Vec::new(),
12256 span: context.span,
12257 message: format!("{} has mixed-type array literal", context.subject),
12258 suggestion: Some("use array literals whose elements share one type".to_owned()),
12259 });
12260 return ExprType::Array(Box::new(ExprType::Unknown));
12261 }
12262 }
12263 }
12264 ExprType::Array(Box::new(item_ty.unwrap_or(ExprType::Unknown)))
12265}
12266
12267fn expr_path_type(
12268 path: &[String],
12269 semantic: &SemanticContext,
12270 scope: &ExprScope,
12271) -> Option<ExprType> {
12272 if path.len() < 2 {
12273 return None;
12274 }
12275 if let Some(schema) = scope.binding_types.get(&path[0]) {
12276 if schema.contains('.') {
12277 return Some(ExprType::Unknown);
12279 }
12280 return semantic
12281 .schemas
12282 .resolve_field_path(schema, &path[1..])
12283 .ok()
12284 .map(|ty| expr_type_from_type_syntax(&ty, semantic));
12285 }
12286 let schema = scope.implicit_schema.as_ref()?;
12287 if schema.contains('.') {
12288 return Some(ExprType::Unknown);
12289 }
12290 semantic
12291 .schemas
12292 .resolve_field_path(schema, path)
12293 .ok()
12294 .map(|ty| expr_type_from_type_syntax(&ty, semantic))
12295}
12296
12297fn expr_type_from_type_syntax(ty: &TypeSyntax, semantic: &SemanticContext) -> ExprType {
12298 match ty {
12299 TypeSyntax::Primitive { name, .. } => match name.as_str() {
12300 "bool" => ExprType::Bool,
12301 "int" => ExprType::Int,
12302 "float" => ExprType::Float,
12303 "string" => ExprType::String,
12304 "duration" => ExprType::Duration,
12305 "time" => ExprType::Time,
12306 "secret" => ExprType::Secret,
12307 _ => ExprType::Unknown,
12308 },
12309 TypeSyntax::LiteralString { value, .. } => ExprType::Finite {
12310 label: "literal".to_owned(),
12311 values: vec![value.clone()],
12312 },
12313 TypeSyntax::AgentRef { agents, .. } => ExprType::Finite {
12314 label: "AgentRef".to_owned(),
12315 values: agents.iter().map(|agent| agent.name.clone()).collect(),
12316 },
12317 TypeSyntax::Ref { name } => semantic
12318 .schemas
12319 .enums
12320 .get(&name.name)
12321 .map(|variants| ExprType::Finite {
12322 label: format!("enum `{}`", name.name),
12323 values: variants.iter().cloned().collect(),
12324 })
12325 .unwrap_or(ExprType::Object),
12326 TypeSyntax::Optional { inner, .. } => {
12327 ExprType::Optional(Box::new(expr_type_from_type_syntax(inner, semantic)))
12328 }
12329 TypeSyntax::Array { inner, .. } => {
12330 ExprType::Array(Box::new(expr_type_from_type_syntax(inner, semantic)))
12331 }
12332 TypeSyntax::Map { inner, .. } => {
12333 ExprType::Map(Box::new(expr_type_from_type_syntax(inner, semantic)))
12334 }
12335 TypeSyntax::Union { variants, .. } => {
12336 let values = variants
12337 .iter()
12338 .filter_map(|variant| match variant {
12339 TypeSyntax::LiteralString { value, .. } => Some(value.clone()),
12340 _ => None,
12341 })
12342 .collect::<Vec<_>>();
12343 if values.len() == variants.len() && !values.is_empty() {
12344 ExprType::Finite {
12345 label: "literal union".to_owned(),
12346 values,
12347 }
12348 } else {
12349 ExprType::Unknown
12350 }
12351 }
12352 }
12353}
12354
12355fn expr_literal_type(literal: &ExprLiteral) -> ExprType {
12356 match literal {
12357 ExprLiteral::String(_) | ExprLiteral::Ident(_) => ExprType::String,
12358 ExprLiteral::Number(value) if value.contains('.') => ExprType::Float,
12359 ExprLiteral::Number(_) => ExprType::Int,
12360 ExprLiteral::Bool(_) => ExprType::Bool,
12361 ExprLiteral::Null => ExprType::Null,
12362 }
12363}
12364
12365fn types_comparable(left: &ExprType, right: &ExprType) -> bool {
12366 if matches!(left, ExprType::Unknown) || matches!(right, ExprType::Unknown) {
12367 return true;
12368 }
12369 if matches!(left, ExprType::Null) || matches!(right, ExprType::Null) {
12370 return true;
12371 }
12372 if is_numeric_type(left) && is_numeric_type(right) {
12373 return true;
12374 }
12375 match (left, right) {
12376 (ExprType::Optional(left), right) | (right, ExprType::Optional(left)) => {
12377 types_comparable(left, right)
12378 }
12379 (ExprType::Finite { .. }, ExprType::String)
12380 | (ExprType::String, ExprType::Finite { .. })
12381 | (ExprType::Finite { .. }, ExprType::Finite { .. }) => true,
12382 _ => left == right,
12383 }
12384}
12385
12386fn is_numeric_type(ty: &ExprType) -> bool {
12387 matches!(ty, ExprType::Int | ExprType::Float)
12388}
12389
12390fn is_string_like_key_type(ty: &ExprType) -> bool {
12391 match ty {
12392 ExprType::String | ExprType::Unknown | ExprType::Finite { .. } => true,
12393 ExprType::Optional(inner) => is_string_like_key_type(inner),
12394 _ => false,
12395 }
12396}
12397
12398fn is_orderable_pair(left: &ExprType, right: &ExprType) -> bool {
12399 if matches!(left, ExprType::Unknown) || matches!(right, ExprType::Unknown) {
12400 return true;
12401 }
12402 (is_numeric_type(left) && is_numeric_type(right))
12403 || matches!(
12404 (left, right),
12405 (ExprType::Duration, ExprType::Duration)
12406 | (ExprType::Time, ExprType::Time)
12407 | (ExprType::Time, ExprType::String)
12410 | (ExprType::String, ExprType::Time)
12411 )
12412}
12413
12414fn is_countable_type(ty: &ExprType) -> bool {
12415 matches!(
12416 ty,
12417 ExprType::Array(_) | ExprType::Map(_) | ExprType::Collection | ExprType::Unknown
12418 )
12419}
12420
12421fn is_exists_type(ty: &ExprType) -> bool {
12422 matches!(
12423 ty,
12424 ExprType::Array(_)
12425 | ExprType::Map(_)
12426 | ExprType::Collection
12427 | ExprType::Optional(_)
12428 | ExprType::Unknown
12429 )
12430}
12431
12432fn is_emptiable_type(ty: &ExprType) -> bool {
12437 match ty {
12438 ExprType::Array(_)
12439 | ExprType::Map(_)
12440 | ExprType::String
12441 | ExprType::Collection
12442 | ExprType::Null
12443 | ExprType::Unknown => true,
12444 ExprType::Optional(inner) => is_emptiable_type(inner),
12445 _ => false,
12446 }
12447}
12448
12449fn expr_type_label(ty: &ExprType) -> String {
12450 match ty {
12451 ExprType::Bool => "bool".to_owned(),
12452 ExprType::Int => "int".to_owned(),
12453 ExprType::Float => "float".to_owned(),
12454 ExprType::String => "string".to_owned(),
12455 ExprType::Finite { label, values } => format!("{label}<{}>", values.join(" | ")),
12456 ExprType::Duration => "duration".to_owned(),
12457 ExprType::Time => "time".to_owned(),
12458 ExprType::Secret => "secret".to_owned(),
12459 ExprType::Null => "null".to_owned(),
12460 ExprType::Object => "object".to_owned(),
12461 ExprType::Array(inner) => format!("{}[]", expr_type_label(inner)),
12462 ExprType::Map(inner) => format!("map<{}>", expr_type_label(inner)),
12463 ExprType::Optional(inner) => format!("{}?", expr_type_label(inner)),
12464 ExprType::Collection => "query".to_owned(),
12465 ExprType::Unknown => "unknown".to_owned(),
12466 }
12467}
12468
12469fn validate_finite_domain_expr(
12470 op: BinaryOp,
12471 left: &Expr,
12472 right: &Expr,
12473 semantic: &SemanticContext,
12474 scope: &ExprScope,
12475 context: &ExprValidationContext,
12476 diagnostics: &mut Vec<Diagnostic>,
12477) {
12478 if !matches!(
12479 op,
12480 BinaryOp::Eq | BinaryOp::Ne | BinaryOp::In | BinaryOp::NotIn
12481 ) {
12482 return;
12483 }
12484 let Some((domain, literals)) = finite_domain_comparison(left, right, semantic, scope)
12485 .or_else(|| finite_domain_comparison(right, left, semantic, scope))
12486 else {
12487 validate_finite_domain_relation(op, left, right, semantic, scope, context, diagnostics);
12488 return;
12489 };
12490 for literal in literals.into_iter().flatten() {
12491 if !domain.iter().any(|value| value == &literal) {
12492 diagnostics.push(Diagnostic {
12493 related: Vec::new(),
12494 span: context.span,
12495 message: format!(
12496 "{} compares finite-domain value to unknown `{literal}`",
12497 context.subject
12498 ),
12499 suggestion: Some(format!("use one of: {}", domain.join(", "))),
12500 });
12501 }
12502 }
12503 validate_finite_domain_relation(op, left, right, semantic, scope, context, diagnostics);
12504}
12505
12506fn validate_finite_domain_relation(
12507 op: BinaryOp,
12508 left: &Expr,
12509 right: &Expr,
12510 semantic: &SemanticContext,
12511 scope: &ExprScope,
12512 context: &ExprValidationContext,
12513 diagnostics: &mut Vec<Diagnostic>,
12514) {
12515 match op {
12516 BinaryOp::Eq => {
12517 let Some(left_domain) = expr_domain(left, semantic, scope) else {
12518 return;
12519 };
12520 let Some(right_domain) = expr_domain(right, semantic, scope) else {
12521 return;
12522 };
12523 if left_domain
12524 .iter()
12525 .all(|value| !right_domain.iter().any(|right| right == value))
12526 {
12527 diagnostics.push(Diagnostic {
12528 related: Vec::new(),
12529 span: context.span,
12530 message: format!(
12531 "{} has statically unsatisfiable finite-domain equality",
12532 context.subject
12533 ),
12534 suggestion: Some(format!(
12535 "compare domains with at least one shared value; left: {}, right: {}",
12536 left_domain.join(", "),
12537 right_domain.join(", ")
12538 )),
12539 });
12540 }
12541 }
12542 BinaryOp::In => {
12543 let Some(domain) = expr_domain(left, semantic, scope) else {
12544 return;
12545 };
12546 let Some(literals) = literal_array_values(right) else {
12547 return;
12548 };
12549 if literals
12550 .iter()
12551 .all(|literal| !domain.iter().any(|value| value == literal))
12552 {
12553 diagnostics.push(Diagnostic {
12554 related: Vec::new(),
12555 span: context.span,
12556 message: format!(
12557 "{} has statically unsatisfiable finite-domain membership",
12558 context.subject
12559 ),
12560 suggestion: Some(format!("use one of: {}", domain.join(", "))),
12561 });
12562 }
12563 }
12564 BinaryOp::NotIn => {
12565 let Some(domain) = expr_domain(left, semantic, scope) else {
12566 return;
12567 };
12568 let Some(literals) = literal_array_values(right) else {
12569 return;
12570 };
12571 if !domain.is_empty()
12572 && domain
12573 .iter()
12574 .all(|value| literals.iter().any(|literal| literal == value))
12575 {
12576 diagnostics.push(Diagnostic {
12577 related: Vec::new(),
12578 span: context.span,
12579 message: format!(
12580 "{} has statically unsatisfiable finite-domain exclusion",
12581 context.subject
12582 ),
12583 suggestion: Some(
12584 "leave at least one domain value outside the exclusion set".to_owned(),
12585 ),
12586 });
12587 }
12588 }
12589 _ => {}
12590 }
12591}
12592
12593fn finite_domain_comparison(
12594 domain_expr: &Expr,
12595 literal_expr: &Expr,
12596 semantic: &SemanticContext,
12597 scope: &ExprScope,
12598) -> Option<(Vec<String>, Vec<Option<String>>)> {
12599 let domain = expr_domain(domain_expr, semantic, scope)?;
12600 let literals = match literal_expr {
12601 Expr::Literal(literal) => vec![expr_literal_name(literal)],
12602 Expr::Array(items) => items
12603 .iter()
12604 .filter_map(|item| match item {
12605 Expr::Literal(literal) => Some(expr_literal_name(literal)),
12606 _ => None,
12607 })
12608 .collect(),
12609 _ => Vec::new(),
12610 };
12611 Some((domain, literals))
12612}
12613
12614fn expr_domain(expr: &Expr, semantic: &SemanticContext, scope: &ExprScope) -> Option<Vec<String>> {
12615 let ty = match expr {
12616 Expr::Path(path) => {
12617 let root = path.first()?;
12618 if let Some(schema) = scope.binding_types.get(root) {
12619 semantic
12620 .schemas
12621 .resolve_field_path(schema, path.get(1..)?)
12622 .ok()?
12623 } else {
12624 let schema = scope.implicit_schema.as_ref()?;
12625 semantic.schemas.resolve_field_path(schema, path).ok()?
12626 }
12627 }
12628 Expr::Literal(ExprLiteral::Ident(name)) => implicit_field_type(name, semantic, scope)?,
12629 _ => return None,
12630 };
12631 finite_expr_domain(&ty, semantic)
12632}
12633
12634fn finite_expr_domain(ty: &TypeSyntax, semantic: &SemanticContext) -> Option<Vec<String>> {
12635 match ty {
12636 TypeSyntax::Ref { name } => semantic
12637 .schemas
12638 .enums
12639 .get(&name.name)
12640 .map(|variants| variants.iter().cloned().collect()),
12641 TypeSyntax::Union { variants, .. } => {
12642 let values = variants
12643 .iter()
12644 .filter_map(|variant| match variant {
12645 TypeSyntax::LiteralString { value, .. } => Some(value.clone()),
12646 _ => None,
12647 })
12648 .collect::<Vec<_>>();
12649 (!values.is_empty()).then_some(values)
12650 }
12651 TypeSyntax::AgentRef { agents, .. } => {
12652 Some(agents.iter().map(|agent| agent.name.clone()).collect())
12653 }
12654 _ => None,
12655 }
12656}
12657
12658fn expr_literal_name(literal: &ExprLiteral) -> Option<String> {
12659 match literal {
12660 ExprLiteral::String(value) | ExprLiteral::Ident(value) => Some(value.clone()),
12661 _ => None,
12662 }
12663}
12664
12665fn literal_array_values(expr: &Expr) -> Option<Vec<String>> {
12666 let Expr::Array(items) = expr else {
12667 return None;
12668 };
12669 items
12670 .iter()
12671 .map(|item| match item {
12672 Expr::Literal(literal) => expr_literal_name(literal),
12673 _ => None,
12674 })
12675 .collect()
12676}
12677
12678fn parse_tell_target(line: &str) -> Option<&str> {
12679 line.strip_prefix("tell ")?
12680 .split_whitespace()
12681 .next()
12682 .filter(|target| !target.is_empty())
12683}
12684
12685fn parse_required_capabilities(line: &str) -> Vec<String> {
12686 let Some(rest) = line.split_once(" requires ") else {
12687 return Vec::new();
12688 };
12689 let Some(list) = rest.1.trim_start().strip_prefix('[') else {
12690 return Vec::new();
12691 };
12692 let Some((items, _)) = list.split_once(']') else {
12693 return Vec::new();
12694 };
12695 let mut capabilities = items
12696 .split(',')
12697 .filter_map(|item| {
12698 let value = item.trim().trim_matches('"');
12699 (!value.is_empty()).then(|| value.to_owned())
12700 })
12701 .collect::<Vec<_>>();
12702 capabilities.sort();
12703 capabilities.dedup();
12704 capabilities
12705}
12706
12707fn validate_case_blocks(
12708 rule: &RuleDecl,
12709 semantic: &SemanticContext,
12710 binding_types: &BTreeMap<String, String>,
12711 diagnostics: &mut Vec<Diagnostic>,
12712) {
12713 let lines = rule
12714 .body
12715 .text
12716 .lines()
12717 .scan(0usize, |offset, line| {
12718 let current = *offset;
12719 *offset += line.len() + 1;
12720 Some((line, current))
12721 })
12722 .collect::<Vec<_>>();
12723 let text_lines = lines.iter().map(|(line, _)| *line).collect::<Vec<_>>();
12724 let mut index = 0usize;
12725 while index < lines.len() {
12726 let trimmed = lines[index].0.trim();
12727 let Some(scrutinee) = case_scrutinee(trimmed) else {
12728 index += 1;
12729 continue;
12730 };
12731 let scrutinee_ty = expression_type(scrutinee, semantic, binding_types);
12732 let terminal_case = scrutinee_ty.is_none()
12733 && active_completes_binding_for_case(&text_lines, index, scrutinee);
12734 if scrutinee_ty.is_none() && !terminal_case {
12735 diagnostics.push(Diagnostic {
12736 related: Vec::new(),
12737 span: rule.body.span,
12738 message: format!(
12739 "rule `{}` has case scrutinee `{scrutinee}` that is not a typed path",
12740 rule.name.name
12741 ),
12742 suggestion: Some("match on a bound field such as `task.provider`".to_owned()),
12743 });
12744 }
12745 let mut depth = brace_delta(trimmed).max(1);
12746 let mut case_index = index + 1;
12747 let mut branches = Vec::new();
12748 while case_index < lines.len() && depth > 0 {
12749 let (raw_line, line_offset) = lines[case_index];
12750 let line = raw_line.trim();
12751 if depth == 1 {
12752 if let Some(branch) = parse_case_branch_head(line) {
12753 let pattern_column = case_pattern_column(raw_line, branch.pattern);
12754 let branch = SpanCaseBranchHead {
12755 pattern: branch.pattern,
12756 guard: branch.guard,
12757 pattern_span: SourceSpan {
12758 start: rule_body_text_start(rule) + line_offset + pattern_column,
12759 end: rule_body_text_start(rule)
12760 + line_offset
12761 + pattern_column
12762 + branch.pattern.len(),
12763 },
12764 };
12765 branches.push(branch);
12766 if terminal_case {
12767 validate_terminal_case_pattern(
12768 rule,
12769 branch.pattern,
12770 branch.pattern_span,
12771 diagnostics,
12772 );
12773 } else {
12774 validate_case_pattern(
12775 rule,
12776 branch.pattern,
12777 scrutinee_ty.as_ref(),
12778 branch.pattern_span,
12779 semantic,
12780 diagnostics,
12781 );
12782 }
12783 if let Some(guard) = branch.guard.filter(|_| !terminal_case) {
12790 let mut branch_scope = binding_types.clone();
12791 if let Some(scrutinee_ty) = scrutinee_ty.as_ref() {
12792 if let Some((binding, schema)) =
12793 case_branch_payload_binding(branch.pattern, scrutinee_ty, semantic)
12794 {
12795 branch_scope.insert(binding, schema);
12796 }
12797 }
12798 validate_expression(
12799 rule,
12800 guard,
12801 semantic,
12802 &branch_scope,
12803 "case guard",
12804 diagnostics,
12805 );
12806 validate_known_field_paths_at_span(
12807 rule,
12808 guard,
12809 branch.pattern_span,
12810 semantic,
12811 &branch_scope,
12812 diagnostics,
12813 );
12814 }
12815 }
12816 }
12817 depth += brace_delta(line);
12818 case_index += 1;
12819 }
12820 if terminal_case {
12821 validate_terminal_case_coverage(rule, &branches, diagnostics);
12822 } else {
12823 validate_case_coverage(
12824 rule,
12825 scrutinee_ty.as_ref(),
12826 &branches,
12827 semantic,
12828 diagnostics,
12829 );
12830 }
12831 index += 1;
12832 }
12833}
12834
12835fn active_completes_binding_for_case(lines: &[&str], case_index: usize, scrutinee: &str) -> bool {
12836 let mut scopes: Vec<(String, DependencyPredicate, i32)> = Vec::new();
12837 for line in lines.iter().take(case_index) {
12838 let trimmed = line.trim();
12839 if let Some((binding, predicate)) = parse_after_line(trimmed) {
12840 scopes.push((binding, predicate, brace_delta(trimmed).max(1)));
12841 } else {
12842 let delta = brace_delta(trimmed);
12843 for (_, _, depth) in &mut scopes {
12844 *depth += delta;
12845 }
12846 scopes.retain(|(_, _, depth)| *depth > 0);
12847 }
12848 }
12849 scopes.iter().any(|(binding, predicate, _)| {
12850 binding == scrutinee && predicate == &DependencyPredicate::Completes
12851 })
12852}
12853
12854fn brace_delta(line: &str) -> i32 {
12855 line.chars().fold(0, |depth, ch| match ch {
12856 '{' => depth + 1,
12857 '}' => depth - 1,
12858 _ => depth,
12859 })
12860}
12861
12862fn case_scrutinee(line: &str) -> Option<&str> {
12863 let rest = line.strip_prefix("case ")?;
12864 let expr = rest.strip_suffix('{').unwrap_or(rest).trim();
12865 (!expr.is_empty()).then_some(expr)
12866}
12867
12868fn is_case_branch_start(line: &str) -> bool {
12869 line.contains("=>")
12870}
12871
12872#[derive(Clone, Copy)]
12873struct CaseBranchHead<'a> {
12874 pattern: &'a str,
12875 guard: Option<&'a str>,
12876}
12877
12878#[derive(Clone, Copy)]
12879struct SpanCaseBranchHead<'a> {
12880 pattern: &'a str,
12881 guard: Option<&'a str>,
12882 pattern_span: SourceSpan,
12883}
12884
12885fn parse_case_branch_head(line: &str) -> Option<CaseBranchHead<'_>> {
12886 let (pattern, _) = line.split_once("=>")?;
12887 let pattern = pattern.trim();
12888 if pattern.is_empty() {
12889 return None;
12890 }
12891 match pattern.split_once(" where ") {
12892 Some((pattern, guard)) => Some(CaseBranchHead {
12893 pattern: pattern.trim(),
12894 guard: Some(guard.trim()),
12895 }),
12896 None => Some(CaseBranchHead {
12897 pattern,
12898 guard: None,
12899 }),
12900 }
12901}
12902
12903fn expression_type(
12904 expr: &str,
12905 semantic: &SemanticContext,
12906 binding_types: &BTreeMap<String, String>,
12907) -> Option<TypeSyntax> {
12908 let is_bare_ident = !expr.is_empty()
12912 && expr.chars().all(|ch| ch.is_alphanumeric() || ch == '_')
12913 && expr.chars().next().is_some_and(char::is_alphabetic);
12914 if is_bare_ident {
12915 let schema = binding_types.get(expr)?;
12916 if semantic.schemas.enums.contains_key(schema) {
12917 return Some(TypeSyntax::Ref {
12918 name: Ident {
12919 name: schema.clone(),
12920 span: zero_span(),
12921 },
12922 });
12923 }
12924 return None;
12925 }
12926 let (root, path) = expression_path(expr)?;
12927 let schema = binding_types.get(&root)?;
12928 semantic.schemas.resolve_field_path(schema, &path).ok()
12929}
12930
12931fn validate_case_pattern(
12932 rule: &RuleDecl,
12933 pattern: &str,
12934 scrutinee_ty: Option<&TypeSyntax>,
12935 span: SourceSpan,
12936 semantic: &SemanticContext,
12937 diagnostics: &mut Vec<Diagnostic>,
12938) {
12939 if matches!(pattern, "_" | "default") {
12940 return;
12941 }
12942 if pattern == "None" {
12943 if !matches!(scrutinee_ty, Some(TypeSyntax::Optional { .. })) {
12944 diagnostics.push(Diagnostic {
12945 related: Vec::new(),
12946 span,
12947 message: format!(
12948 "rule `{}` uses `None` for a non-optional case",
12949 rule.name.name
12950 ),
12951 suggestion: Some("use `None` only when matching an optional field".to_owned()),
12952 });
12953 }
12954 return;
12955 }
12956 if pattern.starts_with("Some ") {
12957 if !matches!(scrutinee_ty, Some(TypeSyntax::Optional { .. })) {
12958 diagnostics.push(Diagnostic {
12959 related: Vec::new(),
12960 span,
12961 message: format!(
12962 "rule `{}` uses `Some` for a non-optional case",
12963 rule.name.name
12964 ),
12965 suggestion: Some("use `Some name` only when matching an optional field".to_owned()),
12966 });
12967 }
12968 return;
12969 }
12970 let Some(scrutinee_ty) = scrutinee_ty else {
12971 return;
12972 };
12973 match scrutinee_ty {
12974 TypeSyntax::Ref { name } => {
12975 let Some(variants) = semantic.schemas.enums.get(&name.name) else {
12976 return;
12977 };
12978 let (variant, binding) = sum_case_pattern_parts(pattern);
12979 if !variants.contains(variant) {
12980 diagnostics.push(Diagnostic {
12981 related: Vec::new(),
12982 span,
12983 message: format!("enum `{}` has no variant `{variant}`", name.name),
12984 suggestion: Some(format!(
12985 "use one of: {}",
12986 variants.iter().cloned().collect::<Vec<_>>().join(", ")
12987 )),
12988 });
12989 return;
12990 }
12991 if binding.is_some()
12994 && !semantic
12995 .schemas
12996 .class_exists(&format!("{}.{variant}", name.name))
12997 {
12998 diagnostics.push(Diagnostic {
12999 related: Vec::new(),
13000 span,
13001 message: format!(
13002 "variant `{variant}` of enum `{}` carries no payload to bind",
13003 name.name
13004 ),
13005 suggestion: Some(format!("write `{variant} => {{ ... }}` without `as`")),
13006 });
13007 }
13008 }
13009 TypeSyntax::Union { variants, .. } => {
13010 let Some(literal) = parse_literal_expr(pattern) else {
13011 diagnostics.push(Diagnostic {
13012 related: Vec::new(),
13013 span,
13014 message: format!(
13015 "rule `{}` has unsupported case pattern `{pattern}`",
13016 rule.name.name
13017 ),
13018 suggestion: Some("use a literal branch value or `_`".to_owned()),
13019 });
13020 return;
13021 };
13022 validate_union_case_pattern(rule, variants, &literal, span, diagnostics);
13023 }
13024 TypeSyntax::AgentRef { agents, .. } => {
13025 let Some(literal) = parse_literal_expr(pattern) else {
13026 diagnostics.push(Diagnostic {
13027 related: Vec::new(),
13028 span,
13029 message: format!(
13030 "rule `{}` has unsupported AgentRef case pattern `{pattern}`",
13031 rule.name.name
13032 ),
13033 suggestion: Some(
13034 "use a declared agent name, a string literal, or `_`".to_owned(),
13035 ),
13036 });
13037 return;
13038 };
13039 validate_agent_ref_case_pattern(rule, agents, &literal, span, diagnostics);
13040 }
13041 TypeSyntax::Optional { inner, .. } => {
13042 validate_case_pattern(rule, pattern, Some(inner), span, semantic, diagnostics);
13043 }
13044 TypeSyntax::Primitive { name, .. } if name == "bool" => {
13047 if !matches!(pattern, "true" | "false") {
13048 diagnostics.push(Diagnostic {
13049 related: Vec::new(),
13050 span,
13051 message: format!(
13052 "rule `{}` has case pattern `{pattern}` that is not a `bool` value",
13053 rule.name.name
13054 ),
13055 suggestion: Some("match `true`, `false`, or `_`".to_owned()),
13056 });
13057 }
13058 }
13059 _ => {
13060 diagnostics.push(Diagnostic {
13061 related: Vec::new(),
13062 span,
13063 message: format!(
13064 "rule `{}` cannot pattern-match this scrutinee type",
13065 rule.name.name
13066 ),
13067 suggestion: Some(
13068 "match an enum, literal union, optional, or tagged output union".to_owned(),
13069 ),
13070 });
13071 }
13072 }
13073}
13074
13075fn terminal_case_tags() -> [&'static str; 4] {
13076 ["Completed", "Failed", "TimedOut", "Cancelled"]
13077}
13078
13079fn validate_terminal_case_pattern(
13080 rule: &RuleDecl,
13081 pattern: &str,
13082 span: SourceSpan,
13083 diagnostics: &mut Vec<Diagnostic>,
13084) {
13085 if is_fallback_pattern(pattern) {
13086 return;
13087 }
13088 let mut parts = pattern.split_whitespace();
13089 let Some(tag) = parts.next() else {
13090 return;
13091 };
13092 let second = parts.next();
13095 let binding = match second {
13096 Some("as") => parts.next(),
13097 other => other,
13098 };
13099 let uses_as = matches!(second, Some("as"));
13100 if parts.next().is_some() || binding.is_none() || !uses_as {
13101 diagnostics.push(Diagnostic { related: Vec::new(),
13102 span,
13103 message: format!(
13104 "rule `{}` has malformed terminal-output case pattern `{pattern}`",
13105 rule.name.name
13106 ),
13107 suggestion: Some("write `Completed as result`, `Failed as failure`, `TimedOut as timeout`, or `Cancelled as cancel` (the `as` is required)".to_owned()),
13108 });
13109 return;
13110 }
13111 let tags = terminal_case_tags();
13112 if !tags.contains(&tag) {
13113 diagnostics.push(Diagnostic {
13114 related: Vec::new(),
13115 span,
13116 message: format!(
13117 "rule `{}` terminal-output case pattern cannot be `{tag}`",
13118 rule.name.name
13119 ),
13120 suggestion: Some(format!("use one of: {}", tags.join(", "))),
13121 });
13122 }
13123}
13124
13125fn validate_terminal_case_coverage(
13126 rule: &RuleDecl,
13127 branches: &[SpanCaseBranchHead<'_>],
13128 diagnostics: &mut Vec<Diagnostic>,
13129) {
13130 validate_unreachable_after_fallback(rule, branches, diagnostics);
13131 if branches.is_empty()
13132 || branches
13133 .iter()
13134 .any(|branch| is_fallback_pattern(branch.pattern))
13135 {
13136 validate_duplicate_terminal_case_patterns(rule, branches, diagnostics);
13137 return;
13138 }
13139 validate_duplicate_terminal_case_patterns(rule, branches, diagnostics);
13140 let covered = branches
13141 .iter()
13142 .filter(|branch| branch.guard.is_none())
13143 .filter_map(|branch| normalized_terminal_case_pattern(branch.pattern))
13144 .collect::<BTreeSet<_>>();
13145 let missing = terminal_case_tags()
13146 .iter()
13147 .filter(|tag| !covered.contains(**tag))
13148 .copied()
13149 .collect::<Vec<_>>();
13150 if !missing.is_empty() {
13151 diagnostics.push(Diagnostic {
13152 related: Vec::new(),
13153 span: rule.body.span,
13154 message: format!(
13155 "rule `{}` has non-exhaustive terminal-output case; missing {}",
13156 rule.name.name,
13157 missing.join(", ")
13158 ),
13159 suggestion: Some(
13160 "add terminal branches for every value or add `_ => { ... }`".to_owned(),
13161 ),
13162 });
13163 }
13164}
13165
13166fn validate_duplicate_terminal_case_patterns(
13167 rule: &RuleDecl,
13168 branches: &[SpanCaseBranchHead<'_>],
13169 diagnostics: &mut Vec<Diagnostic>,
13170) {
13171 let mut seen = BTreeSet::new();
13172 for branch in branches.iter().filter(|branch| branch.guard.is_none()) {
13173 let Some(pattern) = normalized_terminal_case_pattern(branch.pattern) else {
13174 continue;
13175 };
13176 if !seen.insert(pattern.to_owned()) {
13177 diagnostics.push(Diagnostic {
13178 related: Vec::new(),
13179 span: branch.pattern_span,
13180 message: format!(
13181 "rule `{}` has duplicate unguarded terminal-output case pattern `{pattern}`",
13182 rule.name.name
13183 ),
13184 suggestion: Some(
13185 "remove the duplicate branch or add mutually exclusive `where` guards"
13186 .to_owned(),
13187 ),
13188 });
13189 }
13190 }
13191}
13192
13193fn validate_case_coverage(
13194 rule: &RuleDecl,
13195 scrutinee_ty: Option<&TypeSyntax>,
13196 branches: &[SpanCaseBranchHead<'_>],
13197 semantic: &SemanticContext,
13198 diagnostics: &mut Vec<Diagnostic>,
13199) {
13200 validate_unreachable_after_fallback(rule, branches, diagnostics);
13201 if branches.is_empty()
13202 || branches
13203 .iter()
13204 .any(|branch| is_fallback_pattern(branch.pattern))
13205 {
13206 validate_duplicate_case_patterns(rule, branches, diagnostics);
13207 return;
13208 }
13209 validate_duplicate_case_patterns(rule, branches, diagnostics);
13210
13211 let Some(domain) = finite_case_domain(scrutinee_ty, semantic) else {
13212 return;
13213 };
13214 let covered = branches
13215 .iter()
13216 .filter(|branch| branch.guard.is_none())
13217 .filter_map(|branch| normalized_case_pattern(branch.pattern))
13218 .collect::<BTreeSet<_>>();
13219 let missing = domain
13220 .iter()
13221 .filter(|value| !covered.contains(value.as_str()))
13222 .cloned()
13223 .collect::<Vec<_>>();
13224 if !missing.is_empty() {
13225 diagnostics.push(Diagnostic {
13226 related: Vec::new(),
13227 span: rule.body.span,
13228 message: format!(
13229 "rule `{}` has non-exhaustive case; missing {}",
13230 rule.name.name,
13231 missing.join(", ")
13232 ),
13233 suggestion: Some("add branches for every value or add `_ => { ... }`".to_owned()),
13234 });
13235 }
13236}
13237
13238fn validate_duplicate_case_patterns(
13239 rule: &RuleDecl,
13240 branches: &[SpanCaseBranchHead<'_>],
13241 diagnostics: &mut Vec<Diagnostic>,
13242) {
13243 let mut seen = BTreeSet::new();
13244 for branch in branches.iter().filter(|branch| branch.guard.is_none()) {
13245 let Some(pattern) = normalized_case_pattern(branch.pattern) else {
13246 continue;
13247 };
13248 if !seen.insert(pattern.to_owned()) {
13249 diagnostics.push(Diagnostic {
13250 related: Vec::new(),
13251 span: branch.pattern_span,
13252 message: format!(
13253 "rule `{}` has duplicate unguarded case pattern `{pattern}`",
13254 rule.name.name
13255 ),
13256 suggestion: Some(
13257 "remove the duplicate branch or add mutually exclusive `where` guards"
13258 .to_owned(),
13259 ),
13260 });
13261 }
13262 }
13263}
13264
13265fn validate_unreachable_after_fallback(
13271 rule: &RuleDecl,
13272 branches: &[SpanCaseBranchHead<'_>],
13273 diagnostics: &mut Vec<Diagnostic>,
13274) {
13275 let mut ordered: Vec<&SpanCaseBranchHead<'_>> = branches.iter().collect();
13276 ordered.sort_by_key(|branch| branch.pattern_span.start);
13277 let mut fallback_span: Option<SourceSpan> = None;
13278 for branch in ordered {
13279 if let Some(prior) = fallback_span {
13280 diagnostics.push(
13281 Diagnostic {
13282 related: Vec::new(),
13283 span: branch.pattern_span,
13284 message: format!(
13285 "rule `{}` has an unreachable case branch after the `_` wildcard",
13286 rule.name.name
13287 ),
13288 suggestion: Some(
13289 "move this branch before the wildcard, or remove it".to_owned(),
13290 ),
13291 }
13292 .with_related(
13293 prior,
13294 "this unguarded wildcard already matches every remaining value",
13295 ),
13296 );
13297 } else if branch.guard.is_none() && is_fallback_pattern(branch.pattern) {
13298 fallback_span = Some(branch.pattern_span);
13299 }
13300 }
13301}
13302
13303fn finite_case_domain(
13304 scrutinee_ty: Option<&TypeSyntax>,
13305 semantic: &SemanticContext,
13306) -> Option<Vec<String>> {
13307 match scrutinee_ty? {
13308 TypeSyntax::Ref { name } => semantic
13309 .schemas
13310 .enums
13311 .get(&name.name)
13312 .map(|variants| variants.iter().cloned().collect()),
13313 TypeSyntax::Union { variants, .. } => {
13314 let values = variants
13315 .iter()
13316 .filter_map(|variant| match variant {
13317 TypeSyntax::LiteralString { value, .. } => Some(value.clone()),
13318 _ => None,
13319 })
13320 .collect::<Vec<_>>();
13321 (!values.is_empty()).then_some(values)
13322 }
13323 TypeSyntax::Optional { .. } => Some(vec!["Some".to_owned(), "None".to_owned()]),
13324 TypeSyntax::AgentRef { agents, .. } => {
13325 Some(agents.iter().map(|agent| agent.name.clone()).collect())
13326 }
13327 TypeSyntax::Primitive { name, .. } if name == "bool" => {
13330 Some(vec!["true".to_owned(), "false".to_owned()])
13331 }
13332 _ => None,
13333 }
13334}
13335
13336fn sum_case_pattern_parts(pattern: &str) -> (&str, Option<&str>) {
13339 match pattern.split_once(" as ") {
13340 Some((variant, binding)) => (variant.trim(), Some(binding.trim())),
13341 None => (pattern.trim(), None),
13342 }
13343}
13344
13345fn normalized_case_pattern(pattern: &str) -> Option<&str> {
13346 if is_fallback_pattern(pattern) {
13347 return None;
13348 }
13349 if pattern.starts_with("Some ") {
13350 return Some("Some");
13351 }
13352 if pattern == "None" {
13353 return Some("None");
13354 }
13355 let (pattern, _) = sum_case_pattern_parts(pattern);
13357 if matches!(pattern, "true" | "false") {
13360 return Some(pattern);
13361 }
13362 parse_literal_expr(pattern).and_then(|literal| match literal {
13363 LiteralExpr::String(value) | LiteralExpr::Ident(value) => Some(value),
13364 _ => None,
13365 })
13366}
13367
13368fn normalized_terminal_case_pattern(pattern: &str) -> Option<&str> {
13369 if is_fallback_pattern(pattern) {
13370 return None;
13371 }
13372 pattern.split_whitespace().next()
13373}
13374
13375fn is_fallback_pattern(pattern: &str) -> bool {
13376 matches!(pattern, "_" | "default")
13377}
13378
13379fn validate_union_case_pattern(
13380 rule: &RuleDecl,
13381 variants: &[TypeSyntax],
13382 literal: &LiteralExpr<'_>,
13383 span: SourceSpan,
13384 diagnostics: &mut Vec<Diagnostic>,
13385) {
13386 let allowed = variants
13387 .iter()
13388 .filter_map(|variant| match variant {
13389 TypeSyntax::LiteralString { value, .. } => Some(value.as_str()),
13390 _ => None,
13391 })
13392 .collect::<Vec<_>>();
13393 if allowed.is_empty() {
13394 return;
13395 }
13396 let LiteralExpr::String(value) = literal else {
13397 diagnostics.push(Diagnostic {
13398 related: Vec::new(),
13399 span,
13400 message: format!(
13401 "rule `{}` case pattern must be one of its literal variants",
13402 rule.name.name
13403 ),
13404 suggestion: Some(format!("use one of: {}", allowed.join(", "))),
13405 });
13406 return;
13407 };
13408 if !allowed.contains(value) {
13409 diagnostics.push(Diagnostic {
13410 related: Vec::new(),
13411 span,
13412 message: format!("rule `{}` case pattern cannot be `{value}`", rule.name.name),
13413 suggestion: Some(format!("use one of: {}", allowed.join(", "))),
13414 });
13415 }
13416}
13417
13418fn validate_agent_ref_case_pattern(
13419 rule: &RuleDecl,
13420 agents: &[Ident],
13421 literal: &LiteralExpr<'_>,
13422 span: SourceSpan,
13423 diagnostics: &mut Vec<Diagnostic>,
13424) {
13425 let allowed = agents
13426 .iter()
13427 .map(|agent| agent.name.as_str())
13428 .collect::<Vec<_>>();
13429 let (LiteralExpr::String(value) | LiteralExpr::Ident(value)) = literal else {
13430 diagnostics.push(Diagnostic {
13431 related: Vec::new(),
13432 span,
13433 message: format!("rule `{}` has non-agent case pattern", rule.name.name),
13434 suggestion: Some(format!("use one of: {}", allowed.join(", "))),
13435 });
13436 return;
13437 };
13438 if !allowed.contains(value) {
13439 diagnostics.push(Diagnostic {
13440 related: Vec::new(),
13441 span,
13442 message: format!("AgentRef has no agent `{value}`"),
13443 suggestion: Some(format!("use one of: {}", allowed.join(", "))),
13444 });
13445 }
13446}
13447
13448fn validate_binding_uses(
13449 rule: &RuleDecl,
13450 line: &str,
13451 seen_bindings: &BTreeSet<String>,
13452 scope_stack: &[(String, DependencyPredicate)],
13453 diagnostics: &mut Vec<Diagnostic>,
13454) {
13455 for root in interpolation_roots(line) {
13456 if !seen_bindings.contains(&root) {
13457 continue;
13458 }
13459 if scope_stack.iter().any(|(binding, _)| binding == &root) {
13460 continue;
13461 }
13462
13463 diagnostics.push(Diagnostic { related: Vec::new(),
13464 span: rule.body.span,
13465 message: format!(
13466 "rule `{}` uses effect output `{root}` outside a matching `after {root} ...` block",
13467 rule.name.name
13468 ),
13469 suggestion: Some(format!(
13470 "move this use into `after {root} succeeds {{ ... }}` or another matching terminal branch"
13471 )),
13472 });
13473 }
13474}
13475
13476fn after_scopes(block_stack: &[BlockFrame]) -> Vec<(String, DependencyPredicate)> {
13477 block_stack
13478 .iter()
13479 .map(|frame| match frame {
13480 BlockFrame::After { binding, predicate } => (binding.clone(), predicate.clone()),
13481 })
13482 .collect()
13483}
13484
13485pub fn runtime_fact_name_for_pattern(pattern: &str) -> Option<String> {
13490 let pattern = pattern.trim();
13491 if let Some(rest) = pattern.strip_prefix("fact ") {
13492 let name = rest.split_whitespace().next()?;
13493 return Some(name.to_owned());
13494 }
13495 if let Some(rest) = pattern.strip_prefix("message from ") {
13498 if let Some(channel) = rest.split_whitespace().next() {
13499 return Some(format!("message.{channel}"));
13500 }
13501 }
13502 if pattern == "line changed" || pattern == "line changed by others" {
13506 return Some("vcs.cut.recorded".to_owned());
13507 }
13508 if pattern == "reconcile stalled" {
13509 return Some("vcs.reconcile.stalled".to_owned());
13510 }
13511 {
13512 let words: Vec<&str> = pattern.split_whitespace().collect();
13513 match words.as_slice() {
13514 [_, "has", "contention"] => {
13515 return Some("vcs.contention.predicted".to_owned());
13516 }
13517 [_, "promoted"] => {
13518 return Some("vcs.stream.promoted".to_owned());
13519 }
13520 [_, "is", "quiescent"] => {
13521 return Some("vcs.stream.quiescent".to_owned());
13522 }
13523 _ => {}
13524 }
13525 }
13526 let mut words = pattern.split_whitespace();
13527 let first = words.next()?;
13528 if words.next() == Some("completed") && words.next() == Some("turn") {
13529 let _ = first;
13530 return Some("agent.turn.completed".to_owned());
13531 }
13532 {
13533 let mut words = pattern.split_whitespace();
13534 let _tracker = words.next();
13535 if words.next() == Some("has")
13536 && words.next() == Some("ready")
13537 && words.next() == Some("issue")
13538 {
13539 return Some("tracker.issue.ready".to_owned());
13540 }
13541 }
13542 if first.chars().next().is_some_and(char::is_uppercase) {
13543 return Some(first.to_owned());
13544 }
13545 None
13546}
13547
13548fn binding_from_when(when: &str) -> Option<(String, String)> {
13552 let (pattern, _) = split_when_guard(when);
13553 let binding = binding_after_as(pattern)?;
13554 let first = pattern.split_whitespace().next()?;
13555 let completed_turn = {
13556 let mut words = pattern.split_whitespace();
13557 words.next();
13558 words.next() == Some("completed") && words.next() == Some("turn")
13559 };
13560 let has_ready_issue = {
13561 let mut words = pattern.split_whitespace();
13562 words.next();
13563 words.next() == Some("has")
13564 && words.next() == Some("ready")
13565 && words.next() == Some("issue")
13566 };
13567 let schema = if let Some(rest) = pattern.strip_prefix("fact ") {
13568 rest.split_whitespace().next()?.to_owned()
13569 } else if first.chars().next().is_some_and(char::is_uppercase) {
13570 first.to_owned()
13571 } else if first.contains('.') {
13572 first.to_owned()
13576 } else if completed_turn {
13577 "AgentTurn".to_owned()
13578 } else if has_ready_issue {
13579 "WorkItem".to_owned()
13580 } else if pattern.starts_with("message from ") {
13581 "Message".to_owned()
13584 } else {
13585 let schema = vcs_sugar_schema(pattern.split(" as ").next().unwrap_or(pattern).trim())?;
13586 schema.to_owned()
13589 };
13590
13591 Some((binding, schema))
13592}
13593
13594fn vcs_sugar_schema(phrase: &str) -> Option<&'static str> {
13596 if phrase == "line changed" || phrase == "line changed by others" {
13597 return Some("VcsChange");
13598 }
13599 if phrase == "reconcile stalled" {
13600 return Some("VcsStall");
13601 }
13602 let words: Vec<&str> = phrase.split_whitespace().collect();
13603 match words.as_slice() {
13604 [_, "has", "contention"] => Some("VcsContention"),
13605 [_, "promoted"] => Some("VcsPromotion"),
13606 _ => None,
13607 }
13608}
13609
13610pub(crate) fn split_when_guard(when: &str) -> (&str, Option<&str>) {
13611 match when.split_once(" where ") {
13612 Some((pattern, guard)) => (pattern.trim(), Some(guard.trim())),
13613 None => (when.trim(), None),
13614 }
13615}
13616
13617fn effect_binding_schema(
13618 line: &str,
13619 kind: &IrEffectKind,
13620 semantic: &SemanticContext,
13621) -> Option<String> {
13622 match kind {
13623 IrEffectKind::SchemaCoerce => parse_coerce_call_name(line).and_then(|name| {
13624 semantic
13625 .coerce_outputs
13626 .get(name)
13627 .and_then(schema_name_for_path)
13628 }),
13629 IrEffectKind::AgentTell
13630 | IrEffectKind::CapabilityCall
13631 | IrEffectKind::EventEmit
13632 | IrEffectKind::WorkflowInvoke
13633 | IrEffectKind::TimerWait
13634 | IrEffectKind::ExecCommand
13635 | IrEffectKind::TrackerFile
13636 | IrEffectKind::TrackerClaim
13637 | IrEffectKind::TrackerRenew
13638 | IrEffectKind::TrackerRelease
13639 | IrEffectKind::TrackerFinish
13640 | IrEffectKind::LeaseAcquire
13641 | IrEffectKind::LeaseRenew
13642 | IrEffectKind::LedgerAppend
13643 | IrEffectKind::CounterConsume
13644 | IrEffectKind::SignalEmit
13645 | IrEffectKind::FileRead
13646 | IrEffectKind::FileWrite
13647 | IrEffectKind::FileImport
13648 | IrEffectKind::FileExport => None,
13649 }
13650}
13651
13652fn parse_coerce_call_name(line: &str) -> Option<&str> {
13653 let rest = line.strip_prefix("coerce ")?;
13654 rest.split_once('(').map(|(name, _)| name.trim())
13655}
13656
13657fn parse_coerce_call(line: &str) -> Option<(&str, Vec<&str>)> {
13658 let rest = line.strip_prefix("coerce ")?;
13659 let call = rest.split(" as ").next().unwrap_or(rest).trim();
13660 let (name, tail) = call.split_once('(')?;
13661 let (args, _) = tail.rsplit_once(')')?;
13662 Some((name.trim(), split_expression_args(args)))
13663}
13664
13665fn split_expression_args(args: &str) -> Vec<&str> {
13666 let mut values = Vec::new();
13667 let mut start = 0usize;
13668 let mut depth = 0i32;
13669 let mut in_string = false;
13670 let mut previous = '\0';
13671 for (index, ch) in args.char_indices() {
13672 if ch == '"' && previous != '\\' {
13673 in_string = !in_string;
13674 } else if !in_string {
13675 match ch {
13676 '(' | '[' | '{' => depth += 1,
13677 ')' | ']' | '}' => depth -= 1,
13678 ',' if depth == 0 => {
13679 let value = args[start..index].trim();
13680 if !value.is_empty() {
13681 values.push(value);
13682 }
13683 start = index + ch.len_utf8();
13684 }
13685 _ => {}
13686 }
13687 }
13688 previous = ch;
13689 }
13690 let value = args[start..].trim();
13691 if !value.is_empty() {
13692 values.push(value);
13693 }
13694 values
13695}
13696
13697fn effect_payload_statements(body: &str) -> Vec<String> {
13698 collect_body_statements(body, effect_payload_statement_balance)
13699}
13700
13701fn workflow_invoke_statements(body: &str) -> Vec<String> {
13702 collect_body_statements(body, workflow_invoke_statement_balance)
13703}
13704
13705#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13706enum StatementBalance {
13707 None,
13708 Parens,
13709 Braces,
13710}
13711
13712fn collect_body_statements(
13713 body: &str,
13714 statement_balance: fn(&str) -> Option<StatementBalance>,
13715) -> Vec<String> {
13716 let lines = body.lines().collect::<Vec<_>>();
13717 let mut statements = Vec::new();
13718 let mut index = 0usize;
13719 let mut record_depth = 0i32;
13720 let mut multiline_string = false;
13721 while index < lines.len() {
13722 let trimmed = lines[index].trim();
13723 if trimmed.is_empty() {
13724 index += 1;
13725 continue;
13726 }
13727 if multiline_string {
13728 if trimmed.contains("\"\"\"") {
13729 multiline_string = false;
13730 }
13731 index += 1;
13732 continue;
13733 }
13734 if record_depth > 0 {
13735 record_depth += brace_delta(trimmed);
13736 index += 1;
13737 continue;
13738 }
13739 if parse_record_start(trimmed).is_some() {
13740 record_depth = brace_delta(trimmed).max(1);
13741 index += 1;
13742 continue;
13743 }
13744 if trimmed.contains("\"\"\"") {
13745 multiline_string = trimmed.matches("\"\"\"").count() % 2 == 1;
13746 index += 1;
13747 continue;
13748 }
13749 if let Some(balance) = statement_balance(trimmed) {
13750 match balance {
13751 StatementBalance::None => statements.push(trimmed.to_owned()),
13752 StatementBalance::Parens => {
13753 let (statement, next_index) =
13754 statement_until_balanced(&lines, index, trimmed, paren_delta);
13755 statements.push(statement);
13756 index = next_index + 1;
13757 continue;
13758 }
13759 StatementBalance::Braces => {
13760 let (statement, next_index) =
13761 statement_until_balanced(&lines, index, trimmed, brace_delta);
13762 statements.push(statement);
13763 index = next_index + 1;
13764 continue;
13765 }
13766 }
13767 }
13768 index += 1;
13769 }
13770 statements
13771}
13772
13773fn effect_payload_statement_balance(trimmed: &str) -> Option<StatementBalance> {
13774 if trimmed.starts_with("coerce ") {
13775 Some(StatementBalance::Parens)
13776 } else if trimmed.starts_with("claim ") {
13777 Some(StatementBalance::None)
13778 } else {
13779 None
13780 }
13781}
13782
13783fn workflow_invoke_statement_balance(trimmed: &str) -> Option<StatementBalance> {
13784 trimmed
13785 .starts_with("invoke ")
13786 .then_some(StatementBalance::Braces)
13787}
13788
13789fn invoke_statement_parts(statement: &str) -> Option<(&str, &str)> {
13790 let rest = statement.trim().strip_prefix("invoke ")?;
13791 let target = rest
13792 .split_whitespace()
13793 .next()
13794 .unwrap_or("")
13795 .trim_end_matches('{');
13796 if target.is_empty() {
13797 return None;
13798 }
13799 let open = statement.find('{')?;
13800 let mut depth = 0i32;
13801 let mut close = None;
13802 for (offset, ch) in statement[open..].char_indices() {
13803 match ch {
13804 '{' => depth += 1,
13805 '}' => {
13806 depth -= 1;
13807 if depth == 0 {
13808 close = Some(open + offset);
13809 break;
13810 }
13811 }
13812 _ => {}
13813 }
13814 }
13815 let close = close?;
13816 (close > open).then_some((target, statement[open + 1..close].trim()))
13817}
13818
13819fn statement_until_balanced(
13820 lines: &[&str],
13821 index: usize,
13822 trimmed: &str,
13823 delta: fn(&str) -> i32,
13824) -> (String, usize) {
13825 let mut statement = trimmed.to_owned();
13826 let mut depth = delta(trimmed);
13827 let mut cursor = index;
13828 while depth > 0 && cursor + 1 < lines.len() {
13829 cursor += 1;
13830 let next = lines[cursor].trim();
13831 statement.push(' ');
13832 statement.push_str(next);
13833 depth += delta(next);
13834 }
13835 (statement, cursor)
13836}
13837
13838fn paren_delta(line: &str) -> i32 {
13839 line.chars().fold(0, |depth, ch| match ch {
13840 '(' => depth + 1,
13841 ')' => depth - 1,
13842 _ => depth,
13843 })
13844}
13845
13846pub fn inline_decide_schema_name(rule: &str, binding: &str) -> String {
13854 format!("decide.{rule}.{binding}")
13855}
13856
13857fn decide_field_type_syntax(ty: &str, span: SourceSpan) -> TypeSyntax {
13861 if is_primitive_type(ty) {
13862 TypeSyntax::Primitive {
13863 name: ty.to_owned(),
13864 span,
13865 }
13866 } else {
13867 TypeSyntax::Ref {
13868 name: Ident {
13869 name: ty.to_owned(),
13870 span,
13871 },
13872 }
13873 }
13874}
13875
13876#[allow(clippy::type_complexity)]
13880fn collect_decide_effects<'a>(
13881 statements: &'a [body::BodyStmt],
13882 out: &mut Vec<(&'a str, &'a [(String, String)], SourceSpan)>,
13883) {
13884 for statement in statements {
13885 match statement {
13886 body::BodyStmt::Effect(effect) => {
13887 if let body::BodyEffectKind::Decide { result_fields } = &effect.kind {
13888 if let Some(binding) = &effect.binding {
13889 out.push((binding.as_str(), result_fields.as_slice(), effect.span));
13890 }
13891 }
13892 }
13893 body::BodyStmt::After(after) => collect_decide_effects(&after.body, out),
13894 body::BodyStmt::Case(case) => {
13895 for branch in &case.branches {
13896 collect_decide_effects(&branch.body, out);
13897 }
13898 }
13899 _ => {}
13900 }
13901 }
13902}
13903
13904fn collect_decide_payload_types(
13909 statements: &[body::BodyStmt],
13910 rule_name: &str,
13911 payloads: &mut BTreeMap<String, IrType>,
13912) {
13913 let mut decides = Vec::new();
13914 collect_decide_effects(statements, &mut decides);
13915 for (binding, _fields, _span) in decides {
13916 payloads.insert(
13917 binding.to_owned(),
13918 IrType::Ref(inline_decide_schema_name(rule_name, binding)),
13919 );
13920 }
13921}
13922
13923fn collect_prompt_payload_types(
13924 statements: &[body::BodyStmt],
13925 payloads: &mut BTreeMap<String, IrType>,
13926) {
13927 for statement in statements {
13928 match statement {
13929 body::BodyStmt::Effect(effect) => {
13930 if matches!(&effect.kind, body::BodyEffectKind::Prompt { .. }) {
13931 if let Some(binding) = &effect.binding {
13932 payloads
13933 .insert(binding.clone(), IrType::Primitive(IrPrimitiveType::String));
13934 }
13935 }
13936 }
13937 body::BodyStmt::After(after) => collect_prompt_payload_types(&after.body, payloads),
13938 body::BodyStmt::Case(case) => {
13939 for branch in &case.branches {
13940 collect_prompt_payload_types(&branch.body, payloads);
13941 }
13942 }
13943 _ => {}
13944 }
13945 }
13946}
13947
13948fn collect_inline_decide_schemas(
13954 items: &[Item],
13955 semantic: &mut SemanticContext,
13956 ir: &mut IrProgram,
13957) {
13958 for item in items {
13959 let Item::Rule(rule) = item else {
13960 continue;
13961 };
13962 let (body_ast, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
13963 let mut decides = Vec::new();
13964 collect_decide_effects(&body_ast.statements, &mut decides);
13965 for (binding, fields, span) in decides {
13966 let name = inline_decide_schema_name(&rule.name.name, binding);
13967 let mut syntax_fields: BTreeMap<String, TypeSyntax> = BTreeMap::new();
13970 let mut ir_fields = Vec::new();
13971 for (field_name, field_ty) in fields {
13972 let ty = decide_field_type_syntax(field_ty, span);
13973 ir_fields.push(IrClassField {
13974 name: field_name.clone(),
13975 ty: lower_type(ty.clone()),
13976 is_key: false,
13977 presence_condition: None,
13978 span,
13979 });
13980 syntax_fields.insert(field_name.clone(), ty);
13981 }
13982 semantic.schemas.classes.insert(name.clone(), syntax_fields);
13983 ir.schemas.push(IrSchema::Class(IrClass {
13984 name,
13985 fields: ir_fields,
13986 span,
13987 }));
13988 }
13989 }
13990}
13991
13992pub fn redact_schema_name(rule: &str, binding: &str) -> String {
13995 format!("redact.{rule}.{binding}")
13996}
13997
13998#[allow(clippy::type_complexity)]
14002fn collect_redact_effects<'a>(
14003 statements: &'a [body::BodyStmt],
14004 out: &mut Vec<(&'a str, &'a [String], &'a str, SourceSpan)>,
14005) {
14006 for statement in statements {
14007 match statement {
14008 body::BodyStmt::Redact {
14009 source,
14010 keep,
14011 binding,
14012 span,
14013 } => out.push((source.as_str(), keep.as_slice(), binding.as_str(), *span)),
14014 body::BodyStmt::After(after) => collect_redact_effects(&after.body, out),
14015 body::BodyStmt::Case(case) => {
14016 for branch in &case.branches {
14017 collect_redact_effects(&branch.body, out);
14018 }
14019 }
14020 _ => {}
14021 }
14022 }
14023}
14024
14025fn rule_binding_schemas(rule: &RuleDecl, semantic: &SemanticContext) -> BTreeMap<String, String> {
14033 let mut schemas = binding_types_for_rule(rule);
14034 let (body_ast, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
14035 let mut payloads = collect_effect_payload_types(rule, semantic, &mut Vec::new());
14036 collect_exec_payload_types(&body_ast.statements, semantic, &mut payloads);
14037 collect_decide_payload_types(&body_ast.statements, &rule.name.name, &mut payloads);
14038 collect_redact_payload_types(&body_ast.statements, &rule.name.name, &mut payloads);
14039 for line in rule.body.text.lines() {
14045 let Some(rest) = line.trim().strip_prefix("after ") else {
14046 continue;
14047 };
14048 let mut words = rest.split_whitespace();
14049 let Some(binding) = words.next() else {
14050 continue;
14051 };
14052 let Some(predicate) = words.next() else {
14053 continue;
14054 };
14055 if predicate == "times" && words.next() != Some("out") {
14056 continue;
14057 }
14058 let (Some("as"), Some(alias)) = (words.next(), words.next()) else {
14059 continue;
14060 };
14061 let alias = alias.trim_end_matches('{').trim();
14062 if alias.is_empty() {
14063 continue;
14064 }
14065 if let Some(IrType::Ref(schema)) = payloads.get(binding) {
14066 schemas.insert(alias.to_owned(), schema.clone());
14067 }
14068 }
14069 for (binding, ty) in payloads {
14070 if let IrType::Ref(schema) = ty {
14071 schemas.insert(binding, schema);
14072 }
14073 }
14074 schemas
14075}
14076
14077fn collect_redact_schemas(items: &[Item], semantic: &mut SemanticContext, ir: &mut IrProgram) {
14087 for item in items {
14088 let Item::Rule(rule) = item else {
14089 continue;
14090 };
14091 let (body_ast, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
14092 let mut redacts = Vec::new();
14093 collect_redact_effects(&body_ast.statements, &mut redacts);
14094 if redacts.is_empty() {
14095 continue;
14096 }
14097 let binding_schemas = rule_binding_schemas(rule, semantic);
14098 let mut local: BTreeMap<String, String> = BTreeMap::new();
14099 for (source, keep, binding, span) in redacts {
14100 let name = redact_schema_name(&rule.name.name, binding);
14101 let source_schema = binding_schemas
14102 .get(source)
14103 .cloned()
14104 .or_else(|| local.get(source).cloned());
14105 let projected: Vec<(String, TypeSyntax)> = source_schema
14108 .as_ref()
14109 .and_then(|schema| semantic.schemas.classes.get(schema))
14110 .map(|src_fields| {
14111 keep.iter()
14112 .filter_map(|field| {
14113 src_fields.get(field).map(|ty| (field.clone(), ty.clone()))
14114 })
14115 .collect()
14116 })
14117 .unwrap_or_default();
14118 let mut syntax_fields: BTreeMap<String, TypeSyntax> = BTreeMap::new();
14119 let mut ir_fields = Vec::new();
14120 for (field_name, ty) in &projected {
14121 syntax_fields.insert(field_name.clone(), ty.clone());
14122 ir_fields.push(IrClassField {
14123 name: field_name.clone(),
14124 ty: lower_type(ty.clone()),
14125 is_key: false,
14126 presence_condition: None,
14127 span,
14128 });
14129 }
14130 semantic.schemas.classes.insert(name.clone(), syntax_fields);
14131 ir.schemas.push(IrSchema::Class(IrClass {
14132 name: name.clone(),
14133 fields: ir_fields,
14134 span,
14135 }));
14136 local.insert(binding.to_owned(), name);
14137 }
14138 }
14139}
14140
14141fn collect_redact_payload_types(
14146 statements: &[body::BodyStmt],
14147 rule_name: &str,
14148 payloads: &mut BTreeMap<String, IrType>,
14149) {
14150 let mut redacts = Vec::new();
14151 collect_redact_effects(statements, &mut redacts);
14152 for (_source, _keep, binding, _span) in redacts {
14153 payloads.insert(
14154 binding.to_owned(),
14155 IrType::Ref(redact_schema_name(rule_name, binding)),
14156 );
14157 }
14158}
14159
14160fn validate_redactions(
14165 rule: &RuleDecl,
14166 statements: &[body::BodyStmt],
14167 semantic: &SemanticContext,
14168 binding_schemas: &BTreeMap<String, String>,
14169 diagnostics: &mut Vec<Diagnostic>,
14170) {
14171 let mut redacts = Vec::new();
14172 collect_redact_effects(statements, &mut redacts);
14173 let mut local: BTreeMap<String, String> = BTreeMap::new();
14174 for (source, keep, binding, span) in redacts {
14175 let source_schema = binding_schemas
14176 .get(source)
14177 .cloned()
14178 .or_else(|| local.get(source).cloned());
14179 local.insert(
14180 binding.to_owned(),
14181 redact_schema_name(&rule.name.name, binding),
14182 );
14183 let Some(schema) = source_schema else {
14184 diagnostics.push(Diagnostic {
14185 related: Vec::new(),
14186 span,
14187 message: format!(
14188 "rule `{}` redacts `{source}`, which has no known schema",
14189 rule.name.name
14190 ),
14191 suggestion: Some(
14192 "redact a binding with a known record type — a matched `when Class as x`, or a \
14193 coerce/decide/exec result"
14194 .to_owned(),
14195 ),
14196 });
14197 continue;
14198 };
14199 let Some(src_fields) = semantic.schemas.classes.get(&schema) else {
14200 continue;
14201 };
14202 for field in keep {
14203 if !src_fields.contains_key(field) {
14204 diagnostics.push(Diagnostic {
14205 related: Vec::new(),
14206 span,
14207 message: format!(
14208 "rule `{}` redacts `{source}` keeping unknown field `{field}` of `{schema}`",
14209 rule.name.name
14210 ),
14211 suggestion: Some(format!("keep a field declared on `{schema}`")),
14212 });
14213 }
14214 }
14215 }
14216}
14217
14218fn collect_exec_payload_types(
14224 statements: &[body::BodyStmt],
14225 semantic: &SemanticContext,
14226 payloads: &mut BTreeMap<String, IrType>,
14227) {
14228 for statement in statements {
14229 match statement {
14230 body::BodyStmt::Effect(effect) => {
14231 if let body::BodyEffectKind::Exec {
14232 parse_target: Some(parse),
14233 ..
14234 } = &effect.kind
14235 {
14236 if !parse.each {
14237 if let Some(binding) = &effect.binding {
14238 if semantic.schemas.class_exists(&parse.schema) {
14239 payloads.insert(binding.clone(), IrType::Ref(parse.schema.clone()));
14240 }
14241 }
14242 }
14243 }
14244 }
14245 body::BodyStmt::After(after) => {
14246 collect_exec_payload_types(&after.body, semantic, payloads)
14247 }
14248 body::BodyStmt::Case(case) => {
14249 for branch in &case.branches {
14250 collect_exec_payload_types(&branch.body, semantic, payloads);
14251 }
14252 }
14253 _ => {}
14254 }
14255 }
14256}
14257
14258fn push_ingest_fact_writes(statements: &[body::BodyStmt], fact_writes: &mut Vec<String>) {
14260 for statement in statements {
14261 match statement {
14262 body::BodyStmt::Effect(effect) => {
14263 match &effect.kind {
14264 body::BodyEffectKind::Exec {
14265 parse_target: Some(parse),
14266 ..
14267 } if parse.each => {
14268 fact_writes.push(format!("schema:{}", parse.schema));
14269 }
14270 body::BodyEffectKind::FileImport { schema, .. } => {
14274 fact_writes.push(format!("schema:{schema}"));
14275 }
14276 _ => {}
14277 }
14278 }
14279 body::BodyStmt::After(after) => push_ingest_fact_writes(&after.body, fact_writes),
14280 body::BodyStmt::Case(case) => {
14281 for branch in &case.branches {
14282 push_ingest_fact_writes(&branch.body, fact_writes);
14283 }
14284 }
14285 _ => {}
14286 }
14287 }
14288}
14289
14290fn validate_coordination_discipline(
14304 rule: &RuleDecl,
14305 statements: &[body::BodyStmt],
14306 diagnostics: &mut Vec<Diagnostic>,
14307) {
14308 let mut acquires = Vec::new();
14309 let mut consumes = Vec::new();
14310 let mut claims = Vec::new();
14311 collect_coordination_effects(statements, &mut acquires, &mut consumes, &mut claims);
14312
14313 let mut vcs_verbs: Vec<(&'static str, [&'static str; 2], String, SourceSpan)> = Vec::new();
14317 for_each_body(statements, &mut |stmt| {
14318 if let body::BodyStmt::Effect(effect) = stmt {
14319 if let body::BodyEffectKind::ConstructCapabilityCall { keyword, .. } = &effect.kind {
14320 let arms: Option<(&'static str, [&'static str; 2])> = match keyword.as_str() {
14321 "promote" => Some(("promote", ["promoted", "conflicted"])),
14322 "undo" => Some(("undo", ["applied", "stranded"])),
14323 "transport" => Some(("transport", ["applied", "conflicted"])),
14324 _ => None,
14325 };
14326 if let (Some((verb, required)), Some(binding)) = (arms, &effect.binding) {
14327 vcs_verbs.push((verb, required, binding.clone(), effect.span));
14328 }
14329 }
14330 }
14331 });
14332 for (verb, required_arms, binding, span) in &vcs_verbs {
14333 let mut predicates = BTreeSet::new();
14334 collect_after_predicates(statements, binding, &mut predicates);
14335 for required in required_arms {
14336 if !predicates.contains(*required) {
14337 diagnostics.push(Diagnostic {
14338 related: Vec::new(),
14339 span: *span,
14340 message: format!(
14341 "rule `{}` does not handle the `{required}` outcome of {verb} `{binding}`",
14342 rule.name.name
14343 ),
14344 suggestion: Some(format!(
14345 "{verb} outcomes are exhaustive: add `after {binding} {required} {{ ... }}`"
14346 )),
14347 });
14348 }
14349 }
14350 }
14351
14352 let claim_bindings = collect_claim_bindings(statements);
14363 let renewable: BTreeSet<&str> = acquires
14364 .iter()
14365 .map(|(b, _, _)| b.as_str())
14366 .chain(claim_bindings.iter().map(String::as_str))
14367 .collect();
14368 for_each_body(statements, &mut |stmt| {
14369 if let body::BodyStmt::Effect(effect) = stmt {
14370 if let body::BodyEffectKind::LeaseRenew {
14371 acquire_binding, ..
14372 } = &effect.kind
14373 {
14374 if !renewable.contains(acquire_binding.as_str()) {
14375 diagnostics.push(Diagnostic {
14376 related: Vec::new(),
14377 span: effect.span,
14378 message: format!(
14379 "rule `{}` renews unbound coordination binding `{}`",
14380 rule.name.name, acquire_binding
14381 ),
14382 suggestion: Some(format!(
14383 "`renew {acquire_binding}` must name a lease acquired here (`acquire ... as {acquire_binding}`) or an issue claimed here (`claim ... as {acquire_binding}`)"
14384 )),
14385 });
14386 }
14387 }
14388 }
14389 });
14390
14391 let work_items: Vec<String> = rule
14402 .whens
14403 .iter()
14404 .filter_map(|when| when_has_ready_binding(&when.text))
14405 .collect();
14406 let releasable: BTreeSet<&str> = acquires
14407 .iter()
14408 .map(|(b, _, _)| b.as_str())
14409 .chain(claims.iter().map(|(item, _)| item.as_str()))
14410 .chain(work_items.iter().map(String::as_str))
14411 .collect();
14412 for_each_body(statements, &mut |stmt| {
14413 if let body::BodyStmt::Effect(effect) = stmt {
14414 if let body::BodyEffectKind::TrackerRelease { item } = &effect.kind {
14415 if !releasable.contains(item.as_str()) {
14416 diagnostics.push(Diagnostic {
14417 related: Vec::new(),
14418 span: effect.span,
14419 message: format!(
14420 "rule `{}` releases unbound coordination item `{}`",
14421 rule.name.name, item
14422 ),
14423 suggestion: Some(format!(
14424 "`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"
14425 )),
14426 });
14427 }
14428 }
14429 }
14430 });
14431
14432 if acquires.len() > 1 {
14433 diagnostics.push(Diagnostic { related: Vec::new(),
14434 span: acquires[1].2,
14435 message: format!(
14436 "rule `{}` acquires more than one lease in a single progression",
14437 rule.name.name
14438 ),
14439 suggestion: Some(
14440 "the hard default is at most one held lease per progression (it breaks hold-and-wait); restructure into separate rules"
14441 .to_owned(),
14442 ),
14443 });
14444 }
14445 for (binding, until_ttl, span) in &acquires {
14446 if *until_ttl {
14447 continue;
14448 }
14449 let mut predicates = BTreeSet::new();
14450 collect_after_predicates(statements, binding, &mut predicates);
14451 for required in ["held", "contended"] {
14452 if !predicates.contains(required) {
14453 diagnostics.push(Diagnostic { related: Vec::new(),
14454 span: *span,
14455 message: format!(
14456 "rule `{}` does not handle the `{required}` outcome of lease `{binding}`",
14457 rule.name.name
14458 ),
14459 suggestion: Some(format!(
14460 "coordination outcomes are exhaustive: add `after {binding} {required} {{ ... }}`"
14461 )),
14462 });
14463 }
14464 }
14465 if let Some(held_body) = find_after_body(statements, binding, body::AfterPredicate::Held) {
14466 if !releases_or_terminates(held_body, binding) {
14467 diagnostics.push(Diagnostic { related: Vec::new(),
14468 span: *span,
14469 message: format!(
14470 "rule `{}` can hold lease `{binding}` forever: the `held` branch neither releases it nor reaches a workflow terminal",
14471 rule.name.name
14472 ),
14473 suggestion: Some(format!(
14474 "add `release {binding}` on every non-terminal path, or use `acquire ... until ttl` for fire-and-forget"
14475 )),
14476 });
14477 }
14478 }
14479 }
14480 for (binding, span) in &consumes {
14481 let mut predicates = BTreeSet::new();
14482 collect_after_predicates(statements, binding, &mut predicates);
14483 for required in ["ok", "over"] {
14484 if !predicates.contains(required) {
14485 diagnostics.push(Diagnostic { related: Vec::new(),
14486 span: *span,
14487 message: format!(
14488 "rule `{}` does not handle the `{required}` outcome of counter consume `{binding}`",
14489 rule.name.name
14490 ),
14491 suggestion: Some(format!(
14492 "coordination outcomes are exhaustive: add `after {binding} {required} {{ ... }}`"
14493 )),
14494 });
14495 }
14496 }
14497 }
14498}
14499
14500fn collect_coordination_effects(
14501 statements: &[body::BodyStmt],
14502 acquires: &mut Vec<(String, bool, SourceSpan)>,
14503 consumes: &mut Vec<(String, SourceSpan)>,
14504 claims: &mut Vec<(String, SourceSpan)>,
14505) {
14506 for_each_body(statements, &mut |stmt| {
14507 if let body::BodyStmt::Effect(effect) = stmt {
14508 match &effect.kind {
14509 body::BodyEffectKind::LeaseAcquire { until_ttl, .. } => {
14510 if let Some(binding) = &effect.binding {
14511 acquires.push((binding.clone(), *until_ttl, effect.span));
14512 }
14513 }
14514 body::BodyEffectKind::CounterConsume { .. } => {
14515 if let Some(binding) = &effect.binding {
14516 consumes.push((binding.clone(), effect.span));
14517 }
14518 }
14519 body::BodyEffectKind::TrackerClaim { item, .. } => {
14523 claims.push((item.clone(), effect.span));
14524 }
14525 _ => {}
14526 }
14527 }
14528 });
14529}
14530
14531fn when_has_ready_binding(when: &str) -> Option<String> {
14537 let (pattern, _) = split_when_guard(when);
14538 let mut words = pattern.split_whitespace();
14539 let _queue = words.next()?;
14540 if words.next() == Some("has") && words.next() == Some("ready") {
14541 return binding_after_as(pattern);
14542 }
14543 None
14544}
14545
14546fn collect_after_predicates(
14547 statements: &[body::BodyStmt],
14548 binding: &str,
14549 predicates: &mut BTreeSet<&'static str>,
14550) {
14551 for_each_body(statements, &mut |stmt| {
14552 if let body::BodyStmt::After(after) = stmt {
14553 if after.binding == binding {
14554 predicates.insert(after.predicate.as_str());
14555 }
14556 }
14557 });
14558}
14559
14560fn find_after_body<'a>(
14561 statements: &'a [body::BodyStmt],
14562 binding: &str,
14563 predicate: body::AfterPredicate,
14564) -> Option<&'a [body::BodyStmt]> {
14565 for statement in statements {
14566 match statement {
14567 body::BodyStmt::After(after) => {
14568 if after.binding == binding && after.predicate == predicate {
14569 return Some(&after.body);
14570 }
14571 if let Some(found) = find_after_body(&after.body, binding, predicate) {
14572 return Some(found);
14573 }
14574 }
14575 body::BodyStmt::Case(case) => {
14576 for branch in &case.branches {
14577 if let Some(found) = find_after_body(&branch.body, binding, predicate) {
14578 return Some(found);
14579 }
14580 }
14581 }
14582 _ => {}
14583 }
14584 }
14585 None
14586}
14587
14588fn releases_or_terminates(statements: &[body::BodyStmt], binding: &str) -> bool {
14593 statements.iter().any(|statement| match statement {
14594 body::BodyStmt::Effect(effect) => matches!(
14595 &effect.kind,
14596 body::BodyEffectKind::TrackerRelease { item } if item == binding
14597 ),
14598 body::BodyStmt::Terminal(_) => true,
14599 body::BodyStmt::After(after) => releases_or_terminates(&after.body, binding),
14600 body::BodyStmt::Case(case) => {
14601 !case.branches.is_empty()
14602 && case
14603 .branches
14604 .iter()
14605 .all(|branch| releases_or_terminates(&branch.body, binding))
14606 }
14607 _ => false,
14608 })
14609}
14610
14611fn for_each_body(statements: &[body::BodyStmt], visit: &mut impl FnMut(&body::BodyStmt)) {
14612 for statement in statements {
14613 visit(statement);
14614 match statement {
14615 body::BodyStmt::After(after) => for_each_body(&after.body, visit),
14616 body::BodyStmt::Case(case) => {
14617 for branch in &case.branches {
14618 for_each_body(&branch.body, visit);
14619 }
14620 }
14621 _ => {}
14622 }
14623 }
14624}
14625
14626fn family_b_arm_allowed(
14631 scrutinee: &str,
14632 pattern: &str,
14633 binding_types: &BTreeMap<String, String>,
14634 semantic: &SemanticContext,
14635) -> BTreeSet<(String, String)> {
14636 let mut allowed = BTreeSet::new();
14637 let Some((root, disc)) = scrutinee.split_once('.') else {
14638 return allowed;
14639 };
14640 if disc.contains('.') {
14641 return allowed;
14642 }
14643 let trimmed = pattern.trim();
14644 if trimmed == "_" || trimmed == "default" {
14645 return allowed;
14646 }
14647 let literal = trimmed.trim_matches('"');
14648 if literal.is_empty() {
14649 return allowed;
14650 }
14651 let Some(schema) = binding_types.get(root) else {
14652 return allowed;
14653 };
14654 if let Some(conditions) = semantic.schemas.presence.get(schema) {
14655 for (field, (cond_disc, cond_literal)) in conditions {
14656 if cond_disc == disc && cond_literal == literal {
14657 allowed.insert((root.to_owned(), field.clone()));
14658 }
14659 }
14660 }
14661 allowed
14662}
14663
14664#[allow(clippy::too_many_arguments)]
14670fn check_conditioned_read(
14671 rule: &RuleDecl,
14672 root: &str,
14673 field: &str,
14674 span: SourceSpan,
14675 semantic: &SemanticContext,
14676 binding_types: &BTreeMap<String, String>,
14677 allowed: &BTreeSet<(String, String)>,
14678 diagnostics: &mut Vec<Diagnostic>,
14679) {
14680 let Some(schema) = binding_types.get(root) else {
14681 return;
14682 };
14683 let Some((disc, _literal)) = semantic.schemas.field_presence(schema, field) else {
14684 return;
14685 };
14686 if allowed.contains(&(root.to_owned(), field.to_owned())) {
14687 return;
14688 }
14689 diagnostics.push(Diagnostic {
14690 related: Vec::new(),
14691 span,
14692 message: format!(
14693 "rule `{}` reads conditional field `{root}.{field}` outside a matching `case {root}.{disc}` arm",
14694 rule.name.name
14695 ),
14696 suggestion: Some(format!(
14697 "read `{root}.{field}` inside `case {root}.{disc} {{ \"...\" => ... }}` — it is present only for a specific `{disc}`"
14698 )),
14699 });
14700}
14701
14702fn check_conditioned_reads_in_text(
14706 rule: &RuleDecl,
14707 text: &str,
14708 span: SourceSpan,
14709 semantic: &SemanticContext,
14710 binding_types: &BTreeMap<String, String>,
14711 allowed: &BTreeSet<(String, String)>,
14712 diagnostics: &mut Vec<Diagnostic>,
14713) {
14714 for (root, path) in dotted_paths(text) {
14715 let Some(first) = path.first() else {
14716 continue;
14717 };
14718 check_conditioned_read(
14719 rule,
14720 &root,
14721 first,
14722 span,
14723 semantic,
14724 binding_types,
14725 allowed,
14726 diagnostics,
14727 );
14728 }
14729}
14730
14731fn interpolation_paths(text: &str) -> Vec<(String, Vec<String>)> {
14736 let mut paths = Vec::new();
14737 let mut rest = text;
14738 while let Some(open) = rest.find("{{") {
14739 let after_open = &rest[open + 2..];
14740 let Some(close) = after_open.find("}}") else {
14741 break;
14742 };
14743 paths.extend(dotted_paths(&after_open[..close]));
14744 rest = &after_open[close + 2..];
14745 }
14746 paths
14747}
14748
14749fn check_conditioned_reads_in_interpolations(
14753 rule: &RuleDecl,
14754 text: &str,
14755 span: SourceSpan,
14756 semantic: &SemanticContext,
14757 binding_types: &BTreeMap<String, String>,
14758 allowed: &BTreeSet<(String, String)>,
14759 diagnostics: &mut Vec<Diagnostic>,
14760) {
14761 for (root, path) in interpolation_paths(text) {
14762 let Some(first) = path.first() else {
14763 continue;
14764 };
14765 check_conditioned_read(
14766 rule,
14767 &root,
14768 first,
14769 span,
14770 semantic,
14771 binding_types,
14772 allowed,
14773 diagnostics,
14774 );
14775 }
14776}
14777
14778fn check_conditioned_reads_in_fields(
14784 rule: &RuleDecl,
14785 fields: &[body::FieldAssign],
14786 from_binding: Option<&str>,
14787 semantic: &SemanticContext,
14788 binding_types: &BTreeMap<String, String>,
14789 allowed: &BTreeSet<(String, String)>,
14790 diagnostics: &mut Vec<Diagnostic>,
14791) {
14792 for field in fields {
14793 match &field.value {
14794 body::FieldValue::Expr { source, .. } => check_conditioned_reads_in_text(
14795 rule,
14796 source,
14797 field.span,
14798 semantic,
14799 binding_types,
14800 allowed,
14801 diagnostics,
14802 ),
14803 body::FieldValue::Nested { fields, .. } => check_conditioned_reads_in_fields(
14804 rule,
14805 fields,
14806 from_binding,
14807 semantic,
14808 binding_types,
14809 allowed,
14810 diagnostics,
14811 ),
14812 body::FieldValue::Shorthand => {
14813 if let Some(root) = from_binding {
14814 check_conditioned_read(
14815 rule,
14816 root,
14817 &field.name,
14818 field.span,
14819 semantic,
14820 binding_types,
14821 allowed,
14822 diagnostics,
14823 );
14824 }
14825 }
14826 }
14827 }
14828}
14829
14830#[allow(clippy::too_many_arguments)]
14839fn check_conditioned_implicit_copies(
14840 rule: &RuleDecl,
14841 from_binding: Option<&str>,
14842 target_fields: Option<&BTreeMap<String, TypeSyntax>>,
14843 fields: &[body::FieldAssign],
14844 span: SourceSpan,
14845 semantic: &SemanticContext,
14846 binding_types: &BTreeMap<String, String>,
14847 allowed: &BTreeSet<(String, String)>,
14848 diagnostics: &mut Vec<Diagnostic>,
14849) {
14850 let (Some(root), Some(target_fields)) = (from_binding, target_fields) else {
14851 return;
14852 };
14853 let written: BTreeSet<&str> = fields.iter().map(|field| field.name.as_str()).collect();
14854 for name in target_fields.keys() {
14855 if written.contains(name.as_str()) {
14856 continue;
14857 }
14858 check_conditioned_read(
14859 rule,
14860 root,
14861 name,
14862 span,
14863 semantic,
14864 binding_types,
14865 allowed,
14866 diagnostics,
14867 );
14868 }
14869}
14870
14871fn check_conditioned_record_reads(
14874 rule: &RuleDecl,
14875 record: &body::RecordStmt,
14876 semantic: &SemanticContext,
14877 binding_types: &BTreeMap<String, String>,
14878 allowed: &BTreeSet<(String, String)>,
14879 diagnostics: &mut Vec<Diagnostic>,
14880) {
14881 check_conditioned_reads_in_fields(
14882 rule,
14883 &record.fields,
14884 record.from.as_deref(),
14885 semantic,
14886 binding_types,
14887 allowed,
14888 diagnostics,
14889 );
14890 check_conditioned_implicit_copies(
14891 rule,
14892 record.from.as_deref(),
14893 semantic.schemas.classes.get(&record.schema),
14894 &record.fields,
14895 record.span,
14896 semantic,
14897 binding_types,
14898 allowed,
14899 diagnostics,
14900 );
14901}
14902
14903fn check_conditioned_effect_reads(
14927 rule: &RuleDecl,
14928 effect: &body::EffectStmt,
14929 semantic: &SemanticContext,
14930 binding_types: &BTreeMap<String, String>,
14931 allowed: &BTreeSet<(String, String)>,
14932 diagnostics: &mut Vec<Diagnostic>,
14933) {
14934 let span = effect.span;
14935 let expression = |text: &str, diagnostics: &mut Vec<Diagnostic>| {
14936 check_conditioned_reads_in_text(
14937 rule,
14938 text,
14939 span,
14940 semantic,
14941 binding_types,
14942 allowed,
14943 diagnostics,
14944 );
14945 };
14946
14947 if let Some(prompt) = &effect.prompt {
14950 check_conditioned_reads_in_interpolations(
14951 rule,
14952 &prompt.text,
14953 span,
14954 semantic,
14955 binding_types,
14956 allowed,
14957 diagnostics,
14958 );
14959 }
14960
14961 match &effect.kind {
14962 body::BodyEffectKind::Coerce { args, .. } => {
14963 for arg in args {
14964 expression(arg, diagnostics);
14965 }
14966 }
14967 body::BodyEffectKind::ConstructCapabilityCall { fields, .. } => {
14968 for field in fields {
14969 expression(&field.source, diagnostics);
14970 }
14971 }
14972 body::BodyEffectKind::Invoke { payload, .. } => check_conditioned_reads_in_fields(
14973 rule,
14974 payload,
14975 None,
14977 semantic,
14978 binding_types,
14979 allowed,
14980 diagnostics,
14981 ),
14982 body::BodyEffectKind::Timer { until, .. } => {
14983 if let Some(until) = until {
14984 expression(until, diagnostics);
14985 }
14986 }
14987 body::BodyEffectKind::Exec {
14988 target,
14989 parse_target: _,
14990 } => match target {
14991 body::ExecTarget::RawCommand(command) => {
14993 check_conditioned_reads_in_interpolations(
14994 rule,
14995 command,
14996 span,
14997 semantic,
14998 binding_types,
14999 allowed,
15000 diagnostics,
15001 );
15002 }
15003 body::ExecTarget::Capability { .. } => {}
15005 },
15006 body::BodyEffectKind::TrackerFile { fields, .. }
15007 | body::BodyEffectKind::TrackerFinish { fields, .. }
15008 | body::BodyEffectKind::LedgerAppend { fields, .. } => check_conditioned_reads_in_fields(
15009 rule,
15010 fields,
15011 None,
15012 semantic,
15013 binding_types,
15014 allowed,
15015 diagnostics,
15016 ),
15017 body::BodyEffectKind::LeaseAcquire { key_expr, .. } => expression(key_expr, diagnostics),
15018 body::BodyEffectKind::CounterConsume {
15019 key_expr,
15020 amount_expr,
15021 ..
15022 } => {
15023 expression(key_expr, diagnostics);
15024 expression(amount_expr, diagnostics);
15025 }
15026 body::BodyEffectKind::Notify {
15031 target_expr,
15032 event,
15033 from,
15034 fields,
15035 } => {
15036 expression(target_expr, diagnostics);
15037 check_conditioned_reads_in_fields(
15038 rule,
15039 fields,
15040 from.as_deref(),
15041 semantic,
15042 binding_types,
15043 allowed,
15044 diagnostics,
15045 );
15046 check_conditioned_implicit_copies(
15047 rule,
15048 from.as_deref(),
15049 semantic.schemas.classes.get(event),
15050 fields,
15051 span,
15052 semantic,
15053 binding_types,
15054 allowed,
15055 diagnostics,
15056 );
15057 }
15058 body::BodyEffectKind::FileRead { path, .. }
15059 | body::BodyEffectKind::FileImport { path, .. } => expression(path, diagnostics),
15060 body::BodyEffectKind::FileWrite { path, body, .. } => {
15061 expression(path, diagnostics);
15062 expression(body, diagnostics);
15063 }
15064 body::BodyEffectKind::FileExport {
15065 path, predicate, ..
15066 } => {
15067 expression(path, diagnostics);
15068 if let Some(predicate) = predicate {
15069 expression(predicate, diagnostics);
15070 }
15071 }
15072 body::BodyEffectKind::Tell { .. }
15075 | body::BodyEffectKind::Prompt { .. }
15076 | body::BodyEffectKind::Decide { .. }
15077 | body::BodyEffectKind::Call { .. }
15078 | body::BodyEffectKind::TrackerClaim { .. }
15079 | body::BodyEffectKind::TrackerRelease { .. }
15080 | body::BodyEffectKind::LeaseRenew { .. } => {}
15081 }
15082}
15083
15084fn terminal_output_fields<'a>(
15089 terminal: &body::TerminalStmt,
15090 semantic: &'a SemanticContext,
15091) -> Option<&'a BTreeMap<String, TypeSyntax>> {
15092 if terminal.kind != body::TerminalKind::Complete {
15093 return None;
15094 }
15095 let workflow = semantic.workflow.as_ref()?;
15096 let surface = semantic.workflow_inputs.get(workflow)?;
15097 match surface.outputs.get(&terminal.name)? {
15098 TypeSyntax::Ref { name } => semantic.schemas.classes.get(&name.name),
15099 _ => None,
15100 }
15101}
15102
15103fn validate_conditioned_field_reads(
15114 rule: &RuleDecl,
15115 statements: &[body::BodyStmt],
15116 semantic: &SemanticContext,
15117 binding_types: &BTreeMap<String, String>,
15118 allowed: &BTreeSet<(String, String)>,
15119 diagnostics: &mut Vec<Diagnostic>,
15120) {
15121 for statement in statements {
15122 match statement {
15123 body::BodyStmt::Record(record) => {
15124 check_conditioned_record_reads(
15125 rule,
15126 record,
15127 semantic,
15128 binding_types,
15129 allowed,
15130 diagnostics,
15131 );
15132 }
15133 body::BodyStmt::Terminal(terminal) => {
15134 check_conditioned_reads_in_fields(
15135 rule,
15136 &terminal.fields,
15137 terminal.from.as_deref(),
15138 semantic,
15139 binding_types,
15140 allowed,
15141 diagnostics,
15142 );
15143 check_conditioned_implicit_copies(
15144 rule,
15145 terminal.from.as_deref(),
15146 terminal_output_fields(terminal, semantic),
15147 &terminal.fields,
15148 terminal.span,
15149 semantic,
15150 binding_types,
15151 allowed,
15152 diagnostics,
15153 );
15154 if let Some(body::FieldValue::Expr { source, .. }) = &terminal.scalar {
15156 check_conditioned_reads_in_text(
15157 rule,
15158 source,
15159 terminal.span,
15160 semantic,
15161 binding_types,
15162 allowed,
15163 diagnostics,
15164 );
15165 }
15166 }
15167 body::BodyStmt::Done {
15168 replacement: Some(record),
15169 ..
15170 } => check_conditioned_record_reads(
15171 rule,
15172 record,
15173 semantic,
15174 binding_types,
15175 allowed,
15176 diagnostics,
15177 ),
15178 body::BodyStmt::Milestone { fields, .. } => check_conditioned_reads_in_fields(
15179 rule,
15180 fields,
15181 None,
15183 semantic,
15184 binding_types,
15185 allowed,
15186 diagnostics,
15187 ),
15188 body::BodyStmt::Effect(effect) => check_conditioned_effect_reads(
15189 rule,
15190 effect,
15191 semantic,
15192 binding_types,
15193 allowed,
15194 diagnostics,
15195 ),
15196 body::BodyStmt::Done { .. }
15197 | body::BodyStmt::Cancel { .. }
15198 | body::BodyStmt::Redact { .. } => {}
15199 body::BodyStmt::After(after) => validate_conditioned_field_reads(
15200 rule,
15201 &after.body,
15202 semantic,
15203 binding_types,
15204 allowed,
15205 diagnostics,
15206 ),
15207 body::BodyStmt::Region(region) => {
15208 validate_conditioned_field_reads(
15209 rule,
15210 ®ion.body,
15211 semantic,
15212 binding_types,
15213 allowed,
15214 diagnostics,
15215 );
15216 validate_conditioned_field_reads(
15217 rule,
15218 ®ion.lapse_body,
15219 semantic,
15220 binding_types,
15221 allowed,
15222 diagnostics,
15223 );
15224 }
15225 body::BodyStmt::Case(case) => {
15226 for arm in &case.branches {
15227 let mut arm_allowed = allowed.clone();
15228 arm_allowed.extend(family_b_arm_allowed(
15229 &case.scrutinee,
15230 &arm.pattern,
15231 binding_types,
15232 semantic,
15233 ));
15234 if let Some(guard) = &arm.guard {
15235 check_conditioned_reads_in_text(
15236 rule,
15237 guard,
15238 arm.span,
15239 semantic,
15240 binding_types,
15241 &arm_allowed,
15242 diagnostics,
15243 );
15244 }
15245 validate_conditioned_field_reads(
15246 rule,
15247 &arm.body,
15248 semantic,
15249 binding_types,
15250 &arm_allowed,
15251 diagnostics,
15252 );
15253 }
15254 }
15255 }
15256 }
15257}
15258
15259fn validate_body_effect_operands(
15260 rule: &RuleDecl,
15261 statements: &[body::BodyStmt],
15262 semantic: &SemanticContext,
15263 binding_types: &BTreeMap<String, String>,
15264 diagnostics: &mut Vec<Diagnostic>,
15265) {
15266 for statement in statements {
15267 match statement {
15268 body::BodyStmt::Effect(effect) => {
15269 match &effect.kind {
15270 body::BodyEffectKind::LeaseAcquire { resource, .. }
15271 if !semantic.leases.contains(resource) =>
15272 {
15273 diagnostics.push(Diagnostic { related: Vec::new(),
15274 span: effect.span,
15275 message: format!(
15276 "rule `{}` acquires undeclared lease `{resource}`",
15277 rule.name.name
15278 ),
15279 suggestion: Some(format!(
15280 "declare `lease {resource} {{ key <Type> slots <N> ttl <duration> }}`"
15281 )),
15282 });
15283 }
15284 body::BodyEffectKind::LedgerAppend { ledger, schema, .. } => {
15285 if !semantic.ledgers.contains(ledger) {
15286 diagnostics.push(Diagnostic { related: Vec::new(),
15287 span: effect.span,
15288 message: format!(
15289 "rule `{}` appends to undeclared ledger `{ledger}`",
15290 rule.name.name
15291 ),
15292 suggestion: Some(format!(
15293 "declare `ledger {ledger} {{ entry <Type> partition by <field> retain <duration> }}`"
15294 )),
15295 });
15296 }
15297 if !semantic.schemas.class_exists(schema) {
15298 diagnostics.push(Diagnostic {
15299 related: Vec::new(),
15300 span: effect.span,
15301 message: format!(
15302 "rule `{}` appends unknown entry class `{schema}`",
15303 rule.name.name
15304 ),
15305 suggestion: Some(format!("declare `class {schema}` first")),
15306 });
15307 }
15308 }
15309 body::BodyEffectKind::CounterConsume { counter, .. }
15310 if !semantic.counters.contains(counter) =>
15311 {
15312 diagnostics.push(Diagnostic { related: Vec::new(),
15313 span: effect.span,
15314 message: format!(
15315 "rule `{}` consumes undeclared counter `{counter}`",
15316 rule.name.name
15317 ),
15318 suggestion: Some(format!(
15319 "declare `counter {counter} {{ key <Type> cap <N> reset <period> }}`"
15320 )),
15321 });
15322 }
15323 _ => {}
15324 }
15325 if let body::BodyEffectKind::Exec {
15330 target:
15331 body::ExecTarget::Capability {
15332 name,
15333 stdin_binding,
15334 },
15335 ..
15336 } = &effect.kind
15337 {
15338 match binding_types.get(stdin_binding) {
15339 None => {
15340 diagnostics.push(Diagnostic {
15341 related: Vec::new(),
15342 span: effect.span,
15343 message: format!(
15344 "rule `{}` uses unknown binding `{stdin_binding}` in `exec {name} with {stdin_binding}` — `with` requires a typed record binding",
15345 rule.name.name
15346 ),
15347 suggestion: Some(format!(
15348 "bind a typed record first (e.g. `when <Class> as {stdin_binding}` or `coerce ... -> <Class> as {stdin_binding}`) and pass that binding to `with`"
15349 )),
15350 });
15351 }
15352 Some(schema)
15358 if schema.contains('.') && !semantic.schemas.class_exists(schema) =>
15359 {
15360 diagnostics.push(Diagnostic {
15361 related: Vec::new(),
15362 span: effect.span,
15363 message: format!(
15364 "rule `{}` passes untyped fact binding `{stdin_binding}` to `exec {name} with` — `with` requires a typed record binding",
15365 rule.name.name
15366 ),
15367 suggestion: Some(format!(
15368 "declare `signal {schema} {{ ... }}` for a typed reaction, or bind a declared class and pass that to `with`"
15369 )),
15370 });
15371 }
15372 Some(_) => {}
15373 }
15374 }
15375 if let body::BodyEffectKind::Exec {
15376 parse_target: Some(parse),
15377 ..
15378 } = &effect.kind
15379 {
15380 if !semantic.schemas.class_exists(&parse.schema) {
15381 let suggestion =
15382 match closest_name(&parse.schema, semantic.schemas.classes.keys()) {
15383 Some(candidate) => format!(
15384 "did you mean `{candidate}`? otherwise declare `class {}`",
15385 parse.schema
15386 ),
15387 None => format!(
15388 "declare `class {}` before parsing into it",
15389 parse.schema
15390 ),
15391 };
15392 diagnostics.push(Diagnostic {
15393 related: Vec::new(),
15394 span: effect.span,
15395 message: format!(
15396 "rule `{}` parses exec output into unknown schema `{}`",
15397 rule.name.name, parse.schema
15398 ),
15399 suggestion: Some(suggestion),
15400 });
15401 }
15402 }
15403 let body::BodyEffectKind::Timer {
15404 until: Some(until), ..
15405 } = &effect.kind
15406 else {
15407 continue;
15408 };
15409 if body::is_iso8601_instant(until) {
15410 continue;
15411 }
15412 let mut segments = until.split('.');
15413 let root = segments.next().unwrap_or_default();
15414 let path = segments.map(str::to_owned).collect::<Vec<_>>();
15415 let Some(schema) = binding_types.get(root) else {
15416 diagnostics.push(Diagnostic { related: Vec::new(),
15417 span: effect.span,
15418 message: format!(
15419 "rule `{}` uses unknown binding `{root}` in `timer until {until}`",
15420 rule.name.name
15421 ),
15422 suggestion: Some(
15423 "bind a fact in `when` and reference a `time` field on it, or use an ISO-8601 literal"
15424 .to_owned(),
15425 ),
15426 });
15427 continue;
15428 };
15429 if schema.contains('.') {
15432 continue;
15433 }
15434 let resolved = if path.is_empty() {
15435 Err(format!(
15436 "`{root}` is a `{schema}` record, not a `time` value"
15437 ))
15438 } else {
15439 semantic.schemas.resolve_field_path(schema, &path)
15440 };
15441 match resolved {
15442 Ok(TypeSyntax::Primitive { ref name, .. }) if name == "time" => {}
15443 Ok(_) => {
15444 diagnostics.push(Diagnostic { related: Vec::new(),
15445 span: effect.span,
15446 message: format!(
15447 "rule `{}` uses non-time operand `{until}` in `timer until`",
15448 rule.name.name
15449 ),
15450 suggestion: Some(format!(
15451 "declare the field as `time` on `{schema}` or use an ISO-8601 literal"
15452 )),
15453 });
15454 }
15455 Err(message) => {
15456 diagnostics.push(Diagnostic { related: Vec::new(),
15457 span: effect.span,
15458 message: format!(
15459 "rule `{}` has invalid `timer until` operand `{until}`: {message}",
15460 rule.name.name
15461 ),
15462 suggestion: Some(
15463 "reference a `time`-typed field on a bound fact, or use an ISO-8601 literal"
15464 .to_owned(),
15465 ),
15466 });
15467 }
15468 }
15469 }
15470 body::BodyStmt::After(after) => {
15471 validate_body_effect_operands(
15472 rule,
15473 &after.body,
15474 semantic,
15475 binding_types,
15476 diagnostics,
15477 );
15478 }
15479 body::BodyStmt::Case(case) => {
15480 for branch in &case.branches {
15481 validate_body_effect_operands(
15482 rule,
15483 &branch.body,
15484 semantic,
15485 binding_types,
15486 diagnostics,
15487 );
15488 }
15489 }
15490 _ => {}
15491 }
15492 }
15493}
15494
15495const PROGRESS_VIEW_NAMESPACE: &str = "region";
15500
15501fn validate_lapse_arm(
15523 rule: &RuleDecl,
15524 region: &IrRegion,
15525 semantic: &SemanticContext,
15526 binding_types: &BTreeMap<String, String>,
15527 foreign_schemas: &BTreeMap<String, String>,
15528 effect_payload_types: &BTreeMap<String, IrType>,
15529 diagnostics: &mut Vec<Diagnostic>,
15530) {
15531 let mut schemas = semantic.schemas.clone();
15532 let mut arm_bindings = binding_types.clone();
15533
15534 if let Some(view) = ®ion.lapse_binding {
15535 let progress = format!("{PROGRESS_VIEW_NAMESPACE}.{}.Progress", rule.name.name);
15536 let steps = format!("{PROGRESS_VIEW_NAMESPACE}.{}.Steps", rule.name.name);
15537
15538 let mut progress_fields = BTreeMap::new();
15539 let mut steps_fields = BTreeMap::new();
15540 for effect in ®ion.effects {
15545 let step = effect
15546 .binding
15547 .strip_prefix(then_expand::THEN_BINDING_PREFIX)
15548 .unwrap_or(&effect.binding)
15549 .to_owned();
15550 steps_fields.insert(step.clone(), string_ty());
15553 let settled = match effect_payload_types.get(&effect.binding) {
15554 Some(IrType::Ref(name)) if semantic.schemas.class_exists(name) => TypeSyntax::Ref {
15555 name: Ident {
15556 name: name.clone(),
15557 span: zero_span(),
15558 },
15559 },
15560 _ => string_ty(),
15563 };
15564 progress_fields.insert(step, optional_ty(settled));
15565 }
15566 progress_fields.insert(
15567 "steps".to_owned(),
15568 TypeSyntax::Ref {
15569 name: Ident {
15570 name: steps.clone(),
15571 span: zero_span(),
15572 },
15573 },
15574 );
15575
15576 schemas.classes.insert(steps, steps_fields);
15577 schemas.classes.insert(progress.clone(), progress_fields);
15578 arm_bindings.insert(view.clone(), progress);
15579 }
15580
15581 for line in region.arm_content.lines() {
15582 let line = line.trim();
15583 if line.is_empty() {
15584 continue;
15585 }
15586 validate_known_field_paths_in_index(
15587 rule,
15588 line,
15589 rule.body.span,
15590 SchemaScopes {
15594 local: &schemas,
15595 foreign: foreign_schemas,
15596 workflows: &semantic.workflow_inputs,
15597 },
15598 &arm_bindings,
15599 diagnostics,
15600 );
15601 }
15602
15603 let (arm_ast, _) = body::parse_rule_body(®ion.arm_content, 0);
15609 let mut allowed = BTreeSet::new();
15610 for (scrutinee, pattern) in ®ion.arm_case_arms {
15611 allowed.extend(family_b_arm_allowed(
15612 scrutinee,
15613 pattern,
15614 &arm_bindings,
15615 semantic,
15616 ));
15617 }
15618 let mut arm_diagnostics = Vec::new();
15619 validate_conditioned_field_reads(
15620 rule,
15621 &arm_ast.statements,
15622 semantic,
15623 &arm_bindings,
15624 &allowed,
15625 &mut arm_diagnostics,
15626 );
15627 for mut diagnostic in arm_diagnostics {
15631 diagnostic.span = rule.body.span;
15632 diagnostics.push(diagnostic);
15633 }
15634}
15635
15636static NO_FOREIGN_SCHEMAS: BTreeMap<String, String> = BTreeMap::new();
15638static NO_WORKFLOW_SURFACES: BTreeMap<String, WorkflowInputSurface> = BTreeMap::new();
15639
15640#[derive(Clone, Copy)]
15653struct SchemaScopes<'a> {
15654 local: &'a SchemaIndex,
15655 foreign: &'a BTreeMap<String, String>,
15657 workflows: &'a BTreeMap<String, WorkflowInputSurface>,
15658}
15659
15660impl<'a> SchemaScopes<'a> {
15661 fn local(local: &'a SchemaIndex) -> Self {
15662 Self {
15663 local,
15664 foreign: &NO_FOREIGN_SCHEMAS,
15665 workflows: &NO_WORKFLOW_SURFACES,
15666 }
15667 }
15668
15669 fn index_for(&self, binding: &str) -> &'a SchemaIndex {
15670 self.foreign
15671 .get(binding)
15672 .and_then(|workflow| self.workflows.get(workflow))
15673 .map_or(self.local, |surface| &surface.schemas)
15674 }
15675}
15676
15677fn validate_known_field_paths(
15678 rule: &RuleDecl,
15679 line: &str,
15680 semantic: &SemanticContext,
15681 binding_types: &BTreeMap<String, String>,
15682 diagnostics: &mut Vec<Diagnostic>,
15683) {
15684 validate_known_field_paths_at_span(
15685 rule,
15686 line,
15687 rule.body.span,
15688 semantic,
15689 binding_types,
15690 diagnostics,
15691 );
15692}
15693
15694fn validate_known_field_paths_scoped(
15697 rule: &RuleDecl,
15698 line: &str,
15699 semantic: &SemanticContext,
15700 binding_types: &BTreeMap<String, String>,
15701 foreign: &BTreeMap<String, String>,
15702 diagnostics: &mut Vec<Diagnostic>,
15703) {
15704 validate_known_field_paths_in_index(
15705 rule,
15706 line,
15707 rule.body.span,
15708 SchemaScopes {
15709 local: &semantic.schemas,
15710 foreign,
15711 workflows: &semantic.workflow_inputs,
15712 },
15713 binding_types,
15714 diagnostics,
15715 );
15716}
15717
15718fn validate_known_field_paths_at_span(
15719 rule: &RuleDecl,
15720 line: &str,
15721 span: SourceSpan,
15722 semantic: &SemanticContext,
15723 binding_types: &BTreeMap<String, String>,
15724 diagnostics: &mut Vec<Diagnostic>,
15725) {
15726 validate_known_field_paths_in_index(
15727 rule,
15728 line,
15729 span,
15730 SchemaScopes::local(&semantic.schemas),
15731 binding_types,
15732 diagnostics,
15733 );
15734}
15735
15736#[derive(Clone, Copy, Eq, PartialEq)]
15746enum FieldPathCheck {
15747 Unbound,
15750 SchemaNotIndexed,
15762 Resolved,
15764}
15765
15766fn check_field_path(
15774 rule: &RuleDecl,
15775 root: &str,
15776 path: &[String],
15777 span: SourceSpan,
15778 scopes: SchemaScopes,
15779 binding_types: &BTreeMap<String, String>,
15780 diagnostics: &mut Vec<Diagnostic>,
15781) -> FieldPathCheck {
15782 let Some(schema) = binding_types.get(root) else {
15783 return FieldPathCheck::Unbound;
15784 };
15785 let schemas = scopes.index_for(root);
15786 if !schemas.class_exists(schema) {
15787 return FieldPathCheck::SchemaNotIndexed;
15788 }
15789 if let Err(message) = schemas.resolve_field_path(schema, path) {
15790 diagnostics.push(Diagnostic {
15791 related: Vec::new(),
15792 span,
15793 message: format!(
15794 "rule `{}` has invalid field path `{root}.{}`: {message}",
15795 rule.name.name,
15796 path.join(".")
15797 ),
15798 suggestion: Some(
15799 "use a field declared on the bound schema or add it to the class declaration"
15800 .to_owned(),
15801 ),
15802 });
15803 }
15804 FieldPathCheck::Resolved
15805}
15806
15807fn validate_known_field_paths_in_index(
15808 rule: &RuleDecl,
15809 line: &str,
15810 span: SourceSpan,
15811 scopes: SchemaScopes,
15812 binding_types: &BTreeMap<String, String>,
15813 diagnostics: &mut Vec<Diagnostic>,
15814) {
15815 for (root, path) in dotted_paths(line) {
15816 check_field_path(rule, &root, &path, span, scopes, binding_types, diagnostics);
15817 }
15818}
15819
15820fn dotted_paths(line: &str) -> Vec<(String, Vec<String>)> {
15821 let bytes = line.as_bytes();
15822 let mut paths = Vec::new();
15823 let mut index = 0;
15824
15825 while index < bytes.len() {
15826 if !is_ident_start(bytes[index]) {
15827 index += 1;
15828 continue;
15829 }
15830
15831 let root_start = index;
15832 index += 1;
15833 while index < bytes.len() && is_ident_continue(bytes[index]) {
15834 index += 1;
15835 }
15836 let root = &line[root_start..index];
15837 let mut fields = Vec::new();
15838
15839 while bytes.get(index) == Some(&b'.')
15840 && bytes
15841 .get(index + 1)
15842 .is_some_and(|byte| is_ident_start(*byte))
15843 {
15844 index += 1;
15845 let field_start = index;
15846 index += 1;
15847 while index < bytes.len() && is_ident_continue(bytes[index]) {
15848 index += 1;
15849 }
15850 fields.push(line[field_start..index].to_owned());
15851 }
15852
15853 if !fields.is_empty() {
15854 paths.push((root.to_owned(), fields));
15855 }
15856 }
15857
15858 paths
15859}
15860
15861fn interpolation_roots(line: &str) -> Vec<String> {
15862 let mut roots = Vec::new();
15863 let mut rest = line;
15864
15865 while let Some(open) = rest.find("{{") {
15866 let after_open = &rest[open + 2..];
15867 let Some(close) = after_open.find("}}") else {
15868 break;
15869 };
15870 let expr = after_open[..close].trim();
15871 if let Some(root) = expr
15872 .split(|ch: char| !ch.is_alphanumeric() && ch != '_')
15873 .find(|part| !part.is_empty())
15874 {
15875 roots.push(root.to_owned());
15876 }
15877 rest = &after_open[close + 2..];
15878 }
15879
15880 roots
15881}
15882
15883const RESERVED_BINDING_KEYWORDS: &[&str] = &[
15886 "after", "call", "case", "coerce", "complete", "consume", "done", "emit", "fail", "invoke",
15887 "record", "tell", "when", "where",
15888];
15889
15890fn validate_binding_name(
15891 rule: &RuleDecl,
15892 binding: &str,
15893 span: SourceSpan,
15894 diagnostics: &mut Vec<Diagnostic>,
15895) {
15896 if RESERVED_BINDING_KEYWORDS.contains(&binding) {
15897 diagnostics.push(Diagnostic {
15898 related: Vec::new(),
15899 span,
15900 message: format!(
15901 "rule `{}` binds reserved keyword `{binding}`",
15902 rule.name.name
15903 ),
15904 suggestion: Some(format!(
15905 "`{binding}` is a rule body keyword; choose another binding name"
15906 )),
15907 });
15908 }
15909}
15910
15911fn closest_name<'a>(target: &str, candidates: impl Iterator<Item = &'a String>) -> Option<String> {
15912 let target_lower = target.to_lowercase();
15913 candidates
15914 .map(|candidate| {
15915 let distance = edit_distance(&target_lower, &candidate.to_lowercase());
15916 (distance, candidate)
15917 })
15918 .filter(|(distance, candidate)| {
15919 *distance <= 2 && *distance < target.len().min(candidate.len())
15920 })
15921 .min_by_key(|(distance, candidate)| (*distance, candidate.as_str().to_owned()))
15922 .map(|(_, candidate)| candidate.clone())
15923}
15924
15925fn edit_distance(a: &str, b: &str) -> usize {
15926 let a: Vec<char> = a.chars().collect();
15927 let b: Vec<char> = b.chars().collect();
15928 let mut previous: Vec<usize> = (0..=b.len()).collect();
15929 let mut current = vec![0usize; b.len() + 1];
15930 for (i, a_char) in a.iter().enumerate() {
15931 current[0] = i + 1;
15932 for (j, b_char) in b.iter().enumerate() {
15933 let substitution = previous[j] + usize::from(a_char != b_char);
15934 current[j + 1] = substitution.min(previous[j + 1] + 1).min(current[j] + 1);
15935 }
15936 std::mem::swap(&mut previous, &mut current);
15937 }
15938 previous[b.len()]
15939}
15940
15941fn fact_read_from_when(when: &str) -> String {
15942 let (pattern, _) = split_when_guard(when);
15943 let first = pattern.split_whitespace().next().unwrap_or("<empty>");
15944 if first.chars().next().is_some_and(char::is_uppercase) {
15945 format!("schema:{first}")
15946 } else {
15947 format!("pattern:{pattern}")
15948 }
15949}
15950
15951fn parse_record_start(line: &str) -> Option<(String, Option<String>)> {
15952 let rest = line.strip_prefix("record ").or_else(|| {
15953 line.strip_prefix("done ")
15954 .and_then(|rest| rest.split_once("->"))
15955 .map(|(_, record)| record.trim())
15956 .and_then(|record| record.strip_prefix("record "))
15957 })?;
15958 let before_brace = rest.split('{').next().unwrap_or(rest).trim();
15959 let mut parts = before_brace.split_whitespace();
15960 let schema = parts.next()?.to_owned();
15961 let from_binding = match (parts.next(), parts.next(), parts.next()) {
15962 (None, None, None) => None,
15963 (Some("from"), Some(binding), None) => Some(binding.to_owned()),
15964 _ => return None,
15965 };
15966 Some((schema, from_binding))
15967}
15968
15969fn validate_record_field(
15970 rule: &RuleDecl,
15971 line: &str,
15972 record_schema: &str,
15973 semantic: &SemanticContext,
15974 binding_types: &BTreeMap<String, String>,
15975 known_roots: &BTreeSet<String>,
15976 diagnostics: &mut Vec<Diagnostic>,
15977) {
15978 let Some((field, expr)) = record_field_assignment(line) else {
15979 diagnostics.push(Diagnostic {
15980 related: Vec::new(),
15981 span: rule.body.span,
15982 message: format!(
15983 "rule `{}` has malformed field assignment in `record {record_schema}`",
15984 rule.name.name
15985 ),
15986 suggestion: Some("write record fields as `field value`".to_owned()),
15987 });
15988 return;
15989 };
15990
15991 let Some(fields) = semantic.schemas.classes.get(record_schema) else {
15992 return;
15993 };
15994 let Some(field_ty) = fields.get(field) else {
15995 diagnostics.push(Diagnostic {
15996 related: Vec::new(),
15997 span: rule.body.span,
15998 message: format!("class `{record_schema}` has no field `{field}`"),
15999 suggestion: Some(format!(
16000 "add `{field}` to `class {record_schema}` or record an existing field"
16001 )),
16002 });
16003 return;
16004 };
16005
16006 if let Some((root, path)) = expression_path(expr) {
16007 match check_field_path(
16009 rule,
16010 &root,
16011 &path,
16012 rule.body.span,
16013 SchemaScopes::local(&semantic.schemas),
16014 binding_types,
16015 diagnostics,
16016 ) {
16017 FieldPathCheck::SchemaNotIndexed => return,
16021 FieldPathCheck::Resolved => {}
16022 FieldPathCheck::Unbound => {
16025 if let Some(root) = dangling_value_root(expr, known_roots) {
16026 diagnostics.push(Diagnostic {
16027 related: Vec::new(),
16028 span: rule.body.span,
16029 message: format!(
16030 "rule `{}` has unknown binding `{root}` in `record {record_schema}` field `{field}`",
16031 rule.name.name
16032 ),
16033 suggestion: Some(
16034 "reference a binding from a `when ... as name` clause, an effect `as` binding, or a `case` pattern"
16035 .to_owned(),
16036 ),
16037 });
16038 }
16039 }
16040 }
16041 }
16042
16043 validate_literal_assignment(
16044 rule,
16045 record_schema,
16046 field,
16047 field_ty,
16048 expr,
16049 semantic,
16050 diagnostics,
16051 );
16052 validate_expected_assignment(
16053 rule,
16054 record_schema,
16055 field,
16056 field_ty,
16057 expr,
16058 semantic,
16059 binding_types,
16060 diagnostics,
16061 );
16062}
16063
16064fn record_field_assignment(line: &str) -> Option<(&str, &str)> {
16065 let field_end = line.find(char::is_whitespace)?;
16066 let field = &line[..field_end];
16067 let expr = line[field_end..].trim();
16068 (!field.is_empty() && !expr.is_empty()).then_some((field, expr))
16069}
16070
16071const SPECIAL_VALUE_ROOTS: &[&str] = &["external", "ctx"];
16075
16076fn collect_all_binding_names(statements: &[body::BodyStmt], out: &mut BTreeSet<String>) {
16082 for statement in statements {
16083 match statement {
16084 body::BodyStmt::Effect(effect) => {
16085 if let Some(binding) = &effect.binding {
16086 out.insert(binding.clone());
16087 }
16088 }
16089 body::BodyStmt::Region(region) => {
16090 if let Some(view) = ®ion.lapse_binding {
16091 out.insert(view.clone());
16092 }
16093 collect_all_binding_names(®ion.body, out);
16094 collect_all_binding_names(®ion.lapse_body, out);
16095 }
16096 body::BodyStmt::After(after) => {
16097 if let Some(alias) = &after.alias {
16098 out.insert(alias.clone());
16099 }
16100 collect_all_binding_names(&after.body, out);
16101 }
16102 body::BodyStmt::Case(case) => {
16103 for branch in &case.branches {
16104 if let Some(binding) = &branch.binding {
16105 out.insert(binding.clone());
16106 }
16107 collect_all_binding_names(&branch.body, out);
16108 }
16109 }
16110 body::BodyStmt::Redact { binding, .. } => {
16112 out.insert(binding.clone());
16113 }
16114 body::BodyStmt::Record(_)
16115 | body::BodyStmt::Done { .. }
16116 | body::BodyStmt::Terminal(_)
16117 | body::BodyStmt::Milestone { .. }
16118 | body::BodyStmt::Cancel { .. } => {}
16119 }
16120 }
16121}
16122
16123fn validate_source_emit_signal_declared(
16132 source: &SourceDecl,
16133 declared_signals: &BTreeSet<String>,
16134 diagnostics: &mut Vec<Diagnostic>,
16135) {
16136 let signal = &source.emit.signal;
16137 if signal.contains('.') && !declared_signals.contains(signal) {
16138 let suggestion = match closest_name(signal, declared_signals.iter()) {
16139 Some(candidate) => {
16140 format!("did you mean `{candidate}`? otherwise declare `signal {signal} {{ ... }}`")
16141 }
16142 None => format!("declare `signal {signal} {{ ... }}` so rules can react to it"),
16143 };
16144 diagnostics.push(Diagnostic {
16145 related: Vec::new(),
16146 span: source.emit.signal_span,
16147 message: format!(
16148 "source `{}` emits undeclared signal `{}`",
16149 source.name.name, signal
16150 ),
16151 suggestion: Some(suggestion),
16152 });
16153 }
16154
16155 let observation_fields: Option<&[&str]> = match source.provider.name.as_str() {
16163 "clock" => Some(&[
16164 "scheduled_at",
16165 "observed_at",
16166 "occurrence_id",
16167 "missed_count",
16168 "schedule_name",
16169 ]),
16170 "file" if source.watch.is_some() => Some(&["path", "content_hash", "watch"]),
16174 "file" => Some(&["line", "line_index", "path"]),
16175 "http" => Some(&["item", "item_index", "url"]),
16176 _ => None,
16177 };
16178 if let (Some(fields), Some(SourceValue::Path { segments, .. })) =
16181 (observation_fields, &source.dedup)
16182 {
16183 if let [field] = segments.as_slice() {
16184 if !fields.contains(&field.name.as_str()) {
16185 diagnostics.push(Diagnostic {
16186 related: Vec::new(),
16187 span: field.span,
16188 message: format!(
16189 "source `{}` `dedup` reads `{}.{}`, but a `{}` source's observation has no field `{}`",
16190 source.name.name,
16191 source.observe_binding.name,
16192 field.name,
16193 source.provider.name,
16194 field.name
16195 ),
16196 suggestion: Some(format!(
16197 "available observation fields: {}",
16198 fields.join(", ")
16199 )),
16200 });
16201 }
16202 }
16203 }
16204 if let Some(fields) = observation_fields {
16205 let observe = &source.observe_binding.name;
16206 for emit_field in &source.emit.fields {
16207 let SourceValue::Path {
16208 binding,
16209 segments,
16210 span,
16211 } = &emit_field.value
16212 else {
16213 continue;
16214 };
16215 if &binding.name != observe {
16216 diagnostics.push(Diagnostic {
16217 related: Vec::new(),
16218 span: *span,
16219 message: format!(
16220 "source `{}` emit reads unknown binding `{}`",
16221 source.name.name, binding.name
16222 ),
16223 suggestion: Some(format!(
16224 "the source's observation binding is `{observe}` (declared by `observe as {observe}`)"
16225 )),
16226 });
16227 continue;
16228 }
16229 if let Some(obs_field) = segments.first() {
16230 if !fields.contains(&obs_field.name.as_str()) {
16231 diagnostics.push(Diagnostic {
16232 related: Vec::new(),
16233 span: obs_field.span,
16234 message: format!(
16235 "source `{}` emit reads `{}.{}`, but a `{}` source's observation has no field `{}`",
16236 source.name.name, observe, obs_field.name, source.provider.name, obs_field.name
16237 ),
16238 suggestion: Some(format!(
16239 "available observation fields: {}",
16240 fields.join(", ")
16241 )),
16242 });
16243 }
16244 }
16245 }
16246 }
16247}
16248
16249fn validate_emit_signal_declarations(
16258 rule: &RuleDecl,
16259 statements: &[body::BodyStmt],
16260 declared_signals: &BTreeSet<String>,
16261 diagnostics: &mut Vec<Diagnostic>,
16262) {
16263 for statement in statements {
16264 match statement {
16265 body::BodyStmt::Effect(effect) => {
16266 if let body::BodyEffectKind::Notify { event, .. } = &effect.kind {
16267 if !declared_signals.contains(event) {
16268 diagnostics.push(Diagnostic {
16269 related: Vec::new(),
16270 span: effect.span,
16271 message: format!(
16272 "rule `{}` emits undeclared signal `{event}`",
16273 rule.name.name
16274 ),
16275 suggestion: Some(format!(
16276 "declare `signal {event} {{ ... }}` so the emitted payload is typed and admissible, \
16277 or check the signal name"
16278 )),
16279 });
16280 }
16281 }
16282 }
16283 body::BodyStmt::After(after) => {
16284 validate_emit_signal_declarations(rule, &after.body, declared_signals, diagnostics)
16285 }
16286 body::BodyStmt::Case(case) => {
16287 for branch in &case.branches {
16288 validate_emit_signal_declarations(
16289 rule,
16290 &branch.body,
16291 declared_signals,
16292 diagnostics,
16293 );
16294 }
16295 }
16296 _ => {}
16297 }
16298 }
16299}
16300
16301fn validate_effect_field_roots(
16306 rule: &RuleDecl,
16307 statements: &[body::BodyStmt],
16308 known_roots: &BTreeSet<String>,
16309 diagnostics: &mut Vec<Diagnostic>,
16310) {
16311 for statement in statements {
16312 match statement {
16313 body::BodyStmt::Effect(effect) => match &effect.kind {
16314 body::BodyEffectKind::Notify {
16315 target_expr,
16316 event,
16317 from,
16318 fields,
16319 } => {
16320 if let Some(from) = from {
16321 check_operand_root(
16322 rule,
16323 &format!("emit `{event}` from"),
16324 from,
16325 known_roots,
16326 diagnostics,
16327 );
16328 }
16329 check_operand_root(
16330 rule,
16331 &format!("emit `{event}` target"),
16332 target_expr,
16333 known_roots,
16334 diagnostics,
16335 );
16336 check_field_value_roots(
16337 rule,
16338 &format!("emit `{event}`"),
16339 fields,
16340 known_roots,
16341 diagnostics,
16342 );
16343 }
16344 body::BodyEffectKind::TrackerFile { queue, fields } => {
16345 check_field_value_roots(
16346 rule,
16347 &format!("file into `{queue}`"),
16348 fields,
16349 known_roots,
16350 diagnostics,
16351 );
16352 }
16353 body::BodyEffectKind::TrackerFinish { item, fields } => {
16354 check_operand_root(rule, "finish item", item, known_roots, diagnostics);
16355 check_field_value_roots(rule, "finish", fields, known_roots, diagnostics);
16356 }
16357 body::BodyEffectKind::LedgerAppend { ledger, fields, .. } => {
16358 check_field_value_roots(
16359 rule,
16360 &format!("append to `{ledger}`"),
16361 fields,
16362 known_roots,
16363 diagnostics,
16364 );
16365 }
16366 body::BodyEffectKind::LeaseAcquire {
16367 resource, key_expr, ..
16368 } => {
16369 check_operand_root(
16370 rule,
16371 &format!("acquire `{resource}` key"),
16372 key_expr,
16373 known_roots,
16374 diagnostics,
16375 );
16376 }
16377 body::BodyEffectKind::CounterConsume {
16378 counter,
16379 key_expr,
16380 amount_expr,
16381 } => {
16382 check_operand_root(
16383 rule,
16384 &format!("consume `{counter}` key"),
16385 key_expr,
16386 known_roots,
16387 diagnostics,
16388 );
16389 check_operand_root(
16390 rule,
16391 &format!("consume `{counter}` amount"),
16392 amount_expr,
16393 known_roots,
16394 diagnostics,
16395 );
16396 }
16397 _ => {}
16398 },
16399 body::BodyStmt::After(after) => {
16400 validate_effect_field_roots(rule, &after.body, known_roots, diagnostics)
16401 }
16402 body::BodyStmt::Case(case) => {
16403 for branch in &case.branches {
16404 validate_effect_field_roots(rule, &branch.body, known_roots, diagnostics);
16405 }
16406 }
16407 _ => {}
16408 }
16409 }
16410}
16411
16412fn dangling_value_root(value: &str, known_roots: &BTreeSet<String>) -> Option<String> {
16420 let (root, path) = expression_path(value)?;
16421 if !path.is_empty()
16422 && !value.contains('"')
16423 && !known_roots.contains(&root)
16424 && !SPECIAL_VALUE_ROOTS.contains(&root.as_str())
16425 {
16426 Some(root)
16427 } else {
16428 None
16429 }
16430}
16431
16432fn check_operand_root(
16436 rule: &RuleDecl,
16437 context: &str,
16438 operand: &str,
16439 known_roots: &BTreeSet<String>,
16440 diagnostics: &mut Vec<Diagnostic>,
16441) {
16442 if let Some(root) = dangling_value_root(operand, known_roots) {
16443 diagnostics.push(Diagnostic { related: Vec::new(),
16444 span: rule.body.span,
16445 message: format!(
16446 "rule `{}` has unknown binding `{root}` in {context} `{operand}`",
16447 rule.name.name
16448 ),
16449 suggestion: Some(
16450 "reference a binding from a `when ... as name` clause, an effect `as` binding, or a `case` pattern"
16451 .to_owned(),
16452 ),
16453 });
16454 }
16455}
16456
16457fn check_field_value_roots(
16458 rule: &RuleDecl,
16459 context: &str,
16460 fields: &[body::FieldAssign],
16461 known_roots: &BTreeSet<String>,
16462 diagnostics: &mut Vec<Diagnostic>,
16463) {
16464 for field in fields {
16465 match &field.value {
16466 body::FieldValue::Expr { source, .. } => {
16467 if let Some(root) = dangling_value_root(source, known_roots) {
16468 diagnostics.push(Diagnostic { related: Vec::new(),
16469 span: rule.body.span,
16470 message: format!(
16471 "rule `{}` has unknown binding `{root}` in {context} field `{}`",
16472 rule.name.name, field.name
16473 ),
16474 suggestion: Some(
16475 "reference a binding from a `when ... as name` clause, an effect `as` binding, or a `case` pattern"
16476 .to_owned(),
16477 ),
16478 });
16479 }
16480 }
16481 body::FieldValue::Nested { fields, .. } => {
16482 check_field_value_roots(rule, context, fields, known_roots, diagnostics)
16483 }
16484 body::FieldValue::Shorthand => {}
16485 }
16486 }
16487}
16488
16489fn known_roots_for_rule(rule: &RuleDecl) -> BTreeSet<String> {
16492 let mut roots: BTreeSet<String> = binding_types_for_rule(rule).into_keys().collect();
16493 let (body_ast, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
16494 collect_all_binding_names(&body_ast.statements, &mut roots);
16495 roots
16496}
16497
16498fn validate_record_blocks(
16499 rule: &RuleDecl,
16500 semantic: &SemanticContext,
16501 binding_types: &BTreeMap<String, String>,
16502 known_roots: &BTreeSet<String>,
16503 diagnostics: &mut Vec<Diagnostic>,
16504) {
16505 for (schema, from_binding, body) in record_blocks(&rule.body.text) {
16506 for stray in body::stray_value_tokens(&body) {
16512 diagnostics.push(Diagnostic {
16513 related: Vec::new(),
16514 span: rule.body.span,
16515 message: format!(
16516 "rule `{}` has a value with no field name in `record {schema}`: `{stray}`",
16517 rule.name.name
16518 ),
16519 suggestion: Some(format!(
16520 "give it a field name (`<field> {stray}`), or remove it"
16521 )),
16522 });
16523 }
16524 for assignment in collect_field_assignments(&body) {
16525 let (field, value) = match assignment {
16526 RecordFieldAssignment::Value { field, value } => (field, value),
16527 RecordFieldAssignment::Shorthand { field } => {
16528 let value = from_binding
16529 .as_ref()
16530 .map(|binding| format!("{binding}.{field}"))
16531 .unwrap_or_else(|| field.clone());
16532 (field, value)
16533 }
16534 };
16535 let line = format!("{field} {value}");
16536 validate_record_field(
16537 rule,
16538 &line,
16539 &schema,
16540 semantic,
16541 binding_types,
16542 known_roots,
16543 diagnostics,
16544 );
16545 }
16546 }
16547}
16548
16549fn record_blocks(body: &str) -> Vec<(String, Option<String>, String)> {
16550 let mut blocks = Vec::new();
16551 let lines = body.lines().collect::<Vec<_>>();
16552 let mut index = 0usize;
16553 while index < lines.len() {
16554 let trimmed = lines[index].trim();
16555 let Some((schema, from_binding)) = parse_record_start(trimmed) else {
16556 index += 1;
16557 continue;
16558 };
16559 if brace_delta(trimmed) == 0 && trimmed.contains('{') {
16563 if let (Some(open), Some(close)) = (trimmed.find('{'), trimmed.rfind('}')) {
16564 if close > open {
16565 blocks.push((
16566 schema,
16567 from_binding,
16568 trimmed[open + 1..close].trim().to_owned(),
16569 ));
16570 }
16571 }
16572 index += 1;
16573 continue;
16574 }
16575 let mut depth = brace_delta(trimmed);
16576 let mut record_lines = Vec::new();
16577 index += 1;
16578 while index < lines.len() && depth > 0 {
16579 let line = lines[index];
16580 let before = depth;
16581 depth += brace_delta(line);
16582 if !(before == 1 && depth == 0 && line.trim() == "}") {
16583 record_lines.push(line.to_owned());
16584 }
16585 index += 1;
16586 }
16587 blocks.push((schema, from_binding, record_lines.join("\n")));
16588 }
16589 blocks
16590}
16591
16592fn workflow_terminal_blocks(body: &str) -> Vec<(String, String, String)> {
16593 let mut blocks = Vec::new();
16594 let lines = body.lines().collect::<Vec<_>>();
16595 let mut index = 0usize;
16596 while index < lines.len() {
16597 let trimmed = lines[index].trim();
16598 let terminal = trimmed
16599 .strip_prefix("complete ")
16600 .map(|rest| ("complete", rest))
16601 .or_else(|| trimmed.strip_prefix("fail ").map(|rest| ("fail", rest)));
16602 let Some((action, rest)) = terminal else {
16603 index += 1;
16604 continue;
16605 };
16606 let Some(name) = rest.split('{').next().and_then(|header| {
16607 let mut parts = header.split_whitespace();
16608 match (parts.next(), parts.next()) {
16609 (Some(name), None) => Some(name.to_owned()),
16610 _ => None,
16611 }
16612 }) else {
16613 index += 1;
16614 continue;
16615 };
16616 let mut depth = brace_delta(trimmed);
16617 let mut terminal_lines = Vec::new();
16618 if depth == 0 && trimmed.contains('{') {
16619 if let (Some(open), Some(close)) = (trimmed.find('{'), trimmed.rfind('}')) {
16623 if close > open {
16624 let inner = trimmed[open + 1..close].trim();
16625 if !inner.is_empty() {
16626 terminal_lines.push(inner.to_owned());
16627 }
16628 }
16629 }
16630 index += 1;
16631 } else {
16632 index += 1;
16633 while index < lines.len() && depth > 0 {
16634 let line = lines[index];
16635 let before = depth;
16636 depth += brace_delta(line);
16637 if !(before == 1 && depth == 0 && line.trim() == "}") {
16638 terminal_lines.push(line.to_owned());
16639 }
16640 index += 1;
16641 }
16642 }
16643 blocks.push((action.to_owned(), name, terminal_lines.join("\n")));
16644 }
16645 blocks
16646}
16647
16648#[derive(Clone, Debug, Eq, PartialEq)]
16649enum RecordFieldAssignment {
16650 Value { field: String, value: String },
16651 Shorthand { field: String },
16652}
16653
16654fn collect_field_assignments(body: &str) -> Vec<RecordFieldAssignment> {
16655 body::split_field_assignments(body)
16660 .into_iter()
16661 .map(|assignment| match assignment.value {
16662 Some(value) => RecordFieldAssignment::Value {
16663 field: assignment.name,
16664 value,
16665 },
16666 None => RecordFieldAssignment::Shorthand {
16667 field: assignment.name,
16668 },
16669 })
16670 .collect()
16671}
16672
16673fn expression_path(expr: &str) -> Option<(String, Vec<String>)> {
16674 let mut paths = dotted_paths(expr);
16675 if paths.len() != 1 {
16676 return None;
16677 }
16678 Some(paths.remove(0))
16679}
16680
16681fn validate_literal_assignment(
16682 rule: &RuleDecl,
16683 record_schema: &str,
16684 field: &str,
16685 field_ty: &TypeSyntax,
16686 expr: &str,
16687 semantic: &SemanticContext,
16688 diagnostics: &mut Vec<Diagnostic>,
16689) {
16690 let Some(literal) = parse_literal_expr(expr) else {
16691 return;
16692 };
16693
16694 match field_ty {
16695 TypeSyntax::Primitive { name, .. } => {
16696 validate_primitive_literal(rule, record_schema, field, name, &literal, diagnostics)
16697 }
16698 TypeSyntax::LiteralString { value, .. } => {
16699 if literal != LiteralExpr::String(value.as_str()) {
16700 diagnostics.push(Diagnostic {
16701 related: Vec::new(),
16702 span: rule.body.span,
16703 message: format!(
16704 "field `{record_schema}.{field}` expects literal string `{value}`"
16705 ),
16706 suggestion: Some(format!("record `{field} {value:?}`")),
16707 });
16708 }
16709 }
16710 TypeSyntax::Ref { name } => {
16711 validate_enum_literal(
16712 rule,
16713 record_schema,
16714 field,
16715 &name.name,
16716 &literal,
16717 semantic,
16718 diagnostics,
16719 );
16720 }
16721 TypeSyntax::Union { variants, .. } => {
16722 validate_union_literal(rule, record_schema, field, variants, &literal, diagnostics);
16723 }
16724 TypeSyntax::AgentRef { agents, .. } => {
16725 validate_agent_ref_literal(rule, record_schema, field, agents, &literal, diagnostics);
16726 }
16727 TypeSyntax::Optional { inner, .. } => {
16728 if literal != LiteralExpr::Null {
16729 validate_literal_assignment(
16730 rule,
16731 record_schema,
16732 field,
16733 inner,
16734 expr,
16735 semantic,
16736 diagnostics,
16737 );
16738 }
16739 }
16740 TypeSyntax::Array { .. } | TypeSyntax::Map { .. } => {}
16741 }
16742}
16743
16744#[allow(clippy::too_many_arguments)]
16745fn validate_expected_assignment(
16746 rule: &RuleDecl,
16747 record_schema: &str,
16748 field: &str,
16749 field_ty: &TypeSyntax,
16750 expr: &str,
16751 semantic: &SemanticContext,
16752 binding_types: &BTreeMap<String, String>,
16753 diagnostics: &mut Vec<Diagnostic>,
16754) {
16755 if !(expr.trim_start().starts_with('{') || expr.trim_start().starts_with('[')) {
16756 return;
16757 }
16758 validate_expr_source_against_type(
16759 rule,
16760 record_schema,
16761 field,
16762 field_ty,
16763 expr,
16764 semantic,
16765 &ExprScope::from_bindings(binding_types),
16766 diagnostics,
16767 );
16768}
16769
16770#[allow(clippy::too_many_arguments)]
16771fn validate_expr_source_against_type(
16772 rule: &RuleDecl,
16773 record_schema: &str,
16774 field: &str,
16775 expected_ty: &TypeSyntax,
16776 expr: &str,
16777 semantic: &SemanticContext,
16778 scope: &ExprScope,
16779 diagnostics: &mut Vec<Diagnostic>,
16780) {
16781 match expected_ty {
16782 TypeSyntax::Map { inner, .. } => {
16783 let parsed = match parse_expression(expr) {
16784 Ok(Expr::Object(fields)) => fields,
16785 Ok(_) => {
16786 diagnostics.push(Diagnostic {
16787 related: Vec::new(),
16788 span: rule.body.span,
16789 message: format!("field `{record_schema}.{field}` expects a map literal"),
16790 suggestion: Some(format!("record `{field} {{ key value }}`")),
16791 });
16792 return;
16793 }
16794 Err(message) => {
16795 diagnostics.push(Diagnostic {
16796 related: Vec::new(),
16797 span: rule.body.span,
16798 message: format!(
16799 "field `{record_schema}.{field}` expects a map literal: {message}"
16800 ),
16801 suggestion: Some(format!("record `{field} {{ key value }}`")),
16802 });
16803 return;
16804 }
16805 };
16806 for map_field in &parsed {
16807 validate_expr_against_type(
16808 rule,
16809 record_schema,
16810 field,
16811 inner,
16812 &map_field.value,
16813 semantic,
16814 scope,
16815 diagnostics,
16816 );
16817 }
16818 }
16819 TypeSyntax::Array { inner, .. } => match parse_expression(expr) {
16820 Ok(Expr::Array(items)) => {
16821 for item in items {
16822 validate_expr_against_type(
16823 rule,
16824 record_schema,
16825 field,
16826 inner,
16827 &item,
16828 semantic,
16829 scope,
16830 diagnostics,
16831 );
16832 }
16833 }
16834 Ok(expr) => validate_inferred_assignment_type(
16835 rule,
16836 record_schema,
16837 field,
16838 expected_ty,
16839 &expr,
16840 semantic,
16841 scope,
16842 diagnostics,
16843 ),
16844 Err(message) => {
16845 push_invalid_assignment_expr(rule, record_schema, field, message, diagnostics)
16846 }
16847 },
16848 TypeSyntax::Optional { inner, .. } => {
16849 if expr.trim() != "null" {
16850 validate_expr_source_against_type(
16851 rule,
16852 record_schema,
16853 field,
16854 inner,
16855 expr,
16856 semantic,
16857 scope,
16858 diagnostics,
16859 );
16860 }
16861 }
16862 TypeSyntax::Ref { name } if semantic.schemas.class_exists(&name.name) => {
16863 let parsed = match parse_expression(expr) {
16864 Ok(Expr::Object(fields)) => fields,
16865 Ok(expr) => {
16866 validate_inferred_assignment_type(
16867 rule,
16868 record_schema,
16869 field,
16870 expected_ty,
16871 &expr,
16872 semantic,
16873 scope,
16874 diagnostics,
16875 );
16876 return;
16877 }
16878 Err(message) => {
16879 push_invalid_assignment_expr(rule, record_schema, field, message, diagnostics);
16880 return;
16881 }
16882 };
16883 validate_object_literal_fields(
16884 rule,
16885 record_schema,
16886 field,
16887 &name.name,
16888 &parsed,
16889 semantic,
16890 scope,
16891 diagnostics,
16892 );
16893 }
16894 _ => match parse_expression(expr) {
16895 Ok(expr) => validate_inferred_assignment_type(
16896 rule,
16897 record_schema,
16898 field,
16899 expected_ty,
16900 &expr,
16901 semantic,
16902 scope,
16903 diagnostics,
16904 ),
16905 Err(message) => {
16906 push_invalid_assignment_expr(rule, record_schema, field, message, diagnostics)
16907 }
16908 },
16909 }
16910}
16911
16912#[allow(clippy::too_many_arguments)]
16913fn validate_expr_against_type(
16914 rule: &RuleDecl,
16915 record_schema: &str,
16916 field: &str,
16917 expected_ty: &TypeSyntax,
16918 expr: &Expr,
16919 semantic: &SemanticContext,
16920 scope: &ExprScope,
16921 diagnostics: &mut Vec<Diagnostic>,
16922) {
16923 match expr {
16924 Expr::Array(items) if matches!(expected_ty, TypeSyntax::Array { .. }) => {
16925 if let TypeSyntax::Array { inner, .. } = expected_ty {
16926 for item in items {
16927 validate_expr_against_type(
16928 rule,
16929 record_schema,
16930 field,
16931 inner,
16932 item,
16933 semantic,
16934 scope,
16935 diagnostics,
16936 );
16937 }
16938 }
16939 }
16940 Expr::Object(fields) => match expected_ty {
16941 TypeSyntax::Map { inner, .. } => {
16942 for field in fields {
16943 validate_expr_against_type(
16944 rule,
16945 record_schema,
16946 field.key.as_str(),
16947 inner,
16948 &field.value,
16949 semantic,
16950 scope,
16951 diagnostics,
16952 );
16953 }
16954 }
16955 TypeSyntax::Ref { name } if semantic.schemas.class_exists(&name.name) => {
16956 validate_object_literal_fields(
16957 rule,
16958 record_schema,
16959 field,
16960 &name.name,
16961 fields,
16962 semantic,
16963 scope,
16964 diagnostics,
16965 );
16966 }
16967 _ => validate_inferred_assignment_type(
16968 rule,
16969 record_schema,
16970 field,
16971 expected_ty,
16972 expr,
16973 semantic,
16974 scope,
16975 diagnostics,
16976 ),
16977 },
16978 _ => validate_inferred_assignment_type(
16979 rule,
16980 record_schema,
16981 field,
16982 expected_ty,
16983 expr,
16984 semantic,
16985 scope,
16986 diagnostics,
16987 ),
16988 }
16989}
16990
16991fn push_invalid_assignment_expr(
16992 rule: &RuleDecl,
16993 record_schema: &str,
16994 field: &str,
16995 message: String,
16996 diagnostics: &mut Vec<Diagnostic>,
16997) {
16998 diagnostics.push(Diagnostic {
16999 related: Vec::new(),
17000 span: rule.body.span,
17001 message: format!(
17002 "rule `{}` has invalid expression for field `{record_schema}.{field}`: {message}",
17003 rule.name.name
17004 ),
17005 suggestion: Some(
17006 "use array literals or expected-schema object literals for collection fields"
17007 .to_owned(),
17008 ),
17009 });
17010}
17011
17012#[allow(clippy::too_many_arguments)]
17013fn validate_object_literal_fields(
17014 rule: &RuleDecl,
17015 record_schema: &str,
17016 field: &str,
17017 object_schema: &str,
17018 object_fields: &[ExprObjectField],
17019 semantic: &SemanticContext,
17020 scope: &ExprScope,
17021 diagnostics: &mut Vec<Diagnostic>,
17022) {
17023 let Some(schema_fields) = semantic.schemas.classes.get(object_schema) else {
17024 return;
17025 };
17026 let mut seen = BTreeSet::new();
17027 for object_field in object_fields {
17028 if !seen.insert(object_field.key.clone()) {
17029 diagnostics.push(Diagnostic {
17030 related: Vec::new(),
17031 span: rule.body.span,
17032 message: format!(
17033 "field `{record_schema}.{field}` repeats object field `{}`",
17034 object_field.key
17035 ),
17036 suggestion: Some("remove the duplicate object field".to_owned()),
17037 });
17038 continue;
17039 }
17040 let Some(field_ty) = schema_fields.get(&object_field.key) else {
17041 diagnostics.push(Diagnostic {
17042 related: Vec::new(),
17043 span: rule.body.span,
17044 message: format!(
17045 "class `{object_schema}` has no field `{}`",
17046 object_field.key
17047 ),
17048 suggestion: Some(format!(
17049 "add `{}` to `class {object_schema}` or use an existing field",
17050 object_field.key
17051 )),
17052 });
17053 continue;
17054 };
17055 validate_expr_against_type(
17056 rule,
17057 object_schema,
17058 &object_field.key,
17059 field_ty,
17060 &object_field.value,
17061 semantic,
17062 scope,
17063 diagnostics,
17064 );
17065 }
17066 for (required, ty) in schema_fields {
17067 if seen.contains(required) || matches!(ty, TypeSyntax::Optional { .. }) {
17068 continue;
17069 }
17070 diagnostics.push(Diagnostic { related: Vec::new(),
17071 span: rule.body.span,
17072 message: format!(
17073 "field `{record_schema}.{field}` is missing required object field `{object_schema}.{required}`"
17074 ),
17075 suggestion: Some(format!("add `{required}` to the `{field}` object literal")),
17076 });
17077 }
17078}
17079
17080#[allow(clippy::too_many_arguments)]
17081fn validate_inferred_assignment_type(
17082 rule: &RuleDecl,
17083 record_schema: &str,
17084 field: &str,
17085 expected_ty: &TypeSyntax,
17086 expr: &Expr,
17087 semantic: &SemanticContext,
17088 scope: &ExprScope,
17089 diagnostics: &mut Vec<Diagnostic>,
17090) {
17091 let literal = expr_literal_as_literal_expr(expr);
17092 if let Some(literal) = literal {
17093 validate_literal_against_type(
17094 rule,
17095 record_schema,
17096 field,
17097 expected_ty,
17098 &literal,
17099 semantic,
17100 diagnostics,
17101 );
17102 return;
17103 }
17104
17105 let context = ExprValidationContext::rule(rule);
17106 let mut local_diagnostics = Vec::new();
17107 let actual_ty = infer_expr_type(expr, semantic, scope, &context, &mut local_diagnostics);
17108 diagnostics.extend(local_diagnostics);
17109 let expected_expr_ty = expr_type_from_type_syntax(expected_ty, semantic);
17110 if !types_comparable(&actual_ty, &expected_expr_ty) {
17111 diagnostics.push(Diagnostic {
17112 related: Vec::new(),
17113 span: rule.body.span,
17114 message: format!(
17115 "field `{record_schema}.{field}` receives incompatible expression type"
17116 ),
17117 suggestion: Some(format!(
17118 "record a value compatible with `{}`",
17119 expected_ty.to_source()
17120 )),
17121 });
17122 }
17123}
17124
17125fn validate_literal_against_type(
17126 rule: &RuleDecl,
17127 record_schema: &str,
17128 field: &str,
17129 field_ty: &TypeSyntax,
17130 literal: &LiteralExpr<'_>,
17131 semantic: &SemanticContext,
17132 diagnostics: &mut Vec<Diagnostic>,
17133) {
17134 match field_ty {
17135 TypeSyntax::Primitive { name, .. } => {
17136 validate_primitive_literal(rule, record_schema, field, name, literal, diagnostics)
17137 }
17138 TypeSyntax::LiteralString { value, .. } => {
17139 if literal != &LiteralExpr::String(value.as_str()) {
17140 diagnostics.push(Diagnostic {
17141 related: Vec::new(),
17142 span: rule.body.span,
17143 message: format!(
17144 "field `{record_schema}.{field}` expects literal string `{value}`"
17145 ),
17146 suggestion: Some(format!("record `{field} {value:?}`")),
17147 });
17148 }
17149 }
17150 TypeSyntax::Ref { name } => {
17151 validate_enum_literal(
17152 rule,
17153 record_schema,
17154 field,
17155 &name.name,
17156 literal,
17157 semantic,
17158 diagnostics,
17159 );
17160 }
17161 TypeSyntax::Union { variants, .. } => {
17162 validate_union_literal(rule, record_schema, field, variants, literal, diagnostics);
17163 }
17164 TypeSyntax::AgentRef { agents, .. } => {
17165 validate_agent_ref_literal(rule, record_schema, field, agents, literal, diagnostics);
17166 }
17167 TypeSyntax::Optional { inner, .. } => {
17168 if literal != &LiteralExpr::Null {
17169 validate_literal_against_type(
17170 rule,
17171 record_schema,
17172 field,
17173 inner,
17174 literal,
17175 semantic,
17176 diagnostics,
17177 );
17178 }
17179 }
17180 TypeSyntax::Array { .. } | TypeSyntax::Map { .. } => {}
17181 }
17182}
17183
17184fn expr_literal_as_literal_expr(expr: &Expr) -> Option<LiteralExpr<'_>> {
17185 match expr {
17186 Expr::Literal(ExprLiteral::String(value)) => Some(LiteralExpr::String(value)),
17187 Expr::Literal(ExprLiteral::Number(value)) => Some(LiteralExpr::Number(value)),
17188 Expr::Literal(ExprLiteral::Bool(_)) => Some(LiteralExpr::Bool),
17189 Expr::Literal(ExprLiteral::Null) => Some(LiteralExpr::Null),
17190 Expr::Literal(ExprLiteral::Ident(value)) => Some(LiteralExpr::Ident(value)),
17191 _ => None,
17192 }
17193}
17194
17195fn validate_agent_ref_literal(
17196 rule: &RuleDecl,
17197 record_schema: &str,
17198 field: &str,
17199 agents: &[Ident],
17200 literal: &LiteralExpr<'_>,
17201 diagnostics: &mut Vec<Diagnostic>,
17202) {
17203 let allowed = agents
17204 .iter()
17205 .map(|agent| agent.name.as_str())
17206 .collect::<Vec<_>>();
17207 if let LiteralExpr::String(value) = literal {
17208 diagnostics.push(Diagnostic {
17209 related: Vec::new(),
17210 span: rule.body.span,
17211 message: format!(
17212 "field `{record_schema}.{field}` expects an AgentRef value, not string `{value}`"
17213 ),
17214 suggestion: Some(format!(
17215 "use an unquoted declared agent name: {}",
17216 allowed.join(", ")
17217 )),
17218 });
17219 return;
17220 }
17221 let LiteralExpr::Ident(value) = literal else {
17222 diagnostics.push(Diagnostic {
17223 related: Vec::new(),
17224 span: rule.body.span,
17225 message: format!("field `{record_schema}.{field}` expects an AgentRef value"),
17226 suggestion: Some(format!("use one of: {}", allowed.join(", "))),
17227 });
17228 return;
17229 };
17230 if !allowed.contains(value) {
17231 diagnostics.push(Diagnostic {
17232 related: Vec::new(),
17233 span: rule.body.span,
17234 message: format!("field `{record_schema}.{field}` cannot reference agent `{value}`"),
17235 suggestion: Some(format!("use one of: {}", allowed.join(", "))),
17236 });
17237 }
17238}
17239
17240fn parse_literal_expr(expr: &str) -> Option<LiteralExpr<'_>> {
17241 let expr = expr.trim().trim_end_matches(',');
17242 if let Some(value) = expr
17243 .strip_prefix('"')
17244 .and_then(|rest| rest.strip_suffix('"'))
17245 {
17246 return Some(LiteralExpr::String(value));
17247 }
17248 if expr.chars().all(|ch| ch.is_ascii_digit() || ch == '.')
17249 && expr.chars().any(|ch| ch.is_ascii_digit())
17250 {
17251 return Some(LiteralExpr::Number(expr));
17252 }
17253 match expr {
17254 "true" => Some(LiteralExpr::Bool),
17255 "false" => Some(LiteralExpr::Bool),
17256 "null" => Some(LiteralExpr::Null),
17257 value if value.chars().all(|ch| ch.is_alphanumeric() || ch == '_') => {
17258 Some(LiteralExpr::Ident(value))
17259 }
17260 _ => None,
17261 }
17262}
17263
17264struct ExprParser<'a> {
17265 source: &'a str,
17266 tokens: Vec<ExprToken>,
17267 pos: usize,
17268 depth: usize,
17269}
17270
17271const MAX_EXPR_DEPTH: usize = 256;
17277
17278#[derive(Clone, Debug, Eq, PartialEq)]
17279struct ExprToken {
17280 kind: ExprTokenKind,
17281}
17282
17283#[derive(Clone, Debug, Eq, PartialEq)]
17284enum ExprTokenKind {
17285 Ident(String),
17286 String(String),
17287 Number(String),
17288 Symbol(char),
17289 Op(&'static str),
17290}
17291
17292impl<'a> ExprParser<'a> {
17293 fn new(source: &'a str) -> Self {
17294 Self {
17295 source,
17296 tokens: lex_expr(source),
17297 pos: 0,
17298 depth: 0,
17299 }
17300 }
17301
17302 fn parse(mut self) -> Result<Expr, String> {
17303 let expr = self.parse_or()?;
17304 if self.peek().is_some() {
17305 return Err(format!(
17306 "unexpected token in expression `{}`",
17307 self.source.trim()
17308 ));
17309 }
17310 Ok(expr)
17311 }
17312
17313 fn parse_or(&mut self) -> Result<Expr, String> {
17314 let mut expr = self.parse_and()?;
17315 while self.consume_op("||") || self.consume_ident("or") {
17316 let right = self.parse_and()?;
17317 expr = Expr::Binary {
17318 op: BinaryOp::Or,
17319 left: Box::new(expr),
17320 right: Box::new(right),
17321 };
17322 }
17323 Ok(expr)
17324 }
17325
17326 fn parse_and(&mut self) -> Result<Expr, String> {
17327 let mut expr = self.parse_comparison()?;
17328 while self.consume_op("&&") || self.consume_ident("and") {
17329 let right = self.parse_comparison()?;
17330 expr = Expr::Binary {
17331 op: BinaryOp::And,
17332 left: Box::new(expr),
17333 right: Box::new(right),
17334 };
17335 }
17336 Ok(expr)
17337 }
17338
17339 fn parse_comparison(&mut self) -> Result<Expr, String> {
17340 let mut expr = self.parse_additive()?;
17341 loop {
17342 let op = if self.consume_op("==") {
17343 Some(BinaryOp::Eq)
17344 } else if self.consume_op("!=") {
17345 Some(BinaryOp::Ne)
17346 } else if self.consume_op("<=") {
17347 Some(BinaryOp::Le)
17348 } else if self.consume_op(">=") {
17349 Some(BinaryOp::Ge)
17350 } else if self.consume_symbol('<') {
17351 Some(BinaryOp::Lt)
17352 } else if self.consume_symbol('>') {
17353 Some(BinaryOp::Gt)
17354 } else if self.consume_ident("not") {
17355 if !self.consume_ident("in") {
17356 return Err("expected `in` after `not`".to_owned());
17357 }
17358 Some(BinaryOp::NotIn)
17359 } else if self.consume_ident("in") {
17360 Some(BinaryOp::In)
17361 } else {
17362 None
17363 };
17364 let Some(op) = op else {
17365 return Ok(expr);
17366 };
17367 let right = self.parse_additive()?;
17368 expr = Expr::Binary {
17369 op,
17370 left: Box::new(expr),
17371 right: Box::new(right),
17372 };
17373 }
17374 }
17375
17376 fn parse_additive(&mut self) -> Result<Expr, String> {
17377 let mut expr = self.parse_multiplicative()?;
17378 loop {
17379 let op = if self.consume_symbol('+') {
17380 Some(BinaryOp::Add)
17381 } else if self.consume_symbol('-') {
17382 Some(BinaryOp::Sub)
17383 } else {
17384 None
17385 };
17386 let Some(op) = op else {
17387 return Ok(expr);
17388 };
17389 let right = self.parse_multiplicative()?;
17390 expr = Expr::Binary {
17391 op,
17392 left: Box::new(expr),
17393 right: Box::new(right),
17394 };
17395 }
17396 }
17397
17398 fn parse_multiplicative(&mut self) -> Result<Expr, String> {
17399 let mut expr = self.parse_unary()?;
17400 loop {
17401 let op = if self.consume_symbol('*') {
17402 Some(BinaryOp::Mul)
17403 } else if self.consume_symbol('/') {
17404 Some(BinaryOp::Div)
17405 } else {
17406 None
17407 };
17408 let Some(op) = op else {
17409 return Ok(expr);
17410 };
17411 let right = self.parse_unary()?;
17412 expr = Expr::Binary {
17413 op,
17414 left: Box::new(expr),
17415 right: Box::new(right),
17416 };
17417 }
17418 }
17419
17420 fn parse_unary(&mut self) -> Result<Expr, String> {
17421 self.depth += 1;
17424 if self.depth > MAX_EXPR_DEPTH {
17425 self.depth -= 1;
17426 return Err(format!(
17427 "expression in `{}` is nested too deeply (limit {MAX_EXPR_DEPTH})",
17428 self.source.trim()
17429 ));
17430 }
17431 let result = self.parse_unary_inner();
17432 self.depth -= 1;
17433 result
17434 }
17435
17436 fn parse_unary_inner(&mut self) -> Result<Expr, String> {
17437 if self.consume_symbol('!') {
17438 return Ok(Expr::Unary {
17439 op: UnaryOp::Not,
17440 expr: Box::new(self.parse_unary()?),
17441 });
17442 }
17443 if self.consume_ident("not") {
17447 return Ok(Expr::Unary {
17448 op: UnaryOp::Not,
17449 expr: Box::new(self.parse_comparison()?),
17450 });
17451 }
17452 self.parse_postfix()
17453 }
17454
17455 fn parse_postfix(&mut self) -> Result<Expr, String> {
17456 let mut expr = self.parse_primary()?;
17457 loop {
17458 if self.consume_symbol('[') {
17459 let key = self.parse_or()?;
17460 self.expect_symbol(']')?;
17461 expr = Expr::Index {
17462 target: Box::new(expr),
17463 key: Box::new(key),
17464 };
17465 continue;
17466 }
17467 return Ok(expr);
17468 }
17469 }
17470
17471 fn parse_primary(&mut self) -> Result<Expr, String> {
17472 if self.consume_symbol('(') {
17473 let expr = self.parse_or()?;
17474 self.expect_symbol(')')?;
17475 return Ok(expr);
17476 }
17477 if self.consume_symbol('[') {
17478 let mut items = Vec::new();
17479 if self.consume_symbol(']') {
17480 return Ok(Expr::Array(items));
17481 }
17482 loop {
17483 items.push(self.parse_or()?);
17484 if self.consume_symbol(']') {
17485 break;
17486 }
17487 self.expect_symbol(',')?;
17488 }
17489 return Ok(Expr::Array(items));
17490 }
17491 if self.consume_symbol('{') {
17492 let mut fields = Vec::new();
17493 if self.consume_symbol('}') {
17494 return Ok(Expr::Object(fields));
17495 }
17496 loop {
17497 let key = match self.advance().map(|token| token.kind.clone()) {
17498 Some(ExprTokenKind::Ident(value) | ExprTokenKind::String(value)) => value,
17499 _ => return Err("expected object field name".to_owned()),
17500 };
17501 let value = self.parse_or()?;
17502 fields.push(ExprObjectField { key, value });
17503 if self.consume_symbol('}') {
17504 break;
17505 }
17506 let _ = self.consume_symbol(',');
17507 }
17508 return Ok(Expr::Object(fields));
17509 }
17510 match self.advance().map(|token| token.kind.clone()) {
17511 Some(ExprTokenKind::String(value)) => Ok(Expr::Literal(ExprLiteral::String(value))),
17512 Some(ExprTokenKind::Number(value)) => Ok(Expr::Literal(ExprLiteral::Number(value))),
17513 Some(ExprTokenKind::Ident(value)) if value == "true" => {
17514 Ok(Expr::Literal(ExprLiteral::Bool(true)))
17515 }
17516 Some(ExprTokenKind::Ident(value)) if value == "false" => {
17517 Ok(Expr::Literal(ExprLiteral::Bool(false)))
17518 }
17519 Some(ExprTokenKind::Ident(value)) if value == "null" => {
17520 Ok(Expr::Literal(ExprLiteral::Null))
17521 }
17522 Some(ExprTokenKind::Ident(value)) if value == "exists" && !self.at_symbol('(') => {
17523 let arg = match self.parse_postfix()? {
17524 Expr::Literal(ExprLiteral::Ident(path)) => Expr::Path(vec![path]),
17525 expr => expr,
17526 };
17527 Ok(Expr::Call {
17528 name: value,
17529 args: vec![arg],
17530 })
17531 }
17532 Some(ExprTokenKind::Ident(value))
17533 if matches!(value.as_str(), "count" | "exists" | "empty")
17534 && self.at_symbol('(') =>
17535 {
17536 self.expect_symbol('(')?;
17537 if let Some(query) = self.try_parse_query()? {
17538 self.expect_symbol(')')?;
17539 Ok(Expr::Call {
17540 name: value,
17541 args: vec![query],
17542 })
17543 } else {
17544 let mut args = Vec::new();
17545 if self.consume_symbol(')') {
17546 return Ok(Expr::Call { name: value, args });
17547 }
17548 loop {
17549 args.push(self.parse_or()?);
17550 if self.consume_symbol(')') {
17551 break;
17552 }
17553 self.expect_symbol(',')?;
17554 }
17555 Ok(Expr::Call { name: value, args })
17556 }
17557 }
17558 Some(ExprTokenKind::Ident(value)) => {
17559 let mut path = vec![value];
17560 while self.consume_symbol('.') {
17561 let Some(ExprTokenKind::Ident(field)) =
17562 self.advance().map(|token| token.kind.clone())
17563 else {
17564 return Err("expected field name after `.`".to_owned());
17565 };
17566 path.push(field);
17567 }
17568 if path.len() == 1 {
17569 Ok(Expr::Literal(ExprLiteral::Ident(path.remove(0))))
17570 } else {
17571 Ok(Expr::Path(path))
17572 }
17573 }
17574 _ => Err(format!("expected expression in `{}`", self.source.trim())),
17575 }
17576 }
17577
17578 fn try_parse_query(&mut self) -> Result<Option<Expr>, String> {
17579 let checkpoint = self.pos;
17580 let kind = if self.consume_ident("effect") {
17581 QueryKind::Effect
17582 } else if matches!(
17583 self.peek().map(|token| &token.kind),
17584 Some(ExprTokenKind::Ident(value)) if value.chars().next().is_some_and(char::is_uppercase)
17585 ) {
17586 QueryKind::Fact
17587 } else {
17588 return Ok(None);
17589 };
17590 let mut head = Vec::new();
17591 while let Some(token) = self.peek() {
17592 if self.at_symbol(')') || self.at_ident("where") {
17593 break;
17594 }
17595 head.push(self.token_text(token));
17596 self.pos += 1;
17597 }
17598 if head.is_empty() {
17599 self.pos = checkpoint;
17600 return Ok(None);
17601 }
17602 let guard = if self.consume_ident("where") {
17603 Some(Box::new(self.parse_or()?))
17604 } else {
17605 None
17606 };
17607 Ok(Some(Expr::Query {
17608 kind,
17609 head: join_query_head(&head),
17610 guard,
17611 }))
17612 }
17613
17614 fn token_text(&self, token: &ExprToken) -> String {
17615 match &token.kind {
17616 ExprTokenKind::Ident(value) | ExprTokenKind::Number(value) => value.clone(),
17617 ExprTokenKind::String(value) => format!("{value:?}"),
17618 ExprTokenKind::Symbol(value) => value.to_string(),
17619 ExprTokenKind::Op(value) => value.to_string(),
17620 }
17621 }
17622
17623 fn peek(&self) -> Option<&ExprToken> {
17624 self.tokens.get(self.pos)
17625 }
17626
17627 fn advance(&mut self) -> Option<&ExprToken> {
17628 let token = self.tokens.get(self.pos)?;
17629 self.pos += 1;
17630 Some(token)
17631 }
17632
17633 fn at_symbol(&self, symbol: char) -> bool {
17634 matches!(
17635 self.peek().map(|token| &token.kind),
17636 Some(ExprTokenKind::Symbol(value)) if *value == symbol
17637 )
17638 }
17639
17640 fn consume_symbol(&mut self, symbol: char) -> bool {
17641 if self.at_symbol(symbol) {
17642 self.pos += 1;
17643 true
17644 } else {
17645 false
17646 }
17647 }
17648
17649 fn expect_symbol(&mut self, symbol: char) -> Result<(), String> {
17650 if self.consume_symbol(symbol) {
17651 Ok(())
17652 } else {
17653 Err(format!("expected `{symbol}`"))
17654 }
17655 }
17656
17657 fn at_ident(&self, ident: &str) -> bool {
17658 matches!(
17659 self.peek().map(|token| &token.kind),
17660 Some(ExprTokenKind::Ident(value)) if value == ident
17661 )
17662 }
17663
17664 fn consume_ident(&mut self, ident: &str) -> bool {
17665 if self.at_ident(ident) {
17666 self.pos += 1;
17667 true
17668 } else {
17669 false
17670 }
17671 }
17672
17673 fn consume_op(&mut self, op: &'static str) -> bool {
17674 if matches!(
17675 self.peek().map(|token| &token.kind),
17676 Some(ExprTokenKind::Op(value)) if *value == op
17677 ) {
17678 self.pos += 1;
17679 true
17680 } else {
17681 false
17682 }
17683 }
17684}
17685
17686fn join_query_head(tokens: &[String]) -> String {
17687 let mut head = String::new();
17688 for token in tokens {
17689 if token == "." {
17690 head.push('.');
17691 } else if head.ends_with('.') || head.is_empty() {
17692 head.push_str(token);
17693 } else {
17694 head.push(' ');
17695 head.push_str(token);
17696 }
17697 }
17698 head
17699}
17700
17701fn lex_expr(source: &str) -> Vec<ExprToken> {
17702 let bytes = source.as_bytes();
17703 let mut tokens = Vec::new();
17704 let mut index = 0usize;
17705 while index < bytes.len() {
17706 let byte = bytes[index];
17707 if byte.is_ascii_whitespace() {
17708 index += 1;
17709 continue;
17710 }
17711 if is_ident_start(byte) {
17712 let start = index;
17713 index += 1;
17714 while index < bytes.len() && is_ident_continue(bytes[index]) {
17715 index += 1;
17716 }
17717 tokens.push(ExprToken {
17718 kind: ExprTokenKind::Ident(source[start..index].to_owned()),
17719 });
17720 continue;
17721 }
17722 if byte.is_ascii_digit() {
17723 let start = index;
17724 index += 1;
17725 while index < bytes.len() && (bytes[index].is_ascii_digit() || bytes[index] == b'.') {
17726 index += 1;
17727 }
17728 tokens.push(ExprToken {
17729 kind: ExprTokenKind::Number(source[start..index].to_owned()),
17730 });
17731 continue;
17732 }
17733 if byte == b'"' {
17734 let start = index + 1;
17735 index += 1;
17736 while index < bytes.len() && bytes[index] != b'"' {
17737 index += 1;
17738 }
17739 let value = source[start..index.min(bytes.len())].to_owned();
17740 index = (index + 1).min(bytes.len());
17741 tokens.push(ExprToken {
17742 kind: ExprTokenKind::String(value),
17743 });
17744 continue;
17745 }
17746 let rest = &source[index..];
17747 if rest.starts_with("&&") {
17748 tokens.push(ExprToken {
17749 kind: ExprTokenKind::Op("&&"),
17750 });
17751 index += 2;
17752 } else if rest.starts_with("||") {
17753 tokens.push(ExprToken {
17754 kind: ExprTokenKind::Op("||"),
17755 });
17756 index += 2;
17757 } else if rest.starts_with("==") {
17758 tokens.push(ExprToken {
17759 kind: ExprTokenKind::Op("=="),
17760 });
17761 index += 2;
17762 } else if rest.starts_with("!=") {
17763 tokens.push(ExprToken {
17764 kind: ExprTokenKind::Op("!="),
17765 });
17766 index += 2;
17767 } else if rest.starts_with("<=") {
17768 tokens.push(ExprToken {
17769 kind: ExprTokenKind::Op("<="),
17770 });
17771 index += 2;
17772 } else if rest.starts_with(">=") {
17773 tokens.push(ExprToken {
17774 kind: ExprTokenKind::Op(">="),
17775 });
17776 index += 2;
17777 } else {
17778 tokens.push(ExprToken {
17779 kind: ExprTokenKind::Symbol(byte as char),
17780 });
17781 index += 1;
17782 }
17783 }
17784 tokens
17785}
17786
17787fn validate_primitive_literal(
17788 rule: &RuleDecl,
17789 record_schema: &str,
17790 field: &str,
17791 primitive: &str,
17792 literal: &LiteralExpr<'_>,
17793 diagnostics: &mut Vec<Diagnostic>,
17794) {
17795 if primitive == "secret" {
17799 diagnostics.push(Diagnostic {
17800 related: Vec::new(),
17801 span: rule.body.span,
17802 message: format!(
17803 "field `{record_schema}.{field}` is `secret`: secrets have no literal form"
17804 ),
17805 suggestion: Some(
17806 "reference a declared credential; material lives with the custodian, never in \
17807 source"
17808 .to_owned(),
17809 ),
17810 });
17811 return;
17812 }
17813 let valid = matches!(
17814 (primitive, literal),
17815 ("string", LiteralExpr::String(_))
17816 | ("string", LiteralExpr::Ident(_))
17817 | ("int", LiteralExpr::Number(_))
17818 | ("float", LiteralExpr::Number(_))
17819 | ("bool", LiteralExpr::Bool)
17820 | ("null", LiteralExpr::Null)
17821 | ("duration", LiteralExpr::String(_))
17822 | ("time", LiteralExpr::String(_))
17823 );
17824 if !valid {
17825 diagnostics.push(Diagnostic {
17826 related: Vec::new(),
17827 span: rule.body.span,
17828 message: format!("field `{record_schema}.{field}` expects `{primitive}`"),
17829 suggestion: Some(format!("record a value compatible with `{primitive}`")),
17830 });
17831 return;
17832 }
17833 match (primitive, literal) {
17834 ("duration", LiteralExpr::String(value)) if parse_duration_seconds(value).is_none() => {
17835 diagnostics.push(Diagnostic {
17836 related: Vec::new(),
17837 span: rule.body.span,
17838 message: format!("field `{record_schema}.{field}` has invalid duration literal"),
17839 suggestion: Some("use an ISO-8601 duration such as `\"PT30M\"`".to_owned()),
17840 });
17841 }
17842 ("time", LiteralExpr::String(value)) if parse_time_epoch_seconds(value).is_none() => {
17843 diagnostics.push(Diagnostic {
17844 related: Vec::new(),
17845 span: rule.body.span,
17846 message: format!("field `{record_schema}.{field}` has invalid time literal"),
17847 suggestion: Some(
17848 "use an RFC3339 timestamp such as `\"2026-05-29T10:00:00Z\"`".to_owned(),
17849 ),
17850 });
17851 }
17852 _ => {}
17853 }
17854}
17855
17856fn validate_enum_literal(
17857 rule: &RuleDecl,
17858 record_schema: &str,
17859 field: &str,
17860 schema: &str,
17861 literal: &LiteralExpr<'_>,
17862 semantic: &SemanticContext,
17863 diagnostics: &mut Vec<Diagnostic>,
17864) {
17865 let Some(variants) = semantic.schemas.enums.get(schema) else {
17866 return;
17867 };
17868 let LiteralExpr::Ident(variant) = literal else {
17869 diagnostics.push(Diagnostic {
17870 related: Vec::new(),
17871 span: rule.body.span,
17872 message: format!("field `{record_schema}.{field}` expects enum `{schema}`"),
17873 suggestion: Some(format!(
17874 "use one of: {}",
17875 variants.iter().cloned().collect::<Vec<_>>().join(", ")
17876 )),
17877 });
17878 return;
17879 };
17880 if !variants.contains(*variant) {
17881 diagnostics.push(Diagnostic {
17882 related: Vec::new(),
17883 span: rule.body.span,
17884 message: format!("enum `{schema}` has no variant `{variant}`"),
17885 suggestion: Some(format!(
17886 "use one of: {}",
17887 variants.iter().cloned().collect::<Vec<_>>().join(", ")
17888 )),
17889 });
17890 }
17891}
17892
17893fn validate_union_literal(
17894 rule: &RuleDecl,
17895 record_schema: &str,
17896 field: &str,
17897 variants: &[TypeSyntax],
17898 literal: &LiteralExpr<'_>,
17899 diagnostics: &mut Vec<Diagnostic>,
17900) {
17901 let allowed = variants
17902 .iter()
17903 .filter_map(|variant| match variant {
17904 TypeSyntax::LiteralString { value, .. } => Some(value.as_str()),
17905 _ => None,
17906 })
17907 .collect::<Vec<_>>();
17908 if allowed.is_empty() {
17909 return;
17910 }
17911 let LiteralExpr::String(value) = literal else {
17912 diagnostics.push(Diagnostic {
17913 related: Vec::new(),
17914 span: rule.body.span,
17915 message: format!("field `{record_schema}.{field}` expects one of its literal variants"),
17916 suggestion: Some(format!("use one of: {}", allowed.join(", "))),
17917 });
17918 return;
17919 };
17920 if !allowed.contains(value) {
17921 diagnostics.push(Diagnostic {
17922 related: Vec::new(),
17923 span: rule.body.span,
17924 message: format!("field `{record_schema}.{field}` cannot be `{value}`"),
17925 suggestion: Some(format!("use one of: {}", allowed.join(", "))),
17926 });
17927 }
17928}
17929
17930fn parse_effect_line(line: &str) -> Option<(IrEffectKind, Option<String>)> {
17931 let kind = if line.starts_with("tell ") {
17932 IrEffectKind::AgentTell
17933 } else if line.starts_with("coerce ") || line.starts_with("prompt ") {
17934 IrEffectKind::SchemaCoerce
17935 } else if line.starts_with("claim ") {
17936 IrEffectKind::TrackerClaim
17937 } else if line.starts_with("call ")
17938 || line.starts_with("recall ")
17939 || line.starts_with("learn ")
17940 || line.starts_with("curate ")
17941 || line.starts_with("promote ")
17942 || line.starts_with("undo ")
17943 || line.starts_with("transport ")
17944 {
17945 IrEffectKind::CapabilityCall
17946 } else if line.starts_with("emit ") {
17947 IrEffectKind::EventEmit
17948 } else if line.starts_with("invoke ") {
17949 IrEffectKind::WorkflowInvoke
17950 } else if line.starts_with("read ") {
17951 IrEffectKind::FileRead
17952 } else if line.starts_with("write ") {
17953 IrEffectKind::FileWrite
17954 } else if line.starts_with("import ") {
17955 IrEffectKind::FileImport
17956 } else if line.starts_with("export ") {
17957 IrEffectKind::FileExport
17958 } else if line.starts_with("acquire ") {
17959 IrEffectKind::LeaseAcquire
17960 } else if line.starts_with("renew ") {
17961 IrEffectKind::LeaseRenew
17962 } else if line.starts_with("append ") {
17963 IrEffectKind::LedgerAppend
17964 } else if line.starts_with("consume ") && line.contains(" for ") {
17965 IrEffectKind::CounterConsume
17969 } else {
17970 return None;
17971 };
17972
17973 Some((kind, binding_after_as(line)))
17974}
17975
17976fn parse_consume_line(line: &str) -> Option<String> {
17977 let binding = line
17981 .trim()
17982 .trim_end_matches(';')
17983 .strip_prefix("done ")?
17984 .split("->")
17985 .next()
17986 .unwrap_or_default()
17987 .trim();
17988 let mut chars = binding.chars();
17989 let first = chars.next()?;
17990 if !(first.is_ascii_alphabetic() || first == '_') {
17991 return None;
17992 }
17993 chars
17994 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
17995 .then(|| binding.to_owned())
17996}
17997
17998fn binding_after_multiline_string_end(line: &str) -> Option<String> {
17999 line.strip_prefix("\"\"\"")
18000 .and_then(|rest| rest.trim().strip_prefix("as "))
18001 .and_then(|rest| rest.split_whitespace().next())
18002 .map(|binding| binding.trim_matches(|ch: char| !ch.is_alphanumeric() && ch != '_'))
18003 .filter(|binding| !binding.is_empty())
18004 .map(str::to_owned)
18005}
18006
18007fn validate_rule_prompt_content_type_annotation(
18008 rule: &RuleDecl,
18009 line: &str,
18010 diagnostics: &mut Vec<Diagnostic>,
18011) {
18012 if !(line.starts_with("tell ") || line.starts_with("coerce ")) {
18013 return;
18014 }
18015 let Some(annotation) = malformed_prompt_content_type_annotation(line) else {
18016 return;
18017 };
18018 diagnostics.push(Diagnostic {
18019 related: Vec::new(),
18020 span: rule.body.span,
18021 message: format!(
18022 "rule `{}` has malformed multiline prompt content type `{annotation}`",
18023 rule.name.name
18024 ),
18025 suggestion: Some(
18026 "write a supported token such as `\"\"\"markdown` or put prompt text on the next line"
18027 .to_owned(),
18028 ),
18029 });
18030}
18031
18032fn validate_coerce_prompt_content_type_annotations(
18033 coerce: &CoerceDecl,
18034 diagnostics: &mut Vec<Diagnostic>,
18035) {
18036 for line in coerce.body.text.lines().map(str::trim) {
18037 if !line.starts_with("prompt ") {
18038 continue;
18039 }
18040 let Some(annotation) = malformed_prompt_content_type_annotation(line) else {
18041 continue;
18042 };
18043 diagnostics.push(Diagnostic { related: Vec::new(),
18044 span: coerce.body.span,
18045 message: format!(
18046 "coerce `{}` has malformed multiline prompt content type `{annotation}`",
18047 coerce.name.name
18048 ),
18049 suggestion: Some(
18050 "write a supported token such as `\"\"\"markdown` or put prompt text on the next line"
18051 .to_owned(),
18052 ),
18053 });
18054 }
18055}
18056
18057fn malformed_prompt_content_type_annotation(line: &str) -> Option<String> {
18058 let (_, tail) = line.split_once("\"\"\"")?;
18059 let candidate = tail.trim();
18060 if candidate.is_empty() || candidate.contains("\"\"\"") {
18061 return None;
18062 }
18063 let mut parts = candidate.split_whitespace();
18064 let first = parts.next()?;
18065 let has_extra_text = parts.next().is_some();
18066 let first_is_supported = is_supported_prompt_content_type(first);
18067 let first_is_annotation_shaped = first_is_supported || first.contains('/');
18068 if has_extra_text && first_is_annotation_shaped {
18069 return Some(candidate.to_owned());
18070 }
18071 if first.contains('/') && !first_is_supported {
18072 return Some(first.to_owned());
18073 }
18074 None
18075}
18076
18077fn is_supported_prompt_content_type(candidate: &str) -> bool {
18078 if !is_prompt_content_type_token(candidate) {
18079 return false;
18080 }
18081 let normalized = candidate.to_ascii_lowercase();
18082 normalized.contains('/')
18083 || matches!(
18084 normalized.as_str(),
18085 "markdown" | "json" | "text" | "plain" | "html" | "xml" | "yaml" | "yml"
18086 )
18087}
18088
18089fn is_prompt_content_type_token(candidate: &str) -> bool {
18090 let mut chars = candidate.chars();
18091 let Some(first) = chars.next() else {
18092 return false;
18093 };
18094 first.is_ascii_alphanumeric()
18095 && chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '+' | '-' | '_'))
18096}
18097
18098pub(crate) fn binding_after_as(line: &str) -> Option<String> {
18099 let mut tokens = line.split_whitespace();
18100 while let Some(token) = tokens.next() {
18101 if token == "as" {
18102 return tokens
18103 .next()
18104 .map(|binding| binding.trim_matches(|ch: char| !ch.is_alphanumeric() && ch != '_'))
18105 .filter(|binding| !binding.is_empty())
18106 .map(str::to_owned);
18107 }
18108 }
18109 None
18110}
18111
18112fn parse_after_line(line: &str) -> Option<(String, DependencyPredicate)> {
18113 let rest = line.strip_prefix("after ")?;
18114 if rest.contains("=>") {
18115 return None;
18116 }
18117 let before_body = rest.split('{').next().unwrap_or(rest).trim();
18118 let mut parts = before_body.split_whitespace();
18119 let binding = parts.next()?.to_owned();
18120 let predicate = match parts.next()? {
18121 "succeeds" => DependencyPredicate::Succeeds,
18122 "fails" => DependencyPredicate::Fails,
18123 "cancelled" => DependencyPredicate::Cancelled,
18126 "times" => {
18127 if parts.next()? != "out" {
18128 return None;
18129 }
18130 DependencyPredicate::TimedOut
18131 }
18132 "completes" | "held" | "contended" | "ok" | "over" | "promoted" | "conflicted"
18135 | "applied" | "stranded" => DependencyPredicate::Completes,
18136 "reaches" => {
18141 let rest = before_body.trim().strip_prefix(&binding)?.trim_start();
18142 let after_kw = rest.strip_prefix("reaches")?.trim_start();
18143 let quoted = after_kw.strip_prefix('"')?;
18144 let close = quoted.find('"')?;
18145 let tail = "ed[close + 1..];
18146 let mut tail_parts = tail.split_whitespace();
18147 match (tail_parts.next(), tail_parts.next(), tail_parts.next()) {
18148 (None, None, None) => {}
18149 (Some("as"), Some(alias), None) if is_identifier(alias) => {}
18150 _ => return None,
18151 }
18152 return Some((binding, DependencyPredicate::Completes));
18153 }
18154 _ => return None,
18155 };
18156 match (parts.next(), parts.next(), parts.next()) {
18157 (None, None, None) => {}
18158 (Some("as"), Some(alias), None) if is_identifier(alias) => {}
18159 _ => return None,
18160 }
18161 Some((binding, predicate))
18162}
18163
18164pub(crate) fn is_identifier(value: &str) -> bool {
18165 let mut chars = value.chars();
18166 let Some(first) = chars.next() else {
18167 return false;
18168 };
18169 (first.is_ascii_alphabetic() || first == '_')
18170 && chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
18171}
18172
18173pub(crate) fn push_line(snapshot: &mut String, line: impl AsRef<str>) {
18174 snapshot.push_str(line.as_ref());
18175 snapshot.push('\n');
18176}
18177
18178pub(crate) fn stable_hash(value: &str) -> String {
18179 use sha2::Digest;
18184 let digest = sha2::Sha256::digest(value.as_bytes());
18185 let mut hex = String::with_capacity(32);
18186 for byte in &digest[..16] {
18187 hex.push_str(&format!("{byte:02x}"));
18188 }
18189 hex
18190}
18191
18192pub fn parse_duration_seconds(value: &str) -> Option<f64> {
18193 let value = value.strip_prefix('P')?;
18194 let mut rest = value;
18195 let mut seconds = 0.0;
18196 let mut consumed = false;
18197 let mut in_time = false;
18198
18199 while !rest.is_empty() {
18200 if let Some(next) = rest.strip_prefix('T') {
18201 if in_time {
18202 return None;
18203 }
18204 in_time = true;
18205 rest = next;
18206 continue;
18207 }
18208
18209 let number_len = rest
18210 .char_indices()
18211 .take_while(|(_, ch)| ch.is_ascii_digit() || *ch == '.')
18212 .map(|(index, ch)| index + ch.len_utf8())
18213 .last()?;
18214 let number = rest[..number_len].parse::<f64>().ok()?;
18215 if !number.is_finite() {
18216 return None;
18217 }
18218 let unit = rest[number_len..].chars().next()?;
18219 rest = &rest[number_len + unit.len_utf8()..];
18220 let multiplier = match (in_time, unit) {
18221 (false, 'D') => 86_400.0,
18222 (true, 'H') => 3_600.0,
18223 (true, 'M') => 60.0,
18224 (true, 'S') => 1.0,
18225 _ => return None,
18226 };
18227 seconds += number * multiplier;
18228 consumed = true;
18229 }
18230
18231 consumed.then_some(seconds)
18232}
18233
18234pub fn parse_time_epoch_seconds(value: &str) -> Option<f64> {
18235 if value.len() < 20 {
18236 return None;
18237 }
18238 let year = parse_fixed_i32(value, 0, 4)?;
18239 require_byte(value, 4, b'-')?;
18240 let month = parse_fixed_u32(value, 5, 2)?;
18241 require_byte(value, 7, b'-')?;
18242 let day = parse_fixed_u32(value, 8, 2)?;
18243 require_byte(value, 10, b'T')?;
18244 let hour = parse_fixed_u32(value, 11, 2)?;
18245 require_byte(value, 13, b':')?;
18246 let minute = parse_fixed_u32(value, 14, 2)?;
18247 require_byte(value, 16, b':')?;
18248 let second = parse_fixed_u32(value, 17, 2)?;
18249 let mut offset_start = 19;
18250 let mut fractional_second = 0.0;
18251 if value.as_bytes().get(offset_start).copied() == Some(b'.') {
18252 let fraction_start = offset_start + 1;
18253 let fraction_len = value[fraction_start..]
18254 .char_indices()
18255 .take_while(|(_, ch)| ch.is_ascii_digit())
18256 .map(|(index, ch)| index + ch.len_utf8())
18257 .last()?;
18258 let fraction = &value[fraction_start..fraction_start + fraction_len];
18259 let scale = 10_f64.powi(i32::try_from(fraction.len()).ok()?);
18260 fractional_second = fraction.parse::<f64>().ok()? / scale;
18261 offset_start = fraction_start + fraction_len;
18262 }
18263 if !(1..=12).contains(&month)
18264 || !(1..=days_in_month(year, month)).contains(&day)
18265 || hour > 23
18266 || minute > 59
18267 || second > 60
18268 {
18269 return None;
18270 }
18271
18272 let offset_seconds = match value.as_bytes().get(offset_start).copied()? {
18273 b'Z' if value.len() == offset_start + 1 => 0,
18274 b'+' | b'-' if value.len() == offset_start + 6 => {
18275 let sign = if value.as_bytes()[offset_start] == b'+' {
18276 1
18277 } else {
18278 -1
18279 };
18280 let offset_hour = parse_fixed_i32(value, offset_start + 1, 2)?;
18281 require_byte(value, offset_start + 3, b':')?;
18282 let offset_minute = parse_fixed_i32(value, offset_start + 4, 2)?;
18283 if offset_hour > 23 || offset_minute > 59 {
18284 return None;
18285 }
18286 sign * (offset_hour * 3_600 + offset_minute * 60)
18287 }
18288 _ => return None,
18289 };
18290
18291 let days = days_from_civil(year, month, day);
18292 let local_seconds = days * 86_400 + i64::from(hour * 3_600 + minute * 60 + second.min(59));
18293 Some((local_seconds - i64::from(offset_seconds)) as f64 + fractional_second)
18294}
18295
18296fn parse_fixed_i32(value: &str, start: usize, len: usize) -> Option<i32> {
18297 value.get(start..start + len)?.parse::<i32>().ok()
18298}
18299
18300fn parse_fixed_u32(value: &str, start: usize, len: usize) -> Option<u32> {
18301 value.get(start..start + len)?.parse::<u32>().ok()
18302}
18303
18304fn require_byte(value: &str, index: usize, expected: u8) -> Option<()> {
18305 (value.as_bytes().get(index).copied()? == expected).then_some(())
18306}
18307
18308fn days_in_month(year: i32, month: u32) -> u32 {
18309 match month {
18310 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
18311 4 | 6 | 9 | 11 => 30,
18312 2 if is_leap_year(year) => 29,
18313 2 => 28,
18314 _ => 0,
18315 }
18316}
18317
18318fn is_leap_year(year: i32) -> bool {
18319 (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
18320}
18321
18322fn days_from_civil(year: i32, month: u32, day: u32) -> i64 {
18323 let year = year - i32::from(month <= 2);
18324 let era = if year >= 0 { year } else { year - 399 } / 400;
18325 let year_of_era = year - era * 400;
18326 let month = month as i32;
18327 let day = day as i32;
18328 let day_of_year = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day - 1;
18329 let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
18330 i64::from(era * 146_097 + day_of_era - 719_468)
18331}
18332
18333fn push_block_body(body: &str, formatted: &mut String) {
18338 if body.is_empty() {
18339 return;
18340 }
18341 for line in body.lines() {
18342 if line.trim().is_empty() {
18343 formatted.push('\n');
18344 } else {
18345 push_line(formatted, format!(" {}", line.trim_end()));
18346 }
18347 }
18348}
18349
18350fn scan_braces(line: &str) -> (i32, bool) {
18356 let bytes = line.as_bytes();
18357 let mut index = 0;
18358 let mut delta = 0i32;
18359 let mut in_string = false;
18360 while index < bytes.len() {
18361 if in_string {
18362 match bytes[index] {
18363 b'\\' => index += 1,
18364 b'"' => in_string = false,
18365 _ => {}
18366 }
18367 index += 1;
18368 continue;
18369 }
18370 if line[index..].starts_with("\"\"\"") {
18371 match line[index + 3..].find("\"\"\"") {
18372 Some(offset) => index += 3 + offset + 3,
18373 None => return (delta, true),
18374 }
18375 continue;
18376 }
18377 match bytes[index] {
18378 b'"' => in_string = true,
18379 b'{' | b'[' | b'(' => delta += 1,
18380 b'}' | b']' | b')' => delta -= 1,
18381 _ => {}
18382 }
18383 index += 1;
18384 }
18385 (delta, false)
18386}
18387
18388impl TypeSyntax {
18389 fn to_source(&self) -> String {
18390 match self {
18391 Self::Primitive { name, .. } => name.clone(),
18392 Self::LiteralString { value, .. } => format!("{value:?}"),
18393 Self::Ref { name } => name.name.clone(),
18394 Self::AgentRef { agents, .. } => {
18395 let agents = agents
18396 .iter()
18397 .map(|agent| agent.name.as_str())
18398 .collect::<Vec<_>>()
18399 .join(" | ");
18400 format!("AgentRef<{agents}>")
18401 }
18402 Self::Optional { inner, .. } => format!("{}?", inner.to_source()),
18403 Self::Array { inner, .. } => format!("{}[]", inner.to_source()),
18404 Self::Map { inner, .. } => format!("map<{}>", inner.to_source()),
18405 Self::Union { variants, .. } => variants
18406 .iter()
18407 .map(Self::to_source)
18408 .collect::<Vec<_>>()
18409 .join(" | "),
18410 }
18411 }
18412}
18413
18414#[cfg(test)]
18415#[path = "lib_tests/tests.rs"]
18416mod tests;