1mod action_expand;
8pub mod body;
9mod body_print;
10mod then_expand;
11
12use std::{
13 collections::{BTreeMap, BTreeSet, VecDeque},
14 fmt,
15};
16use whipplescript_core::{
17 ContractRegistry, EffectContract, LibraryRegistration, TypedOutputValidation,
18};
19
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21pub struct SourceSpan {
22 pub start: usize,
23 pub end: usize,
24}
25
26impl SourceSpan {
27 fn join(self, other: Self) -> Self {
28 Self {
29 start: self.start,
30 end: other.end,
31 }
32 }
33}
34
35#[derive(Clone, Debug, Eq, PartialEq)]
36pub struct Diagnostic {
37 pub span: SourceSpan,
38 pub message: String,
39 pub suggestion: Option<String>,
40 pub related: Vec<RelatedInfo>,
46}
47
48#[derive(Clone, Debug, Eq, PartialEq)]
51pub struct RelatedInfo {
52 pub span: SourceSpan,
53 pub message: String,
54}
55
56impl Diagnostic {
57 pub fn with_related(mut self, span: SourceSpan, message: impl Into<String>) -> Self {
61 self.related.push(RelatedInfo {
62 span,
63 message: message.into(),
64 });
65 self
66 }
67}
68
69#[derive(Clone, Copy, Debug, Eq, PartialEq)]
72pub enum CommentMarker {
73 Hash,
75 Slash,
77}
78
79#[derive(Clone, Debug, Eq, PartialEq)]
84pub struct Comment {
85 pub marker: CommentMarker,
86 pub text: String,
87 pub span: SourceSpan,
88}
89
90#[derive(Clone, Debug, Eq, PartialEq)]
91pub struct Ident {
92 pub name: String,
93 pub span: SourceSpan,
94}
95
96#[derive(Clone, Debug, Eq, PartialEq)]
97pub struct StringLiteral {
98 pub value: String,
99 pub span: SourceSpan,
100}
101
102#[derive(Clone, Debug, Eq, PartialEq)]
103pub struct Program {
104 pub workflow: Option<Ident>,
105 pub workflow_tags: Vec<TagDecl>,
106 pub workflow_description: Option<StringLiteral>,
107 pub explicit_workflow_body: bool,
108 pub workflows: Vec<WorkflowDecl>,
109 pub patterns: Vec<PatternDecl>,
110 pub items: Vec<Item>,
111}
112
113#[derive(Clone, Debug, Eq, PartialEq)]
114pub struct WorkflowDecl {
115 pub name: Ident,
116 pub tags: Vec<TagDecl>,
117 pub description: Option<StringLiteral>,
118 pub items: Vec<Item>,
119 pub span: SourceSpan,
120}
121
122#[derive(Clone, Debug, Eq, PartialEq)]
123pub enum Item {
124 Include(IncludeDecl),
125 Use(UseDecl),
126 Pattern(PatternDecl),
127 Apply(ApplyDecl),
128 WorkflowContract(WorkflowContractDecl),
129 Harness(HarnessDecl),
130 Tracker(TrackerDecl),
131 Channel(ChannelDecl),
132 Gauge(GaugeDecl),
133 Mark(MarkDecl),
134 Campaign(CampaignDecl),
135 FileStore(FileStoreDecl),
136 MemoryPool(MemoryPoolDecl),
137 Action(ActionDecl),
138 Agent(AgentDecl),
139 Enum(EnumDecl),
140 Event(EventDecl),
141 Source(Box<SourceDecl>),
144 Test(TestDecl),
145 Lease(LeaseDecl),
146 Ledger(LedgerDecl),
147 Counter(CounterDecl),
148 Class(ClassDecl),
149 Table(TableDecl),
150 Coerce(CoerceDecl),
151 Assert(AssertDecl),
152 Rule(RuleDecl),
153}
154
155impl Item {
156 fn span(&self) -> SourceSpan {
158 match self {
159 Self::Include(decl) => decl.path.span,
160 Self::Use(decl) => decl.name.span,
161 Self::Pattern(decl) => decl.span,
162 Self::Apply(decl) => decl.span,
163 Self::WorkflowContract(decl) => decl.span,
164 Self::Harness(decl) => decl.span,
165 Self::Tracker(decl) => decl.span,
166 Self::Channel(decl) => decl.span,
167 Self::Gauge(decl) => decl.span,
168 Self::Mark(decl) => decl.span,
169 Self::Campaign(decl) => decl.span,
170 Self::FileStore(decl) => decl.span,
171 Self::MemoryPool(decl) => decl.span,
172 Self::Action(decl) => decl.span,
173 Self::Agent(decl) => decl.span,
174 Self::Enum(decl) => decl.span,
175 Self::Event(decl) => decl.span,
176 Self::Source(decl) => decl.span,
177 Self::Test(decl) => decl.span,
178 Self::Lease(decl) => decl.span,
179 Self::Ledger(decl) => decl.span,
180 Self::Counter(decl) => decl.span,
181 Self::Class(decl) => decl.span,
182 Self::Table(decl) => decl.span,
183 Self::Coerce(decl) => decl.span,
184 Self::Assert(decl) => decl.span,
185 Self::Rule(decl) => decl.span,
186 }
187 }
188}
189
190#[derive(Clone, Debug, Eq, PartialEq)]
191pub struct PatternDecl {
192 pub name: Ident,
193 pub type_params: Vec<Ident>,
194 pub items: Vec<Item>,
195 pub span: SourceSpan,
196}
197
198#[derive(Clone, Debug, Eq, PartialEq)]
199pub struct ApplyDecl {
200 pub pattern: Ident,
201 pub type_args: Vec<TypeSyntax>,
202 pub alias: Ident,
203 pub body: BlockSource,
204 pub span: SourceSpan,
205}
206
207#[derive(Clone, Debug, Eq, PartialEq)]
208pub struct IncludeDecl {
209 pub path: StringLiteral,
210}
211
212#[derive(Clone, Debug, Eq, PartialEq)]
213pub struct WorkflowContractDecl {
214 pub kind: WorkflowContractKind,
215 pub name: Ident,
216 pub ty: TypeSyntax,
217 pub span: SourceSpan,
218}
219
220#[derive(Clone, Debug, Eq, PartialEq)]
221pub enum WorkflowContractKind {
222 Input,
223 Output,
224 Failure,
225}
226
227impl WorkflowContractKind {
228 fn as_str(&self) -> &'static str {
229 match self {
230 Self::Input => "input",
231 Self::Output => "output",
232 Self::Failure => "failure",
233 }
234 }
235}
236
237#[derive(Clone, Debug, Eq, PartialEq)]
238pub struct AssertDecl {
239 pub tags: Vec<TagDecl>,
240 pub description: Option<StringLiteral>,
241 pub expr: String,
242 pub span: SourceSpan,
243}
244
245#[derive(Clone, Debug, Eq, PartialEq)]
246pub struct TagDecl {
247 pub name: String,
248 pub span: SourceSpan,
249}
250
251#[derive(Clone, Debug, Eq, PartialEq)]
252pub struct UseDecl {
253 pub name: StringLiteral,
254}
255
256#[derive(Clone, Debug, Eq, PartialEq)]
257pub struct HarnessDecl {
258 pub name: Ident,
259 pub kind: Ident,
260 pub span: SourceSpan,
261}
262
263#[derive(Clone, Debug, Eq, PartialEq)]
264pub struct TrackerDecl {
265 pub name: Ident,
266 pub provider: Ident,
267 pub span: SourceSpan,
268}
269
270#[derive(Clone, Debug, Eq, PartialEq)]
277pub struct ChannelDecl {
278 pub name: Ident,
279 pub provider: Ident,
280 pub workspace: Option<Ident>,
281 pub destination: Option<StringLiteral>,
282 pub span: SourceSpan,
283}
284
285#[derive(Clone, Debug, Eq, PartialEq)]
294pub struct MarkDecl {
295 pub name: StringLiteral,
296 pub site: String,
299 pub site_span: SourceSpan,
300 pub span: SourceSpan,
301}
302
303#[derive(Clone, Debug, Eq, PartialEq)]
312pub struct GaugeDecl {
313 pub name: Ident,
314 pub site: Option<String>,
318 pub site_span: Option<SourceSpan>,
319 pub judge: GaugeJudge,
320 pub expect: Option<GaugeBar>,
321 pub inputs: Vec<GaugeRef>,
325 pub span: SourceSpan,
326}
327
328#[derive(Clone, Debug, Eq, PartialEq)]
336pub enum GaugeJudge {
337 Coerce(Ident, Vec<String>),
338 Prompt(StringLiteral),
339 Exec(StringLiteral),
340 Labels(StringLiteral),
341}
342
343#[derive(Clone, Debug, Eq, PartialEq)]
349pub struct GaugeBar {
350 pub subject: GaugeBarSubject,
351 pub at_least: bool,
353 pub threshold: String,
354 pub span: SourceSpan,
355}
356
357#[derive(Clone, Debug, Eq, PartialEq)]
358pub enum GaugeBarSubject {
359 Chance { field: Ident },
361 Stat { stat: Ident },
363}
364
365#[derive(Clone, Debug, Eq, PartialEq)]
369pub struct GaugeRef {
370 pub name: String,
371 pub span: SourceSpan,
372}
373
374#[derive(Clone, Debug, Eq, PartialEq)]
380pub struct CampaignDecl {
381 pub name: Ident,
382 pub ascend: Vec<GaugeRef>,
383 pub reach: Vec<CampaignReach>,
384 pub guard: Vec<CampaignGuard>,
385 pub sacrifice: Vec<GaugeRef>,
386 pub proposer_redacted: bool,
390 pub span: SourceSpan,
391}
392
393#[derive(Clone, Debug, Eq, PartialEq)]
395pub struct CampaignReach {
396 pub gauge: GaugeRef,
397 pub at_least: bool,
398 pub threshold: String,
399 pub unit: Option<String>,
401 pub span: SourceSpan,
402}
403
404#[derive(Clone, Debug, Eq, PartialEq)]
406pub struct CampaignGuard {
407 pub gauge: GaugeRef,
408 pub band_percent: String,
409 pub span: SourceSpan,
410}
411
412#[derive(Clone, Debug, Eq, PartialEq)]
415pub struct FileStoreDecl {
416 pub name: Ident,
417 pub root: String,
418 pub read_globs: Vec<String>,
419 pub write_globs: Vec<String>,
420 pub provider: Option<Ident>,
424 pub root_span: Option<SourceSpan>,
428 pub read_span: Option<SourceSpan>,
429 pub write_span: Option<SourceSpan>,
430 pub provider_span: Option<SourceSpan>,
431 pub span: SourceSpan,
432}
433
434#[derive(Clone, Debug, Eq, PartialEq)]
440pub struct MemoryPoolDecl {
441 pub name: Ident,
442 pub context_limit: Option<u64>,
443 pub context_limit_span: Option<SourceSpan>,
446 pub span: SourceSpan,
447}
448
449#[derive(Clone, Debug, Eq, PartialEq)]
451pub struct ActionParam {
452 pub name: Ident,
453 pub ty: TypeSyntax,
454 pub span: SourceSpan,
455}
456
457#[derive(Clone, Debug, Eq, PartialEq)]
461pub struct ActionDecl {
462 pub name: Ident,
463 pub params: Vec<ActionParam>,
464 pub body: BlockSource,
465 pub span: SourceSpan,
466}
467
468#[derive(Clone, Debug, Eq, PartialEq)]
469pub struct AgentDecl {
470 pub name: Ident,
471 pub harness: Option<Ident>,
472 pub delegated_to: Option<Ident>,
476 pub fields: Vec<AgentField>,
477 pub span: SourceSpan,
478}
479
480#[derive(Clone, Debug, Eq, PartialEq)]
481pub enum AgentField {
482 Provider(Ident),
483 Profile(StringLiteral),
484 Capacity(u32, SourceSpan),
485 Skills(Vec<StringLiteral>, SourceSpan),
486 Capabilities(Vec<StringLiteral>, SourceSpan),
487 Requires(Vec<Ident>, SourceSpan),
492 Tools(Vec<Ident>, SourceSpan),
495 Compaction(Ident),
499 Thread(Ident),
504 Settings(Ident),
508 Unknown {
509 name: Ident,
510 span: SourceSpan,
511 },
512}
513
514#[derive(Clone, Debug, Eq, PartialEq)]
515pub struct EnumDecl {
516 pub name: Ident,
517 pub variants: Vec<EnumVariantDecl>,
518 pub span: SourceSpan,
519}
520
521#[derive(Clone, Debug, Eq, PartialEq)]
524pub struct EnumVariantDecl {
525 pub name: Ident,
526 pub fields: Vec<ClassField>,
527 pub span: SourceSpan,
528}
529
530#[derive(Clone, Debug, Eq, PartialEq)]
531pub struct ClassDecl {
532 pub name: Ident,
533 pub fields: Vec<ClassField>,
534 pub span: SourceSpan,
535}
536
537#[derive(Clone, Debug, Eq, PartialEq)]
541pub struct LeaseDecl {
542 pub name: Ident,
543 pub key_type: Ident,
544 pub slots: u32,
545 pub ttl_seconds: u64,
546 pub shared: bool,
547 pub span: SourceSpan,
548}
549
550#[derive(Clone, Debug, Eq, PartialEq)]
551pub struct LedgerDecl {
552 pub name: Ident,
553 pub entry_schema: Ident,
554 pub partition_field: Ident,
555 pub retain_seconds: u64,
556 pub shared: bool,
557 pub span: SourceSpan,
558}
559
560#[derive(Clone, Debug, Eq, PartialEq)]
561pub struct CounterDecl {
562 pub name: Ident,
563 pub key_type: Ident,
564 pub cap: i64,
565 pub reset: String,
566 pub timezone: Option<String>,
569 pub shared: bool,
570 pub span: SourceSpan,
571}
572
573#[derive(Clone, Debug, Eq, PartialEq)]
577pub struct EventDecl {
578 pub name: String,
580 pub name_span: SourceSpan,
581 pub fields: Vec<ClassField>,
582 pub span: SourceSpan,
583}
584
585#[derive(Clone, Debug, Eq, PartialEq)]
586pub struct ClassField {
587 pub name: Ident,
588 pub ty: TypeSyntax,
589 pub is_key: bool,
592 pub presence_condition: Option<(String, String)>,
596 pub span: SourceSpan,
597}
598
599#[derive(Clone, Debug, Eq, PartialEq)]
606pub struct SourceDecl {
607 pub name: Ident,
609 pub provider: Ident,
611 pub clock: Option<ClockPolicy>,
613 pub path: Option<StringLiteral>,
618 pub watch: Option<StringLiteral>,
624 pub url: Option<StringLiteral>,
628 pub dedup: Option<SourceValue>,
634 pub observe_binding: Ident,
636 pub emit: SourceEmit,
639 pub span: SourceSpan,
640}
641
642#[derive(Clone, Debug, Eq, PartialEq)]
643pub struct ClockPolicy {
644 pub recurrence: Recurrence,
645 pub timezone: Option<StringLiteral>,
646 pub missed: Option<MissedPolicy>,
647 pub span: SourceSpan,
648}
649
650#[derive(Clone, Debug, Eq, PartialEq)]
652pub enum Recurrence {
653 At { time: TimeOfDay, span: SourceSpan },
655 EveryDuration {
657 seconds: u64,
658 source: String,
659 span: SourceSpan,
660 },
661 EveryCalendar {
663 pattern: CalendarPattern,
664 time: TimeOfDay,
665 span: SourceSpan,
666 },
667}
668
669#[derive(Clone, Copy, Debug, Eq, PartialEq)]
670pub enum CalendarPattern {
671 Day,
672 Weekday,
673 Weekly(Weekday),
674}
675
676#[derive(Clone, Copy, Debug, Eq, PartialEq)]
677pub enum Weekday {
678 Monday,
679 Tuesday,
680 Wednesday,
681 Thursday,
682 Friday,
683 Saturday,
684 Sunday,
685}
686
687#[derive(Clone, Copy, Debug, Eq, PartialEq)]
688pub struct TimeOfDay {
689 pub hour: u8,
690 pub minute: u8,
691 pub span: SourceSpan,
692}
693
694#[derive(Clone, Copy, Debug, Eq, PartialEq)]
697pub enum MissedPolicy {
698 Skip,
699 Coalesce,
700 CatchUp { limit: u32 },
701}
702
703#[derive(Clone, Debug, Eq, PartialEq)]
704pub struct SourceEmit {
705 pub signal: String,
707 pub signal_span: SourceSpan,
708 pub from: Option<Ident>,
712 pub fields: Vec<SourceEmitField>,
713 pub span: SourceSpan,
714}
715
716#[derive(Clone, Debug, Eq, PartialEq)]
717pub struct SourceEmitField {
718 pub name: Ident,
719 pub value: SourceValue,
720 pub span: SourceSpan,
721}
722
723#[derive(Clone, Debug, Eq, PartialEq)]
726pub enum SourceValue {
727 Path {
728 binding: Ident,
729 segments: Vec<Ident>,
730 span: SourceSpan,
731 },
732 String(StringLiteral),
733 Number(String, SourceSpan),
734}
735
736#[derive(Clone, Debug, Eq, PartialEq)]
739pub struct TestDecl {
740 pub name: StringLiteral,
741 pub workflow: Option<Ident>,
745 pub clauses: Vec<TestClause>,
746 pub span: SourceSpan,
747}
748
749#[derive(Clone, Debug, Eq, PartialEq)]
750pub enum TestClause {
751 Given(GivenClause),
752 Stub(StubClause),
753 Run(RunClause),
754 Expect(ExpectClause),
755}
756
757#[derive(Clone, Debug, Eq, PartialEq)]
761pub struct TestField {
762 pub name: Ident,
763 pub value: String,
764 pub span: SourceSpan,
765}
766
767#[derive(Clone, Debug, Eq, PartialEq)]
768pub enum GivenClause {
769 Input {
770 fields: Vec<TestField>,
771 span: SourceSpan,
772 },
773 Fact {
774 ty: Ident,
775 fields: Vec<TestField>,
776 span: SourceSpan,
777 },
778 Signal {
779 name: String,
780 fields: Vec<TestField>,
781 span: SourceSpan,
782 },
783 Clock {
784 at: StringLiteral,
785 span: SourceSpan,
786 },
787 Tracker {
788 tracker: String,
789 fields: Vec<TestField>,
790 span: SourceSpan,
791 },
792 File {
796 store: String,
797 path: StringLiteral,
798 content: StringLiteral,
799 span: SourceSpan,
800 },
801}
802
803#[derive(Clone, Debug, Eq, PartialEq)]
806pub struct StubClause {
807 pub surface: Vec<String>,
810 pub outcome: String,
811 pub payload: Option<StubPayload>,
812 pub span: SourceSpan,
813}
814
815#[derive(Clone, Debug, Eq, PartialEq)]
816pub enum StubPayload {
817 Record(Vec<TestField>),
818 Message(StringLiteral),
819}
820
821#[derive(Clone, Debug, Eq, PartialEq)]
822pub struct RunClause {
823 pub kind: RunKind,
824 pub span: SourceSpan,
825}
826
827#[derive(Clone, Debug, Eq, PartialEq)]
828pub enum RunKind {
829 UntilIdle,
830 UntilWorkflowCompleted,
831 UntilWorkflowFailed,
832 ForSteps(u32),
833}
834
835#[derive(Clone, Debug, Eq, PartialEq)]
836pub struct ExpectClause {
837 pub target: ExpectTarget,
838 pub span: SourceSpan,
839}
840
841#[derive(Clone, Debug, Eq, PartialEq)]
842pub enum ExpectTarget {
843 WorkflowCompleted,
844 WorkflowFailed { failure: Option<Ident> },
845 Rule { name: Ident, status: RuleStatus },
846 Effect { name: String, status: EffectStatus },
847 Diagnostic { code: String },
848 NoEffect { name: String },
849 Projection(ProjQuery),
850}
851
852#[derive(Clone, Debug, Eq, PartialEq)]
853pub enum RuleStatus {
854 Fired,
855 FiredTimes(u32),
856 DidNotFire,
857}
858
859#[derive(Clone, Debug, Eq, PartialEq)]
860pub enum EffectStatus {
861 Requested,
862 Completed,
863 Failed,
864}
865
866#[derive(Clone, Debug, Eq, PartialEq)]
871pub struct ProjQuery {
872 pub noun: String,
873 pub kind: ProjQueryKind,
874 pub span: SourceSpan,
875}
876
877#[derive(Clone, Debug, Eq, PartialEq)]
878pub enum ProjQueryKind {
879 Exists,
880 Count { predicate: String, count: u32 },
881 Where { predicate: String },
882}
883
884#[derive(Clone, Debug, Eq, PartialEq)]
885pub struct TableDecl {
886 pub name: Ident,
887 pub tags: Vec<TagDecl>,
888 pub description: Option<StringLiteral>,
889 pub schema: Ident,
890 pub rows: Vec<TableRow>,
891 pub span: SourceSpan,
892}
893
894#[derive(Clone, Debug, Eq, PartialEq)]
895pub struct TableRow {
896 pub body: BlockSource,
897 pub span: SourceSpan,
898}
899
900#[derive(Clone, Debug, Eq, PartialEq)]
901pub struct CoerceDecl {
902 pub name: Ident,
903 pub params: Vec<ParamDecl>,
904 pub output: TypeSyntax,
905 pub body: BlockSource,
906 pub span: SourceSpan,
907}
908
909#[derive(Clone, Debug, Eq, PartialEq)]
910pub struct ParamDecl {
911 pub name: Ident,
912 pub ty: TypeSyntax,
913 pub span: SourceSpan,
914}
915
916#[derive(Clone, Debug, Eq, PartialEq)]
917pub enum TypeSyntax {
918 Primitive {
919 name: String,
920 span: SourceSpan,
921 },
922 LiteralString {
923 value: String,
924 span: SourceSpan,
925 },
926 Ref {
927 name: Ident,
928 },
929 AgentRef {
930 agents: Vec<Ident>,
931 span: SourceSpan,
932 },
933 Optional {
934 inner: Box<TypeSyntax>,
935 span: SourceSpan,
936 },
937 Array {
938 inner: Box<TypeSyntax>,
939 span: SourceSpan,
940 },
941 Map {
942 inner: Box<TypeSyntax>,
943 span: SourceSpan,
944 },
945 Union {
946 variants: Vec<TypeSyntax>,
947 span: SourceSpan,
948 },
949}
950
951impl TypeSyntax {
952 fn span(&self) -> SourceSpan {
953 match self {
954 Self::Primitive { span, .. }
955 | Self::LiteralString { span, .. }
956 | Self::Optional { span, .. }
957 | Self::Array { span, .. }
958 | Self::Map { span, .. }
959 | Self::Union { span, .. }
960 | Self::AgentRef { span, .. } => *span,
961 Self::Ref { name } => name.span,
962 }
963 }
964}
965
966#[derive(Clone, Debug, Eq, PartialEq)]
967pub struct RuleDecl {
968 pub name: Ident,
969 pub tags: Vec<TagDecl>,
970 pub description: Option<StringLiteral>,
971 pub whens: Vec<WhenClause>,
972 pub body: BlockSource,
973 pub span: SourceSpan,
974}
975
976#[derive(Clone, Debug, Eq, PartialEq)]
977pub struct WhenClause {
978 pub text: String,
979 pub span: SourceSpan,
980}
981
982#[derive(Clone, Debug, Eq, PartialEq)]
983pub struct BlockSource {
984 pub text: String,
985 pub span: SourceSpan,
986}
987
988#[derive(Clone, Debug, Eq, PartialEq)]
989pub struct ParseOutput {
990 pub program: Program,
991 pub diagnostics: Vec<Diagnostic>,
992}
993
994#[derive(Clone, Debug, Eq, PartialEq)]
995pub struct CompileOutput {
996 pub ir: Option<IrProgram>,
997 pub diagnostics: Vec<Diagnostic>,
998 pub warnings: Vec<Diagnostic>,
1000}
1001
1002#[derive(Clone, Debug, Eq, PartialEq)]
1003pub struct FormatOutput {
1004 pub formatted: Option<String>,
1005 pub diagnostics: Vec<Diagnostic>,
1006}
1007
1008#[derive(Clone, Debug, Eq, PartialEq)]
1009pub struct IrProgram {
1010 pub workflow: String,
1011 pub source_tags: Vec<IrSourceTag>,
1012 pub source_descriptions: Vec<IrSourceDescription>,
1013 pub includes: Vec<IrInclude>,
1014 pub pattern_applications: Vec<IrPatternApplication>,
1015 pub workflow_contracts: Vec<IrWorkflowContract>,
1016 pub uses: Vec<IrUse>,
1017 pub harnesses: Vec<IrHarness>,
1018 pub trackers: Vec<IrTracker>,
1019 pub channels: Vec<IrChannel>,
1020 pub gauges: Vec<IrGauge>,
1021 pub marks: Vec<IrMark>,
1022 pub campaigns: Vec<IrCampaign>,
1023 pub file_stores: Vec<IrFileStore>,
1024 pub memory_pools: Vec<IrMemoryPool>,
1025 pub events: Vec<IrEvent>,
1026 pub sources: Vec<IrSource>,
1027 pub tests: Vec<IrTest>,
1028 pub leases: Vec<IrLease>,
1029 pub ledgers: Vec<IrLedger>,
1030 pub counters: Vec<IrCounter>,
1031 pub shared_coordination_usage: Vec<IrSharedCoordinationUsage>,
1032 pub schemas: Vec<IrSchema>,
1033 pub agents: Vec<IrAgent>,
1034 pub coerces: Vec<IrCoerce>,
1035 pub assertions: Vec<IrAssertion>,
1036 pub rules: Vec<IrRule>,
1037 pub rule_dependencies: Vec<IrRuleDependency>,
1038}
1039
1040#[derive(Clone, Debug, Eq, PartialEq)]
1041pub struct IrSharedCoordinationUsage {
1042 pub resource: String,
1043 pub workflow_principals: Vec<String>,
1044}
1045
1046#[derive(Clone, Debug, Eq, PartialEq)]
1047pub struct IrSourceTag {
1048 pub name: String,
1049 pub target_kind: String,
1050 pub target: String,
1051 pub span: SourceSpan,
1052}
1053
1054#[derive(Clone, Debug, Eq, PartialEq)]
1055pub struct IrSourceDescription {
1056 pub value: String,
1057 pub target_kind: String,
1058 pub target: String,
1059 pub span: SourceSpan,
1060}
1061
1062#[derive(Clone, Debug, Eq, PartialEq)]
1063pub struct IrPatternApplication {
1064 pub pattern: String,
1065 pub alias: String,
1066 pub type_args: Vec<IrType>,
1067 pub value_args: Vec<IrPatternArgument>,
1068 pub generated: Vec<String>,
1069 pub definition_span: SourceSpan,
1072 pub application_span: SourceSpan,
1074}
1075
1076#[derive(Clone, Debug, Eq, PartialEq)]
1077pub struct IrPatternArgument {
1078 pub name: String,
1079 pub value: String,
1080}
1081
1082#[derive(Clone, Debug, Eq, PartialEq)]
1083pub struct IrWorkflowContract {
1084 pub kind: IrWorkflowContractKind,
1085 pub name: String,
1086 pub ty: IrType,
1087 pub span: SourceSpan,
1088}
1089
1090#[derive(Clone, Debug, Eq, PartialEq)]
1091pub enum IrWorkflowContractKind {
1092 Input,
1093 Output,
1094 Failure,
1095}
1096
1097impl IrWorkflowContractKind {
1098 fn as_str(&self) -> &'static str {
1099 match self {
1100 Self::Input => "input",
1101 Self::Output => "output",
1102 Self::Failure => "failure",
1103 }
1104 }
1105}
1106
1107#[derive(Clone, Debug, Eq, PartialEq)]
1108pub struct IrInclude {
1109 pub path: String,
1110 pub source_hash: Option<String>,
1111}
1112
1113#[derive(Clone, Debug, Eq, PartialEq)]
1114pub struct IrAssertion {
1115 pub expr: IrExpression,
1116 pub projection_reads: Vec<IrProjectionRead>,
1117}
1118
1119#[derive(Clone, Debug, Eq, PartialEq)]
1120pub struct IrExpression {
1121 pub source: String,
1122 pub expr: Expr,
1123 pub span: SourceSpan,
1124}
1125
1126#[derive(Clone, Debug, Eq, PartialEq)]
1127pub struct IrUse {
1128 pub kind: IrUseKind,
1129 pub name: String,
1130}
1131
1132#[derive(Clone, Debug, Eq, PartialEq)]
1133pub enum IrUseKind {
1134 Package,
1135}
1136
1137#[derive(Clone, Debug, Eq, PartialEq)]
1138pub struct IrTracker {
1139 pub name: String,
1140 pub provider: String,
1141 pub span: SourceSpan,
1142}
1143
1144#[derive(Clone, Debug, Eq, PartialEq)]
1148pub struct IrChannel {
1149 pub name: String,
1150 pub provider: String,
1151 pub workspace: Option<String>,
1152 pub destination: Option<String>,
1153 pub span: SourceSpan,
1154}
1155
1156#[derive(Clone, Debug, Eq, PartialEq)]
1160pub struct IrMark {
1161 pub name: String,
1162 pub site: String,
1163 pub span: SourceSpan,
1164}
1165
1166#[derive(Clone, Debug, Eq, PartialEq)]
1171pub struct IrGauge {
1172 pub name: String,
1173 pub site: Option<String>,
1174 pub judge_kind: String,
1176 pub judge_target: String,
1179 pub judge_args: Vec<String>,
1184 pub expect: Option<IrGaugeBar>,
1185 pub inputs: Vec<String>,
1186 pub span: SourceSpan,
1187}
1188
1189#[derive(Clone, Debug, Eq, PartialEq)]
1193pub struct IrGaugeBar {
1194 pub form: String,
1195 pub subject: String,
1196 pub op: String,
1197 pub threshold: String,
1198}
1199
1200#[derive(Clone, Debug, Eq, PartialEq)]
1204pub struct IrCampaign {
1205 pub name: String,
1206 pub ascend: Vec<String>,
1207 pub reach: Vec<IrCampaignReach>,
1208 pub guard: Vec<IrCampaignGuard>,
1209 pub sacrifice: Vec<String>,
1210 pub proposer_redacted: bool,
1212 pub span: SourceSpan,
1213}
1214
1215#[derive(Clone, Debug, Eq, PartialEq)]
1216pub struct IrCampaignReach {
1217 pub gauge: String,
1218 pub op: String,
1219 pub threshold: String,
1220 pub unit: Option<String>,
1221}
1222
1223#[derive(Clone, Debug, Eq, PartialEq)]
1224pub struct IrCampaignGuard {
1225 pub gauge: String,
1226 pub band_percent: String,
1227}
1228
1229#[derive(Clone, Debug, Eq, PartialEq)]
1232pub struct IrFileStore {
1233 pub name: String,
1234 pub root: String,
1235 pub read_globs: Vec<String>,
1239 pub write_globs: Vec<String>,
1244 pub provider: Option<String>,
1248}
1249
1250#[derive(Clone, Debug, Eq, PartialEq)]
1254pub struct IrMemoryPool {
1255 pub name: String,
1256 pub context_limit: Option<u64>,
1259}
1260
1261#[derive(Clone, Debug, Eq, PartialEq)]
1262pub struct IrHarness {
1263 pub name: String,
1264 pub kind: String,
1265 pub span: SourceSpan,
1266}
1267
1268#[derive(Clone, Debug, Eq, PartialEq)]
1269pub enum IrSchema {
1270 Enum(IrEnum),
1271 Class(IrClass),
1272}
1273
1274#[derive(Clone, Debug, Eq, PartialEq)]
1275pub struct IrEnum {
1276 pub name: String,
1277 pub variants: Vec<String>,
1278 pub span: SourceSpan,
1279}
1280
1281#[derive(Clone, Debug, Eq, PartialEq)]
1282pub struct IrClass {
1283 pub name: String,
1284 pub fields: Vec<IrClassField>,
1285 pub span: SourceSpan,
1286}
1287
1288#[derive(Clone, Debug, Eq, PartialEq)]
1291pub struct IrEvent {
1292 pub name: String,
1293 pub fields: Vec<IrClassField>,
1294 pub span: SourceSpan,
1295}
1296
1297#[derive(Clone, Debug, Eq, PartialEq)]
1301pub struct IrSource {
1302 pub name: String,
1303 pub provider: String,
1304 pub is_clock: bool,
1305 pub is_file: bool,
1308 pub is_http: bool,
1312 pub recurrence: Option<Recurrence>,
1313 pub timezone: Option<String>,
1314 pub missed: Option<MissedPolicy>,
1315 pub path: Option<String>,
1318 pub watch: Option<String>,
1321 pub url: Option<String>,
1323 pub dedup_field: Option<String>,
1327 pub observe_binding: String,
1328 pub emit_signal: String,
1329 pub emit_from: Option<String>,
1333 pub emit_fields: Vec<IrSourceEmitField>,
1334 pub span: SourceSpan,
1335}
1336
1337#[derive(Clone, Debug, Eq, PartialEq)]
1338pub struct IrSourceEmitField {
1339 pub name: String,
1340 pub value: SourceValue,
1341 pub span: SourceSpan,
1342}
1343
1344#[derive(Clone, Debug, Eq, PartialEq)]
1348pub struct IrTest {
1349 pub name: String,
1350 pub workflow: Option<String>,
1351 pub clauses: Vec<TestClause>,
1352 pub span: SourceSpan,
1353}
1354
1355#[derive(Clone, Debug, Eq, PartialEq)]
1357pub struct IrLease {
1358 pub name: String,
1359 pub key_type: String,
1360 pub slots: u32,
1361 pub ttl_seconds: u64,
1362 pub shared: bool,
1363 pub span: SourceSpan,
1364}
1365
1366#[derive(Clone, Debug, Eq, PartialEq)]
1367pub struct IrLedger {
1368 pub name: String,
1369 pub entry_schema: String,
1370 pub partition_field: String,
1371 pub retain_seconds: u64,
1372 pub shared: bool,
1373 pub span: SourceSpan,
1374}
1375
1376#[derive(Clone, Debug, Eq, PartialEq)]
1377pub struct IrCounter {
1378 pub name: String,
1379 pub key_type: String,
1380 pub cap: i64,
1381 pub reset: String,
1382 pub timezone: Option<String>,
1384 pub shared: bool,
1385 pub span: SourceSpan,
1386}
1387
1388#[derive(Clone, Debug, Eq, PartialEq)]
1389pub struct IrClassField {
1390 pub name: String,
1391 pub ty: IrType,
1392 pub is_key: bool,
1394 pub presence_condition: Option<(String, String)>,
1398 pub span: SourceSpan,
1399}
1400
1401#[derive(Clone, Debug, Eq, PartialEq)]
1402pub enum IrType {
1403 Primitive(IrPrimitiveType),
1404 LiteralString(String),
1405 Ref(String),
1406 AgentRef(Vec<String>),
1407 Object(Vec<IrClassField>),
1408 Optional(Box<IrType>),
1409 Array(Box<IrType>),
1410 Map(Box<IrType>),
1411 Union(Vec<IrType>),
1412}
1413
1414#[derive(Clone, Debug, Eq, PartialEq)]
1415pub enum IrPrimitiveType {
1416 String,
1417 Int,
1418 Float,
1419 Bool,
1420 Null,
1421 Duration,
1422 Time,
1423 Image,
1424 Audio,
1425 Pdf,
1426 Video,
1427}
1428
1429#[derive(Clone, Debug, Eq, PartialEq)]
1430pub struct IrAgent {
1431 pub name: String,
1432 pub harness: Option<String>,
1433 pub provider: Option<String>,
1434 pub profile: Option<String>,
1435 pub capacity: Option<u32>,
1436 pub skills: Vec<String>,
1437 pub capabilities: Vec<String>,
1438 pub requires: Vec<String>,
1442 pub tools: Vec<String>,
1444 pub compaction: Option<String>,
1448 pub thread: Option<String>,
1451 pub settings: Option<String>,
1455 pub harness_class: HarnessClass,
1459}
1460
1461#[derive(Clone, Debug, Eq, PartialEq)]
1462pub struct IrCoerce {
1463 pub name: String,
1464 pub params: Vec<IrParam>,
1465 pub output: IrType,
1466 pub body: String,
1467}
1468
1469#[derive(Clone, Debug, Eq, PartialEq)]
1470pub struct IrParam {
1471 pub name: String,
1472 pub ty: IrType,
1473}
1474
1475#[derive(Clone, Debug, Eq, PartialEq)]
1476pub struct IrRule {
1477 pub name: String,
1478 pub whens: Vec<IrWhen>,
1479 pub body: String,
1480 pub metadata: IrRuleMetadata,
1481}
1482
1483#[derive(Clone, Debug, Eq, PartialEq)]
1484pub struct IrWhen {
1485 pub source: String,
1486 pub pattern: String,
1487 pub guard: Option<IrExpression>,
1488 pub span: SourceSpan,
1489}
1490
1491#[derive(Clone, Debug, Eq, PartialEq)]
1492pub struct IrRuleDependency {
1493 pub producer: String,
1494 pub consumer: String,
1495 pub fact: String,
1496}
1497
1498#[derive(Clone, Debug, Eq, PartialEq)]
1501pub struct IrRegionEffect {
1502 pub binding: String,
1503 pub scope: Option<(String, String)>,
1504}
1505
1506#[derive(Clone, Debug, Eq, PartialEq)]
1514pub struct IrRegion {
1515 pub until: bool,
1516 pub condition: String,
1519 pub lapse_binding: Option<String>,
1520 pub effects: Vec<IrRegionEffect>,
1521 pub body_removed: String,
1522 pub body_lapsed: String,
1523}
1524
1525#[derive(Clone, Debug, Default, Eq, PartialEq)]
1526pub struct IrRuleMetadata {
1527 pub fact_reads: Vec<String>,
1528 pub projection_reads: Vec<IrProjectionRead>,
1529 pub fact_writes: Vec<String>,
1530 pub record_sources: Vec<IrRecordSource>,
1531 pub fact_consumes: Vec<String>,
1532 pub effects: Vec<IrEffectNode>,
1533 pub dependencies: Vec<IrEffectDependency>,
1534 pub region: Option<IrRegion>,
1536 pub case_branches: Vec<IrRuleCaseBranch>,
1537 pub terminal_outputs: Vec<IrTerminalOutput>,
1538 pub terminal_branches: Vec<IrTerminalCaseBranch>,
1539 pub terminal_completes: Vec<String>,
1546 pub redactions: Vec<IrRedaction>,
1554 pub egress_payload_reads: BTreeMap<String, BTreeSet<String>>,
1563 pub declassified_roots: BTreeSet<String>,
1571 pub endorsed_roots: BTreeSet<String>,
1576 pub endorsed_claim_items: BTreeSet<String>,
1586 pub record_field_reads: BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
1596 pub coerce_input_roots: BTreeMap<String, BTreeSet<String>>,
1604 pub after_aliases: BTreeMap<String, String>,
1609 pub egress_case_influence: BTreeMap<String, BTreeSet<String>>,
1615 pub complete_field_reads: BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
1624 pub milestone_field_reads: BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
1630 pub bounded_egresses: Vec<IrBoundedEgress>,
1634 pub max_after_depth: usize,
1638}
1639
1640#[derive(Clone, Debug, Eq, PartialEq)]
1647pub struct IrBoundedEgress {
1648 pub sink: String,
1650 pub source_schema: String,
1653 pub keep: Vec<String>,
1655}
1656
1657#[derive(Clone, Debug, Eq, PartialEq)]
1661pub struct IrRedaction {
1662 pub source: String,
1663 pub keep: Vec<String>,
1664 pub binding: String,
1665 pub source_schema: Option<String>,
1673}
1674
1675#[derive(Clone, Debug, Eq, PartialEq)]
1676pub struct IrRecordSource {
1677 pub schema: String,
1678 pub construct: String,
1679 pub span: SourceSpan,
1680}
1681
1682#[derive(Clone, Debug, Eq, PartialEq)]
1683pub struct IrProjectionRead {
1684 pub kind: QueryKind,
1685 pub head: String,
1686 pub guard: Option<String>,
1687}
1688
1689impl IrProjectionRead {
1690 fn to_snapshot(&self) -> String {
1691 let prefix = match self.kind {
1692 QueryKind::Fact => format!("fact:{}", self.head),
1693 QueryKind::Effect => format!("effect:{}", self.head),
1694 };
1695 match &self.guard {
1696 Some(guard) => format!("{prefix} where {guard}"),
1697 None => prefix,
1698 }
1699 }
1700}
1701
1702#[derive(Clone, Debug, Eq, PartialEq)]
1703pub struct IrEffectNode {
1704 pub id: String,
1705 pub kind: IrEffectKind,
1706 pub binding: Option<String>,
1707 pub required_capabilities: Vec<String>,
1708 pub construct_use: Option<IrConstructUse>,
1709 pub idempotency_key: String,
1710 pub span: SourceSpan,
1711 pub timeout_seconds: Option<u64>,
1713 pub access_grants: Vec<IrAccessGrant>,
1716 pub turn_skills: Vec<String>,
1720 pub resource: Option<String>,
1725 pub agent: Option<String>,
1729 pub workflow_target: Option<String>,
1733 pub endorsed: bool,
1737 pub declassified: bool,
1741 pub selected_by: Option<(String, String)>,
1747 pub exec_target: Option<IrExecTarget>,
1753}
1754
1755#[derive(Clone, Debug, Eq, PartialEq)]
1759pub enum IrExecTarget {
1760 Raw,
1761 Capability { name: String },
1762}
1763
1764#[derive(Clone, Debug, Eq, PartialEq)]
1767pub struct IrAccessGrant {
1768 pub resource: String,
1769 pub operations: Vec<IrAccessGrantOp>,
1770}
1771
1772#[derive(Clone, Debug, Eq, PartialEq)]
1773pub struct IrAccessGrantOp {
1774 pub operation: String,
1775 pub target: Option<String>,
1776 pub globs: Vec<String>,
1777}
1778
1779#[derive(Clone, Debug, Eq, PartialEq)]
1780pub struct IrConstructUse {
1781 pub keyword: String,
1782 pub scope: String,
1783 pub construct_family: String,
1784 pub lowering_target: String,
1785 pub target_capability: String,
1786}
1787
1788#[derive(Clone, Debug, Eq, PartialEq)]
1789pub enum IrEffectKind {
1790 AgentTell,
1791 SchemaCoerce,
1792 CapabilityCall,
1793 EventEmit,
1794 WorkflowInvoke,
1795 TimerWait,
1796 ExecCommand,
1797 TrackerFile,
1798 TrackerClaim,
1799 TrackerRenew,
1800 TrackerRelease,
1801 TrackerFinish,
1802 LeaseAcquire,
1803 LeaseRenew,
1804 LedgerAppend,
1805 CounterConsume,
1806 SignalEmit,
1807 FileRead,
1808 FileWrite,
1809 FileImport,
1810 FileExport,
1811}
1812
1813#[derive(Clone, Debug, Eq, PartialEq)]
1814pub struct IrEffectDependency {
1815 pub upstream: String,
1816 pub predicate: DependencyPredicate,
1817 pub downstream: String,
1818}
1819
1820#[derive(Clone, Debug, Eq, PartialEq)]
1821pub struct IrRuleCaseBranch {
1822 pub scrutinee: String,
1823 pub scrutinee_type: IrType,
1824 pub pattern: IrCasePattern,
1825 pub guard: Option<IrExpression>,
1826 pub body_hash: String,
1827 pub pattern_span: SourceSpan,
1828}
1829
1830#[derive(Clone, Debug, Eq, PartialEq)]
1831pub enum IrCasePattern {
1832 EnumVariant(String),
1833 LiteralString(String),
1834 Agent(String),
1835 OptionalSome { binding: String },
1836 OptionalNone,
1837 Wildcard,
1838}
1839
1840impl IrCasePattern {
1841 fn to_snapshot(&self) -> String {
1842 match self {
1843 IrCasePattern::EnumVariant(value) => format!("enum:{value}"),
1844 IrCasePattern::LiteralString(value) => format!("literal:\"{value}\""),
1845 IrCasePattern::Agent(value) => format!("agent:{value}"),
1846 IrCasePattern::OptionalSome { binding } => format!("some:{binding}"),
1847 IrCasePattern::OptionalNone => "none".to_owned(),
1848 IrCasePattern::Wildcard => "_".to_owned(),
1849 }
1850 }
1851}
1852
1853#[derive(Clone, Debug, Eq, PartialEq)]
1854pub struct IrTerminalOutput {
1855 pub binding: String,
1856 pub alternatives: Vec<IrTerminalAlternative>,
1857 pub span: SourceSpan,
1858}
1859
1860#[derive(Clone, Debug, Eq, PartialEq)]
1861pub struct IrTerminalAlternative {
1862 pub tag: String,
1863 pub payload_type: IrType,
1864 pub source_span: SourceSpan,
1865}
1866
1867#[derive(Clone, Debug, Eq, PartialEq)]
1868pub struct IrTerminalCaseBranch {
1869 pub scrutinee: String,
1870 pub tag: Option<String>,
1871 pub binding: Option<String>,
1872 pub guard: Option<IrExpression>,
1873 pub body_hash: String,
1874 pub pattern_span: SourceSpan,
1875}
1876
1877#[derive(Clone, Debug, Eq, PartialEq)]
1878pub enum DependencyPredicate {
1879 Succeeds,
1880 Fails,
1881 TimedOut,
1882 Cancelled,
1883 Completes,
1884}
1885
1886#[derive(Clone, Debug)]
1887struct SemanticContext {
1888 workflow: Option<String>,
1889 schemas: SchemaIndex,
1890 agents: BTreeSet<String>,
1891 agent_capabilities: BTreeMap<String, BTreeSet<String>>,
1892 coerce_outputs: BTreeMap<String, TypeSyntax>,
1893 coerce_params: BTreeMap<String, Vec<ParamDecl>>,
1894 workflow_inputs: BTreeMap<String, WorkflowInputSurface>,
1895 leases: BTreeSet<String>,
1897 ledgers: BTreeSet<String>,
1898 counters: BTreeSet<String>,
1899 channels: BTreeSet<String>,
1901 channel_providers: BTreeMap<String, String>,
1906 memory_pools: BTreeSet<String>,
1909}
1910
1911#[derive(Clone, Debug, Default)]
1912struct WorkflowInputSurface {
1913 inputs: BTreeMap<String, TypeSyntax>,
1914 outputs: BTreeMap<String, TypeSyntax>,
1919 failures: BTreeMap<String, TypeSyntax>,
1926 schemas: SchemaIndex,
1927 milestones: BTreeMap<String, String>,
1933}
1934
1935#[derive(Clone, Debug, Default)]
1936struct SchemaIndex {
1937 classes: BTreeMap<String, BTreeMap<String, TypeSyntax>>,
1938 enums: BTreeMap<String, BTreeSet<String>>,
1939 events: BTreeSet<String>,
1942 presence: BTreeMap<String, BTreeMap<String, (String, String)>>,
1946}
1947
1948#[derive(Clone, Debug, Eq, PartialEq)]
1949enum BlockFrame {
1950 After {
1951 binding: String,
1952 predicate: DependencyPredicate,
1953 },
1954}
1955
1956#[derive(Clone, Debug, Eq, PartialEq)]
1957enum LiteralExpr<'a> {
1958 String(&'a str),
1959 Number(&'a str),
1960 Bool,
1961 Null,
1962 Ident(&'a str),
1963}
1964
1965#[derive(Clone, Debug, Eq, PartialEq)]
1966enum ExprType {
1967 Bool,
1968 Int,
1969 Float,
1970 String,
1971 Duration,
1972 Time,
1973 Null,
1974 Object,
1975 Optional(Box<ExprType>),
1976 Array(Box<ExprType>),
1977 Map(Box<ExprType>),
1978 Finite { label: String, values: Vec<String> },
1979 Collection,
1980 Unknown,
1981}
1982
1983#[derive(Clone, Debug, Eq, PartialEq)]
1984pub enum Expr {
1985 Literal(ExprLiteral),
1986 Path(Vec<String>),
1987 Index {
1988 target: Box<Expr>,
1989 key: Box<Expr>,
1990 },
1991 Array(Vec<Expr>),
1992 Object(Vec<ExprObjectField>),
1993 Unary {
1994 op: UnaryOp,
1995 expr: Box<Expr>,
1996 },
1997 Binary {
1998 op: BinaryOp,
1999 left: Box<Expr>,
2000 right: Box<Expr>,
2001 },
2002 Call {
2003 name: String,
2004 args: Vec<Expr>,
2005 },
2006 Query {
2007 kind: QueryKind,
2008 head: String,
2009 guard: Option<Box<Expr>>,
2010 },
2011}
2012
2013#[derive(Clone, Debug, Eq, PartialEq)]
2014pub struct ExprObjectField {
2015 pub key: String,
2016 pub value: Expr,
2017}
2018
2019#[derive(Clone, Debug, Eq, PartialEq)]
2020pub enum ExprLiteral {
2021 String(String),
2022 Number(String),
2023 Bool(bool),
2024 Null,
2025 Ident(String),
2026}
2027
2028#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2029pub enum UnaryOp {
2030 Not,
2031}
2032
2033#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2034pub enum BinaryOp {
2035 Or,
2036 And,
2037 Eq,
2038 Ne,
2039 Lt,
2040 Le,
2041 Gt,
2042 Ge,
2043 In,
2044 NotIn,
2045 Add,
2046 Sub,
2047 Mul,
2048 Div,
2049}
2050
2051#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2052pub enum QueryKind {
2053 Fact,
2054 Effect,
2055}
2056
2057pub fn parse_expression(expr: &str) -> Result<Expr, String> {
2059 ExprParser::new(expr).parse()
2060}
2061
2062impl Expr {
2063 pub fn to_snapshot(&self) -> String {
2064 match self {
2065 Self::Literal(literal) => literal.to_snapshot(),
2066 Self::Path(path) => path.join("."),
2067 Self::Index { target, key } => {
2068 format!(
2069 "{}[{}]",
2070 target.to_snapshot_with_parentheses(),
2071 key.to_snapshot()
2072 )
2073 }
2074 Self::Array(items) => {
2075 let items = items
2076 .iter()
2077 .map(Self::to_snapshot)
2078 .collect::<Vec<_>>()
2079 .join(", ");
2080 format!("[{items}]")
2081 }
2082 Self::Object(fields) => {
2083 let fields = fields
2084 .iter()
2085 .map(|field| format!("{} {}", field.key, field.value.to_snapshot()))
2086 .collect::<Vec<_>>()
2087 .join(", ");
2088 format!("{{{fields}}}")
2089 }
2090 Self::Unary { op, expr } => match op {
2091 UnaryOp::Not => format!("!{}", expr.to_snapshot_with_parentheses()),
2092 },
2093 Self::Binary { op, left, right } => format!(
2094 "{} {} {}",
2095 left.to_snapshot_with_parentheses(),
2096 op.to_snapshot(),
2097 right.to_snapshot_with_parentheses()
2098 ),
2099 Self::Call { name, args } => {
2100 let args = args
2101 .iter()
2102 .map(Self::to_snapshot)
2103 .collect::<Vec<_>>()
2104 .join(", ");
2105 format!("{name}({args})")
2106 }
2107 Self::Query { kind, head, guard } => {
2108 let prefix = match kind {
2109 QueryKind::Fact => head.clone(),
2110 QueryKind::Effect => format!("effect {head}"),
2111 };
2112 match guard {
2113 Some(guard) => format!("{prefix} where {}", guard.to_snapshot()),
2114 None => prefix,
2115 }
2116 }
2117 }
2118 }
2119
2120 fn to_snapshot_with_parentheses(&self) -> String {
2121 match self {
2122 Self::Binary { .. } => format!("({})", self.to_snapshot()),
2123 _ => self.to_snapshot(),
2124 }
2125 }
2126}
2127
2128impl ExprLiteral {
2129 fn to_snapshot(&self) -> String {
2130 match self {
2131 Self::String(value) => format!("{value:?}"),
2132 Self::Number(value) | Self::Ident(value) => value.clone(),
2133 Self::Bool(value) => value.to_string(),
2134 Self::Null => "null".to_owned(),
2135 }
2136 }
2137}
2138
2139impl BinaryOp {
2140 fn to_snapshot(self) -> &'static str {
2141 match self {
2142 Self::Or => "||",
2143 Self::And => "&&",
2144 Self::Eq => "==",
2145 Self::Ne => "!=",
2146 Self::Lt => "<",
2147 Self::Le => "<=",
2148 Self::Gt => ">",
2149 Self::Ge => ">=",
2150 Self::In => "in",
2151 Self::NotIn => "not in",
2152 Self::Add => "+",
2153 Self::Sub => "-",
2154 Self::Mul => "*",
2155 Self::Div => "/",
2156 }
2157 }
2158}
2159
2160pub fn parse_program(source: &str) -> ParseOutput {
2162 let lexed = lex(source);
2163 let mut parser = Parser {
2164 source,
2165 tokens: lexed.tokens,
2166 pos: 0,
2167 diagnostics: lexed.diagnostics,
2168 pending_contract_classes: Vec::new(),
2169 };
2170
2171 let program = parser.parse_program();
2172 ParseOutput {
2173 program,
2174 diagnostics: parser.diagnostics,
2175 }
2176}
2177
2178pub fn compile_program(source: &str) -> CompileOutput {
2180 compile_program_with_root(source, None)
2181}
2182
2183pub fn compile_program_with_root(source: &str, root: Option<&str>) -> CompileOutput {
2186 let parsed = parse_program(source);
2187 if !parsed.diagnostics.is_empty() {
2188 return CompileOutput {
2189 ir: None,
2190 diagnostics: parsed.diagnostics,
2191 warnings: Vec::new(),
2192 };
2193 }
2194
2195 let mut invoke_recursion_diagnostics = Vec::new();
2200 detect_workflow_invoke_recursion(&parsed.program, &mut invoke_recursion_diagnostics);
2201 detect_private_workflow_invocations(&parsed.program, &mut invoke_recursion_diagnostics);
2202 if !invoke_recursion_diagnostics.is_empty() {
2203 return CompileOutput {
2204 ir: None,
2205 diagnostics: invoke_recursion_diagnostics,
2206 warnings: Vec::new(),
2207 };
2208 }
2209
2210 let workflow_inputs = collect_workflow_input_surfaces(&parsed.program);
2211 let shared_coordination_usage = collect_shared_coordination_usage(&parsed.program);
2212
2213 if parsed.program.workflows.len() > 1 {
2224 let global_names: BTreeSet<String> = parsed
2231 .program
2232 .items
2233 .iter()
2234 .filter_map(|item| referenced_decl_name(item).map(|(name, _)| name))
2235 .collect();
2236 let mut sibling_locals: BTreeMap<String, Vec<(String, SourceSpan)>> = BTreeMap::new();
2237 for workflow in &parsed.program.workflows {
2238 for item in &workflow.items {
2239 if let Some((name, span)) = referenced_decl_name(item) {
2240 sibling_locals
2241 .entry(name)
2242 .or_default()
2243 .push((workflow.name.name.clone(), span));
2244 }
2245 }
2246 }
2247
2248 let mut aggregated = Vec::new();
2249 for workflow in &parsed.program.workflows {
2250 let name = workflow.name.name.clone();
2251 let own_locals: BTreeSet<String> = workflow
2252 .items
2253 .iter()
2254 .filter_map(|item| referenced_decl_name(item).map(|(name, _)| name))
2255 .collect();
2256 let mut diagnostics = match select_root_workflow(parsed.program.clone(), Some(&name)) {
2257 Ok(scoped) => {
2258 lower_program(
2259 scoped,
2260 workflow_inputs.clone(),
2261 shared_coordination_usage.clone(),
2262 )
2263 .diagnostics
2264 }
2265 Err(diagnostics) => diagnostics,
2266 };
2267 for diagnostic in &mut diagnostics {
2268 annotate_cross_workflow_leak(
2269 diagnostic,
2270 &name,
2271 &own_locals,
2272 &global_names,
2273 &sibling_locals,
2274 );
2275 }
2276 aggregated.extend(diagnostics);
2277 }
2278 if !aggregated.is_empty() {
2279 return CompileOutput {
2280 ir: None,
2281 diagnostics: aggregated,
2282 warnings: Vec::new(),
2283 };
2284 }
2285 }
2286
2287 match select_root_workflow(parsed.program, root) {
2288 Ok(program) => lower_program(program, workflow_inputs, shared_coordination_usage),
2289 Err(diagnostics) => CompileOutput {
2290 ir: None,
2291 diagnostics,
2292 warnings: Vec::new(),
2293 },
2294 }
2295}
2296
2297#[derive(Clone, Debug, Eq, PartialEq)]
2300pub struct DeclSymbol {
2301 pub name: String,
2302 pub kind: &'static str,
2303 pub span: SourceSpan,
2304}
2305
2306pub fn document_symbols(source: &str) -> Vec<DeclSymbol> {
2309 let program = parse_program(source).program;
2310 let mut symbols = Vec::new();
2311 if let Some(workflow) = &program.workflow {
2312 symbols.push(DeclSymbol {
2313 name: workflow.name.clone(),
2314 kind: "workflow",
2315 span: workflow.span,
2316 });
2317 }
2318 for workflow in &program.workflows {
2319 symbols.push(DeclSymbol {
2320 name: workflow.name.name.clone(),
2321 kind: "workflow",
2322 span: workflow.span,
2323 });
2324 }
2325 for pattern in &program.patterns {
2326 symbols.push(DeclSymbol {
2327 name: pattern.name.name.clone(),
2328 kind: "pattern",
2329 span: pattern.span,
2330 });
2331 }
2332 for item in &program.items {
2333 let symbol = match item {
2334 Item::Class(decl) => ("class", decl.name.name.clone(), decl.span),
2335 Item::Enum(decl) => ("enum", decl.name.name.clone(), decl.span),
2336 Item::Agent(decl) => ("agent", decl.name.name.clone(), decl.span),
2337 Item::Rule(decl) => ("rule", decl.name.name.clone(), decl.span),
2338 Item::Coerce(decl) => ("coerce", decl.name.name.clone(), decl.span),
2339 Item::Action(decl) => ("action", decl.name.name.clone(), decl.span),
2340 Item::Lease(decl) => ("lease", decl.name.name.clone(), decl.span),
2341 Item::Ledger(decl) => ("ledger", decl.name.name.clone(), decl.span),
2342 Item::Counter(decl) => ("counter", decl.name.name.clone(), decl.span),
2343 Item::Tracker(decl) => ("tracker", decl.name.name.clone(), decl.span),
2344 Item::Channel(decl) => ("channel", decl.name.name.clone(), decl.span),
2345 Item::FileStore(decl) => ("file store", decl.name.name.clone(), decl.span),
2346 Item::MemoryPool(decl) => ("memory pool", decl.name.name.clone(), decl.span),
2347 Item::Event(decl) => ("signal", decl.name.clone(), decl.span),
2348 Item::Table(decl) => ("table", decl.name.name.clone(), decl.span),
2349 Item::Gauge(decl) => ("gauge", decl.name.name.clone(), decl.span),
2350 Item::Campaign(decl) => ("campaign", decl.name.name.clone(), decl.span),
2351 Item::Mark(decl) => ("mark", decl.name.value.clone(), decl.span),
2352 _ => continue,
2353 };
2354 symbols.push(DeclSymbol {
2355 name: symbol.1,
2356 kind: symbol.0,
2357 span: symbol.2,
2358 });
2359 }
2360 symbols
2361}
2362
2363pub fn format_program(source: &str) -> FormatOutput {
2365 let parsed = parse_program(source);
2366 if !parsed.diagnostics.is_empty() {
2367 return FormatOutput {
2368 formatted: None,
2369 diagnostics: parsed.diagnostics,
2370 };
2371 }
2372
2373 FormatOutput {
2374 formatted: Some(format_syntax(parsed.program)),
2375 diagnostics: Vec::new(),
2376 }
2377}
2378
2379pub fn format_program_preserving_comments(source: &str) -> Option<String> {
2393 let parsed = parse_program(source);
2394 if !parsed.diagnostics.is_empty() {
2395 return None;
2396 }
2397 let mut comments = lex_comments(source);
2398 if comments.is_empty() {
2399 return Some(format_syntax(parsed.program));
2400 }
2401 comments.sort_by_key(|comment| comment.span.start);
2404 let program = parsed.program;
2405
2406 let mut elements: Vec<(SourceSpan, String)> = Vec::new();
2408 if let Some(workflow) = program.workflow {
2409 let mut chunk = String::new();
2410 format_tags(&program.workflow_tags, &mut chunk);
2411 format_description(program.workflow_description.as_ref(), &mut chunk);
2412 push_line(&mut chunk, format!("workflow {}", workflow.name));
2413 elements.push((workflow.span, chunk));
2414 }
2415 for pattern in program.patterns {
2416 let span = pattern.span;
2417 let mut chunk = String::new();
2418 format_pattern(pattern, &mut chunk);
2419 elements.push((span, chunk));
2420 }
2421 for item in program.items {
2422 let span = item.span();
2423 let mut chunk = String::new();
2424 let placed = match &item {
2429 Item::Class(class_decl) => Some(try_format_class_with_comments(
2430 class_decl, source, &comments, &mut chunk,
2431 )),
2432 Item::Agent(agent) => Some(try_format_agent_with_comments(
2433 agent, source, &comments, &mut chunk,
2434 )),
2435 Item::Enum(enum_decl) => Some(try_format_enum_with_comments(
2436 enum_decl, source, &comments, &mut chunk,
2437 )),
2438 Item::Event(event) => Some(try_format_event_with_comments(
2439 event, source, &comments, &mut chunk,
2440 )),
2441 Item::Tracker(queue) => Some(try_format_tracker_with_comments(
2442 queue, source, &comments, &mut chunk,
2443 )),
2444 Item::FileStore(file_store) => Some(try_format_filestore_with_comments(
2445 file_store, source, &comments, &mut chunk,
2446 )),
2447 _ => None,
2448 };
2449 match placed {
2450 Some(true) => {}
2451 Some(false) => return None,
2452 None => format_item(item, &mut chunk),
2453 }
2454 elements.push((span, chunk));
2455 }
2456 for workflow in program.workflows {
2457 let span = workflow.span;
2458 let mut chunk = String::new();
2459 format_workflow(workflow, &mut chunk);
2460 elements.push((span, chunk));
2461 }
2462 elements.sort_by_key(|(span, _)| span.start);
2463
2464 let mut leading: Vec<&Comment> = Vec::new();
2474 let mut element_trailing: Vec<Option<&Comment>> = vec![None; elements.len()];
2475 for comment in &comments {
2476 let in_body = elements
2477 .iter()
2478 .any(|(span, _)| span.start < comment.span.start && comment.span.start < span.end);
2479 if in_body {
2480 continue;
2481 }
2482 let line_start = source[..comment.span.start]
2483 .rfind('\n')
2484 .map(|newline| newline + 1)
2485 .unwrap_or(0);
2486 if source[line_start..comment.span.start].trim().is_empty() {
2487 leading.push(comment);
2488 continue;
2489 }
2490 let comment_line = line_index(source, comment.span.start);
2491 let mut placed = false;
2492 for (index, (span, _)) in elements.iter().enumerate() {
2493 if line_index(source, span.end.saturating_sub(1)) == comment_line {
2494 if element_trailing[index].is_some() {
2495 return None;
2496 }
2497 element_trailing[index] = Some(comment);
2498 placed = true;
2499 break;
2500 }
2501 }
2502 if !placed {
2503 return None;
2504 }
2505 }
2506
2507 let mut out = String::new();
2508 let mut next_comment = 0;
2509 let element_count = elements.len();
2510 for (index, (span, chunk)) in elements.iter().enumerate() {
2511 while next_comment < leading.len() && leading[next_comment].span.start < span.start {
2512 push_line(&mut out, format_comment(leading[next_comment]));
2513 next_comment += 1;
2514 }
2515 match element_trailing[index] {
2516 Some(comment) => {
2517 out.push_str(chunk.strip_suffix('\n').unwrap_or(chunk));
2518 out.push_str(&format!(" {}\n", format_comment(comment)));
2519 }
2520 None => out.push_str(chunk),
2521 }
2522 if index + 1 < element_count {
2523 out.push('\n');
2524 }
2525 }
2526 if next_comment < leading.len() {
2527 if element_count > 0 {
2528 out.push('\n');
2529 }
2530 while next_comment < leading.len() {
2531 push_line(&mut out, format_comment(leading[next_comment]));
2532 next_comment += 1;
2533 }
2534 }
2535
2536 if lex_comments(&out).len() != comments.len() {
2541 return None;
2542 }
2543 Some(out)
2544}
2545
2546fn format_comment(comment: &Comment) -> String {
2547 let marker = match comment.marker {
2548 CommentMarker::Hash => "#",
2549 CommentMarker::Slash => "//",
2550 };
2551 let text = comment.text.trim();
2552 if text.is_empty() {
2553 marker.to_owned()
2554 } else {
2555 format!("{marker} {text}")
2556 }
2557}
2558
2559fn line_index(source: &str, offset: usize) -> usize {
2561 source.as_bytes()[..offset]
2562 .iter()
2563 .filter(|&&byte| byte == b'\n')
2564 .count()
2565}
2566
2567fn classify_body_comments<'a>(
2576 source: &str,
2577 body: SourceSpan,
2578 members: &[(SourceSpan, Vec<String>)],
2579 comments: &'a [Comment],
2580) -> Option<(Vec<&'a Comment>, Vec<Option<&'a Comment>>)> {
2581 let mut own_line: Vec<&Comment> = Vec::new();
2582 let mut trailing: Vec<Option<&Comment>> = vec![None; members.len()];
2583 for comment in comments {
2584 if comment.span.start <= body.start || comment.span.start >= body.end {
2585 continue;
2586 }
2587 if members.iter().any(|(span, lines)| {
2590 lines.len() > 1 && span.start < comment.span.start && comment.span.start < span.end
2591 }) {
2592 return None;
2593 }
2594 let line_start = source[..comment.span.start]
2595 .rfind('\n')
2596 .map(|index| index + 1)
2597 .unwrap_or(0);
2598 if source[line_start..comment.span.start].trim().is_empty() {
2599 own_line.push(comment);
2600 continue;
2601 }
2602 let comment_line = line_index(source, comment.span.start);
2604 let mut placed = false;
2605 for (index, (span, lines)) in members.iter().enumerate() {
2606 if lines.len() == 1 && line_index(source, span.start) == comment_line {
2607 if trailing[index].is_some() {
2608 return None;
2609 }
2610 trailing[index] = Some(comment);
2611 placed = true;
2612 break;
2613 }
2614 }
2615 if !placed {
2616 return None;
2617 }
2618 }
2619 Some((own_line, trailing))
2620}
2621
2622fn emit_members_with_comments(
2627 members: &[(SourceSpan, Vec<String>)],
2628 own_line: &[&Comment],
2629 trailing: &[Option<&Comment>],
2630 indent: &str,
2631 formatted: &mut String,
2632) {
2633 let mut next = 0;
2634 for (index, (span, lines)) in members.iter().enumerate() {
2635 while next < own_line.len() && own_line[next].span.start < span.start {
2636 push_line(
2637 formatted,
2638 format!("{indent}{}", format_comment(own_line[next])),
2639 );
2640 next += 1;
2641 }
2642 let last = lines.len().saturating_sub(1);
2643 for (offset, line) in lines.iter().enumerate() {
2644 match trailing[index] {
2645 Some(comment) if offset == last => {
2646 push_line(formatted, format!("{line} {}", format_comment(comment)));
2647 }
2648 _ => push_line(formatted, line.clone()),
2649 }
2650 }
2651 }
2652 while next < own_line.len() {
2653 push_line(
2654 formatted,
2655 format!("{indent}{}", format_comment(own_line[next])),
2656 );
2657 next += 1;
2658 }
2659}
2660
2661fn try_format_class_with_comments(
2665 class_decl: &ClassDecl,
2666 source: &str,
2667 comments: &[Comment],
2668 formatted: &mut String,
2669) -> bool {
2670 let members: Vec<(SourceSpan, Vec<String>)> = class_decl
2671 .fields
2672 .iter()
2673 .map(|field| {
2674 let key = if field.is_key { " @key" } else { "" };
2675 (
2676 field.span,
2677 vec![format!(
2678 " {} {}{key}",
2679 field.name.name,
2680 field.ty.to_source()
2681 )],
2682 )
2683 })
2684 .collect();
2685 let Some((own_line, trailing)) =
2686 classify_body_comments(source, class_decl.span, &members, comments)
2687 else {
2688 return false;
2689 };
2690 push_line(formatted, format!("class {} {{", class_decl.name.name));
2691 emit_members_with_comments(&members, &own_line, &trailing, " ", formatted);
2692 push_line(formatted, "}");
2693 true
2694}
2695
2696fn try_format_tracker_with_comments(
2700 queue: &TrackerDecl,
2701 source: &str,
2702 comments: &[Comment],
2703 formatted: &mut String,
2704) -> bool {
2705 let members: Vec<(SourceSpan, Vec<String>)> = vec![(
2706 queue.provider.span,
2707 vec![format!(" provider {}", queue.provider.name)],
2708 )];
2709 let Some((own_line, trailing)) = classify_body_comments(source, queue.span, &members, comments)
2710 else {
2711 return false;
2712 };
2713 push_line(formatted, format!("tracker {} {{", queue.name.name));
2714 emit_members_with_comments(&members, &own_line, &trailing, " ", formatted);
2715 push_line(formatted, "}");
2716 true
2717}
2718
2719fn try_format_filestore_with_comments(
2724 file_store: &FileStoreDecl,
2725 source: &str,
2726 comments: &[Comment],
2727 formatted: &mut String,
2728) -> bool {
2729 let render = |globs: &[String]| {
2730 globs
2731 .iter()
2732 .map(|glob| format!("{glob:?}"))
2733 .collect::<Vec<_>>()
2734 .join(", ")
2735 };
2736 let mut members: Vec<(SourceSpan, Vec<String>)> = Vec::new();
2737 if let Some(span) = file_store.root_span {
2738 members.push((span, vec![format!(" root {:?}", file_store.root)]));
2739 }
2740 if !file_store.read_globs.is_empty() {
2741 if let Some(span) = file_store.read_span {
2742 members.push((
2743 span,
2744 vec![format!(" allow read [{}]", render(&file_store.read_globs))],
2745 ));
2746 }
2747 }
2748 if !file_store.write_globs.is_empty() {
2749 if let Some(span) = file_store.write_span {
2750 members.push((
2751 span,
2752 vec![format!(
2753 " allow write [{}]",
2754 render(&file_store.write_globs)
2755 )],
2756 ));
2757 }
2758 }
2759 if let Some(provider) = &file_store.provider {
2760 if let Some(span) = file_store.provider_span {
2761 members.push((span, vec![format!(" provider {}", provider.name)]));
2762 }
2763 }
2764 members.sort_by_key(|(span, _)| span.start);
2765 let Some((own_line, trailing)) =
2766 classify_body_comments(source, file_store.span, &members, comments)
2767 else {
2768 return false;
2769 };
2770 push_line(formatted, format!("file store {} {{", file_store.name.name));
2771 emit_members_with_comments(&members, &own_line, &trailing, " ", formatted);
2772 push_line(formatted, "}");
2773 true
2774}
2775
2776fn try_format_event_with_comments(
2780 event: &EventDecl,
2781 source: &str,
2782 comments: &[Comment],
2783 formatted: &mut String,
2784) -> bool {
2785 let members: Vec<(SourceSpan, Vec<String>)> = event
2786 .fields
2787 .iter()
2788 .map(|field| {
2789 (
2790 field.span,
2791 vec![format!(" {} {}", field.name.name, field.ty.to_source())],
2792 )
2793 })
2794 .collect();
2795 let Some((own_line, trailing)) = classify_body_comments(source, event.span, &members, comments)
2796 else {
2797 return false;
2798 };
2799 push_line(formatted, format!("signal {} {{", event.name));
2800 emit_members_with_comments(&members, &own_line, &trailing, " ", formatted);
2801 push_line(formatted, "}");
2802 true
2803}
2804
2805fn agent_field_span(field: &AgentField) -> SourceSpan {
2806 match field {
2807 AgentField::Provider(ident) => ident.span,
2808 AgentField::Profile(profile) => profile.span,
2809 AgentField::Capacity(_, span)
2810 | AgentField::Skills(_, span)
2811 | AgentField::Capabilities(_, span)
2812 | AgentField::Requires(_, span)
2813 | AgentField::Tools(_, span) => *span,
2814 AgentField::Compaction(strategy) => strategy.span,
2815 AgentField::Thread(mode) => mode.span,
2816 AgentField::Settings(sources) => sources.span,
2817 AgentField::Unknown { span, .. } => *span,
2818 }
2819}
2820
2821fn agent_field_line(field: &AgentField) -> String {
2822 match field {
2823 AgentField::Provider(provider) => format!(" provider {}", provider.name),
2824 AgentField::Profile(profile) => format!(" profile {:?}", profile.value),
2825 AgentField::Capacity(capacity, _) => format!(" capacity {capacity}"),
2826 AgentField::Skills(skills, _) => {
2827 let skills = skills
2828 .iter()
2829 .map(|skill| format!("{:?}", skill.value))
2830 .collect::<Vec<_>>()
2831 .join(", ");
2832 format!(" skills [{skills}]")
2833 }
2834 AgentField::Capabilities(capabilities, _) => {
2835 let capabilities = capabilities
2836 .iter()
2837 .map(|capability| format!("{:?}", capability.value))
2838 .collect::<Vec<_>>()
2839 .join(", ");
2840 format!(" capabilities [{capabilities}]")
2841 }
2842 AgentField::Requires(classes, _) => {
2843 let classes = classes
2844 .iter()
2845 .map(|class| class.name.as_str())
2846 .collect::<Vec<_>>()
2847 .join(", ");
2848 format!(" requires [{classes}]")
2849 }
2850 AgentField::Tools(tools, _) => {
2851 let tools = tools
2852 .iter()
2853 .map(|tool| tool.name.as_str())
2854 .collect::<Vec<_>>()
2855 .join(", ");
2856 format!(" tools [{tools}]")
2857 }
2858 AgentField::Compaction(strategy) => format!(" compaction {}", strategy.name),
2859 AgentField::Thread(mode) => format!(" thread {}", mode.name),
2860 AgentField::Settings(sources) => format!(" settings {}", sources.name),
2861 AgentField::Unknown { name, .. } => format!(" {}", name.name),
2862 }
2863}
2864
2865fn try_format_agent_with_comments(
2869 agent: &AgentDecl,
2870 source: &str,
2871 comments: &[Comment],
2872 formatted: &mut String,
2873) -> bool {
2874 let members: Vec<(SourceSpan, Vec<String>)> = agent
2875 .fields
2876 .iter()
2877 .map(|field| (agent_field_span(field), vec![agent_field_line(field)]))
2878 .collect();
2879 let Some((own_line, trailing)) = classify_body_comments(source, agent.span, &members, comments)
2880 else {
2881 return false;
2882 };
2883 let harness = agent
2884 .harness
2885 .as_ref()
2886 .map(|harness| format!(" using {}", harness.name))
2887 .or_else(|| {
2888 agent
2889 .delegated_to
2890 .as_ref()
2891 .map(|delegate| format!(" delegated to {}", delegate.name))
2892 })
2893 .unwrap_or_default();
2894 push_line(
2895 formatted,
2896 format!("agent {}{} {{", agent.name.name, harness),
2897 );
2898 emit_members_with_comments(&members, &own_line, &trailing, " ", formatted);
2899 push_line(formatted, "}");
2900 true
2901}
2902
2903fn enum_variant_lines_with_comments(
2908 variant: &EnumVariantDecl,
2909 source: &str,
2910 comments: &[Comment],
2911) -> Option<Vec<String>> {
2912 if variant.fields.is_empty() {
2913 return Some(vec![format!(" {}", variant.name.name)]);
2914 }
2915 let members: Vec<(SourceSpan, Vec<String>)> = variant
2916 .fields
2917 .iter()
2918 .map(|field| {
2919 (
2920 field.span,
2921 vec![format!(" {} {}", field.name.name, field.ty.to_source())],
2922 )
2923 })
2924 .collect();
2925 let (own_line, trailing) = classify_body_comments(source, variant.span, &members, comments)?;
2927 let mut block = String::new();
2928 emit_members_with_comments(&members, &own_line, &trailing, " ", &mut block);
2929 let mut lines = vec![format!(" {} {{", variant.name.name)];
2930 lines.extend(block.lines().map(str::to_owned));
2931 lines.push(" }".to_owned());
2932 Some(lines)
2933}
2934
2935fn try_format_enum_with_comments(
2941 enum_decl: &EnumDecl,
2942 source: &str,
2943 comments: &[Comment],
2944 formatted: &mut String,
2945) -> bool {
2946 let mut members: Vec<(SourceSpan, Vec<String>)> = Vec::with_capacity(enum_decl.variants.len());
2947 for variant in &enum_decl.variants {
2948 let Some(lines) = enum_variant_lines_with_comments(variant, source, comments) else {
2949 return false;
2950 };
2951 members.push((variant.span, lines));
2952 }
2953 let body_level: Vec<Comment> = comments
2957 .iter()
2958 .filter(|comment| {
2959 !enum_decl.variants.iter().any(|variant| {
2960 !variant.fields.is_empty()
2961 && variant.span.start < comment.span.start
2962 && comment.span.start < variant.span.end
2963 })
2964 })
2965 .cloned()
2966 .collect();
2967 let Some((own_line, trailing)) =
2968 classify_body_comments(source, enum_decl.span, &members, &body_level)
2969 else {
2970 return false;
2971 };
2972 push_line(formatted, format!("enum {} {{", enum_decl.name.name));
2973 emit_members_with_comments(&members, &own_line, &trailing, " ", formatted);
2974 push_line(formatted, "}");
2975 true
2976}
2977
2978fn referenced_decl_name(item: &Item) -> Option<(String, SourceSpan)> {
2985 match item {
2986 Item::Class(decl) => Some((decl.name.name.clone(), decl.span)),
2987 Item::Enum(decl) => Some((decl.name.name.clone(), decl.span)),
2988 Item::Agent(decl) => Some((decl.name.name.clone(), decl.span)),
2989 Item::Coerce(decl) => Some((decl.name.name.clone(), decl.span)),
2990 Item::Lease(decl) => Some((decl.name.name.clone(), decl.span)),
2991 Item::Ledger(decl) => Some((decl.name.name.clone(), decl.span)),
2992 Item::Counter(decl) => Some((decl.name.name.clone(), decl.span)),
2993 Item::Tracker(decl) => Some((decl.name.name.clone(), decl.span)),
2994 Item::Channel(decl) => Some((decl.name.name.clone(), decl.span)),
2995 Item::FileStore(decl) => Some((decl.name.name.clone(), decl.span)),
2996 Item::MemoryPool(decl) => Some((decl.name.name.clone(), decl.span)),
2997 Item::Event(decl) => Some((decl.name.clone(), decl.span)),
2998 Item::Table(decl) => Some((decl.name.name.clone(), decl.span)),
2999 Item::Gauge(decl) => Some((decl.name.name.clone(), decl.span)),
3000 Item::Campaign(decl) => Some((decl.name.name.clone(), decl.span)),
3001 Item::Mark(decl) => Some((decl.name.value.clone(), decl.span)),
3002 _ => None,
3003 }
3004}
3005
3006fn annotate_cross_workflow_leak(
3013 diagnostic: &mut Diagnostic,
3014 current: &str,
3015 own_locals: &BTreeSet<String>,
3016 global_names: &BTreeSet<String>,
3017 sibling_locals: &BTreeMap<String, Vec<(String, SourceSpan)>>,
3018) {
3019 for (name, owners) in sibling_locals {
3020 if global_names.contains(name) || own_locals.contains(name) {
3021 continue;
3022 }
3023 if !diagnostic.message.contains(&format!("`{name}`")) {
3026 continue;
3027 }
3028 let Some((owner, span)) = owners.iter().find(|(owner, _)| owner != current) else {
3029 continue;
3030 };
3031 diagnostic.related.push(RelatedInfo {
3032 span: *span,
3033 message: format!(
3034 "`{name}` is declared inside workflow `{owner}`, which makes it \
3035 private to that workflow; move it to a top-level declaration to \
3036 share it across workflows"
3037 ),
3038 });
3039 return;
3040 }
3041}
3042
3043fn select_root_workflow(
3044 mut program: Program,
3045 root: Option<&str>,
3046) -> Result<Program, Vec<Diagnostic>> {
3047 if program.workflow.is_none() && program.workflows.is_empty() {
3053 return Err(vec![Diagnostic {
3054 related: Vec::new(),
3055 span: SourceSpan { start: 0, end: 0 },
3056 message: "program declares no `workflow`".to_owned(),
3057 suggestion: Some(
3058 "add an explicit `workflow Name { ... }` declaration; a runnable \
3059 program requires at least one workflow (files that only declare \
3060 shared types or patterns are libraries, meant to be `include`d)"
3061 .to_owned(),
3062 ),
3063 }]);
3064 }
3065
3066 if program.workflows.is_empty() {
3067 if let Some(root) = root {
3068 match program.workflow.as_ref() {
3069 Some(workflow) if workflow.name == root => {}
3070 Some(workflow) => {
3071 return Err(vec![Diagnostic {
3072 related: Vec::new(),
3073 span: workflow.span,
3074 message: format!("root workflow `{root}` was not found"),
3075 suggestion: Some(format!("available workflow: `{}`", workflow.name)),
3076 }]);
3077 }
3078 None => {
3079 return Err(vec![Diagnostic {
3080 related: Vec::new(),
3081 span: SourceSpan { start: 0, end: 0 },
3082 message: format!("root workflow `{root}` was not found"),
3083 suggestion: Some(
3084 "add an explicit `workflow Name { ... }` declaration".to_owned(),
3085 ),
3086 }]);
3087 }
3088 }
3089 }
3090 return Ok(program);
3091 }
3092
3093 let selected_index = match root {
3094 Some(root) => match program
3095 .workflows
3096 .iter()
3097 .position(|workflow| workflow.name.name == root)
3098 {
3099 Some(index) => index,
3100 None => {
3101 let names = program
3102 .workflows
3103 .iter()
3104 .map(|workflow| format!("`{}`", workflow.name.name))
3105 .collect::<Vec<_>>()
3106 .join(", ");
3107 return Err(vec![Diagnostic {
3108 related: Vec::new(),
3109 span: SourceSpan { start: 0, end: 0 },
3110 message: format!("root workflow `{root}` was not found"),
3111 suggestion: Some(format!("available workflows: {names}")),
3112 }]);
3113 }
3114 },
3115 None if program.workflows.len() == 1 => 0,
3116 None => {
3117 let names = program
3118 .workflows
3119 .iter()
3120 .map(|workflow| format!("`{}`", workflow.name.name))
3121 .collect::<Vec<_>>()
3122 .join(", ");
3123 return Err(vec![Diagnostic {
3124 related: Vec::new(),
3125 span: SourceSpan { start: 0, end: 0 },
3126 message: "multiple workflow declarations require an explicit root".to_owned(),
3127 suggestion: Some(format!(
3128 "pass `--root <name>`; available workflows: {names}"
3129 )),
3130 }]);
3131 }
3132 };
3133
3134 let selected = program.workflows.remove(selected_index);
3135 let mut items = program.items;
3136 let workflow_tags = selected.tags;
3137 let workflow_description = selected.description;
3138 items.extend(selected.items);
3139 Ok(Program {
3140 workflow: Some(selected.name),
3141 workflow_tags,
3142 workflow_description,
3143 explicit_workflow_body: true,
3144 workflows: Vec::new(),
3145 patterns: program.patterns,
3146 items,
3147 })
3148}
3149
3150impl IrProgram {
3151 pub fn construct_uses(&self) -> Vec<&IrConstructUse> {
3152 self.rules
3153 .iter()
3154 .flat_map(|rule| rule.metadata.effects.iter())
3155 .filter_map(|effect| effect.construct_use.as_ref())
3156 .collect()
3157 }
3158
3159 pub fn contract_registry(&self) -> ContractRegistry {
3160 let mut libraries = BTreeMap::<String, LibraryRegistration>::new();
3161 let mut contracts = BTreeMap::<(String, String), EffectContract>::new();
3162
3163 for use_decl in &self.uses {
3164 libraries
3165 .entry(use_decl.name.clone())
3166 .or_insert_with(|| LibraryRegistration {
3167 id: use_decl.name.clone(),
3168 version: "unlocked".to_owned(),
3169 standard: false,
3170 });
3171 }
3172
3173 if !self.harnesses.is_empty() || !self.agents.is_empty() {
3174 register_standard_library(&mut libraries, "std.agent");
3175 }
3176 if !self.trackers.is_empty() {
3177 register_standard_library(&mut libraries, "std.tracker");
3178 }
3179 if !self.events.is_empty() {
3180 register_standard_library(&mut libraries, "std.ingress");
3181 }
3182 if !self.leases.is_empty() || !self.ledgers.is_empty() || !self.counters.is_empty() {
3183 register_standard_library(&mut libraries, "std.coord");
3184 }
3185 if !self.channels.is_empty() {
3186 register_standard_library(&mut libraries, "std.messaging");
3187 }
3188 if !self.file_stores.is_empty() {
3192 register_standard_library(&mut libraries, "std.files");
3193 }
3194 if self.sources.iter().any(|source| source.is_clock) {
3195 register_standard_library(&mut libraries, "std.time");
3196 }
3197 if !self.coerces.is_empty() {
3198 register_standard_library(&mut libraries, "std.coercion");
3199 register_effect_contract(
3200 &mut libraries,
3201 &mut contracts,
3202 IrEffectKind::SchemaCoerce,
3203 Vec::new(),
3204 );
3205 }
3206
3207 for rule in &self.rules {
3208 for effect in &rule.metadata.effects {
3209 register_effect_contract(
3210 &mut libraries,
3211 &mut contracts,
3212 effect.kind.clone(),
3213 effect.required_capabilities.clone(),
3214 );
3215 }
3216 }
3217
3218 ContractRegistry {
3224 libraries: libraries.into_values().collect(),
3225 constructs: Vec::new(),
3226 effect_contracts: contracts.into_values().collect(),
3227 }
3228 }
3229
3230 pub fn to_snapshot(&self) -> String {
3231 let mut snapshot = String::new();
3232 push_line(&mut snapshot, format!("workflow {}", self.workflow));
3233
3234 if !self.source_tags.is_empty() {
3235 push_line(&mut snapshot, "source_tags");
3236 for tag in &self.source_tags {
3237 push_line(
3238 &mut snapshot,
3239 format!("@{} {} {}", tag.name, tag.target_kind, tag.target),
3240 );
3241 }
3242 }
3243
3244 if !self.source_descriptions.is_empty() {
3245 push_line(&mut snapshot, "source_descriptions");
3246 for description in &self.source_descriptions {
3247 push_line(
3248 &mut snapshot,
3249 format!(
3250 "{:?} {} {}",
3251 description.value, description.target_kind, description.target
3252 ),
3253 );
3254 }
3255 }
3256
3257 if !self.shared_coordination_usage.is_empty() {
3258 push_line(&mut snapshot, "shared_coordination_usage");
3259 for usage in &self.shared_coordination_usage {
3260 push_line(
3261 &mut snapshot,
3262 format!(
3263 "{} <- {}",
3264 usage.resource,
3265 usage.workflow_principals.join(",")
3266 ),
3267 );
3268 }
3269 }
3270
3271 if !self.includes.is_empty() {
3272 push_line(&mut snapshot, "includes");
3273 for include in &self.includes {
3274 match &include.source_hash {
3275 Some(source_hash) => {
3276 push_line(
3277 &mut snapshot,
3278 format!(" {} hash {}", include.path, source_hash),
3279 );
3280 }
3281 None => push_line(&mut snapshot, format!(" {}", include.path)),
3282 }
3283 }
3284 }
3285
3286 if !self.pattern_applications.is_empty() {
3287 push_line(&mut snapshot, "pattern_applications");
3288 for application in &self.pattern_applications {
3289 let type_args = application
3290 .type_args
3291 .iter()
3292 .map(IrType::to_snapshot)
3293 .collect::<Vec<_>>()
3294 .join(", ");
3295 push_line(
3296 &mut snapshot,
3297 format!(
3298 " {} as {}<{}>",
3299 application.pattern, application.alias, type_args
3300 ),
3301 );
3302 push_line(
3303 &mut snapshot,
3304 format!(
3305 " defined-at {}..{}",
3306 application.definition_span.start, application.definition_span.end
3307 ),
3308 );
3309 push_line(
3310 &mut snapshot,
3311 format!(
3312 " applied-at {}..{}",
3313 application.application_span.start, application.application_span.end
3314 ),
3315 );
3316 for argument in &application.value_args {
3317 push_line(
3318 &mut snapshot,
3319 format!(" arg {} {}", argument.name, argument.value),
3320 );
3321 }
3322 for generated in &application.generated {
3323 push_line(&mut snapshot, format!(" generated {generated}"));
3324 }
3325 }
3326 }
3327
3328 if !self.workflow_contracts.is_empty() {
3329 push_line(&mut snapshot, "workflow_contracts");
3330 for contract in &self.workflow_contracts {
3331 push_line(
3332 &mut snapshot,
3333 format!(
3334 " {} {} {}",
3335 contract.kind.as_str(),
3336 contract.name,
3337 contract.ty.to_snapshot()
3338 ),
3339 );
3340 }
3341 }
3342
3343 if !self.uses.is_empty() {
3344 push_line(&mut snapshot, "uses");
3345 for use_decl in &self.uses {
3346 push_line(
3347 &mut snapshot,
3348 format!(" {} {}", use_decl.kind.as_str(), use_decl.name),
3349 );
3350 }
3351 }
3352
3353 if !self.schemas.is_empty() {
3354 push_line(&mut snapshot, "schemas");
3355 for schema in &self.schemas {
3356 match schema {
3357 IrSchema::Enum(enum_decl) => {
3358 push_line(
3359 &mut snapshot,
3360 format!(
3361 " enum {} {{ {} }}",
3362 enum_decl.name,
3363 enum_decl.variants.join(", ")
3364 ),
3365 );
3366 }
3367 IrSchema::Class(class_decl) => {
3368 push_line(&mut snapshot, format!(" class {}", class_decl.name));
3369 for field in &class_decl.fields {
3370 let key = if field.is_key { " @key" } else { "" };
3373 push_line(
3374 &mut snapshot,
3375 format!(" {} {}{key}", field.name, field.ty.to_snapshot()),
3376 );
3377 }
3378 }
3379 }
3380 }
3381 }
3382
3383 if !self.harnesses.is_empty() {
3384 push_line(&mut snapshot, "harnesses");
3385 for harness in &self.harnesses {
3386 push_line(
3387 &mut snapshot,
3388 format!(" harness {} kind={}", harness.name, harness.kind),
3389 );
3390 }
3391 }
3392 if !self.trackers.is_empty() {
3393 push_line(&mut snapshot, "trackers");
3394 for queue in &self.trackers {
3395 push_line(
3396 &mut snapshot,
3397 format!(" tracker {} provider={}", queue.name, queue.provider),
3398 );
3399 }
3400 }
3401
3402 if !self.channels.is_empty() {
3403 push_line(&mut snapshot, "channels");
3404 for channel in &self.channels {
3405 let mut line = format!(" channel {} provider={}", channel.name, channel.provider);
3406 if let Some(workspace) = &channel.workspace {
3407 line.push_str(&format!(" workspace={workspace}"));
3408 }
3409 if let Some(destination) = &channel.destination {
3410 line.push_str(&format!(" destination={destination:?}"));
3411 }
3412 push_line(&mut snapshot, line);
3413 }
3414 }
3415
3416 if !self.gauges.is_empty() {
3417 push_line(&mut snapshot, "gauges");
3418 for gauge in &self.gauges {
3419 let mut line = format!(
3420 " gauge {} judge={}:{}",
3421 gauge.name, gauge.judge_kind, gauge.judge_target
3422 );
3423 if !gauge.judge_args.is_empty() {
3424 line.push_str(&format!(" args=({})", gauge.judge_args.join(",")));
3425 }
3426 if let Some(site) = &gauge.site {
3427 line.push_str(&format!(" site={site}"));
3428 }
3429 if let Some(bar) = &gauge.expect {
3430 line.push_str(&format!(
3431 " expect={}:{}{}{}",
3432 bar.form, bar.subject, bar.op, bar.threshold
3433 ));
3434 }
3435 if !gauge.inputs.is_empty() {
3436 line.push_str(&format!(" inputs={}", gauge.inputs.join(",")));
3437 }
3438 push_line(&mut snapshot, line);
3439 }
3440 }
3441
3442 if !self.marks.is_empty() {
3443 push_line(&mut snapshot, "marks");
3444 for mark in &self.marks {
3445 push_line(
3446 &mut snapshot,
3447 format!(" mark {:?} after {}", mark.name, mark.site),
3448 );
3449 }
3450 }
3451
3452 if !self.campaigns.is_empty() {
3453 push_line(&mut snapshot, "campaigns");
3454 for campaign in &self.campaigns {
3455 let mut line = format!(" campaign {}", campaign.name);
3456 if !campaign.ascend.is_empty() {
3457 line.push_str(&format!(" ascend={}", campaign.ascend.join(",")));
3458 }
3459 for reach in &campaign.reach {
3460 line.push_str(&format!(
3461 " reach={}{}{}{}",
3462 reach.gauge,
3463 reach.op,
3464 reach.threshold,
3465 reach.unit.as_deref().unwrap_or("")
3466 ));
3467 }
3468 for guard in &campaign.guard {
3469 line.push_str(&format!(
3470 " guard={}:within:{}%",
3471 guard.gauge, guard.band_percent
3472 ));
3473 }
3474 if !campaign.sacrifice.is_empty() {
3475 line.push_str(&format!(" sacrifice={}", campaign.sacrifice.join(",")));
3476 }
3477 if campaign.proposer_redacted {
3478 line.push_str(" proposer=redacted");
3479 }
3480 push_line(&mut snapshot, line);
3481 }
3482 }
3483
3484 if !self.file_stores.is_empty() {
3485 push_line(&mut snapshot, "file_stores");
3486 for file_store in &self.file_stores {
3487 push_line(
3488 &mut snapshot,
3489 format!(
3490 " file store {} root={:?}",
3491 file_store.name, file_store.root
3492 ),
3493 );
3494 if !file_store.read_globs.is_empty() {
3497 push_line(
3498 &mut snapshot,
3499 format!(" allow read {:?}", file_store.read_globs),
3500 );
3501 }
3502 if !file_store.write_globs.is_empty() {
3503 push_line(
3504 &mut snapshot,
3505 format!(" allow write {:?}", file_store.write_globs),
3506 );
3507 }
3508 if let Some(provider) = &file_store.provider {
3512 push_line(&mut snapshot, format!(" provider {provider}"));
3513 }
3514 }
3515 }
3516
3517 if !self.memory_pools.is_empty() {
3518 push_line(&mut snapshot, "memory_pools");
3519 for pool in &self.memory_pools {
3520 push_line(&mut snapshot, format!(" memory pool {}", pool.name));
3521 if let Some(limit) = pool.context_limit {
3524 push_line(&mut snapshot, format!(" context limit {limit}"));
3525 }
3526 }
3527 }
3528
3529 if !self.agents.is_empty() {
3530 push_line(&mut snapshot, "agents");
3531 for agent in &self.agents {
3532 let profile = agent.profile.as_deref().unwrap_or("<missing>");
3533 let harness = agent.harness.as_deref().unwrap_or("<fallback>");
3534 let provider = agent.provider.as_deref().unwrap_or("<fallback>");
3535 let capacity = agent
3536 .capacity
3537 .map(|capacity| capacity.to_string())
3538 .unwrap_or_else(|| "<missing>".to_owned());
3539 let skills = if agent.skills.is_empty() {
3540 "[]".to_owned()
3541 } else {
3542 format!("[{}]", agent.skills.join(", "))
3543 };
3544 let capabilities = if agent.capabilities.is_empty() {
3545 "[]".to_owned()
3546 } else {
3547 format!("[{}]", agent.capabilities.join(", "))
3548 };
3549 let tools = if agent.tools.is_empty() {
3550 "[]".to_owned()
3551 } else {
3552 format!("[{}]", agent.tools.join(", "))
3553 };
3554 let requires = if agent.requires.is_empty() {
3557 String::new()
3558 } else {
3559 format!(" requires=[{}]", agent.requires.join(", "))
3560 };
3561 let compaction = agent
3564 .compaction
3565 .as_deref()
3566 .map(|strategy| format!(" compaction={strategy}"))
3567 .unwrap_or_default();
3568 let settings = agent
3570 .settings
3571 .as_deref()
3572 .map(|sources| format!(" settings={sources}"))
3573 .unwrap_or_default();
3574 let thread = agent
3576 .thread
3577 .as_deref()
3578 .map(|mode| format!(" thread={mode}"))
3579 .unwrap_or_default();
3580 let class = match agent.harness_class {
3583 HarnessClass::Delegated => " class=delegated",
3584 HarnessClass::Managed => "",
3585 };
3586 push_line(
3587 &mut snapshot,
3588 format!(
3589 " agent {} harness={} provider={} profile={} capacity={} skills={} capabilities={} tools={}{}{}{}{}{}",
3590 agent.name, harness, provider, profile, capacity, skills, capabilities, tools, requires, compaction, settings, thread, class
3591 ),
3592 );
3593 }
3594 }
3595
3596 if !self.coerces.is_empty() {
3597 push_line(&mut snapshot, "coerces");
3598 for coerce in &self.coerces {
3599 let params = coerce
3600 .params
3601 .iter()
3602 .map(|param| format!("{} {}", param.name, param.ty.to_snapshot()))
3603 .collect::<Vec<_>>()
3604 .join(", ");
3605 push_line(
3606 &mut snapshot,
3607 format!(
3608 " coerce {}({}) -> {}",
3609 coerce.name,
3610 params,
3611 coerce.output.to_snapshot()
3612 ),
3613 );
3614 }
3615 }
3616
3617 if !self.assertions.is_empty() {
3618 push_line(&mut snapshot, "assertions");
3619 for assertion in &self.assertions {
3620 push_line(
3621 &mut snapshot,
3622 format!(" assert {}", assertion.expr.expr.to_snapshot()),
3623 );
3624 if !assertion.projection_reads.is_empty() {
3625 push_line(&mut snapshot, " reads");
3626 for read in &assertion.projection_reads {
3627 push_line(&mut snapshot, format!(" {}", read.to_snapshot()));
3628 }
3629 }
3630 }
3631 }
3632
3633 if !self.rules.is_empty() {
3634 push_line(&mut snapshot, "rules");
3635 for rule in &self.rules {
3636 push_line(&mut snapshot, format!(" rule {}", rule.name));
3637 for when in &rule.whens {
3638 match &when.guard {
3639 Some(guard) => push_line(
3640 &mut snapshot,
3641 format!(
3642 " when {} where {}",
3643 when.pattern,
3644 guard.expr.to_snapshot()
3645 ),
3646 ),
3647 None => push_line(&mut snapshot, format!(" when {}", when.pattern)),
3648 }
3649 }
3650 if !rule.metadata.fact_reads.is_empty() {
3651 push_line(&mut snapshot, " reads");
3652 for read in &rule.metadata.fact_reads {
3653 push_line(&mut snapshot, format!(" {}", read));
3654 }
3655 }
3656 if !rule.metadata.projection_reads.is_empty() {
3657 push_line(&mut snapshot, " projection_reads");
3658 for read in &rule.metadata.projection_reads {
3659 push_line(&mut snapshot, format!(" {}", read.to_snapshot()));
3660 }
3661 }
3662 if !rule.metadata.fact_writes.is_empty() {
3663 push_line(&mut snapshot, " writes");
3664 for write in &rule.metadata.fact_writes {
3665 push_line(&mut snapshot, format!(" {}", write));
3666 }
3667 }
3668 if !rule.metadata.record_sources.is_empty() {
3669 push_line(&mut snapshot, " record_sources");
3670 for source in &rule.metadata.record_sources {
3671 push_line(
3672 &mut snapshot,
3673 format!(
3674 " schema:{} construct={} span={}..{}",
3675 source.schema, source.construct, source.span.start, source.span.end
3676 ),
3677 );
3678 }
3679 }
3680 if !rule.metadata.fact_consumes.is_empty() {
3681 push_line(&mut snapshot, " consumes");
3682 for consumed in &rule.metadata.fact_consumes {
3683 push_line(&mut snapshot, format!(" {}", consumed));
3684 }
3685 }
3686 if !rule.metadata.effects.is_empty() {
3687 push_line(&mut snapshot, " effects");
3688 for effect in &rule.metadata.effects {
3689 let binding = effect.binding.as_deref().unwrap_or("-");
3690 let construct = effect
3691 .construct_use
3692 .as_ref()
3693 .map(|form| {
3694 format!(" construct={}->{}", form.keyword, form.target_capability)
3695 })
3696 .unwrap_or_default();
3697 let grants = if effect.access_grants.is_empty() {
3700 String::new()
3701 } else {
3702 let rendered = effect
3703 .access_grants
3704 .iter()
3705 .map(|grant| {
3706 let ops = grant
3707 .operations
3708 .iter()
3709 .map(|op| op.operation.as_str())
3710 .collect::<Vec<_>>()
3711 .join(",");
3712 format!("{}[{ops}]", grant.resource)
3713 })
3714 .collect::<Vec<_>>()
3715 .join(";");
3716 format!(" grants={rendered}")
3717 };
3718 let skills = if effect.turn_skills.is_empty() {
3721 String::new()
3722 } else {
3723 format!(" skills={}", effect.turn_skills.join(","))
3724 };
3725 push_line(
3726 &mut snapshot,
3727 format!(
3728 " {} kind={} binding={}{} key={}{}{}",
3729 effect.id,
3730 effect.kind.as_str(),
3731 binding,
3732 construct,
3733 effect.idempotency_key,
3734 grants,
3735 skills
3736 ),
3737 );
3738 }
3739 }
3740 if !rule.metadata.dependencies.is_empty() {
3741 push_line(&mut snapshot, " dependencies");
3742 for dependency in &rule.metadata.dependencies {
3743 push_line(
3744 &mut snapshot,
3745 format!(
3746 " {} --{}--> {}",
3747 dependency.upstream,
3748 dependency.predicate.as_str(),
3749 dependency.downstream
3750 ),
3751 );
3752 }
3753 }
3754 if !rule.metadata.case_branches.is_empty() {
3755 push_line(&mut snapshot, " case_branches");
3756 for branch in &rule.metadata.case_branches {
3757 let guard = branch
3758 .guard
3759 .as_ref()
3760 .map(|guard| guard.expr.to_snapshot())
3761 .unwrap_or_else(|| "-".to_owned());
3762 push_line(
3763 &mut snapshot,
3764 format!(
3765 " case {} type={} pattern={} guard={} body_hash={} span={}..{}",
3766 branch.scrutinee,
3767 branch.scrutinee_type.to_snapshot(),
3768 branch.pattern.to_snapshot(),
3769 guard,
3770 branch.body_hash,
3771 branch.pattern_span.start,
3772 branch.pattern_span.end
3773 ),
3774 );
3775 }
3776 }
3777 if !rule.metadata.terminal_outputs.is_empty() {
3778 push_line(&mut snapshot, " terminal_outputs");
3779 for output in &rule.metadata.terminal_outputs {
3780 push_line(
3781 &mut snapshot,
3782 format!(
3783 " {} span={}..{}",
3784 output.binding, output.span.start, output.span.end
3785 ),
3786 );
3787 for alternative in &output.alternatives {
3788 push_line(
3789 &mut snapshot,
3790 format!(
3791 " {} payload={} span={}..{}",
3792 alternative.tag,
3793 alternative.payload_type.to_snapshot(),
3794 alternative.source_span.start,
3795 alternative.source_span.end
3796 ),
3797 );
3798 }
3799 }
3800 }
3801 if !rule.metadata.terminal_branches.is_empty() {
3802 push_line(&mut snapshot, " terminal_branches");
3803 for branch in &rule.metadata.terminal_branches {
3804 let tag = branch.tag.as_deref().unwrap_or("_");
3805 let binding = branch.binding.as_deref().unwrap_or("-");
3806 let guard = branch
3807 .guard
3808 .as_ref()
3809 .map(|guard| guard.expr.to_snapshot())
3810 .unwrap_or_else(|| "-".to_owned());
3811 push_line(
3812 &mut snapshot,
3813 format!(
3814 " case {} {} binding={} guard={} body_hash={} span={}..{}",
3815 branch.scrutinee,
3816 tag,
3817 binding,
3818 guard,
3819 branch.body_hash,
3820 branch.pattern_span.start,
3821 branch.pattern_span.end
3822 ),
3823 );
3824 }
3825 }
3826 push_line(
3827 &mut snapshot,
3828 format!(" body_hash {}", stable_hash(&rule.body)),
3829 );
3830 }
3831 }
3832
3833 if !self.rule_dependencies.is_empty() {
3834 push_line(&mut snapshot, "rule_dependencies");
3835 for dependency in &self.rule_dependencies {
3836 push_line(
3837 &mut snapshot,
3838 format!(
3839 " {} --{}--> {}",
3840 dependency.producer, dependency.fact, dependency.consumer
3841 ),
3842 );
3843 }
3844 }
3845
3846 snapshot
3847 }
3848}
3849
3850fn register_standard_library(libraries: &mut BTreeMap<String, LibraryRegistration>, id: &str) {
3851 libraries
3852 .entry(id.to_owned())
3853 .or_insert_with(|| LibraryRegistration {
3854 id: id.to_owned(),
3855 version: "0.1.0".to_owned(),
3856 standard: true,
3857 });
3858}
3859
3860fn register_effect_contract(
3861 libraries: &mut BTreeMap<String, LibraryRegistration>,
3862 contracts: &mut BTreeMap<(String, String), EffectContract>,
3863 kind: IrEffectKind,
3864 required_capabilities: Vec<String>,
3865) {
3866 let contract = effect_contract_for_kind(kind, required_capabilities);
3867 register_standard_library(libraries, contract.library_id.as_str());
3868 contracts
3869 .entry((contract.id.clone(), contract.version.clone()))
3870 .and_modify(|existing| {
3871 merge_unique(
3872 &mut existing.required_capabilities,
3873 &contract.required_capabilities,
3874 );
3875 merge_unique(&mut existing.provider_kinds, &contract.provider_kinds);
3876 merge_unique(&mut existing.source_forms, &contract.source_forms);
3877 merge_unique(&mut existing.projected_facts, &contract.projected_facts);
3878 })
3879 .or_insert(contract);
3880}
3881
3882fn merge_unique(target: &mut Vec<String>, values: &[String]) {
3883 for value in values {
3884 if !target.contains(value) {
3885 target.push(value.clone());
3886 }
3887 }
3888 target.sort();
3889}
3890
3891fn strings(values: &[&str]) -> Vec<String> {
3892 values.iter().map(|value| (*value).to_owned()).collect()
3893}
3894
3895fn effect_contract_for_kind(
3896 kind: IrEffectKind,
3897 required_capabilities: Vec<String>,
3898) -> EffectContract {
3899 let mut required_capabilities = required_capabilities;
3900 required_capabilities.sort();
3901 required_capabilities.dedup();
3902 let effect_kind = kind.as_str().to_owned();
3903
3904 let (
3905 library_id,
3906 source_forms,
3907 input_schema,
3908 output_schema,
3909 default_capabilities,
3910 provider_kinds,
3911 projected_facts,
3912 validation,
3913 ) = match kind {
3914 IrEffectKind::AgentTell => (
3915 "std.agent",
3916 strings(&["tell"]),
3917 Some("agent.turn.request"),
3918 Some("AgentTurn"),
3919 strings(&["agent.turn"]),
3920 strings(&["agent"]),
3921 strings(&["effect.output"]),
3922 TypedOutputValidation::RuntimeBoundary,
3923 ),
3924 IrEffectKind::SchemaCoerce => (
3925 "std.coercion",
3926 strings(&["coerce", "decide", "prompt"]),
3927 Some("schema.coerce.input"),
3928 Some("typed-provider-output"),
3929 strings(&["schema.coerce"]),
3935 strings(&["schema_coercer"]),
3936 strings(&["effect.output"]),
3937 TypedOutputValidation::RuntimeBoundary,
3938 ),
3939 IrEffectKind::CapabilityCall => (
3940 "std.script",
3941 strings(&["call"]),
3942 Some("capability.call.input"),
3943 Some("capability.call.output"),
3944 Vec::new(),
3945 strings(&["capability"]),
3946 strings(&["effect.output"]),
3947 TypedOutputValidation::RuntimeBoundary,
3948 ),
3949 IrEffectKind::EventEmit => (
3950 "std.ingress",
3951 strings(&["emit"]),
3952 Some("event.emit.input"),
3953 None,
3954 Vec::new(),
3955 Vec::new(),
3956 Vec::new(),
3957 TypedOutputValidation::None,
3958 ),
3959 IrEffectKind::WorkflowInvoke => (
3960 "std.workflow",
3961 strings(&["invoke"]),
3962 Some("workflow.invoke.input"),
3963 Some("workflow.terminal"),
3964 Vec::new(),
3965 Vec::new(),
3966 strings(&["effect.output"]),
3967 TypedOutputValidation::RuntimeBoundary,
3968 ),
3969 IrEffectKind::TimerWait => (
3970 "std.time",
3971 strings(&["timer"]),
3972 Some("timer.wait.input"),
3973 Some("TimerElapsed"),
3974 Vec::new(),
3975 Vec::new(),
3976 strings(&["effect.output"]),
3977 TypedOutputValidation::None,
3978 ),
3979 IrEffectKind::ExecCommand => (
3980 "std.script",
3981 strings(&["exec"]),
3982 Some("exec.command.input"),
3983 Some("exec.command.output"),
3984 strings(&["exec.run"]),
3985 strings(&["script", "command"]),
3986 strings(&["effect.output"]),
3987 TypedOutputValidation::RuntimeBoundary,
3988 ),
3989 IrEffectKind::TrackerFile => (
3990 "std.tracker",
3991 strings(&["file"]),
3992 Some("tracker.file.input"),
3993 None,
3994 strings(&["tracker.file"]),
3995 Vec::new(),
3996 Vec::new(),
3997 TypedOutputValidation::None,
3998 ),
3999 IrEffectKind::TrackerClaim => (
4000 "std.tracker",
4001 strings(&["claim"]),
4002 Some("tracker.claim.input"),
4003 Some("TrackerClaim"),
4004 strings(&["tracker.claim"]),
4005 Vec::new(),
4006 strings(&["effect.output"]),
4007 TypedOutputValidation::None,
4008 ),
4009 IrEffectKind::TrackerRenew => (
4014 "std.tracker",
4015 strings(&["renew"]),
4016 Some("tracker.renew.input"),
4017 None,
4018 strings(&["tracker.renew"]),
4019 Vec::new(),
4020 Vec::new(),
4021 TypedOutputValidation::None,
4022 ),
4023 IrEffectKind::TrackerRelease => (
4024 "std.tracker",
4025 strings(&["release"]),
4026 Some("tracker.release.input"),
4027 None,
4028 strings(&["tracker.release"]),
4029 Vec::new(),
4030 Vec::new(),
4031 TypedOutputValidation::None,
4032 ),
4033 IrEffectKind::TrackerFinish => (
4034 "std.tracker",
4035 strings(&["finish"]),
4036 Some("tracker.finish.input"),
4037 None,
4038 strings(&["tracker.finish"]),
4039 Vec::new(),
4040 Vec::new(),
4041 TypedOutputValidation::None,
4042 ),
4043 IrEffectKind::LeaseAcquire => (
4044 "std.coord",
4045 strings(&["acquire"]),
4046 Some("lease.acquire.input"),
4047 Some("LeaseAcquireOutcome"),
4048 Vec::new(),
4049 Vec::new(),
4050 strings(&["effect.output"]),
4051 TypedOutputValidation::None,
4052 ),
4053 IrEffectKind::LeaseRenew => (
4054 "std.coord",
4055 strings(&["renew"]),
4056 Some("lease.renew.input"),
4057 Some("LeaseRenewOutcome"),
4058 Vec::new(),
4059 Vec::new(),
4060 strings(&["effect.output"]),
4061 TypedOutputValidation::None,
4062 ),
4063 IrEffectKind::LedgerAppend => (
4064 "std.coord",
4065 strings(&["append"]),
4066 Some("ledger.append.input"),
4067 None,
4068 Vec::new(),
4069 Vec::new(),
4070 Vec::new(),
4071 TypedOutputValidation::None,
4072 ),
4073 IrEffectKind::CounterConsume => (
4074 "std.coord",
4075 strings(&["consume"]),
4076 Some("counter.consume.input"),
4077 Some("CounterConsumeOutcome"),
4078 Vec::new(),
4079 Vec::new(),
4080 strings(&["effect.output"]),
4081 TypedOutputValidation::None,
4082 ),
4083 IrEffectKind::SignalEmit => (
4084 "std.ingress",
4085 strings(&["emit", "signal"]),
4086 Some("signal.emit.input"),
4087 None,
4088 Vec::new(),
4089 Vec::new(),
4090 Vec::new(),
4091 TypedOutputValidation::None,
4092 ),
4093 IrEffectKind::FileRead => (
4098 "std.files",
4099 strings(&["read"]),
4100 Some("file.read.input"),
4101 Some("FileReadResult"),
4102 strings(&["file.read"]),
4103 Vec::new(),
4104 strings(&["effect.output"]),
4105 TypedOutputValidation::RuntimeBoundary,
4106 ),
4107 IrEffectKind::FileWrite => (
4108 "std.files",
4109 strings(&["write"]),
4110 Some("file.write.input"),
4111 Some("FileWriteResult"),
4112 strings(&["file.write"]),
4113 Vec::new(),
4114 strings(&["effect.output"]),
4115 TypedOutputValidation::RuntimeBoundary,
4116 ),
4117 IrEffectKind::FileImport => (
4118 "std.files",
4119 strings(&["import"]),
4120 Some("file.import.input"),
4121 Some("FileImportResult"),
4122 strings(&["file.import"]),
4123 Vec::new(),
4124 strings(&["effect.output"]),
4125 TypedOutputValidation::RuntimeBoundary,
4126 ),
4127 IrEffectKind::FileExport => (
4128 "std.files",
4129 strings(&["export"]),
4130 Some("file.export.input"),
4131 Some("FileExportResult"),
4132 strings(&["file.export"]),
4133 Vec::new(),
4134 strings(&["effect.output"]),
4135 TypedOutputValidation::RuntimeBoundary,
4136 ),
4137 };
4138
4139 merge_unique(&mut required_capabilities, &default_capabilities);
4140
4141 EffectContract {
4142 id: effect_kind.clone(),
4143 library_id: library_id.to_owned(),
4144 version: "0.1.0".to_owned(),
4145 effect_kind,
4146 source_forms,
4147 input_schema: input_schema.map(str::to_owned),
4148 output_schema: output_schema.map(str::to_owned),
4149 required_capabilities,
4150 provider_kinds,
4151 projected_facts,
4152 validation,
4153 }
4154}
4155
4156impl IrEffectKind {
4157 pub fn as_str(&self) -> &'static str {
4160 match self {
4161 Self::AgentTell => "agent.tell",
4162 Self::SchemaCoerce => "schema.coerce",
4163 Self::CapabilityCall => "capability.call",
4164 Self::EventEmit => "event.emit",
4165 Self::WorkflowInvoke => "workflow.invoke",
4166 Self::TimerWait => "timer.wait",
4167 Self::ExecCommand => "exec.command",
4168 Self::TrackerFile => "tracker.file",
4169 Self::TrackerClaim => "tracker.claim",
4170 Self::TrackerRenew => "tracker.renew",
4171 Self::TrackerRelease => "tracker.release",
4172 Self::TrackerFinish => "tracker.finish",
4173 Self::LeaseAcquire => "lease.acquire",
4174 Self::LeaseRenew => "lease.renew",
4175 Self::LedgerAppend => "ledger.append",
4176 Self::CounterConsume => "counter.consume",
4177 Self::SignalEmit => "signal.emit",
4178 Self::FileRead => "file.read",
4179 Self::FileWrite => "file.write",
4180 Self::FileImport => "file.import",
4181 Self::FileExport => "file.export",
4182 }
4183 }
4184}
4185
4186impl DependencyPredicate {
4187 fn as_str(&self) -> &'static str {
4188 match self {
4189 Self::Succeeds => "succeeds",
4190 Self::Fails => "fails",
4191 Self::TimedOut => "timed_out",
4192 Self::Cancelled => "cancelled",
4193 Self::Completes => "completes",
4194 }
4195 }
4196}
4197
4198impl IrUseKind {
4199 fn as_str(&self) -> &'static str {
4200 match self {
4201 Self::Package => "package",
4202 }
4203 }
4204}
4205
4206impl IrType {
4207 pub fn display_label(&self) -> String {
4210 self.to_snapshot()
4211 }
4212
4213 fn to_snapshot(&self) -> String {
4214 match self {
4215 Self::Primitive(primitive) => primitive.as_str().to_owned(),
4216 Self::LiteralString(value) => format!("literal<{value:?}>"),
4217 Self::Ref(name) => format!("ref<{name}>"),
4218 Self::AgentRef(agents) => format!("agentref<{}>", agents.join(" | ")),
4219 Self::Object(fields) => {
4220 let fields = fields
4221 .iter()
4222 .map(|field| format!("{} {}", field.name, field.ty.to_snapshot()))
4223 .collect::<Vec<_>>()
4224 .join(", ");
4225 format!("object<{{{fields}}}>")
4226 }
4227 Self::Optional(inner) => format!("optional<{}>", inner.to_snapshot()),
4228 Self::Array(inner) => format!("array<{}>", inner.to_snapshot()),
4229 Self::Map(inner) => format!("map<{}>", inner.to_snapshot()),
4230 Self::Union(variants) => {
4231 let variants = variants
4232 .iter()
4233 .map(Self::to_snapshot)
4234 .collect::<Vec<_>>()
4235 .join(" | ");
4236 format!("union<{variants}>")
4237 }
4238 }
4239 }
4240}
4241
4242impl IrPrimitiveType {
4243 fn as_str(&self) -> &'static str {
4244 match self {
4245 Self::String => "string",
4246 Self::Int => "int",
4247 Self::Float => "float",
4248 Self::Bool => "bool",
4249 Self::Null => "null",
4250 Self::Duration => "duration",
4251 Self::Time => "time",
4252 Self::Image => "image",
4253 Self::Audio => "audio",
4254 Self::Pdf => "pdf",
4255 Self::Video => "video",
4256 }
4257 }
4258}
4259
4260fn lower_program(
4261 program: Program,
4262 workflow_inputs: BTreeMap<String, WorkflowInputSurface>,
4263 shared_coordination_usage: Vec<IrSharedCoordinationUsage>,
4264) -> CompileOutput {
4265 let mut diagnostics = Vec::new();
4266 let mut warnings = Vec::new();
4267 let (program, pattern_applications) = expand_pattern_applications(program, &mut diagnostics);
4268 let pending_regions: BTreeMap<String, IrRegion>;
4269 let program = {
4270 let mut program = program;
4271 let actions: Vec<ActionDecl> = program
4277 .items
4278 .iter()
4279 .filter_map(|item| match item {
4280 Item::Action(action) => Some(action.clone()),
4281 _ => None,
4282 })
4283 .collect();
4284 let mut expanded = Vec::with_capacity(program.items.len());
4285 for item in program.items {
4286 match item {
4287 Item::Action(_) => {}
4288 other => expanded.push(other),
4289 }
4290 }
4291 for item in &mut expanded {
4298 if let Item::Rule(rule) = item {
4299 if rule.body.text.contains('#') {
4300 rule.body.text = body::blank_full_line_comments(&rule.body.text);
4301 }
4302 }
4303 }
4304 action_expand::expand_action_calls(&mut expanded, &actions, &mut diagnostics);
4305 then_expand::expand_then_statements(&mut expanded, &mut diagnostics);
4312 pending_regions = extract_rule_regions(&mut expanded, &mut diagnostics);
4317 program.items = expanded;
4318 program
4319 };
4320 let schema_names = collect_schema_names(&program, &mut diagnostics);
4321 let harness_kinds = collect_harness_kinds(&program, &mut diagnostics);
4322 let agent_names = collect_agent_names(&program, &mut diagnostics);
4323 let workflow_contract_names = collect_workflow_contract_names(&program, &mut diagnostics);
4324 let mut semantic = SemanticContext::from_program(&program, workflow_inputs);
4325 let workflow = match program.workflow {
4326 Some(workflow) => workflow.name,
4327 None => {
4328 diagnostics.push(Diagnostic {
4329 related: Vec::new(),
4330 span: SourceSpan { start: 0, end: 0 },
4331 message: "expected workflow declaration".to_owned(),
4332 suggestion: Some("add `workflow Name` before declarations".to_owned()),
4333 });
4334 "<missing>".to_owned()
4335 }
4336 };
4337
4338 let mut ir = IrProgram {
4339 workflow,
4340 source_tags: Vec::new(),
4341 source_descriptions: Vec::new(),
4342 includes: Vec::new(),
4343 pattern_applications,
4344 workflow_contracts: Vec::new(),
4345 uses: Vec::new(),
4346 harnesses: Vec::new(),
4347 trackers: Vec::new(),
4348 channels: Vec::new(),
4349 gauges: Vec::new(),
4350 marks: Vec::new(),
4351 campaigns: Vec::new(),
4352 file_stores: Vec::new(),
4353 memory_pools: Vec::new(),
4354 events: Vec::new(),
4355 sources: Vec::new(),
4356 tests: Vec::new(),
4357 leases: Vec::new(),
4358 ledgers: Vec::new(),
4359 counters: Vec::new(),
4360 shared_coordination_usage,
4361 schemas: Vec::new(),
4362 agents: Vec::new(),
4363 coerces: Vec::new(),
4364 assertions: Vec::new(),
4365 rules: Vec::new(),
4366 rule_dependencies: Vec::new(),
4367 };
4368 let workflow_tag_target = ir.workflow.clone();
4369 lower_source_tags(
4370 &program.workflow_tags,
4371 "workflow",
4372 &workflow_tag_target,
4373 &mut ir,
4374 );
4375 lower_source_description(
4376 program.workflow_description.as_ref(),
4377 "workflow",
4378 &workflow_tag_target,
4379 &mut ir,
4380 );
4381
4382 collect_inline_decide_schemas(&program.items, &mut semantic, &mut ir);
4387
4388 collect_redact_schemas(&program.items, &mut semantic, &mut ir);
4393
4394 for item in program.items {
4395 match item {
4396 Item::Include(include) => lower_include(include, &mut ir),
4397 Item::WorkflowContract(contract) => lower_workflow_contract(
4398 contract,
4399 &mut ir,
4400 &schema_names,
4401 &agent_names,
4402 &mut diagnostics,
4403 ),
4404 Item::Use(use_decl) => lower_use(use_decl, &mut ir, &mut diagnostics),
4405 Item::Action(action) => {
4408 let _ = action;
4409 }
4410 Item::Pattern(pattern) => diagnostics.push(Diagnostic {
4411 related: Vec::new(),
4412 span: pattern.span,
4413 message: format!(
4414 "pattern `{}` is not allowed inside this declaration scope",
4415 pattern.name.name
4416 ),
4417 suggestion: Some("declare patterns at source top level".to_owned()),
4418 }),
4419 Item::Apply(apply) => diagnostics.push(Diagnostic {
4420 related: Vec::new(),
4421 span: apply.span,
4422 message: format!(
4423 "pattern application `{}` was not expanded",
4424 apply.alias.name
4425 ),
4426 suggestion: Some(
4427 "ensure the applied pattern is declared at source top level".to_owned(),
4428 ),
4429 }),
4430 Item::Harness(harness) => lower_harness(harness, &mut ir, &mut diagnostics),
4431 Item::Tracker(queue) => lower_tracker(queue, &mut ir, &mut diagnostics),
4432 Item::Channel(channel) => lower_channel(channel, &mut ir, &mut diagnostics),
4433 Item::Gauge(gauge) => lower_gauge(gauge, &mut ir, &mut diagnostics),
4434 Item::Mark(mark) => lower_mark(mark, &mut ir, &mut diagnostics),
4435 Item::Campaign(campaign) => lower_campaign(campaign, &mut ir, &mut diagnostics),
4436 Item::FileStore(file_store) => {
4440 if let Some(provider) = &file_store.provider {
4447 if !FILE_STORE_PROVIDERS.contains(&provider.name.as_str()) {
4448 diagnostics.push(Diagnostic {
4449 related: Vec::new(),
4450 span: provider.span,
4451 message: format!(
4452 "file store `{}` names unknown provider `{}`",
4453 file_store.name.name, provider.name
4454 ),
4455 suggestion: Some(format!(
4456 "declare one of the v1 file providers: {}",
4457 FILE_STORE_PROVIDERS.join(", ")
4458 )),
4459 });
4460 }
4463 }
4464 ir.file_stores.push(IrFileStore {
4465 name: file_store.name.name,
4466 root: file_store.root,
4467 read_globs: file_store.read_globs,
4468 write_globs: file_store.write_globs,
4469 provider: file_store.provider.map(|provider| provider.name),
4470 });
4471 }
4472 Item::MemoryPool(pool) => {
4476 ir.memory_pools.push(IrMemoryPool {
4477 name: pool.name.name,
4478 context_limit: pool.context_limit,
4479 });
4480 }
4481 Item::Agent(agent) => lower_agent(agent, &mut ir, &harness_kinds, &mut diagnostics),
4482 Item::Enum(enum_decl) => lower_enum(enum_decl, &mut ir, &mut diagnostics),
4483 Item::Event(event) => lower_event(event, &mut ir, &mut diagnostics),
4484 Item::Source(source) => {
4485 validate_source_emit_signal_declared(
4486 &source,
4487 &semantic.schemas.events,
4488 &mut diagnostics,
4489 );
4490 lower_source(*source, &mut ir, &mut diagnostics)
4491 }
4492 Item::Test(test) => lower_test(test, &mut ir, &mut diagnostics),
4493 Item::Lease(lease) => {
4494 if !schema_names.contains(&lease.key_type.name) {
4495 diagnostics.push(Diagnostic {
4496 related: Vec::new(),
4497 span: lease.key_type.span,
4498 message: format!(
4499 "lease `{}` keys on undeclared type `{}`",
4500 lease.name.name, lease.key_type.name
4501 ),
4502 suggestion: Some(
4503 "key a lease on an entity class the workflow already models".to_owned(),
4504 ),
4505 });
4506 }
4507 ir.leases.push(IrLease {
4508 name: lease.name.name,
4509 key_type: lease.key_type.name,
4510 slots: lease.slots.max(1),
4511 ttl_seconds: lease.ttl_seconds,
4512 shared: lease.shared,
4513 span: lease.span,
4514 });
4515 }
4516 Item::Ledger(ledger) => {
4517 if !schema_names.contains(&ledger.entry_schema.name) {
4518 diagnostics.push(Diagnostic {
4519 related: Vec::new(),
4520 span: ledger.entry_schema.span,
4521 message: format!(
4522 "ledger `{}` records undeclared entry type `{}`",
4523 ledger.name.name, ledger.entry_schema.name
4524 ),
4525 suggestion: Some("declare the entry class before the ledger".to_owned()),
4526 });
4527 }
4528 ir.ledgers.push(IrLedger {
4529 name: ledger.name.name,
4530 entry_schema: ledger.entry_schema.name,
4531 partition_field: ledger.partition_field.name,
4532 retain_seconds: ledger.retain_seconds,
4533 shared: ledger.shared,
4534 span: ledger.span,
4535 });
4536 }
4537 Item::Counter(counter) => {
4538 if !schema_names.contains(&counter.key_type.name) {
4539 diagnostics.push(Diagnostic {
4540 related: Vec::new(),
4541 span: counter.key_type.span,
4542 message: format!(
4543 "counter `{}` keys on undeclared type `{}`",
4544 counter.name.name, counter.key_type.name
4545 ),
4546 suggestion: Some(
4547 "key a counter on an entity class the workflow already models"
4548 .to_owned(),
4549 ),
4550 });
4551 }
4552 ir.counters.push(IrCounter {
4553 name: counter.name.name,
4554 key_type: counter.key_type.name,
4555 cap: counter.cap,
4556 reset: counter.reset,
4557 timezone: counter.timezone,
4558 shared: counter.shared,
4559 span: counter.span,
4560 });
4561 }
4562 Item::Class(class_decl) => lower_class(
4563 class_decl,
4564 &mut ir,
4565 &schema_names,
4566 &agent_names,
4567 &mut diagnostics,
4568 ),
4569 Item::Table(table) => {
4570 lower_source_tags(&table.tags, "table", &table.name.name, &mut ir);
4571 lower_source_description(
4572 table.description.as_ref(),
4573 "table",
4574 &table.name.name,
4575 &mut ir,
4576 );
4577 lower_table(
4578 table,
4579 &semantic,
4580 &workflow_contract_names,
4581 &mut ir,
4582 &mut diagnostics,
4583 )
4584 }
4585 Item::Coerce(coerce) => lower_coerce(
4586 coerce,
4587 &mut ir,
4588 &schema_names,
4589 &agent_names,
4590 &mut diagnostics,
4591 ),
4592 Item::Assert(assertion) => {
4593 let assertion_target = stable_hash(&assertion.expr);
4594 lower_source_tags(&assertion.tags, "assertion", &assertion_target, &mut ir);
4595 lower_source_description(
4596 assertion.description.as_ref(),
4597 "assertion",
4598 &assertion_target,
4599 &mut ir,
4600 );
4601 lower_assert(assertion, &semantic, &mut ir, &mut diagnostics)
4602 }
4603 Item::Rule(rule) => {
4604 lower_source_tags(&rule.tags, "rule", &rule.name.name, &mut ir);
4605 lower_source_description(
4606 rule.description.as_ref(),
4607 "rule",
4608 &rule.name.name,
4609 &mut ir,
4610 );
4611 lower_rule(
4612 rule,
4613 &semantic,
4614 &workflow_contract_names,
4615 &mut ir,
4616 &mut diagnostics,
4617 )
4618 }
4619 }
4620 }
4621
4622 ir.rule_dependencies = build_rule_dependencies(&ir.rules);
4623 validate_turn_access_grant_file_operations(&ir, &mut diagnostics);
4624 validate_turn_access_grant_memory_operations(&ir, &mut diagnostics);
4625 for rule in &mut ir.rules {
4627 if let Some(region) = pending_regions.get(&rule.name) {
4628 rule.metadata.region = Some(region.clone());
4629 }
4630 }
4631 expand_source_emit_from(&mut ir, &mut diagnostics);
4632 validate_file_store_write_policy(&ir, &mut diagnostics);
4633 warn_inert_memory_grant_on_native_adapter(&ir, &mut warnings);
4634 warn_counter_without_timezone(&ir, &mut warnings);
4635 warn_unhandled_effect_failures(&ir, &mut warnings);
4636 validate_improve_declarations(&ir, &mut diagnostics);
4637
4638 CompileOutput {
4639 ir: diagnostics.is_empty().then_some(ir),
4640 diagnostics,
4641 warnings,
4642 }
4643}
4644
4645fn validate_turn_access_grant_file_operations(ir: &IrProgram, diagnostics: &mut Vec<Diagnostic>) {
4653 const FILE_OPERATIONS: [&str; 4] = ["read", "write", "import", "export"];
4654 let file_stores: BTreeSet<&str> = ir
4655 .file_stores
4656 .iter()
4657 .map(|store| store.name.as_str())
4658 .collect();
4659 for rule in &ir.rules {
4660 for effect in &rule.metadata.effects {
4661 for grant in &effect.access_grants {
4662 if !file_stores.contains(grant.resource.as_str()) {
4663 continue;
4664 }
4665 for op in &grant.operations {
4666 if !FILE_OPERATIONS.contains(&op.operation.as_str()) {
4667 diagnostics.push(Diagnostic { related: Vec::new(),
4668 span: effect.span,
4669 message: format!(
4670 "rule `{}` grants `{}` on file store `{}`, which is not a file operation",
4671 rule.name, op.operation, grant.resource
4672 ),
4673 suggestion: Some(
4674 "file-store grants allow `read`, `write`, `import`, or `export`"
4675 .to_owned(),
4676 ),
4677 });
4678 }
4679 }
4680 }
4681 }
4682 }
4683}
4684
4685fn validate_turn_access_grant_memory_operations(ir: &IrProgram, diagnostics: &mut Vec<Diagnostic>) {
4694 const MEMORY_OPERATIONS: [&str; 3] = ["recall", "learn", "curate"];
4695 let memory_pools: BTreeSet<&str> = ir
4696 .memory_pools
4697 .iter()
4698 .map(|pool| pool.name.as_str())
4699 .collect();
4700 for rule in &ir.rules {
4701 for effect in &rule.metadata.effects {
4702 for grant in &effect.access_grants {
4703 if !memory_pools.contains(grant.resource.as_str()) {
4704 continue;
4705 }
4706 for op in &grant.operations {
4707 if !MEMORY_OPERATIONS.contains(&op.operation.as_str()) {
4708 diagnostics.push(Diagnostic {
4709 related: Vec::new(),
4710 span: effect.span,
4711 message: format!(
4712 "rule `{}` grants `{}` on memory pool `{}`, which is not a memory operation",
4713 rule.name, op.operation, grant.resource
4714 ),
4715 suggestion: Some(
4716 "memory-pool grants allow `recall`, `learn`, or `curate`".to_owned(),
4717 ),
4718 });
4719 }
4720 }
4721 }
4722 }
4723 }
4724}
4725
4726fn validate_file_store_write_policy(ir: &IrProgram, diagnostics: &mut Vec<Diagnostic>) {
4735 let read_only: BTreeSet<&str> = ir
4736 .file_stores
4737 .iter()
4738 .filter(|store| store.write_globs.is_empty())
4739 .map(|store| store.name.as_str())
4740 .collect();
4741 if read_only.is_empty() {
4742 return;
4743 }
4744 fn walk(
4745 statements: &[body::BodyStmt],
4746 rule_name: &str,
4747 read_only: &BTreeSet<&str>,
4748 diagnostics: &mut Vec<Diagnostic>,
4749 ) {
4750 for statement in statements {
4751 match statement {
4752 body::BodyStmt::Effect(effect) => {
4753 let store = match &effect.kind {
4754 body::BodyEffectKind::FileWrite { store, .. }
4755 | body::BodyEffectKind::FileExport { store, .. } => Some(store),
4756 _ => None,
4757 };
4758 if let Some(store) = store {
4759 if read_only.contains(store.as_str()) {
4760 diagnostics.push(Diagnostic {
4761 related: Vec::new(),
4762 span: effect.span,
4763 message: format!(
4764 "rule `{rule_name}` writes to store `{store}`, which permits \
4765 no writes — stores are read-only by default"
4766 ),
4767 suggestion: Some(format!(
4768 "declare `allow write [\"<glob>\", …]` on `file store {store}` \
4769 to permit (and bound) writes"
4770 )),
4771 });
4772 }
4773 }
4774 }
4775 body::BodyStmt::After(after) => {
4776 walk(&after.body, rule_name, read_only, diagnostics)
4777 }
4778 body::BodyStmt::Case(case) => {
4779 for branch in &case.branches {
4780 walk(&branch.body, rule_name, read_only, diagnostics);
4781 }
4782 }
4783 _ => {}
4784 }
4785 }
4786 }
4787 for rule in &ir.rules {
4788 let (ast, _) = body::parse_rule_body(&rule.body, 0);
4789 walk(&ast.statements, &rule.name, &read_only, diagnostics);
4790 }
4791}
4792
4793fn expand_source_emit_from(ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
4800 let events: BTreeMap<String, Vec<String>> = ir
4801 .events
4802 .iter()
4803 .map(|event| {
4804 (
4805 event.name.clone(),
4806 event
4807 .fields
4808 .iter()
4809 .map(|field| field.name.clone())
4810 .collect(),
4811 )
4812 })
4813 .collect();
4814 for source in &mut ir.sources {
4815 let Some(from) = source.emit_from.clone() else {
4816 continue;
4817 };
4818 if from != source.observe_binding {
4819 diagnostics.push(Diagnostic {
4820 related: Vec::new(),
4821 span: source.span,
4822 message: format!(
4823 "source `{}` emits `from {from}`, but the only binding in scope is the observe binding `{}`",
4824 source.name, source.observe_binding
4825 ),
4826 suggestion: Some(format!("write `emit {} from {}`", source.emit_signal, source.observe_binding)),
4827 });
4828 continue;
4829 }
4830 let Some(signal_fields) = events.get(&source.emit_signal) else {
4831 continue;
4833 };
4834 for field in signal_fields {
4835 if source
4836 .emit_fields
4837 .iter()
4838 .any(|existing| &existing.name == field)
4839 {
4840 continue;
4841 }
4842 source.emit_fields.push(IrSourceEmitField {
4843 name: field.clone(),
4844 value: SourceValue::Path {
4845 binding: Ident {
4846 name: from.clone(),
4847 span: source.span,
4848 },
4849 segments: vec![Ident {
4850 name: field.clone(),
4851 span: source.span,
4852 }],
4853 span: source.span,
4854 },
4855 span: source.span,
4856 });
4857 }
4858 }
4859}
4860
4861fn warn_unhandled_effect_failures(ir: &IrProgram, warnings: &mut Vec<Diagnostic>) {
4873 let service = ir
4874 .source_tags
4875 .iter()
4876 .any(|tag| tag.target_kind == "workflow" && tag.name == "service");
4877 if service {
4878 return;
4879 }
4880 for rule in &ir.rules {
4881 for effect in &rule.metadata.effects {
4882 let Some(binding) = effect.binding.as_deref() else {
4883 continue;
4884 };
4885 if effect.kind == IrEffectKind::TimerWait {
4886 continue;
4887 }
4888 if binding.starts_with(then_expand::THEN_BINDING_PREFIX) {
4891 continue;
4892 }
4893 let observed = rule.body.lines().any(|line| {
4894 let Some(rest) = line.trim().strip_prefix("after ") else {
4895 return false;
4896 };
4897 let mut parts = rest.split_whitespace();
4898 if parts.next() != Some(binding) {
4899 return false;
4900 }
4901 matches!(
4903 parts.next().map(|token| token.trim_end_matches('{')),
4904 Some("fails" | "times" | "completes" | "held" | "contended" | "ok" | "over")
4905 )
4906 });
4907 if observed {
4908 continue;
4909 }
4910 warnings.push(Diagnostic {
4911 related: Vec::new(),
4912 span: effect.span,
4913 message: format!(
4914 "effect `{binding}`'s failure is unhandled in rule `{}`; if it fails or \
4915 times out, the instance will auto-fail with a generic reason",
4916 rule.name
4917 ),
4918 suggestion: Some(format!(
4919 "handle it with `after {binding} fails {{ … }}` (typed failure or recovery) \
4920 or observe every outcome with `after {binding} completes`"
4921 )),
4922 });
4923 }
4924 }
4925}
4926
4927fn warn_counter_without_timezone(ir: &IrProgram, warnings: &mut Vec<Diagnostic>) {
4928 for counter in &ir.counters {
4929 if counter.timezone.is_none() {
4930 warnings.push(Diagnostic {
4931 related: Vec::new(),
4932 span: counter.span,
4933 message: format!(
4934 "counter `{}` declares no `timezone`; its `{}` reset boundary anchors to UTC",
4935 counter.name, counter.reset
4936 ),
4937 suggestion: Some(
4938 "declare `timezone \"<IANA zone>\"` (e.g. `timezone \"America/New_York\"`) to anchor the period locally"
4939 .to_owned(),
4940 ),
4941 });
4942 }
4943 }
4944}
4945
4946fn warn_inert_memory_grant_on_native_adapter(ir: &IrProgram, warnings: &mut Vec<Diagnostic>) {
4951 let memory_pools: BTreeSet<&str> = ir
4952 .memory_pools
4953 .iter()
4954 .map(|pool| pool.name.as_str())
4955 .collect();
4956 if memory_pools.is_empty() {
4957 return;
4958 }
4959 let harness_kind_of: BTreeMap<&str, &str> = ir
4960 .harnesses
4961 .iter()
4962 .map(|harness| (harness.name.as_str(), harness.kind.as_str()))
4963 .collect();
4964 let agent_harness_kind: BTreeMap<&str, &str> = ir
4965 .agents
4966 .iter()
4967 .filter_map(|agent| {
4968 let harness = agent.harness.as_deref()?;
4969 Some((agent.name.as_str(), *harness_kind_of.get(harness)?))
4970 })
4971 .collect();
4972 for rule in &ir.rules {
4973 for effect in &rule.metadata.effects {
4974 let Some(agent) = effect.agent.as_deref() else {
4975 continue;
4976 };
4977 let Some(kind) = agent_harness_kind.get(agent) else {
4978 continue;
4979 };
4980 if !matches!(*kind, "codex" | "claude" | "command") {
4981 continue;
4982 }
4983 for grant in &effect.access_grants {
4984 if memory_pools.contains(grant.resource.as_str()) {
4985 warnings.push(Diagnostic {
4986 related: Vec::new(),
4987 span: effect.span,
4988 message: format!(
4989 "rule `{}` grants memory pool `{}` on a tell to `{agent}`, whose \
4990 harness kind `{kind}` is a native adapter — memory grants only \
4991 take effect on the owned harness, so this grant is inert",
4992 rule.name, grant.resource
4993 ),
4994 suggestion: Some(
4995 "target an owned-harness agent, or drop the memory grant".to_owned(),
4996 ),
4997 });
4998 }
4999 }
5000 }
5001 }
5002}
5003
5004fn lower_source_tags(tags: &[TagDecl], target_kind: &str, target: &str, ir: &mut IrProgram) {
5005 for tag in tags {
5006 ir.source_tags.push(IrSourceTag {
5007 name: tag.name.clone(),
5008 target_kind: target_kind.to_owned(),
5009 target: target.to_owned(),
5010 span: tag.span,
5011 });
5012 }
5013}
5014
5015fn lower_source_description(
5016 description: Option<&StringLiteral>,
5017 target_kind: &str,
5018 target: &str,
5019 ir: &mut IrProgram,
5020) {
5021 if let Some(description) = description {
5022 ir.source_descriptions.push(IrSourceDescription {
5023 value: description.value.clone(),
5024 target_kind: target_kind.to_owned(),
5025 target: target.to_owned(),
5026 span: description.span,
5027 });
5028 }
5029}
5030
5031fn detect_pattern_recursion(
5041 patterns: &BTreeMap<String, PatternDecl>,
5042 diagnostics: &mut Vec<Diagnostic>,
5043) -> BTreeSet<String> {
5044 let mut edges: BTreeMap<&str, Vec<(&str, SourceSpan)>> = BTreeMap::new();
5046 for pattern in patterns.values() {
5047 let mut applied = Vec::new();
5048 for item in &pattern.items {
5049 if let Item::Apply(apply) = item {
5050 applied.push((apply.pattern.name.as_str(), apply.span));
5051 }
5052 }
5053 edges.insert(pattern.name.name.as_str(), applied);
5054 }
5055
5056 let find_cycle = |start: &str| -> Option<(Vec<String>, SourceSpan)> {
5059 let mut queue: VecDeque<&str> = VecDeque::new();
5060 let mut predecessor: BTreeMap<&str, (&str, SourceSpan)> = BTreeMap::new();
5062 for &(target, span) in edges.get(start).into_iter().flatten() {
5063 if target == start {
5064 return Some((vec![start.to_owned(), start.to_owned()], span));
5066 }
5067 if predecessor.insert(target, (start, span)).is_none() {
5068 queue.push_back(target);
5069 }
5070 }
5071 while let Some(node) = queue.pop_front() {
5072 for &(target, span) in edges.get(node).into_iter().flatten() {
5073 if target == start {
5074 let mut path = vec![node.to_owned()];
5076 let mut cursor = node;
5077 while cursor != start {
5078 let (from, _) = predecessor[cursor];
5079 path.push(from.to_owned());
5080 cursor = from;
5081 }
5082 path.reverse();
5083 path.push(start.to_owned());
5084 let first = &path[1];
5086 let entry_span = edges
5087 .get(start)
5088 .into_iter()
5089 .flatten()
5090 .find(|(target, _)| target == first)
5091 .map(|(_, span)| *span)
5092 .unwrap_or(span);
5093 return Some((path, entry_span));
5094 }
5095 if predecessor.insert(target, (node, span)).is_none() {
5096 queue.push_back(target);
5097 }
5098 }
5099 }
5100 None
5101 };
5102
5103 let mut recursive = BTreeSet::new();
5104 for name in patterns.keys() {
5107 if recursive.contains(name) {
5108 continue;
5109 }
5110 if let Some((cycle, span)) = find_cycle(name) {
5111 for member in &cycle {
5112 recursive.insert(member.clone());
5113 }
5114 diagnostics.push(Diagnostic { related: Vec::new(),
5115 span,
5116 message: format!(
5117 "recursive pattern application is not allowed (graph.unbounded_pattern_recursion): expansion cycle {}",
5118 cycle.join(" -> ")
5119 ),
5120 suggestion: Some(
5121 "break the cycle: pattern expansion must elaborate into a finite program"
5122 .to_owned(),
5123 ),
5124 });
5125 }
5126 }
5127 recursive
5128}
5129
5130fn detect_workflow_invoke_recursion(program: &Program, diagnostics: &mut Vec<Diagnostic>) {
5141 let mut edges: BTreeMap<String, Vec<(String, SourceSpan)>> = BTreeMap::new();
5146 let record_invokes =
5147 |name: &str, items: &[Item], edges: &mut BTreeMap<String, Vec<(String, SourceSpan)>>| {
5148 let entry = edges.entry(name.to_owned()).or_default();
5149 for item in items {
5150 let Item::Rule(rule) = item else {
5151 continue;
5152 };
5153 for statement in workflow_invoke_statements(&rule.body.text) {
5154 if let Some((target, _)) = invoke_statement_parts(&statement) {
5155 if target != name {
5156 entry.push((target.to_owned(), rule.body.span));
5157 }
5158 }
5159 }
5160 }
5161 };
5162 if let Some(root) = &program.workflow {
5163 record_invokes(&root.name, &program.items, &mut edges);
5164 }
5165 for workflow in &program.workflows {
5166 record_invokes(&workflow.name.name, &workflow.items, &mut edges);
5167 }
5168
5169 let find_cycle = |start: &str| -> Option<(Vec<String>, SourceSpan)> {
5172 let mut queue: VecDeque<&str> = VecDeque::new();
5173 let mut predecessor: BTreeMap<&str, (&str, SourceSpan)> = BTreeMap::new();
5174 for (target, span) in edges.get(start).into_iter().flatten() {
5175 if predecessor
5176 .insert(target.as_str(), (start, *span))
5177 .is_none()
5178 {
5179 queue.push_back(target.as_str());
5180 }
5181 }
5182 while let Some(node) = queue.pop_front() {
5183 for (target, span) in edges.get(node).into_iter().flatten() {
5184 if target == start {
5185 let mut path = vec![node.to_owned()];
5186 let mut cursor = node;
5187 while cursor != start {
5188 let (from, _) = predecessor[cursor];
5189 path.push(from.to_owned());
5190 cursor = from;
5191 }
5192 path.reverse();
5193 path.push(start.to_owned());
5194 let first = &path[1];
5195 let entry_span = edges
5196 .get(start)
5197 .into_iter()
5198 .flatten()
5199 .find(|(target, _)| target == first)
5200 .map(|(_, span)| *span)
5201 .unwrap_or(*span);
5202 return Some((path, entry_span));
5203 }
5204 if predecessor.insert(target.as_str(), (node, *span)).is_none() {
5205 queue.push_back(target.as_str());
5206 }
5207 }
5208 }
5209 None
5210 };
5211
5212 let mut flagged: BTreeSet<String> = BTreeSet::new();
5213 for name in edges.keys() {
5214 if flagged.contains(name) {
5215 continue;
5216 }
5217 if let Some((cycle, span)) = find_cycle(name) {
5218 for member in &cycle {
5219 flagged.insert(member.clone());
5220 }
5221 diagnostics.push(Diagnostic {
5222 related: Vec::new(),
5223 span,
5224 message: format!(
5225 "recursive workflow invocation is not allowed (graph.unbounded_workflow_invocation_recursion): invocation cycle {}",
5226 cycle.join(" -> ")
5227 ),
5228 suggestion: Some(
5229 "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"
5230 .to_owned(),
5231 ),
5232 });
5233 }
5234 }
5235}
5236
5237fn detect_private_workflow_invocations(program: &Program, diagnostics: &mut Vec<Diagnostic>) {
5238 let private_workflows = program
5239 .workflows
5240 .iter()
5241 .filter(|workflow| workflow.tags.iter().any(|tag| tag.name == "private"))
5242 .map(|workflow| workflow.name.name.as_str())
5243 .collect::<BTreeSet<_>>();
5244 if private_workflows.is_empty() {
5245 return;
5246 }
5247
5248 let mut record_private_invokes = |caller: &str, items: &[Item]| {
5249 for item in items {
5250 let Item::Rule(rule) = item else {
5251 continue;
5252 };
5253 for statement in workflow_invoke_statements(&rule.body.text) {
5254 let Some((target, _)) = invoke_statement_parts(&statement) else {
5255 continue;
5256 };
5257 if caller == target || !private_workflows.contains(target) {
5258 continue;
5259 }
5260 diagnostics.push(Diagnostic {
5261 related: Vec::new(),
5262 span: rule.body.span,
5263 message: format!(
5264 "rule `{}` invokes private workflow `{target}`",
5265 rule.name.name
5266 ),
5267 suggestion: Some(
5268 "remove `@private` from the target workflow or expose a public wrapper workflow"
5269 .to_owned(),
5270 ),
5271 });
5272 }
5273 }
5274 };
5275
5276 if let Some(root) = &program.workflow {
5277 record_private_invokes(&root.name, &program.items);
5278 }
5279 for workflow in &program.workflows {
5280 record_private_invokes(&workflow.name.name, &workflow.items);
5281 }
5282}
5283
5284fn expand_pattern_applications(
5285 mut program: Program,
5286 diagnostics: &mut Vec<Diagnostic>,
5287) -> (Program, Vec<IrPatternApplication>) {
5288 let mut patterns = BTreeMap::new();
5289 for pattern in &program.patterns {
5290 if patterns
5291 .insert(pattern.name.name.clone(), pattern.clone())
5292 .is_some()
5293 {
5294 diagnostics.push(Diagnostic {
5295 related: Vec::new(),
5296 span: pattern.name.span,
5297 message: format!("pattern `{}` is declared more than once", pattern.name.name),
5298 suggestion: Some("rename one pattern declaration".to_owned()),
5299 });
5300 }
5301 }
5302
5303 let recursive_patterns = detect_pattern_recursion(&patterns, diagnostics);
5310
5311 let mut expanded_items = Vec::new();
5312 let mut applications = Vec::new();
5313 for item in program.items {
5314 let Item::Apply(apply) = item else {
5315 expanded_items.push(item);
5316 continue;
5317 };
5318 let Some(pattern) = patterns.get(&apply.pattern.name) else {
5319 diagnostics.push(Diagnostic {
5320 related: Vec::new(),
5321 span: apply.pattern.span,
5322 message: format!("pattern `{}` was not found", apply.pattern.name),
5323 suggestion: Some("declare the pattern before applying it".to_owned()),
5324 });
5325 continue;
5326 };
5327 if pattern.type_params.len() != apply.type_args.len() {
5328 diagnostics.push(Diagnostic {
5329 related: Vec::new(),
5330 span: apply.span,
5331 message: format!(
5332 "pattern `{}` expects {} type arguments but got {}",
5333 pattern.name.name,
5334 pattern.type_params.len(),
5335 apply.type_args.len()
5336 ),
5337 suggestion: Some("match the pattern type parameter list".to_owned()),
5338 });
5339 continue;
5340 }
5341 let type_substitutions = pattern
5342 .type_params
5343 .iter()
5344 .map(|param| param.name.clone())
5345 .zip(apply.type_args.iter().cloned())
5346 .collect::<BTreeMap<_, _>>();
5347 let value_substitutions = parse_pattern_value_arguments(&apply, diagnostics);
5348 let local_names = pattern_local_names(pattern, &apply.alias.name);
5349 let definition_span = pattern.span;
5350 let application_span = apply.span;
5351 let mut generated = Vec::new();
5352 for pattern_item in pattern.items.iter().cloned() {
5353 if let Some(diagnostic) = pattern_body_admission(&pattern_item, &recursive_patterns) {
5357 diagnostics.push(diagnostic);
5358 continue;
5359 }
5360 if let Some((generated_name, item)) = expand_pattern_item(
5361 pattern_item,
5362 &apply.alias.name,
5363 &type_substitutions,
5364 &value_substitutions,
5365 &local_names,
5366 ) {
5367 generated.push(generated_name);
5368 expanded_items.push(item);
5369 }
5370 }
5371 applications.push(IrPatternApplication {
5372 pattern: pattern.name.name.clone(),
5373 alias: apply.alias.name,
5374 type_args: apply.type_args.into_iter().map(lower_type).collect(),
5375 value_args: value_substitutions
5376 .into_iter()
5377 .map(|(name, value)| IrPatternArgument { name, value })
5378 .collect(),
5379 generated,
5380 definition_span,
5381 application_span,
5382 });
5383 }
5384 program.items = expanded_items;
5385 (program, applications)
5386}
5387
5388fn pattern_local_names(pattern: &PatternDecl, alias: &str) -> BTreeMap<String, String> {
5389 let mut names = BTreeMap::new();
5390 for item in &pattern.items {
5391 match item {
5392 Item::Harness(harness) => {
5393 names.insert(
5394 harness.name.name.clone(),
5395 generated_pattern_name(alias, &harness.name.name),
5396 );
5397 }
5398 Item::Agent(agent) => {
5399 names.insert(
5400 agent.name.name.clone(),
5401 generated_pattern_name(alias, &agent.name.name),
5402 );
5403 }
5404 Item::Enum(enum_decl) => {
5405 names.insert(
5406 enum_decl.name.name.clone(),
5407 generated_pattern_name(alias, &enum_decl.name.name),
5408 );
5409 }
5410 Item::Class(class_decl) => {
5411 names.insert(
5412 class_decl.name.name.clone(),
5413 generated_pattern_name(alias, &class_decl.name.name),
5414 );
5415 }
5416 Item::Coerce(coerce) => {
5417 names.insert(
5418 coerce.name.name.clone(),
5419 generated_pattern_name(alias, &coerce.name.name),
5420 );
5421 }
5422 Item::Rule(rule) => {
5423 names.insert(
5424 rule.name.name.clone(),
5425 generated_pattern_name(alias, &rule.name.name),
5426 );
5427 }
5428 _ => {}
5429 }
5430 }
5431 names
5432}
5433
5434fn generated_pattern_name(alias: &str, name: &str) -> String {
5435 format!("{alias}_{name}")
5436}
5437
5438fn parse_pattern_value_arguments(
5439 apply: &ApplyDecl,
5440 diagnostics: &mut Vec<Diagnostic>,
5441) -> BTreeMap<String, String> {
5442 let mut args = BTreeMap::new();
5443 for line in apply
5444 .body
5445 .text
5446 .lines()
5447 .map(str::trim)
5448 .filter(|line| !line.is_empty())
5449 {
5450 let mut parts = line.splitn(2, char::is_whitespace);
5451 let Some(name) = parts.next().filter(|name| is_identifier(name)) else {
5452 diagnostics.push(Diagnostic {
5453 related: Vec::new(),
5454 span: apply.body.span,
5455 message: format!(
5456 "pattern application `{}` has malformed argument `{line}`",
5457 apply.alias.name
5458 ),
5459 suggestion: Some("write pattern arguments as `name value`".to_owned()),
5460 });
5461 continue;
5462 };
5463 let Some(value) = parts
5464 .next()
5465 .map(str::trim)
5466 .filter(|value| !value.is_empty())
5467 else {
5468 diagnostics.push(Diagnostic {
5469 related: Vec::new(),
5470 span: apply.body.span,
5471 message: format!(
5472 "pattern application `{}` argument `{name}` is missing a value",
5473 apply.alias.name
5474 ),
5475 suggestion: Some("write pattern arguments as `name value`".to_owned()),
5476 });
5477 continue;
5478 };
5479 if args.insert(name.to_owned(), value.to_owned()).is_some() {
5480 diagnostics.push(Diagnostic {
5481 related: Vec::new(),
5482 span: apply.body.span,
5483 message: format!(
5484 "pattern application `{}` passes argument `{name}` more than once",
5485 apply.alias.name
5486 ),
5487 suggestion: Some("remove the duplicate pattern argument".to_owned()),
5488 });
5489 }
5490 }
5491 args
5492}
5493
5494fn pattern_body_admission(
5509 item: &Item,
5510 recursive_patterns: &BTreeSet<String>,
5511) -> Option<Diagnostic> {
5512 match item {
5513 Item::WorkflowContract(contract) => Some(Diagnostic {
5514 related: Vec::new(),
5515 span: contract.span,
5516 message: "workflow contracts are not allowed in pattern bodies".to_owned(),
5517 suggestion: Some(
5518 "declare workflow inputs, outputs, and failures on the workflow".to_owned(),
5519 ),
5520 }),
5521 Item::Pattern(pattern) => Some(Diagnostic {
5522 related: Vec::new(),
5523 span: pattern.span,
5524 message: "nested pattern declarations are not supported in pattern bodies".to_owned(),
5525 suggestion: Some("declare reusable patterns at source top level".to_owned()),
5526 }),
5527 Item::Apply(apply) if !recursive_patterns.contains(&apply.pattern.name) => Some(Diagnostic {
5531 related: Vec::new(),
5532 span: apply.span,
5533 message: "pattern applications inside pattern bodies are not supported yet".to_owned(),
5534 suggestion: Some(
5535 "apply patterns from workflow bodies only in this implementation slice".to_owned(),
5536 ),
5537 }),
5538 Item::Gauge(gauge) => Some(Diagnostic {
5542 related: Vec::new(),
5543 span: gauge.span,
5544 message: "gauge declarations are not allowed in pattern bodies".to_owned(),
5545 suggestion: Some("declare gauges at source top level".to_owned()),
5546 }),
5547 Item::Campaign(campaign) => Some(Diagnostic {
5548 related: Vec::new(),
5549 span: campaign.span,
5550 message: "campaign declarations are not allowed in pattern bodies".to_owned(),
5551 suggestion: Some("declare campaigns at source top level".to_owned()),
5552 }),
5553 Item::Mark(mark) => Some(Diagnostic {
5554 related: Vec::new(),
5555 span: mark.span,
5556 message: "mark declarations are not allowed in pattern bodies".to_owned(),
5557 suggestion: Some("declare marks at source top level".to_owned()),
5558 }),
5559 Item::Rule(rule) => pattern_rule_terminal_span(rule).map(|span| Diagnostic {
5560 related: Vec::new(),
5561 span,
5562 message: format!(
5563 "rule `{}` in a pattern body cannot reach a workflow terminal (`complete`/`fail`)",
5564 rule.name.name
5565 ),
5566 suggestion: Some(
5567 "record a fact in the pattern rule and let a workflow rule decide the terminal outcome"
5568 .to_owned(),
5569 ),
5570 }),
5571 _ => None,
5572 }
5573}
5574
5575fn pattern_rule_terminal_span(rule: &RuleDecl) -> Option<SourceSpan> {
5578 let mut offset = 0usize;
5579 for line in rule.body.text.split_inclusive('\n') {
5580 let trimmed_start = line.trim_start();
5581 let leading = line.len() - trimmed_start.len();
5582 let statement = trimmed_start.trim_end();
5583 if is_pattern_terminal_statement(statement) {
5584 let start = rule.body.span.start + offset + leading;
5585 return Some(SourceSpan {
5586 start,
5587 end: start + statement.len(),
5588 });
5589 }
5590 offset += line.len();
5591 }
5592 None
5593}
5594
5595fn is_pattern_terminal_statement(line: &str) -> bool {
5598 for keyword in ["complete", "fail"] {
5599 if let Some(rest) = line.strip_prefix(keyword) {
5600 if rest.is_empty() || rest.starts_with('{') || rest.starts_with(char::is_whitespace) {
5601 return true;
5602 }
5603 }
5604 }
5605 false
5606}
5607
5608fn expand_pattern_item(
5609 item: Item,
5610 alias: &str,
5611 type_substitutions: &BTreeMap<String, TypeSyntax>,
5612 value_substitutions: &BTreeMap<String, String>,
5613 local_names: &BTreeMap<String, String>,
5614) -> Option<(String, Item)> {
5615 match item {
5616 Item::Include(include) => Some((
5617 format!("include:{}", include.path.value),
5618 Item::Include(include),
5619 )),
5620 Item::Use(use_decl) => Some((format!("use:{}", use_decl.name.value), Item::Use(use_decl))),
5621 Item::Tracker(queue) => {
5622 Some((format!("tracker:{}", queue.name.name), Item::Tracker(queue)))
5623 }
5624 Item::Channel(channel) => Some((
5625 format!("channel:{}", channel.name.name),
5626 Item::Channel(channel),
5627 )),
5628 Item::Gauge(_) | Item::Campaign(_) | Item::Mark(_) => None,
5632 Item::FileStore(file_store) => Some((
5633 format!("file-store:{}", file_store.name.name),
5634 Item::FileStore(file_store),
5635 )),
5636 Item::MemoryPool(pool) => Some((
5637 format!("memory-pool:{}", pool.name.name),
5638 Item::MemoryPool(pool),
5639 )),
5640 Item::Event(event) => Some((format!("event:{}", event.name), Item::Event(event))),
5641 Item::Source(source) => {
5642 Some((format!("source:{}", source.name.name), Item::Source(source)))
5643 }
5644 Item::Test(test) => Some((format!("test:{}", test.name.value), Item::Test(test))),
5645 Item::Lease(lease) => Some((format!("lease:{}", lease.name.name), Item::Lease(lease))),
5646 Item::Ledger(ledger) => {
5647 Some((format!("ledger:{}", ledger.name.name), Item::Ledger(ledger)))
5648 }
5649 Item::Counter(counter) => Some((
5650 format!("counter:{}", counter.name.name),
5651 Item::Counter(counter),
5652 )),
5653 Item::Action(action) => {
5654 Some((format!("action:{}", action.name.name), Item::Action(action)))
5655 }
5656 Item::Harness(mut harness) => {
5657 let name = rename_ident(harness.name, alias, local_names);
5658 let generated = format!("harness:{}", name.name);
5659 harness.name = name;
5660 Some((generated, Item::Harness(harness)))
5661 }
5662 Item::WorkflowContract(_) | Item::Pattern(_) | Item::Apply(_) => None,
5666 Item::Agent(mut agent) => {
5667 let name = rename_ident(agent.name, alias, local_names);
5668 let generated = format!("agent:{}", name.name);
5669 agent.name = name;
5670 if let Some(harness) = agent.harness {
5671 agent.harness = Some(Ident {
5672 name: local_names
5673 .get(&harness.name)
5674 .cloned()
5675 .unwrap_or(harness.name),
5676 span: harness.span,
5677 });
5678 }
5679 Some((generated, Item::Agent(agent)))
5680 }
5681 Item::Enum(mut enum_decl) => {
5682 let name = rename_ident(enum_decl.name, alias, local_names);
5683 let generated = format!("enum:{}", name.name);
5684 enum_decl.name = name;
5685 Some((generated, Item::Enum(enum_decl)))
5686 }
5687 Item::Class(mut class_decl) => {
5688 let name = rename_ident(class_decl.name, alias, local_names);
5689 let generated = format!("class:{}", name.name);
5690 class_decl.name = name;
5691 for field in &mut class_decl.fields {
5692 field.ty =
5693 substitute_pattern_type(field.ty.clone(), type_substitutions, local_names);
5694 }
5695 Some((generated, Item::Class(class_decl)))
5696 }
5697 Item::Table(mut table) => {
5698 let name = rename_ident(table.name, alias, local_names);
5699 let generated = format!("table:{}", name.name);
5700 table.name = name;
5701 for row in &mut table.rows {
5702 row.body.text = substitute_pattern_text(
5703 &row.body.text,
5704 type_substitutions,
5705 value_substitutions,
5706 local_names,
5707 );
5708 }
5709 Some((generated, Item::Table(table)))
5710 }
5711 Item::Coerce(mut coerce) => {
5712 let name = rename_ident(coerce.name, alias, local_names);
5713 let generated = format!("coerce:{}", name.name);
5714 coerce.name = name;
5715 for param in &mut coerce.params {
5716 param.ty =
5717 substitute_pattern_type(param.ty.clone(), type_substitutions, local_names);
5718 }
5719 coerce.output =
5720 substitute_pattern_type(coerce.output.clone(), type_substitutions, local_names);
5721 coerce.body.text = substitute_pattern_text(
5722 &coerce.body.text,
5723 type_substitutions,
5724 value_substitutions,
5725 local_names,
5726 );
5727 Some((generated, Item::Coerce(coerce)))
5728 }
5729 Item::Assert(mut assertion) => {
5730 assertion.expr = substitute_pattern_text(
5731 &assertion.expr,
5732 type_substitutions,
5733 value_substitutions,
5734 local_names,
5735 );
5736 Some((format!("assert:{alias}"), Item::Assert(assertion)))
5737 }
5738 Item::Rule(mut rule) => {
5739 let name = rename_ident(rule.name, alias, local_names);
5740 let generated = format!("rule:{}", name.name);
5741 rule.name = name;
5742 for when in &mut rule.whens {
5743 when.text = substitute_pattern_text(
5744 &when.text,
5745 type_substitutions,
5746 value_substitutions,
5747 local_names,
5748 );
5749 }
5750 rule.body.text = substitute_pattern_text(
5751 &rule.body.text,
5752 type_substitutions,
5753 value_substitutions,
5754 local_names,
5755 );
5756 Some((generated, Item::Rule(rule)))
5757 }
5758 }
5759}
5760
5761fn rename_ident(ident: Ident, alias: &str, local_names: &BTreeMap<String, String>) -> Ident {
5762 Ident {
5763 name: local_names
5764 .get(&ident.name)
5765 .cloned()
5766 .unwrap_or_else(|| generated_pattern_name(alias, &ident.name)),
5767 span: ident.span,
5768 }
5769}
5770
5771fn substitute_pattern_type(
5772 ty: TypeSyntax,
5773 type_substitutions: &BTreeMap<String, TypeSyntax>,
5774 local_names: &BTreeMap<String, String>,
5775) -> TypeSyntax {
5776 match ty {
5777 TypeSyntax::Ref { name } => {
5778 if let Some(replacement) = type_substitutions.get(&name.name) {
5779 return replacement.clone();
5780 }
5781 TypeSyntax::Ref {
5782 name: Ident {
5783 name: local_names.get(&name.name).cloned().unwrap_or(name.name),
5784 span: name.span,
5785 },
5786 }
5787 }
5788 TypeSyntax::AgentRef { agents, span } => TypeSyntax::AgentRef {
5789 agents: agents
5790 .into_iter()
5791 .map(|agent| Ident {
5792 name: local_names.get(&agent.name).cloned().unwrap_or(agent.name),
5793 span: agent.span,
5794 })
5795 .collect(),
5796 span,
5797 },
5798 TypeSyntax::Optional { inner, span } => TypeSyntax::Optional {
5799 inner: Box::new(substitute_pattern_type(
5800 *inner,
5801 type_substitutions,
5802 local_names,
5803 )),
5804 span,
5805 },
5806 TypeSyntax::Array { inner, span } => TypeSyntax::Array {
5807 inner: Box::new(substitute_pattern_type(
5808 *inner,
5809 type_substitutions,
5810 local_names,
5811 )),
5812 span,
5813 },
5814 TypeSyntax::Map { inner, span } => TypeSyntax::Map {
5815 inner: Box::new(substitute_pattern_type(
5816 *inner,
5817 type_substitutions,
5818 local_names,
5819 )),
5820 span,
5821 },
5822 TypeSyntax::Union { variants, span } => TypeSyntax::Union {
5823 variants: variants
5824 .into_iter()
5825 .map(|variant| substitute_pattern_type(variant, type_substitutions, local_names))
5826 .collect(),
5827 span,
5828 },
5829 other => other,
5830 }
5831}
5832
5833fn substitute_pattern_text(
5834 text: &str,
5835 type_substitutions: &BTreeMap<String, TypeSyntax>,
5836 value_substitutions: &BTreeMap<String, String>,
5837 local_names: &BTreeMap<String, String>,
5838) -> String {
5839 let mut output = text.to_owned();
5840 for (name, replacement) in type_substitutions {
5841 output = replace_identifier(&output, name, &replacement.to_source());
5842 }
5843 for (name, replacement) in local_names {
5844 output = replace_identifier(&output, name, replacement);
5845 }
5846 for (name, replacement) in value_substitutions {
5847 output = replace_identifier(&output, name, replacement);
5848 }
5849 output
5850}
5851
5852fn replace_identifier(source: &str, from: &str, to: &str) -> String {
5853 let mut output = String::new();
5854 let mut index = 0usize;
5855 while let Some(offset) = source[index..].find(from) {
5856 let start = index + offset;
5857 let end = start + from.len();
5858 output.push_str(&source[index..start]);
5859 let before = source[..start].chars().next_back();
5860 let after = source[end..].chars().next();
5861 if before.is_none_or(|ch| !is_identifier_char(ch))
5862 && after.is_none_or(|ch| !is_identifier_char(ch))
5863 {
5864 output.push_str(to);
5865 } else {
5866 output.push_str(&source[start..end]);
5867 }
5868 index = end;
5869 }
5870 output.push_str(&source[index..]);
5871 output
5872}
5873
5874fn is_identifier_char(ch: char) -> bool {
5875 ch.is_ascii_alphanumeric() || ch == '_' || ch == '-'
5876}
5877
5878fn lower_assert(
5879 assertion: AssertDecl,
5880 semantic: &SemanticContext,
5881 ir: &mut IrProgram,
5882 diagnostics: &mut Vec<Diagnostic>,
5883) {
5884 match parse_expression(&assertion.expr) {
5885 Ok(expr) => {
5886 validate_parsed_expression(
5887 &expr,
5888 semantic,
5889 &ExprScope::default(),
5890 &ExprValidationContext::assertion(assertion.span),
5891 "assertion",
5892 diagnostics,
5893 );
5894 let mut projection_reads = collect_projection_reads(&expr);
5895 sort_projection_reads(&mut projection_reads);
5896 ir.assertions.push(IrAssertion {
5897 expr: IrExpression {
5898 source: assertion.expr,
5899 expr,
5900 span: assertion.span,
5901 },
5902 projection_reads,
5903 });
5904 }
5905 Err(message) => diagnostics.push(Diagnostic {
5906 related: Vec::new(),
5907 span: assertion.span,
5908 message: format!("invalid assertion expression: {message}"),
5909 suggestion: Some(
5910 "use a deterministic expression such as `count(Fact) == 1`".to_owned(),
5911 ),
5912 }),
5913 }
5914}
5915
5916fn lower_expression(source: &str, span: SourceSpan) -> Option<IrExpression> {
5917 parse_expression(source).ok().map(|expr| IrExpression {
5918 source: source.to_owned(),
5919 expr,
5920 span,
5921 })
5922}
5923
5924fn collect_projection_reads(expr: &Expr) -> Vec<IrProjectionRead> {
5925 let mut reads = Vec::new();
5926 collect_projection_reads_into(expr, &mut reads);
5927 reads
5928}
5929
5930fn collect_projection_reads_into(expr: &Expr, reads: &mut Vec<IrProjectionRead>) {
5931 match expr {
5932 Expr::Literal(_) | Expr::Path(_) => {}
5933 Expr::Index { target, key } => {
5934 collect_projection_reads_into(target, reads);
5935 collect_projection_reads_into(key, reads);
5936 }
5937 Expr::Array(items) => {
5938 for item in items {
5939 collect_projection_reads_into(item, reads);
5940 }
5941 }
5942 Expr::Object(fields) => {
5943 for field in fields {
5944 collect_projection_reads_into(&field.value, reads);
5945 }
5946 }
5947 Expr::Unary { expr, .. } => collect_projection_reads_into(expr, reads),
5948 Expr::Binary { left, right, .. } => {
5949 collect_projection_reads_into(left, reads);
5950 collect_projection_reads_into(right, reads);
5951 }
5952 Expr::Call { args, .. } => {
5953 for arg in args {
5954 collect_projection_reads_into(arg, reads);
5955 }
5956 }
5957 Expr::Query { kind, head, guard } => {
5958 reads.push(IrProjectionRead {
5959 kind: *kind,
5960 head: head.clone(),
5961 guard: guard.as_ref().map(|guard| guard.to_snapshot()),
5962 });
5963 if let Some(guard) = guard {
5964 collect_projection_reads_into(guard, reads);
5965 }
5966 }
5967 }
5968}
5969
5970fn sort_projection_reads(reads: &mut Vec<IrProjectionRead>) {
5971 reads.sort_by_key(IrProjectionRead::to_snapshot);
5972 reads.dedup();
5973}
5974
5975fn collect_schema_names(program: &Program, diagnostics: &mut Vec<Diagnostic>) -> BTreeSet<String> {
5976 let mut names = BTreeSet::new();
5977 let mut first_spans: BTreeMap<String, SourceSpan> = BTreeMap::new();
5980 for item in &program.items {
5981 let name = match item {
5982 Item::Enum(enum_decl) => &enum_decl.name,
5983 Item::Class(class_decl) => &class_decl.name,
5984 _ => continue,
5985 };
5986
5987 if !names.insert(name.name.clone()) {
5988 let mut diagnostic = Diagnostic {
5989 related: Vec::new(),
5990 span: name.span,
5991 message: format!("schema `{}` is declared more than once", name.name),
5992 suggestion: Some("rename one declaration or merge the schemas".to_owned()),
5993 };
5994 if let Some(first) = first_spans.get(&name.name) {
5995 diagnostic = diagnostic.with_related(*first, "first declared here");
5996 }
5997 diagnostics.push(diagnostic);
5998 } else {
5999 first_spans.insert(name.name.clone(), name.span);
6000 }
6001 }
6002
6003 names
6004}
6005
6006fn collect_harness_kinds(
6007 program: &Program,
6008 diagnostics: &mut Vec<Diagnostic>,
6009) -> BTreeMap<String, String> {
6010 let mut kinds: BTreeMap<String, String> = BTreeMap::new();
6011 for item in &program.items {
6012 let Item::Harness(harness) = item else {
6013 continue;
6014 };
6015 if kinds
6016 .insert(harness.name.name.clone(), harness.kind.name.clone())
6017 .is_some()
6018 {
6019 diagnostics.push(Diagnostic {
6020 related: Vec::new(),
6021 span: harness.name.span,
6022 message: format!("harness `{}` is declared more than once", harness.name.name),
6023 suggestion: Some(
6024 "rename one harness declaration or merge the harness settings".to_owned(),
6025 ),
6026 });
6027 }
6028 }
6029 kinds
6030}
6031
6032fn collect_agent_names(program: &Program, diagnostics: &mut Vec<Diagnostic>) -> BTreeSet<String> {
6033 let mut names = BTreeSet::new();
6034 for item in &program.items {
6035 let Item::Agent(agent) = item else {
6036 continue;
6037 };
6038 if !names.insert(agent.name.name.clone()) {
6039 diagnostics.push(Diagnostic {
6040 related: Vec::new(),
6041 span: agent.name.span,
6042 message: format!("agent `{}` is declared more than once", agent.name.name),
6043 suggestion: Some("rename one agent declaration or merge the settings".to_owned()),
6044 });
6045 }
6046 }
6047 names
6048}
6049
6050#[derive(Clone, Debug, Default, Eq, PartialEq)]
6051struct WorkflowContractNames {
6052 inputs: BTreeMap<String, TypeSyntax>,
6053 outputs: BTreeMap<String, TypeSyntax>,
6054 failures: BTreeMap<String, TypeSyntax>,
6055}
6056
6057fn collect_workflow_contract_names(
6058 program: &Program,
6059 diagnostics: &mut Vec<Diagnostic>,
6060) -> WorkflowContractNames {
6061 let mut names = WorkflowContractNames::default();
6062 for item in &program.items {
6063 let Item::WorkflowContract(contract) = item else {
6064 continue;
6065 };
6066 let set = match contract.kind {
6067 WorkflowContractKind::Input => &mut names.inputs,
6068 WorkflowContractKind::Output => &mut names.outputs,
6069 WorkflowContractKind::Failure => &mut names.failures,
6070 };
6071 if set
6072 .insert(contract.name.name.clone(), contract.ty.clone())
6073 .is_some()
6074 {
6075 diagnostics.push(Diagnostic {
6076 related: Vec::new(),
6077 span: contract.name.span,
6078 message: format!(
6079 "workflow declares {} `{}` more than once",
6080 contract.kind.as_str(),
6081 contract.name.name
6082 ),
6083 suggestion: Some("remove the duplicate workflow contract".to_owned()),
6084 });
6085 }
6086 }
6087 names
6088}
6089
6090impl SemanticContext {
6091 fn from_program(
6092 program: &Program,
6093 workflow_inputs: BTreeMap<String, WorkflowInputSurface>,
6094 ) -> Self {
6095 let mut schemas = SchemaIndex::with_builtins();
6096 let mut agents = BTreeSet::new();
6097 let mut agent_capabilities = BTreeMap::new();
6098 let mut coerce_outputs = BTreeMap::new();
6099 let mut coerce_params = BTreeMap::new();
6100 let mut leases = BTreeSet::new();
6101 let mut ledgers = BTreeSet::new();
6102 let mut counters = BTreeSet::new();
6103 let mut channels = BTreeSet::new();
6104 let mut channel_providers = BTreeMap::new();
6105 let mut memory_pools = BTreeSet::new();
6106
6107 for item in &program.items {
6108 schemas.insert_item(item);
6109 match item {
6110 Item::Agent(agent) => {
6111 agents.insert(agent.name.name.clone());
6112 let capabilities = agent
6113 .fields
6114 .iter()
6115 .find_map(|field| match field {
6116 AgentField::Capabilities(capabilities, _) => Some(
6117 capabilities
6118 .iter()
6119 .map(|capability| capability.value.clone())
6120 .collect::<BTreeSet<_>>(),
6121 ),
6122 _ => None,
6123 })
6124 .unwrap_or_default();
6125 agent_capabilities.insert(agent.name.name.clone(), capabilities);
6126 }
6127 Item::Coerce(coerce) => {
6128 coerce_outputs.insert(coerce.name.name.clone(), coerce.output.clone());
6129 coerce_params.insert(coerce.name.name.clone(), coerce.params.clone());
6130 }
6131 Item::Lease(lease) => {
6132 leases.insert(lease.name.name.clone());
6133 }
6134 Item::Ledger(ledger) => {
6135 ledgers.insert(ledger.name.name.clone());
6136 }
6137 Item::Counter(counter) => {
6138 counters.insert(counter.name.name.clone());
6139 }
6140 Item::Channel(channel) => {
6141 channels.insert(channel.name.name.clone());
6142 channel_providers
6143 .insert(channel.name.name.clone(), channel.provider.name.clone());
6144 }
6145 Item::MemoryPool(pool) => {
6146 memory_pools.insert(pool.name.name.clone());
6147 }
6148 _ => {}
6149 }
6150 }
6151
6152 Self {
6153 workflow: program
6154 .workflow
6155 .as_ref()
6156 .map(|workflow| workflow.name.clone()),
6157 schemas,
6158 agents,
6159 agent_capabilities,
6160 coerce_outputs,
6161 coerce_params,
6162 workflow_inputs,
6163 leases,
6164 ledgers,
6165 counters,
6166 channels,
6167 channel_providers,
6168 memory_pools,
6169 }
6170 }
6171}
6172
6173fn collect_workflow_input_surfaces(program: &Program) -> BTreeMap<String, WorkflowInputSurface> {
6174 let mut surfaces = BTreeMap::new();
6175 let top_level_schemas = schema_index_for_items(&program.items);
6176
6177 if let Some(workflow) = &program.workflow {
6178 let inputs = workflow_inputs_for_items(&program.items);
6179 surfaces.insert(
6180 workflow.name.clone(),
6181 WorkflowInputSurface {
6182 inputs,
6183 outputs: workflow_outputs_for_items(&program.items),
6184 failures: workflow_failures_for_items(&program.items),
6185 schemas: top_level_schemas.clone(),
6186 milestones: collect_milestone_declarations(&program.items),
6187 },
6188 );
6189 }
6190
6191 for workflow in &program.workflows {
6192 let mut schemas = top_level_schemas.clone();
6193 schemas.merge(schema_index_for_items(&workflow.items));
6194 surfaces.insert(
6195 workflow.name.name.clone(),
6196 WorkflowInputSurface {
6197 inputs: workflow_inputs_for_items(&workflow.items),
6198 outputs: workflow_outputs_for_items(&workflow.items),
6199 failures: workflow_failures_for_items(&workflow.items),
6200 schemas,
6201 milestones: collect_milestone_declarations(&workflow.items),
6202 },
6203 );
6204 }
6205
6206 surfaces
6207}
6208
6209fn collect_shared_coordination_usage(program: &Program) -> Vec<IrSharedCoordinationUsage> {
6210 let global_shared = shared_coordination_declarations(&program.items);
6211 let mut usage: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
6212
6213 let mut record_workflow = |workflow_name: &str, local_items: &[Item]| {
6214 let mut shared = global_shared.clone();
6215 shared.extend(shared_coordination_declarations(local_items));
6216 if shared.is_empty() {
6217 return;
6218 }
6219 let principal = format!("workflow:local/{workflow_name}");
6220 for resource in coordination_resources_used_by_items(&program.items)
6221 .into_iter()
6222 .chain(coordination_resources_used_by_items(local_items))
6223 {
6224 if shared.contains(&resource) {
6225 usage.entry(resource).or_default().insert(principal.clone());
6226 }
6227 }
6228 };
6229
6230 if let Some(workflow) = &program.workflow {
6231 record_workflow(&workflow.name, &[]);
6232 }
6233 for workflow in &program.workflows {
6234 record_workflow(&workflow.name.name, &workflow.items);
6235 }
6236
6237 usage
6238 .into_iter()
6239 .map(|(resource, principals)| IrSharedCoordinationUsage {
6240 resource: format!("resource:{resource}"),
6241 workflow_principals: principals.into_iter().collect(),
6242 })
6243 .collect()
6244}
6245
6246fn shared_coordination_declarations(items: &[Item]) -> BTreeSet<String> {
6247 items
6248 .iter()
6249 .filter_map(|item| match item {
6250 Item::Lease(lease) if lease.shared => Some(lease.name.name.clone()),
6251 Item::Ledger(ledger) if ledger.shared => Some(ledger.name.name.clone()),
6252 Item::Counter(counter) if counter.shared => Some(counter.name.name.clone()),
6253 _ => None,
6254 })
6255 .collect()
6256}
6257
6258fn coordination_resources_used_by_items(items: &[Item]) -> BTreeSet<String> {
6259 let mut resources = BTreeSet::new();
6260 for item in items {
6261 let Item::Rule(rule) = item else {
6262 continue;
6263 };
6264 let (body, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
6265 collect_coordination_resources_from_statements(&body.statements, &mut resources);
6266 }
6267 resources
6268}
6269
6270fn collect_coordination_resources_from_statements(
6271 statements: &[body::BodyStmt],
6272 resources: &mut BTreeSet<String>,
6273) {
6274 for statement in statements {
6275 match statement {
6276 body::BodyStmt::Effect(effect) => match &effect.kind {
6277 body::BodyEffectKind::LeaseAcquire { resource, .. } => {
6278 resources.insert(resource.clone());
6279 }
6280 body::BodyEffectKind::LedgerAppend { ledger, .. } => {
6281 resources.insert(ledger.clone());
6282 }
6283 body::BodyEffectKind::CounterConsume { counter, .. } => {
6284 resources.insert(counter.clone());
6285 }
6286 _ => {}
6287 },
6288 body::BodyStmt::After(after) => {
6289 collect_coordination_resources_from_statements(&after.body, resources);
6290 }
6291 body::BodyStmt::Region(region) => {
6292 collect_coordination_resources_from_statements(®ion.body, resources);
6293 collect_coordination_resources_from_statements(®ion.lapse_body, resources);
6294 }
6295 body::BodyStmt::Case(case_stmt) => {
6296 for branch in &case_stmt.branches {
6297 collect_coordination_resources_from_statements(&branch.body, resources);
6298 }
6299 }
6300 body::BodyStmt::Record(_)
6301 | body::BodyStmt::Done { .. }
6302 | body::BodyStmt::Terminal(_)
6303 | body::BodyStmt::Cancel { .. }
6304 | body::BodyStmt::Milestone { .. }
6305 | body::BodyStmt::Redact { .. } => {}
6306 }
6307 }
6308}
6309
6310fn collect_milestone_declarations(items: &[Item]) -> BTreeMap<String, String> {
6316 let mut milestones = BTreeMap::new();
6317 for item in items {
6318 let Item::Rule(rule) = item else {
6319 continue;
6320 };
6321 for (name, class) in milestone_emissions_in_body(&rule.body.text) {
6322 milestones.entry(name).or_insert(class);
6323 }
6324 }
6325 milestones
6326}
6327
6328fn validate_milestone_statements(
6338 rule: &RuleDecl,
6339 semantic: &SemanticContext,
6340 diagnostics: &mut Vec<Diagnostic>,
6341) {
6342 for (name, class) in milestone_emissions_in_body(&rule.body.text) {
6345 if !class.is_empty() && !semantic.schemas.class_exists(&class) {
6346 diagnostics.push(Diagnostic {
6347 related: Vec::new(),
6348 span: rule.body.span,
6349 message: format!(
6350 "rule `{}` emits milestone `{name}` with unknown payload class `{class}`",
6351 rule.name.name
6352 ),
6353 suggestion: Some(format!("declare `class {class}` before projecting it")),
6354 });
6355 }
6356 }
6357
6358 for (binding, milestone) in milestone_reaches_in_body(&rule.body.text) {
6361 let Some(workflow) = invoke_binding_workflow(rule, &binding) else {
6362 diagnostics.push(Diagnostic {
6363 related: Vec::new(),
6364 span: rule.body.span,
6365 message: format!(
6366 "rule `{}` has `after {binding} reaches \"{milestone}\"` for `{binding}`, which is not a workflow-invoke binding in this rule",
6367 rule.name.name
6368 ),
6369 suggestion: Some(
6370 "`reaches` observes a child workflow milestone; bind the child with `invoke W { ... } as <binding>` first"
6371 .to_owned(),
6372 ),
6373 });
6374 continue;
6375 };
6376 let declared = semantic
6377 .workflow_inputs
6378 .get(&workflow)
6379 .map(|surface| surface.milestones.contains_key(&milestone))
6380 .unwrap_or(false);
6381 if !declared {
6382 let available = semantic
6383 .workflow_inputs
6384 .get(&workflow)
6385 .map(|surface| {
6386 surface
6387 .milestones
6388 .keys()
6389 .map(|name| format!("\"{name}\""))
6390 .collect::<Vec<_>>()
6391 .join(", ")
6392 })
6393 .unwrap_or_default();
6394 let suggestion = if available.is_empty() {
6395 format!("workflow `{workflow}` declares no milestones; add `emit milestone \"{milestone}\" ...` to it")
6396 } else {
6397 format!("workflow `{workflow}` declares: {available}")
6398 };
6399 diagnostics.push(Diagnostic {
6400 related: Vec::new(),
6401 span: rule.body.span,
6402 message: format!(
6403 "rule `{}` reaches milestone `{milestone}` that workflow `{workflow}` does not declare",
6404 rule.name.name
6405 ),
6406 suggestion: Some(suggestion),
6407 });
6408 }
6409 }
6410}
6411
6412fn milestone_reaches_in_body(body: &str) -> Vec<(String, String)> {
6415 let mut out = Vec::new();
6416 for raw in body.lines() {
6417 let trimmed = raw.trim();
6418 let Some(rest) = trimmed.strip_prefix("after ") else {
6419 continue;
6420 };
6421 let mut words = rest.split_whitespace();
6422 let Some(binding) = words.next() else {
6423 continue;
6424 };
6425 if words.next() != Some("reaches") {
6426 continue;
6427 }
6428 let Some(quoted) = words.next() else {
6429 continue;
6430 };
6431 if !(quoted.starts_with('"') && quoted.ends_with('"') && quoted.len() >= 2) {
6432 continue;
6433 }
6434 out.push((binding.to_owned(), quoted.trim_matches('"').to_owned()));
6435 }
6436 out
6437}
6438
6439fn invoke_binding_workflow(rule: &RuleDecl, binding: &str) -> Option<String> {
6443 for statement in workflow_invoke_statements(&rule.body.text) {
6444 let (target, _) = invoke_statement_parts(&statement)?;
6445 if let Some(as_binding) = binding_after_as(&statement) {
6446 if as_binding == binding {
6447 return Some(target.to_owned());
6448 }
6449 }
6450 }
6451 None
6452}
6453
6454fn milestone_payload_class(
6459 rule: &RuleDecl,
6460 binding: &str,
6461 milestone: &str,
6462 semantic: &SemanticContext,
6463) -> Option<String> {
6464 let workflow = invoke_binding_workflow(rule, binding)?;
6465 let surface = semantic.workflow_inputs.get(&workflow)?;
6466 surface.milestones.get(milestone).cloned()
6467}
6468
6469fn invoke_output_class(
6478 rule: &RuleDecl,
6479 binding: &str,
6480 semantic: &SemanticContext,
6481) -> Option<String> {
6482 let workflow = invoke_binding_workflow(rule, binding)?;
6483 let surface = semantic.workflow_inputs.get(&workflow)?;
6484 if surface.outputs.len() != 1 {
6485 return None;
6486 }
6487 match surface.outputs.values().next()? {
6488 TypeSyntax::Ref { name } if semantic.schemas.class_exists(&name.name) => {
6489 Some(name.name.clone())
6490 }
6491 _ => None,
6492 }
6493}
6494
6495fn invoke_failure_class(
6506 rule: &RuleDecl,
6507 binding: &str,
6508 semantic: &SemanticContext,
6509) -> Option<String> {
6510 let workflow = invoke_binding_workflow(rule, binding)?;
6511 let surface = semantic.workflow_inputs.get(&workflow)?;
6512 if surface.failures.len() != 1 {
6513 return None;
6514 }
6515 match surface.failures.values().next()? {
6516 TypeSyntax::Ref { name } if semantic.schemas.class_exists(&name.name) => {
6517 Some(name.name.clone())
6518 }
6519 _ => None,
6520 }
6521}
6522
6523fn milestone_emissions_in_body(body: &str) -> Vec<(String, String)> {
6528 let mut out = Vec::new();
6529 for raw in body.lines() {
6530 let trimmed = raw.trim();
6531 let Some(rest) = trimmed.strip_prefix("emit milestone ") else {
6532 continue;
6533 };
6534 let rest = rest.trim_start();
6537 if !rest.starts_with('"') {
6538 continue;
6539 }
6540 let Some(close) = rest[1..].find('"') else {
6541 continue;
6542 };
6543 let name = rest[1..=close].to_owned();
6544 let after_name = rest[close + 2..].trim_start();
6545 let class = after_name
6546 .strip_prefix("of ")
6547 .map(|tail| {
6548 tail.trim_start()
6549 .split(|c: char| c.is_whitespace() || c == '{')
6550 .next()
6551 .unwrap_or("")
6552 .to_owned()
6553 })
6554 .unwrap_or_default();
6555 out.push((name, class));
6556 }
6557 out
6558}
6559
6560fn schema_index_for_items(items: &[Item]) -> SchemaIndex {
6561 let mut schemas = SchemaIndex::with_builtins();
6562 for item in items {
6563 schemas.insert_item(item);
6564 }
6565 schemas
6566}
6567
6568fn workflow_inputs_for_items(items: &[Item]) -> BTreeMap<String, TypeSyntax> {
6569 items
6570 .iter()
6571 .filter_map(|item| match item {
6572 Item::WorkflowContract(contract) if contract.kind == WorkflowContractKind::Input => {
6573 Some((contract.name.name.clone(), contract.ty.clone()))
6574 }
6575 _ => None,
6576 })
6577 .collect()
6578}
6579
6580fn workflow_outputs_for_items(items: &[Item]) -> BTreeMap<String, TypeSyntax> {
6581 items
6582 .iter()
6583 .filter_map(|item| match item {
6584 Item::WorkflowContract(contract) if contract.kind == WorkflowContractKind::Output => {
6585 Some((contract.name.name.clone(), contract.ty.clone()))
6586 }
6587 _ => None,
6588 })
6589 .collect()
6590}
6591
6592fn workflow_failures_for_items(items: &[Item]) -> BTreeMap<String, TypeSyntax> {
6593 items
6594 .iter()
6595 .filter_map(|item| match item {
6596 Item::WorkflowContract(contract) if contract.kind == WorkflowContractKind::Failure => {
6597 Some((contract.name.name.clone(), contract.ty.clone()))
6598 }
6599 _ => None,
6600 })
6601 .collect()
6602}
6603
6604impl SchemaIndex {
6605 fn with_builtins() -> Self {
6606 let mut index = Self::default();
6607 index.insert_class(
6608 "AgentTurn",
6609 [
6610 ("id", string_ty()),
6611 ("summary", string_ty()),
6612 ("agent", string_ty()),
6613 ("provider", string_ty()),
6614 ("status", string_ty()),
6615 ("run_id", string_ty()),
6616 ("effect_id", string_ty()),
6617 ],
6618 );
6619 index.insert_class(
6620 "WorkItem",
6621 [
6622 ("id", string_ty()),
6623 ("title", string_ty()),
6624 ("body", string_ty()),
6625 ("queue", string_ty()),
6626 ("status", string_ty()),
6627 ("labels", array_ty(string_ty())),
6628 ],
6629 );
6630 index.insert_class(
6631 "Evidence",
6632 [
6633 ("title", string_ty()),
6634 ("path", string_ty()),
6635 ("summary", string_ty()),
6636 ],
6637 );
6638 index.insert_class(
6639 "TerminalFailed",
6640 [
6641 ("reason", string_ty()),
6642 ("summary", string_ty()),
6643 ("effect_id", string_ty()),
6644 ("run_id", string_ty()),
6645 ("kind", string_ty()),
6649 ],
6650 );
6651 index.insert_class(
6658 "TerminalFailedExec",
6659 [
6660 ("reason", string_ty()),
6661 ("summary", string_ty()),
6662 ("effect_id", string_ty()),
6663 ("run_id", string_ty()),
6664 ("kind", string_ty()),
6665 ("exit_code", optional_ty(int_ty())),
6669 ],
6670 );
6671 index.insert_class(
6672 "TerminalFailedCoerce",
6673 [
6674 ("reason", string_ty()),
6675 ("summary", string_ty()),
6676 ("effect_id", string_ty()),
6677 ("run_id", string_ty()),
6678 ("kind", string_ty()),
6679 ("error_class", string_ty()),
6680 ("http_status", optional_ty(int_ty())),
6681 ],
6682 );
6683 index.insert_class(
6684 "TerminalFailedTell",
6685 [
6686 ("reason", string_ty()),
6687 ("summary", string_ty()),
6688 ("effect_id", string_ty()),
6689 ("run_id", string_ty()),
6690 ("kind", string_ty()),
6691 ("error_class", string_ty()),
6692 ],
6693 );
6694 index.insert_class(
6695 "TerminalTimedOut",
6696 [
6697 ("summary", string_ty()),
6698 ("effect_id", string_ty()),
6699 ("run_id", string_ty()),
6700 ],
6701 );
6702 index.insert_class(
6703 "TerminalCancelled",
6704 [
6705 ("summary", string_ty()),
6706 ("effect_id", string_ty()),
6707 ("run_id", string_ty()),
6708 ],
6709 );
6710 index.insert_class(
6717 "TerminalOutcome",
6718 [
6719 ("tag", string_ty()),
6720 ("status", string_ty()),
6721 ("summary", string_ty()),
6722 ("effect_id", string_ty()),
6723 ("run_id", string_ty()),
6724 ],
6725 );
6726 index.insert_class(
6732 "Message",
6733 [
6734 ("message_id", string_ty()),
6735 ("channel", string_ty()),
6736 ("provider", string_ty()),
6737 ("received_at", string_ty()),
6738 ("sender", string_ty()),
6739 ("sender_claims", string_ty()),
6740 ("thread_id", string_ty()),
6741 ("text", string_ty()),
6742 ("markdown", string_ty()),
6743 ("attachments", array_ty(string_ty())),
6744 ("interaction", string_ty()),
6745 ("raw_ref", string_ty()),
6746 ("correlation", string_ty()),
6747 ],
6748 );
6749 index.insert_class(
6760 "MessageSendReceipt",
6761 [
6762 ("message_id", string_ty()),
6763 ("channel", string_ty()),
6764 ("provider", string_ty()),
6765 ("status", string_ty()),
6766 ("provider_message_id", string_ty()),
6767 ("thread_id", string_ty()),
6768 ("destination", string_ty()),
6769 ("accepted_at", string_ty()),
6770 ],
6771 );
6772 index
6773 }
6774
6775 fn insert_class<const N: usize>(&mut self, name: &str, fields: [(&str, TypeSyntax); N]) {
6776 self.classes.insert(
6777 name.to_owned(),
6778 fields
6779 .into_iter()
6780 .map(|(field, ty)| (field.to_owned(), ty))
6781 .collect(),
6782 );
6783 }
6784
6785 fn insert_item(&mut self, item: &Item) {
6786 match item {
6787 Item::Enum(enum_decl) => {
6788 self.enums.insert(
6789 enum_decl.name.name.clone(),
6790 enum_decl
6791 .variants
6792 .iter()
6793 .map(|variant| variant.name.name.clone())
6794 .collect(),
6795 );
6796 for variant in &enum_decl.variants {
6800 if variant.fields.is_empty() {
6801 continue;
6802 }
6803 let mut fields = BTreeMap::new();
6804 fields.insert(
6805 "variant".to_owned(),
6806 TypeSyntax::LiteralString {
6807 value: variant.name.name.clone(),
6808 span: variant.name.span,
6809 },
6810 );
6811 for field in &variant.fields {
6812 fields.insert(field.name.name.clone(), field.ty.clone());
6813 }
6814 self.classes.insert(
6815 format!("{}.{}", enum_decl.name.name, variant.name.name),
6816 fields,
6817 );
6818 }
6819 }
6820 Item::Class(class_decl) => {
6821 self.classes.insert(
6822 class_decl.name.name.clone(),
6823 class_decl
6824 .fields
6825 .iter()
6826 .map(|field| (field.name.name.clone(), field.ty.clone()))
6827 .collect(),
6828 );
6829 self.insert_presence(&class_decl.name.name, &class_decl.fields);
6830 }
6831 Item::Event(event) => {
6832 self.events.insert(event.name.clone());
6833 self.classes.insert(
6837 event.name.clone(),
6838 event
6839 .fields
6840 .iter()
6841 .map(|field| (field.name.name.clone(), field.ty.clone()))
6842 .collect(),
6843 );
6844 self.insert_presence(&event.name, &event.fields);
6845 }
6846 _ => {}
6847 }
6848 }
6849
6850 fn insert_presence(&mut self, schema: &str, fields: &[ClassField]) {
6852 let conditions: BTreeMap<String, (String, String)> = fields
6853 .iter()
6854 .filter_map(|field| {
6855 field
6856 .presence_condition
6857 .clone()
6858 .map(|condition| (field.name.name.clone(), condition))
6859 })
6860 .collect();
6861 if !conditions.is_empty() {
6862 self.presence.insert(schema.to_owned(), conditions);
6863 }
6864 }
6865
6866 fn field_presence(&self, schema: &str, field: &str) -> Option<&(String, String)> {
6868 self.presence
6869 .get(schema)
6870 .and_then(|fields| fields.get(field))
6871 }
6872
6873 fn merge(&mut self, other: SchemaIndex) {
6874 self.classes.extend(other.classes);
6875 self.enums.extend(other.enums);
6876 self.presence.extend(other.presence);
6877 }
6878
6879 fn class_exists(&self, name: &str) -> bool {
6880 self.classes.contains_key(name)
6881 }
6882
6883 fn resolve_field_path(&self, root_schema: &str, path: &[String]) -> Result<TypeSyntax, String> {
6884 if root_schema.contains('.') && !self.classes.contains_key(root_schema) {
6889 return Ok(TypeSyntax::Ref {
6890 name: Ident {
6891 name: root_schema.to_owned(),
6892 span: zero_span(),
6893 },
6894 });
6895 }
6896 let mut schema = root_schema.to_owned();
6897 let mut current = TypeSyntax::Ref {
6898 name: Ident {
6899 name: schema.clone(),
6900 span: zero_span(),
6901 },
6902 };
6903
6904 for field in path {
6905 let Some(fields) = self.classes.get(&schema) else {
6906 return Err(format!("schema `{schema}` has no declared fields"));
6907 };
6908 let Some(field_ty) = fields.get(field) else {
6909 return Err(format!("schema `{schema}` has no field `{field}`"));
6910 };
6911
6912 current = field_ty.clone();
6913 match schema_name_for_path(¤t) {
6914 Some(next_schema) => schema = next_schema,
6915 None if field != path.last().expect("path is non-empty") => {
6916 return Err(format!("field `{field}` is not a schema value"));
6917 }
6918 None => {}
6919 }
6920 }
6921
6922 Ok(current)
6923 }
6924}
6925
6926fn zero_span() -> SourceSpan {
6927 SourceSpan { start: 0, end: 0 }
6928}
6929
6930fn string_ty() -> TypeSyntax {
6931 TypeSyntax::Primitive {
6932 name: "string".to_owned(),
6933 span: zero_span(),
6934 }
6935}
6936
6937fn int_ty() -> TypeSyntax {
6938 TypeSyntax::Primitive {
6939 name: "int".to_owned(),
6940 span: zero_span(),
6941 }
6942}
6943
6944fn optional_ty(inner: TypeSyntax) -> TypeSyntax {
6945 TypeSyntax::Optional {
6946 inner: Box::new(inner),
6947 span: zero_span(),
6948 }
6949}
6950
6951fn array_ty(inner: TypeSyntax) -> TypeSyntax {
6952 TypeSyntax::Array {
6953 inner: Box::new(inner),
6954 span: zero_span(),
6955 }
6956}
6957
6958fn schema_name_for_path(ty: &TypeSyntax) -> Option<String> {
6959 match ty {
6960 TypeSyntax::Ref { name } => Some(name.name.clone()),
6961 TypeSyntax::Optional { inner, .. } => schema_name_for_path(inner),
6962 _ => None,
6963 }
6964}
6965
6966fn lower_include(include: IncludeDecl, ir: &mut IrProgram) {
6967 ir.includes.push(IrInclude {
6968 path: include.path.value,
6969 source_hash: None,
6970 });
6971}
6972
6973fn lower_workflow_contract(
6974 contract: WorkflowContractDecl,
6975 ir: &mut IrProgram,
6976 schema_names: &BTreeSet<String>,
6977 agent_names: &BTreeSet<String>,
6978 diagnostics: &mut Vec<Diagnostic>,
6979) {
6980 validate_type_refs(&contract.ty, schema_names, agent_names, diagnostics);
6981 let kind = match contract.kind {
6982 WorkflowContractKind::Input => IrWorkflowContractKind::Input,
6983 WorkflowContractKind::Output => IrWorkflowContractKind::Output,
6984 WorkflowContractKind::Failure => IrWorkflowContractKind::Failure,
6985 };
6986 ir.workflow_contracts.push(IrWorkflowContract {
6987 kind,
6988 name: contract.name.name,
6989 ty: lower_type(contract.ty),
6990 span: contract.span,
6991 });
6992}
6993
6994pub const STD_PACKAGE_IDS: &[&str] = &[
7000 "std.agent",
7001 "std.coercion",
7002 "std.coord",
7003 "std.files",
7004 "std.human",
7005 "std.ingress",
7006 "std.memory",
7007 "std.messaging",
7008 "std.script",
7009 "std.telemetry",
7010 "std.time",
7011 "std.tracker",
7012 "std.workflow",
7013];
7014
7015fn lower_use(use_decl: UseDecl, ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
7016 let std_family_known = |value: &str| {
7020 STD_PACKAGE_IDS.iter().any(|id| {
7021 value == *id
7022 || value
7023 .strip_prefix(id)
7024 .is_some_and(|rest| rest.starts_with('.'))
7025 })
7026 };
7027 if use_decl.name.value.starts_with("std.") && !std_family_known(&use_decl.name.value) {
7028 diagnostics.push(Diagnostic {
7029 related: Vec::new(),
7030 span: use_decl.name.span,
7031 message: format!("unknown standard package `{}`", use_decl.name.value),
7032 suggestion: Some(format!(
7033 "standard packages are {}",
7034 STD_PACKAGE_IDS.join(", ")
7035 )),
7036 });
7037 }
7038 let kind = IrUseKind::Package;
7039 ir.uses.push(IrUse {
7040 kind,
7041 name: use_decl.name.value,
7042 });
7043}
7044
7045fn lower_tracker(tracker: TrackerDecl, ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
7046 if tracker.provider.name != "builtin" {
7047 diagnostics.push(Diagnostic {
7048 related: Vec::new(),
7049 span: tracker.provider.span,
7050 message: format!(
7051 "tracker `{}` uses unavailable provider `{}`",
7052 tracker.name.name, tracker.provider.name
7053 ),
7054 suggestion: Some(
7055 "`builtin` is the available provider; github/linear/jira are deferred bindings"
7056 .to_owned(),
7057 ),
7058 });
7059 }
7060 ir.trackers.push(IrTracker {
7061 name: tracker.name.name,
7062 provider: tracker.provider.name,
7063 span: tracker.span,
7064 });
7065}
7066
7067fn lower_channel(channel: ChannelDecl, ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
7068 if let Some(existing) = ir
7071 .channels
7072 .iter()
7073 .find(|other| other.name == channel.name.name)
7074 {
7075 diagnostics.push(
7076 Diagnostic {
7077 related: Vec::new(),
7078 span: channel.name.span,
7079 message: format!("channel `{}` is declared more than once", channel.name.name),
7080 suggestion: Some("give each channel a unique name".to_owned()),
7081 }
7082 .with_related(existing.span, "first declared here"),
7083 );
7084 return;
7085 }
7086 if channel_provider_report(&channel.provider.name).is_none() {
7092 let known = CHANNEL_PROVIDER_REPORTS
7093 .iter()
7094 .map(|report| report.short_name)
7095 .collect::<Vec<_>>()
7096 .join(", ");
7097 diagnostics.push(Diagnostic {
7098 related: Vec::new(),
7099 span: channel.provider.span,
7100 message: format!(
7101 "channel `{}` names unknown messaging provider `{}`",
7102 channel.name.name, channel.provider.name
7103 ),
7104 suggestion: Some(format!("declare one of the v1 providers: {known}")),
7105 });
7106 }
7109 ir.channels.push(IrChannel {
7110 name: channel.name.name,
7111 provider: channel.provider.name,
7112 workspace: channel.workspace.map(|workspace| workspace.name),
7113 destination: channel.destination.map(|destination| destination.value),
7114 span: channel.span,
7115 });
7116}
7117
7118#[derive(Clone, Copy, Debug, Eq, PartialEq)]
7129pub struct ChannelProviderReport {
7130 pub short_name: &'static str,
7132 pub provider_id: &'static str,
7134 pub direction: &'static str,
7136 pub identity: &'static str,
7138 pub interactions: &'static [&'static str],
7140 pub content: &'static [&'static str],
7142 pub delivery_receipts: &'static [&'static str],
7144}
7145
7146pub const CHANNEL_PROVIDER_REPORTS: &[ChannelProviderReport] = &[
7148 ChannelProviderReport {
7149 short_name: "fixture",
7150 provider_id: "fixture",
7151 direction: "bidirectional",
7152 identity: "claimed_actor",
7153 interactions: &["buttons", "reactions"],
7154 content: &["text", "markdown"],
7155 delivery_receipts: &["accepted", "failed"],
7156 },
7157 ChannelProviderReport {
7158 short_name: "local",
7159 provider_id: "std.messaging.local",
7160 direction: "bidirectional",
7161 identity: "claimed_actor",
7162 interactions: &["buttons", "reactions"],
7163 content: &["text", "markdown"],
7164 delivery_receipts: &["accepted", "failed"],
7165 },
7166 ChannelProviderReport {
7167 short_name: "desktop",
7168 provider_id: "std.messaging.desktop",
7169 direction: "outbound_only",
7170 identity: "anonymous",
7171 interactions: &[],
7172 content: &["text"],
7173 delivery_receipts: &["accepted", "failed"],
7174 },
7175 ChannelProviderReport {
7176 short_name: "stdio",
7177 provider_id: "std.messaging.stdio",
7178 direction: "bidirectional",
7179 identity: "claimed_actor",
7180 interactions: &["buttons"],
7181 content: &["text", "markdown"],
7182 delivery_receipts: &["accepted", "failed"],
7183 },
7184];
7185
7186pub fn channel_provider_report(provider: &str) -> Option<&'static ChannelProviderReport> {
7192 CHANNEL_PROVIDER_REPORTS
7193 .iter()
7194 .find(|report| report.short_name == provider || report.provider_id == provider)
7195}
7196
7197pub const BUILTIN_GAUGES: &[&str] = &["std.spend", "std.latency", "std.tokens", "std.cache_hit"];
7203
7204pub const FILE_STORE_PROVIDERS: &[&str] = &["local"];
7210
7211fn lower_gauge(gauge: GaugeDecl, ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
7212 if let Some(existing) = ir.gauges.iter().find(|other| other.name == gauge.name.name) {
7213 diagnostics.push(
7214 Diagnostic {
7215 related: Vec::new(),
7216 span: gauge.name.span,
7217 message: format!("gauge `{}` is declared more than once", gauge.name.name),
7218 suggestion: Some("give each gauge a unique name".to_owned()),
7219 }
7220 .with_related(existing.span, "first declared here"),
7221 );
7222 return;
7223 }
7224 let (judge_kind, judge_target, judge_args) = match &gauge.judge {
7225 GaugeJudge::Coerce(target, args) => ("coerce", target.name.clone(), args.clone()),
7226 GaugeJudge::Prompt(template) => ("prompt", template.value.clone(), Vec::new()),
7227 GaugeJudge::Exec(command) => ("exec", command.value.clone(), Vec::new()),
7228 GaugeJudge::Labels(source) => ("labels", source.value.clone(), Vec::new()),
7229 };
7230 let expect = gauge.expect.as_ref().map(|bar| IrGaugeBar {
7231 form: match &bar.subject {
7232 GaugeBarSubject::Chance { .. } => "chance".to_owned(),
7233 GaugeBarSubject::Stat { .. } => "stat".to_owned(),
7234 },
7235 subject: match &bar.subject {
7236 GaugeBarSubject::Chance { field } => field.name.clone(),
7237 GaugeBarSubject::Stat { stat } => stat.name.clone(),
7238 },
7239 op: if bar.at_least { ">=" } else { "<=" }.to_owned(),
7240 threshold: bar.threshold.clone(),
7241 });
7242 ir.gauges.push(IrGauge {
7243 name: gauge.name.name,
7244 site: gauge.site,
7245 judge_kind: judge_kind.to_owned(),
7246 judge_target,
7247 judge_args,
7248 expect,
7249 inputs: gauge.inputs.into_iter().map(|input| input.name).collect(),
7250 span: gauge.span,
7251 });
7252}
7253
7254fn lower_mark(mark: MarkDecl, ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
7255 if let Some(existing) = ir.marks.iter().find(|other| other.name == mark.name.value) {
7256 diagnostics.push(
7257 Diagnostic {
7258 related: Vec::new(),
7259 span: mark.name.span,
7260 message: format!("mark `{}` is declared more than once", mark.name.value),
7261 suggestion: Some("give each mark a unique name".to_owned()),
7262 }
7263 .with_related(existing.span, "first declared here"),
7264 );
7265 return;
7266 }
7267 ir.marks.push(IrMark {
7268 name: mark.name.value,
7269 site: mark.site,
7270 span: mark.span,
7271 });
7272}
7273
7274fn lower_campaign(campaign: CampaignDecl, ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
7275 if let Some(existing) = ir
7276 .campaigns
7277 .iter()
7278 .find(|other| other.name == campaign.name.name)
7279 {
7280 diagnostics.push(
7281 Diagnostic {
7282 related: Vec::new(),
7283 span: campaign.name.span,
7284 message: format!(
7285 "campaign `{}` is declared more than once",
7286 campaign.name.name
7287 ),
7288 suggestion: Some("give each campaign a unique name".to_owned()),
7289 }
7290 .with_related(existing.span, "first declared here"),
7291 );
7292 return;
7293 }
7294 ir.campaigns.push(IrCampaign {
7295 name: campaign.name.name,
7296 ascend: campaign
7297 .ascend
7298 .into_iter()
7299 .map(|gauge| gauge.name)
7300 .collect(),
7301 reach: campaign
7302 .reach
7303 .into_iter()
7304 .map(|reach| IrCampaignReach {
7305 gauge: reach.gauge.name,
7306 op: if reach.at_least { ">=" } else { "<=" }.to_owned(),
7307 threshold: reach.threshold,
7308 unit: reach.unit,
7309 })
7310 .collect(),
7311 guard: campaign
7312 .guard
7313 .into_iter()
7314 .map(|guard| IrCampaignGuard {
7315 gauge: guard.gauge.name,
7316 band_percent: guard.band_percent,
7317 })
7318 .collect(),
7319 sacrifice: campaign
7320 .sacrifice
7321 .into_iter()
7322 .map(|gauge| gauge.name)
7323 .collect(),
7324 proposer_redacted: campaign.proposer_redacted,
7325 span: campaign.span,
7326 });
7327}
7328
7329fn validate_improve_declarations(ir: &IrProgram, diagnostics: &mut Vec<Diagnostic>) {
7335 for mark in &ir.marks {
7338 if !ir.rules.iter().any(|rule| rule.name == mark.site) {
7339 diagnostics.push(Diagnostic {
7340 related: Vec::new(),
7341 span: mark.span,
7342 message: format!("mark `{}` rides unknown site `{}`", mark.name, mark.site),
7343 suggestion: Some(format!(
7344 "declared rules: {}",
7345 ir.rules
7346 .iter()
7347 .map(|rule| rule.name.as_str())
7348 .collect::<Vec<_>>()
7349 .join(", ")
7350 )),
7351 });
7352 }
7353 }
7354 let gauge_names: BTreeSet<&str> = ir.gauges.iter().map(|gauge| gauge.name.as_str()).collect();
7355 let resolves = |name: &str| gauge_names.contains(name) || BUILTIN_GAUGES.contains(&name);
7356 let unknown = |name: &str, span: SourceSpan, diagnostics: &mut Vec<Diagnostic>| {
7357 diagnostics.push(Diagnostic {
7358 related: Vec::new(),
7359 span,
7360 message: format!("unknown gauge `{name}`"),
7361 suggestion: Some(format!(
7362 "declare `gauge {name} {{ ... }}` or use a built-in gauge ({})",
7363 BUILTIN_GAUGES.join(", ")
7364 )),
7365 });
7366 };
7367 for gauge in &ir.gauges {
7368 if gauge.judge_kind == "coerce" {
7369 match ir
7370 .coerces
7371 .iter()
7372 .find(|coerce| coerce.name == gauge.judge_target)
7373 {
7374 None => {
7375 diagnostics.push(Diagnostic {
7376 related: Vec::new(),
7377 span: gauge.span,
7378 message: format!(
7379 "gauge `{}` judges via undeclared coerce `{}`",
7380 gauge.name, gauge.judge_target
7381 ),
7382 suggestion: Some("declare the coerce this gauge judges with".to_owned()),
7383 });
7384 }
7385 Some(coerce) if !gauge.judge_args.is_empty() => {
7394 if gauge.judge_args.len() == 1 && gauge.judge_args[0] == "record" {
7395 if coerce.params.len() != 1 {
7396 diagnostics.push(Diagnostic {
7397 related: Vec::new(),
7398 span: gauge.span,
7399 message: format!(
7400 "gauge `{}`: the reserved `(record)` form needs a \
7401 single-parameter coerce; `{}` takes {}",
7402 gauge.name,
7403 gauge.judge_target,
7404 coerce.params.len()
7405 ),
7406 suggestion: Some(
7407 "give the coerce one record-shaped parameter, or bind each \
7408 parameter to an explicit path"
7409 .to_owned(),
7410 ),
7411 });
7412 }
7413 } else {
7414 for arg in &gauge.judge_args {
7415 let head = arg.split('.').next().unwrap_or_default();
7416 let valid = match head {
7417 "record" => false, "input" => true,
7419 "facts" => arg.splitn(3, '.').count() == 3,
7420 _ => false,
7421 };
7422 if !valid {
7423 diagnostics.push(Diagnostic {
7424 related: Vec::new(),
7425 span: gauge.span,
7426 message: format!(
7427 "gauge `{}`: judge argument `{arg}` is not a record \
7428 path",
7429 gauge.name
7430 ),
7431 suggestion: Some(
7432 "arguments are `input.<path>`, \
7433 `facts.<Class>.<field...>`, or the single reserved \
7434 `record`"
7435 .to_owned(),
7436 ),
7437 });
7438 }
7439 }
7440 if gauge.judge_args.len() != coerce.params.len() {
7441 diagnostics.push(Diagnostic {
7442 related: Vec::new(),
7443 span: gauge.span,
7444 message: format!(
7445 "gauge `{}`: judge passes {} argument{} but coerce `{}` \
7446 takes {}",
7447 gauge.name,
7448 gauge.judge_args.len(),
7449 if gauge.judge_args.len() == 1 { "" } else { "s" },
7450 gauge.judge_target,
7451 coerce.params.len()
7452 ),
7453 suggestion: Some(
7454 "bind one path per coerce parameter, in order".to_owned(),
7455 ),
7456 });
7457 }
7458 }
7459 }
7460 Some(_) => {}
7461 }
7462 }
7463 if !gauge.inputs.is_empty() && gauge.judge_kind != "exec" {
7464 diagnostics.push(Diagnostic {
7465 related: Vec::new(),
7466 span: gauge.span,
7467 message: format!(
7468 "derived gauge `{}` must judge via exec (its judge receives the input score vector)",
7469 gauge.name
7470 ),
7471 suggestion: Some("use `judge via exec \"<validator>\"`".to_owned()),
7472 });
7473 }
7474 for input in &gauge.inputs {
7475 if input == &gauge.name {
7476 diagnostics.push(Diagnostic {
7477 related: Vec::new(),
7478 span: gauge.span,
7479 message: format!("derived gauge `{}` cannot input itself", gauge.name),
7480 suggestion: None,
7481 });
7482 } else if !resolves(input) {
7483 unknown(input, gauge.span, diagnostics);
7484 }
7485 }
7486 }
7487 for campaign in &ir.campaigns {
7488 let mut named: Vec<(&str, &'static str)> = Vec::new();
7489 for name in &campaign.ascend {
7490 named.push((name, "ascend"));
7491 }
7492 for reach in &campaign.reach {
7493 named.push((&reach.gauge, "reach"));
7494 }
7495 for guard in &campaign.guard {
7496 named.push((&guard.gauge, "guard"));
7497 }
7498 for name in &campaign.sacrifice {
7499 named.push((name, "sacrifice"));
7500 }
7501 let mut seen: BTreeMap<&str, &'static str> = BTreeMap::new();
7502 for (name, role) in named {
7503 if !resolves(name) {
7504 unknown(name, campaign.span, diagnostics);
7505 }
7506 if let Some(previous) = seen.insert(name, role) {
7507 let message = if previous == role {
7508 format!(
7509 "campaign `{}` names gauge `{name}` twice in {role}",
7510 campaign.name
7511 )
7512 } else {
7513 format!(
7514 "campaign `{}` names gauge `{name}` as both {previous} and {role}",
7515 campaign.name
7516 )
7517 };
7518 diagnostics.push(Diagnostic {
7519 related: Vec::new(),
7520 span: campaign.span,
7521 message,
7522 suggestion: Some("name each gauge once, in at most one clause".to_owned()),
7523 });
7524 }
7525 }
7526 }
7527}
7528
7529fn lower_harness(harness: HarnessDecl, ir: &mut IrProgram, _diagnostics: &mut [Diagnostic]) {
7530 ir.harnesses.push(IrHarness {
7537 name: harness.name.name,
7538 kind: harness.kind.name,
7539 span: harness.span,
7540 });
7541}
7542
7543#[derive(Clone, Copy, Debug, Eq, PartialEq)]
7548pub enum HarnessClass {
7549 Managed,
7550 Delegated,
7551}
7552
7553impl HarnessClass {
7554 pub fn as_str(self) -> &'static str {
7555 match self {
7556 HarnessClass::Managed => "managed",
7557 HarnessClass::Delegated => "delegated",
7558 }
7559 }
7560}
7561
7562pub fn harness_class(kind: &str) -> HarnessClass {
7569 match kind {
7570 "owned" | "fixture" => HarnessClass::Managed,
7571 _ => HarnessClass::Delegated,
7572 }
7573}
7574
7575fn lower_agent(
7576 agent: AgentDecl,
7577 ir: &mut IrProgram,
7578 harness_kinds: &BTreeMap<String, String>,
7579 diagnostics: &mut Vec<Diagnostic>,
7580) {
7581 let mut lowered = IrAgent {
7582 name: agent.name.name.clone(),
7583 harness: agent.harness.as_ref().map(|harness| harness.name.clone()),
7584 provider: None,
7585 profile: None,
7586 capacity: None,
7587 skills: Vec::new(),
7588 capabilities: Vec::new(),
7589 requires: Vec::new(),
7590 tools: Vec::new(),
7591 compaction: None,
7592 thread: None,
7593 settings: None,
7594 harness_class: HarnessClass::Managed,
7596 };
7597
7598 if let Some(harness) = &agent.harness {
7599 if !harness_kinds.contains_key(&harness.name) {
7600 diagnostics.push(Diagnostic {
7601 related: Vec::new(),
7602 span: harness.span,
7603 message: format!(
7604 "agent `{}` uses unknown harness `{}`",
7605 agent.name.name, harness.name
7606 ),
7607 suggestion: Some(format!(
7608 "declare `harness {}: fixture` before using it",
7609 harness.name
7610 )),
7611 });
7612 }
7613 }
7614
7615 if let Some(delegate) = &agent.delegated_to {
7621 if harness_class(&delegate.name) != HarnessClass::Delegated {
7622 diagnostics.push(Diagnostic {
7623 related: Vec::new(),
7624 span: delegate.span,
7625 message: format!(
7626 "agent `{}` delegates to `{}`, which is a managed kind",
7627 agent.name.name, delegate.name
7628 ),
7629 suggestion: Some(
7630 "a plain `agent name { ... }` is managed by default; `delegated to` names a foreign runtime"
7631 .to_owned(),
7632 ),
7633 });
7634 }
7635 }
7636
7637 let mut compaction_span: Option<SourceSpan> = None;
7640 let mut thread_span: Option<SourceSpan> = None;
7641 let mut settings_span: Option<SourceSpan> = None;
7642
7643 for field in agent.fields {
7644 match field {
7645 AgentField::Provider(provider) => {
7646 if lowered.provider.is_some() {
7647 diagnostics.push(Diagnostic {
7648 related: Vec::new(),
7649 span: provider.span,
7650 message: format!(
7651 "agent `{}` declares provider more than once",
7652 agent.name.name
7653 ),
7654 suggestion: Some(
7655 "keep exactly one `provider` field in the agent block".to_owned(),
7656 ),
7657 });
7658 }
7659 if agent.harness.is_some() {
7660 diagnostics.push(Diagnostic { related: Vec::new(),
7661 span: provider.span,
7662 message: format!(
7663 "agent `{}` declares both `using` harness and direct provider `{}`",
7664 agent.name.name, provider.name
7665 ),
7666 suggestion: Some(
7667 "use either `agent name using harness { ... }` or `provider codex`, not both"
7668 .to_owned(),
7669 ),
7670 });
7671 }
7672 if agent.delegated_to.is_some() {
7673 diagnostics.push(Diagnostic { related: Vec::new(),
7674 span: provider.span,
7675 message: format!(
7676 "agent `{}` declares both `delegated to` and direct provider `{}`",
7677 agent.name.name, provider.name
7678 ),
7679 suggestion: Some(
7680 "use either `agent name delegated to <provider> { ... }` or a `provider` field, not both"
7681 .to_owned(),
7682 ),
7683 });
7684 }
7685 lowered.provider = Some(provider.name);
7689 }
7690 AgentField::Profile(profile) => lowered.profile = Some(profile.value),
7691 AgentField::Capacity(capacity, span) => {
7692 if capacity == 0 {
7693 diagnostics.push(Diagnostic {
7694 related: Vec::new(),
7695 span,
7696 message: format!(
7697 "agent `{}` capacity must be greater than zero",
7698 agent.name.name
7699 ),
7700 suggestion: Some("use `capacity 1` or a larger integer".to_owned()),
7701 });
7702 }
7703 lowered.capacity = Some(capacity);
7704 }
7705 AgentField::Skills(skills, _) => {
7706 let mut seen = BTreeSet::new();
7707 for skill in skills {
7708 if !seen.insert(skill.value.clone()) {
7709 diagnostics.push(Diagnostic {
7710 related: Vec::new(),
7711 span: skill.span,
7712 message: format!(
7713 "agent `{}` attaches skill `{}` more than once",
7714 agent.name.name, skill.value
7715 ),
7716 suggestion: Some("remove the duplicate skill entry".to_owned()),
7717 });
7718 }
7719 lowered.skills.push(skill.value);
7720 }
7721 }
7722 AgentField::Capabilities(capabilities, _) => {
7723 let mut seen = BTreeSet::new();
7724 for capability in capabilities {
7725 if !seen.insert(capability.value.clone()) {
7726 diagnostics.push(Diagnostic {
7727 related: Vec::new(),
7728 span: capability.span,
7729 message: format!(
7730 "agent `{}` declares capability `{}` more than once",
7731 agent.name.name, capability.value
7732 ),
7733 suggestion: Some("remove the duplicate capability entry".to_owned()),
7734 });
7735 }
7736 lowered.capabilities.push(capability.value);
7737 }
7738 }
7739 AgentField::Requires(classes, _) => {
7740 let mut seen = BTreeSet::new();
7741 for class in classes {
7742 if !whipplescript_core::AGENT_FEATURE_CLASS_TAXONOMY
7748 .contains(&class.name.as_str())
7749 {
7750 diagnostics.push(Diagnostic {
7751 related: Vec::new(),
7752 span: class.span,
7753 message: format!(
7754 "agent `{}` requires unknown feature class `{}`",
7755 agent.name.name, class.name
7756 ),
7757 suggestion: Some(format!(
7758 "feature classes come from the DR-0015 taxonomy: {}",
7759 whipplescript_core::AGENT_FEATURE_CLASS_TAXONOMY.join(", ")
7760 )),
7761 });
7762 }
7763 if !seen.insert(class.name.clone()) {
7764 diagnostics.push(Diagnostic {
7765 related: Vec::new(),
7766 span: class.span,
7767 message: format!(
7768 "agent `{}` requires feature class `{}` more than once",
7769 agent.name.name, class.name
7770 ),
7771 suggestion: Some("remove the duplicate requires entry".to_owned()),
7772 });
7773 }
7774 lowered.requires.push(class.name);
7775 }
7776 }
7777 AgentField::Tools(tools, _) => {
7778 let mut seen = BTreeSet::new();
7779 for tool in tools {
7780 if !seen.insert(tool.name.clone()) {
7781 diagnostics.push(Diagnostic {
7782 related: Vec::new(),
7783 span: tool.span,
7784 message: format!(
7785 "agent `{}` grants tool `{}` more than once",
7786 agent.name.name, tool.name
7787 ),
7788 suggestion: Some("remove the duplicate tool entry".to_owned()),
7789 });
7790 }
7791 lowered.tools.push(tool.name);
7792 }
7793 }
7794 AgentField::Compaction(strategy) => {
7795 const STRATEGIES: [&str; 4] = ["summarize", "hard_reset", "tool_results", "none"];
7796 if lowered.compaction.is_some() {
7797 diagnostics.push(Diagnostic {
7798 related: Vec::new(),
7799 span: strategy.span,
7800 message: format!(
7801 "agent `{}` declares compaction more than once",
7802 agent.name.name
7803 ),
7804 suggestion: Some("keep exactly one `compaction` field".to_owned()),
7805 });
7806 }
7807 if !STRATEGIES.contains(&strategy.name.as_str()) {
7808 diagnostics.push(Diagnostic {
7809 related: Vec::new(),
7810 span: strategy.span,
7811 message: format!(
7812 "agent `{}` uses unknown compaction strategy `{}`",
7813 agent.name.name, strategy.name
7814 ),
7815 suggestion: Some(
7816 "supported strategies are `summarize`, `hard_reset`, `tool_results`, and `none`"
7817 .to_owned(),
7818 ),
7819 });
7820 }
7821 compaction_span = Some(strategy.span);
7822 lowered.compaction = Some(strategy.name);
7823 }
7824 AgentField::Thread(mode) => {
7825 const MODES: [&str; 2] = ["continue", "fresh"];
7826 if lowered.thread.is_some() {
7827 diagnostics.push(Diagnostic {
7828 related: Vec::new(),
7829 span: mode.span,
7830 message: format!(
7831 "agent `{}` declares thread more than once",
7832 agent.name.name
7833 ),
7834 suggestion: Some("keep exactly one `thread` field".to_owned()),
7835 });
7836 }
7837 if !MODES.contains(&mode.name.as_str()) {
7838 diagnostics.push(Diagnostic {
7839 related: Vec::new(),
7840 span: mode.span,
7841 message: format!(
7842 "agent `{}` uses unknown thread mode `{}`",
7843 agent.name.name, mode.name
7844 ),
7845 suggestion: Some(
7846 "supported thread modes are `continue` and `fresh`".to_owned(),
7847 ),
7848 });
7849 }
7850 thread_span = Some(mode.span);
7851 lowered.thread = Some(mode.name);
7852 }
7853 AgentField::Settings(sources) => {
7854 const SOURCES: [&str; 3] = ["project", "user", "none"];
7855 if lowered.settings.is_some() {
7856 diagnostics.push(Diagnostic {
7857 related: Vec::new(),
7858 span: sources.span,
7859 message: format!(
7860 "agent `{}` declares settings more than once",
7861 agent.name.name
7862 ),
7863 suggestion: Some("keep exactly one `settings` field".to_owned()),
7864 });
7865 }
7866 if !SOURCES.contains(&sources.name.as_str()) {
7867 diagnostics.push(Diagnostic {
7868 related: Vec::new(),
7869 span: sources.span,
7870 message: format!(
7871 "agent `{}` uses unknown settings source `{}`",
7872 agent.name.name, sources.name
7873 ),
7874 suggestion: Some(
7875 "supported settings sources are `project`, `user`, and `none`"
7876 .to_owned(),
7877 ),
7878 });
7879 }
7880 settings_span = Some(sources.span);
7881 lowered.settings = Some(sources.name);
7882 }
7883 AgentField::Unknown { name, .. } => {
7884 diagnostics.push(Diagnostic { related: Vec::new(),
7885 span: name.span,
7886 message: format!(
7887 "unknown agent field `{}` on agent `{}`",
7888 name.name, agent.name.name
7889 ),
7890 suggestion: Some(
7891 "supported agent fields are `provider`, `profile`, `capacity`, `skills`, `capabilities`, `tools`, `compaction`, and `settings`".to_owned(),
7892 ),
7893 });
7894 }
7895 }
7896 }
7897
7898 if lowered.provider.is_none() {
7904 if let Some(delegate) = &agent.delegated_to {
7905 lowered.provider = Some(delegate.name.clone());
7906 } else if lowered.harness.is_none() {
7907 lowered.provider = Some("owned".to_owned());
7908 }
7909 }
7910
7911 let resolved_kind = lowered.provider.as_deref().or_else(|| {
7916 lowered
7917 .harness
7918 .as_deref()
7919 .and_then(|name| harness_kinds.get(name).map(String::as_str))
7920 });
7921 lowered.harness_class = resolved_kind
7922 .map(harness_class)
7923 .unwrap_or(HarnessClass::Managed);
7924
7925 if resolved_kind.is_some() {
7930 if lowered.harness_class == HarnessClass::Delegated {
7931 if let Some(span) = compaction_span {
7932 diagnostics.push(Diagnostic {
7933 related: Vec::new(),
7934 span,
7935 message: format!(
7936 "agent `{}` is delegated; `compaction` is a managed-harness knob",
7937 agent.name.name
7938 ),
7939 suggestion: Some(
7940 "remove `compaction` — a delegated harness compacts its own context"
7941 .to_owned(),
7942 ),
7943 });
7944 }
7945 if let Some(span) = thread_span {
7946 diagnostics.push(Diagnostic {
7947 related: Vec::new(),
7948 span,
7949 message: format!(
7950 "agent `{}` is delegated; `thread` is a managed-harness knob",
7951 agent.name.name
7952 ),
7953 suggestion: Some(
7954 "remove `thread` — a delegated harness owns its own conversation state"
7955 .to_owned(),
7956 ),
7957 });
7958 }
7959 } else if let Some(span) = settings_span {
7960 diagnostics.push(Diagnostic {
7961 related: Vec::new(),
7962 span,
7963 message: format!(
7964 "agent `{}` is managed; `settings` is a delegated-harness knob",
7965 agent.name.name
7966 ),
7967 suggestion: Some(
7968 "remove `settings` — WhippleScript assembles a managed agent's context"
7969 .to_owned(),
7970 ),
7971 });
7972 }
7973 }
7974
7975 if lowered.profile.is_none() {
7981 lowered.profile = Some("no-repo".to_owned());
7982 }
7983
7984 if lowered.capacity.is_none() {
7985 lowered.capacity = Some(1);
7986 }
7987
7988 ir.agents.push(lowered);
7989}
7990
7991fn lower_enum(enum_decl: EnumDecl, ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
7992 let mut variants = BTreeSet::new();
7993 for variant in &enum_decl.variants {
7994 if !variants.insert(variant.name.name.clone()) {
7995 diagnostics.push(Diagnostic {
7996 related: Vec::new(),
7997 span: variant.span,
7998 message: format!(
7999 "enum `{}` declares variant `{}` more than once",
8000 enum_decl.name.name, variant.name.name
8001 ),
8002 suggestion: Some(
8003 "remove the duplicate variant or give it a distinct name".to_owned(),
8004 ),
8005 });
8006 }
8007 for field in &variant.fields {
8010 if field.name.name == "variant" {
8011 diagnostics.push(Diagnostic {
8012 related: Vec::new(),
8013 span: field.name.span,
8014 message: format!(
8015 "variant `{}` of enum `{}` declares reserved field `variant`",
8016 variant.name.name, enum_decl.name.name
8017 ),
8018 suggestion: Some(
8019 "the discriminant is synthesized from the variant name; rename the field"
8020 .to_owned(),
8021 ),
8022 });
8023 }
8024 }
8025 }
8026
8027 for variant in &enum_decl.variants {
8031 if variant.fields.is_empty() {
8032 continue;
8033 }
8034 let mut fields = vec![IrClassField {
8035 name: "variant".to_owned(),
8036 ty: IrType::LiteralString(variant.name.name.clone()),
8037 is_key: false,
8038 presence_condition: None,
8039 span: variant.name.span,
8040 }];
8041 fields.extend(variant.fields.iter().map(|field| IrClassField {
8042 name: field.name.name.clone(),
8043 ty: lower_type(field.ty.clone()),
8044 is_key: false,
8045 presence_condition: field.presence_condition.clone(),
8046 span: field.span,
8047 }));
8048 ir.schemas.push(IrSchema::Class(IrClass {
8049 name: format!("{}.{}", enum_decl.name.name, variant.name.name),
8050 fields,
8051 span: variant.span,
8052 }));
8053 }
8054
8055 ir.schemas.push(IrSchema::Enum(IrEnum {
8056 name: enum_decl.name.name,
8057 variants: enum_decl
8058 .variants
8059 .into_iter()
8060 .map(|variant| variant.name.name)
8061 .collect(),
8062 span: enum_decl.span,
8063 }));
8064}
8065
8066fn validate_test_expr_source(
8067 label: &str,
8068 source: &str,
8069 span: SourceSpan,
8070 diagnostics: &mut Vec<Diagnostic>,
8071) {
8072 if source.trim().is_empty() {
8073 diagnostics.push(Diagnostic {
8074 related: Vec::new(),
8075 span,
8076 message: format!("{label} is empty"),
8077 suggestion: Some("provide an expression".to_owned()),
8078 });
8079 return;
8080 }
8081 if let Err(error) = parse_expression(source) {
8082 diagnostics.push(Diagnostic {
8083 related: Vec::new(),
8084 span,
8085 message: format!("{label} is not a valid expression: {error}"),
8086 suggestion: None,
8087 });
8088 }
8089}
8090
8091fn lower_test(test: TestDecl, ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
8092 if ir
8093 .tests
8094 .iter()
8095 .any(|existing| existing.name == test.name.value)
8096 {
8097 diagnostics.push(Diagnostic {
8098 related: Vec::new(),
8099 span: test.name.span,
8100 message: format!("test `{}` is declared more than once", test.name.value),
8101 suggestion: Some("give each test scenario a distinct name".to_owned()),
8102 });
8103 }
8104 if !test
8105 .clauses
8106 .iter()
8107 .any(|clause| matches!(clause, TestClause::Expect(_)))
8108 {
8109 diagnostics.push(Diagnostic {
8110 related: Vec::new(),
8111 span: test.span,
8112 message: format!("test `{}` has no `expect` clause", test.name.value),
8113 suggestion: Some("a test must assert at least one expected outcome".to_owned()),
8114 });
8115 }
8116 for clause in &test.clauses {
8119 match clause {
8120 TestClause::Given(
8121 GivenClause::Input { fields, .. }
8122 | GivenClause::Fact { fields, .. }
8123 | GivenClause::Signal { fields, .. },
8124 ) => {
8125 for field in fields {
8126 validate_test_expr_source(
8127 &format!("given field `{}`", field.name.name),
8128 &field.value,
8129 field.span,
8130 diagnostics,
8131 );
8132 }
8133 }
8134 TestClause::Expect(ExpectClause {
8135 target: ExpectTarget::Projection(query),
8136 ..
8137 }) => match &query.kind {
8138 ProjQueryKind::Count { predicate, .. } | ProjQueryKind::Where { predicate } => {
8139 validate_test_expr_source(
8140 &format!("predicate on `{}`", query.noun),
8141 predicate,
8142 query.span,
8143 diagnostics,
8144 );
8145 }
8146 ProjQueryKind::Exists => {}
8147 },
8148 _ => {}
8149 }
8150 }
8151 ir.tests.push(IrTest {
8152 name: test.name.value,
8153 workflow: test.workflow.map(|identifier| identifier.name),
8154 clauses: test.clauses,
8155 span: test.span,
8156 });
8157}
8158
8159fn lower_source(source: SourceDecl, ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
8160 if ir
8161 .sources
8162 .iter()
8163 .any(|existing| existing.name == source.name.name)
8164 {
8165 diagnostics.push(Diagnostic {
8166 related: Vec::new(),
8167 span: source.name.span,
8168 message: format!("source `{}` is declared more than once", source.name.name),
8169 suggestion: Some("remove the duplicate source declaration".to_owned()),
8170 });
8171 }
8172 if let Some(clock) = &source.clock {
8176 let recurring = !matches!(clock.recurrence, Recurrence::At { .. });
8177 if recurring && clock.missed.is_none() {
8178 diagnostics.push(Diagnostic {
8179 related: Vec::new(),
8180 span: clock.span,
8181 message: format!(
8182 "recurring source `{}` must declare a `missed` policy",
8183 source.name.name
8184 ),
8185 suggestion: Some(
8186 "add `missed skip`, `missed coalesce`, or `missed catch_up limit N`".to_owned(),
8187 ),
8188 });
8189 }
8190 if matches!(clock.recurrence, Recurrence::EveryCalendar { .. }) && clock.timezone.is_none()
8191 {
8192 diagnostics.push(Diagnostic { related: Vec::new(),
8193 span: clock.span,
8194 message: format!(
8195 "calendar source `{}` should declare a `timezone`",
8196 source.name.name
8197 ),
8198 suggestion: Some(
8199 "add `timezone \"America/New_York\"`; a calendar schedule without one defaults to UTC".to_owned(),
8200 ),
8201 });
8202 }
8203 }
8204 let is_file = source.provider.name == "file";
8210 if is_file && source.path.is_none() && source.watch.is_none() {
8211 diagnostics.push(Diagnostic {
8212 related: Vec::new(),
8213 span: source.span,
8214 message: format!(
8215 "`file` source `{}` requires a `path` or `watch` clause",
8216 source.name.name
8217 ),
8218 suggestion: Some(
8219 "add `path \"./inbox.txt\"` (one signal per line) or `watch \"./drops/*.json\"` \
8220 (one signal per new file-content occurrence)"
8221 .to_owned(),
8222 ),
8223 });
8224 }
8225 if is_file && source.path.is_some() && source.watch.is_some() {
8226 diagnostics.push(Diagnostic {
8227 related: Vec::new(),
8228 span: source
8229 .watch
8230 .as_ref()
8231 .map(|watch| watch.span)
8232 .unwrap_or(source.span),
8233 message: format!(
8234 "`file` source `{}` declares both `path` and `watch`; the modes are exclusive",
8235 source.name.name
8236 ),
8237 suggestion: Some(
8238 "keep `path` for line-by-line admission or `watch` for per-file-content \
8239 occurrences, not both"
8240 .to_owned(),
8241 ),
8242 });
8243 }
8244 if !is_file {
8245 if let Some(path) = &source.path {
8246 diagnostics.push(Diagnostic {
8247 related: Vec::new(),
8248 span: path.span,
8249 message: format!(
8250 "source `{}` declares a `path` clause but its provider is `{}`, not `file`",
8251 source.name.name, source.provider.name
8252 ),
8253 suggestion: Some(
8254 "use `source file as ...` for a `path`, or remove the clause".to_owned(),
8255 ),
8256 });
8257 }
8258 if let Some(watch) = &source.watch {
8259 diagnostics.push(Diagnostic {
8260 related: Vec::new(),
8261 span: watch.span,
8262 message: format!(
8263 "source `{}` declares a `watch` clause but its provider is `{}`, not `file`",
8264 source.name.name, source.provider.name
8265 ),
8266 suggestion: Some(
8267 "use `source file as ...` for a `watch` glob, or remove the clause".to_owned(),
8268 ),
8269 });
8270 }
8271 }
8272 let is_http = source.provider.name == "http";
8276 if is_http && source.url.is_none() {
8277 diagnostics.push(Diagnostic {
8278 related: Vec::new(),
8279 span: source.span,
8280 message: format!(
8281 "`http` source `{}` requires a `url` clause",
8282 source.name.name
8283 ),
8284 suggestion: Some("add `url \"https://example.com/feed.json\"`".to_owned()),
8285 });
8286 }
8287 if is_http {
8291 if let Some(url) = &source.url {
8292 let scheme_ok = url.value.starts_with("http://") || url.value.starts_with("https://");
8293 if !scheme_ok {
8294 diagnostics.push(Diagnostic {
8295 related: Vec::new(),
8296 span: url.span,
8297 message: format!(
8298 "`http` source `{}` url `{}` is not an absolute http(s) URL",
8299 source.name.name, url.value
8300 ),
8301 suggestion: Some(
8302 "use an absolute `http://` or `https://` URL the runtime can GET"
8303 .to_owned(),
8304 ),
8305 });
8306 }
8307 }
8308 }
8309 if !is_http {
8310 if let Some(url) = &source.url {
8311 diagnostics.push(Diagnostic {
8312 related: Vec::new(),
8313 span: url.span,
8314 message: format!(
8315 "source `{}` declares a `url` clause but its provider is `{}`, not `http`",
8316 source.name.name, source.provider.name
8317 ),
8318 suggestion: Some(
8319 "use `source http as ...` for a `url`, or remove the clause".to_owned(),
8320 ),
8321 });
8322 }
8323 }
8324 let is_clock = source.clock.is_some();
8325 let is_file = source.provider.name == "file";
8326 let is_http = source.provider.name == "http";
8327 let mut dedup_field = None;
8335 if let Some(dedup) = &source.dedup {
8336 let span = match dedup {
8337 SourceValue::Path { span, .. } => *span,
8338 SourceValue::String(literal) => literal.span,
8339 SourceValue::Number(_, span) => *span,
8340 };
8341 if !(is_http || is_file && source.watch.is_none()) {
8342 diagnostics.push(Diagnostic {
8343 related: Vec::new(),
8344 span,
8345 message: format!(
8346 "source `{}` declares a `dedup` clause but its provider is `{}`{}",
8347 source.name.name,
8348 source.provider.name,
8349 if is_file {
8350 " in `watch` mode, which is already content-keyed"
8351 } else {
8352 "; `dedup` applies to `file` (line mode) and `http` sources"
8353 }
8354 ),
8355 suggestion: Some("remove the `dedup` clause".to_owned()),
8356 });
8357 } else {
8358 match dedup {
8359 SourceValue::Path {
8360 binding, segments, ..
8361 } if binding.name == source.observe_binding.name && segments.len() == 1 => {
8362 dedup_field = Some(segments[0].name.clone());
8363 }
8364 _ => {
8365 diagnostics.push(Diagnostic {
8366 related: Vec::new(),
8367 span,
8368 message: format!(
8369 "source `{}` `dedup` must name one observation field off the \
8370 `observe` binding (e.g. `dedup {}.line`)",
8371 source.name.name, source.observe_binding.name
8372 ),
8373 suggestion: Some(format!(
8374 "the observation binding is `{}` (declared by `observe as {}`)",
8375 source.observe_binding.name, source.observe_binding.name
8376 )),
8377 });
8378 }
8379 }
8380 }
8381 }
8382 let path = source.path.as_ref().map(|literal| literal.value.clone());
8383 let watch = source.watch.as_ref().map(|literal| literal.value.clone());
8384 let url = source.url.as_ref().map(|literal| literal.value.clone());
8385 let recurrence = source.clock.as_ref().map(|clock| clock.recurrence.clone());
8386 let timezone = source
8387 .clock
8388 .as_ref()
8389 .and_then(|clock| clock.timezone.as_ref().map(|tz| tz.value.clone()));
8390 let missed = source.clock.as_ref().and_then(|clock| clock.missed);
8391 ir.sources.push(IrSource {
8392 name: source.name.name,
8393 provider: source.provider.name,
8394 is_clock,
8395 is_file,
8396 is_http,
8397 recurrence,
8398 timezone,
8399 missed,
8400 path,
8401 watch,
8402 url,
8403 dedup_field,
8404 observe_binding: source.observe_binding.name,
8405 emit_signal: source.emit.signal,
8406 emit_from: source.emit.from.as_ref().map(|ident| ident.name.clone()),
8407 emit_fields: source
8408 .emit
8409 .fields
8410 .into_iter()
8411 .map(|field| IrSourceEmitField {
8412 name: field.name.name,
8413 value: field.value,
8414 span: field.span,
8415 })
8416 .collect(),
8417 span: source.span,
8418 });
8419}
8420
8421fn lower_event(event: EventDecl, ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
8422 if ir.events.iter().any(|existing| existing.name == event.name) {
8423 diagnostics.push(Diagnostic {
8424 related: Vec::new(),
8425 span: event.name_span,
8426 message: format!("signal `{}` is declared more than once", event.name),
8427 suggestion: Some("remove the duplicate signal declaration".to_owned()),
8428 });
8429 }
8430 let mut fields = BTreeSet::new();
8431 for field in &event.fields {
8432 if !fields.insert(field.name.name.clone()) {
8433 diagnostics.push(Diagnostic {
8434 related: Vec::new(),
8435 span: field.name.span,
8436 message: format!(
8437 "signal `{}` declares field `{}` more than once",
8438 event.name, field.name.name
8439 ),
8440 suggestion: Some(
8441 "remove the duplicate field or give it a distinct name".to_owned(),
8442 ),
8443 });
8444 }
8445 }
8446 validate_presence_conditions(&event.name, &event.fields, diagnostics);
8447
8448 ir.events.push(IrEvent {
8449 name: event.name,
8450 fields: event
8451 .fields
8452 .into_iter()
8453 .map(|field| IrClassField {
8454 name: field.name.name,
8455 ty: lower_type(field.ty),
8456 is_key: false,
8457 presence_condition: field.presence_condition,
8458 span: field.span,
8459 })
8460 .collect(),
8461 span: event.span,
8462 });
8463}
8464
8465fn literal_union_values(ty: &TypeSyntax) -> Option<Vec<String>> {
8469 match ty {
8470 TypeSyntax::LiteralString { value, .. } => Some(vec![value.clone()]),
8471 TypeSyntax::Union { variants, .. } => {
8472 let values = variants
8473 .iter()
8474 .filter_map(|variant| match variant {
8475 TypeSyntax::LiteralString { value, .. } => Some(value.clone()),
8476 _ => None,
8477 })
8478 .collect::<Vec<_>>();
8479 (!values.is_empty() && values.len() == variants.len()).then_some(values)
8480 }
8481 _ => None,
8482 }
8483}
8484
8485fn validate_presence_conditions(
8489 container: &str,
8490 fields: &[ClassField],
8491 diagnostics: &mut Vec<Diagnostic>,
8492) {
8493 for field in fields {
8494 let Some((disc, literal)) = &field.presence_condition else {
8495 continue;
8496 };
8497 let Some(disc_field) = fields.iter().find(|candidate| &candidate.name.name == disc) else {
8498 diagnostics.push(Diagnostic {
8499 related: Vec::new(),
8500 span: field.span,
8501 message: format!(
8502 "`{container}` field `{}` is conditioned on unknown discriminant `{disc}`",
8503 field.name.name
8504 ),
8505 suggestion: Some(
8506 "`when <field> is \"...\"` must name a literal-union field of the same schema"
8507 .to_owned(),
8508 ),
8509 });
8510 continue;
8511 };
8512 match literal_union_values(&disc_field.ty) {
8513 Some(values) if values.iter().any(|value| value == literal) => {}
8514 Some(values) => diagnostics.push(Diagnostic {
8515 related: Vec::new(),
8516 span: field.span,
8517 message: format!(
8518 "`{container}` field `{}` is conditioned on `{disc} is \"{literal}\"`, which is not a value of `{disc}`",
8519 field.name.name
8520 ),
8521 suggestion: Some(format!("use one of: {}", values.join(", "))),
8522 }),
8523 None => diagnostics.push(Diagnostic {
8524 related: Vec::new(),
8525 span: field.span,
8526 message: format!(
8527 "`{container}` field `{}` is conditioned on `{disc}`, which is not a string-literal discriminant",
8528 field.name.name
8529 ),
8530 suggestion: Some(
8531 "the discriminant must be a string-literal union, e.g. `kind \"a\" | \"b\"`"
8532 .to_owned(),
8533 ),
8534 }),
8535 }
8536 }
8537}
8538
8539fn lower_class(
8540 class_decl: ClassDecl,
8541 ir: &mut IrProgram,
8542 schema_names: &BTreeSet<String>,
8543 agent_names: &BTreeSet<String>,
8544 diagnostics: &mut Vec<Diagnostic>,
8545) {
8546 let mut fields = BTreeSet::new();
8547 for field in &class_decl.fields {
8548 if !fields.insert(field.name.name.clone()) {
8549 diagnostics.push(Diagnostic {
8550 related: Vec::new(),
8551 span: field.name.span,
8552 message: format!(
8553 "class `{}` declares field `{}` more than once",
8554 class_decl.name.name, field.name.name
8555 ),
8556 suggestion: Some(
8557 "remove the duplicate field or give it a distinct name".to_owned(),
8558 ),
8559 });
8560 }
8561 validate_type_refs(&field.ty, schema_names, agent_names, diagnostics);
8562 }
8563
8564 let key_fields = class_decl
8566 .fields
8567 .iter()
8568 .filter(|field| field.is_key)
8569 .collect::<Vec<_>>();
8570 if key_fields.len() > 1 {
8571 for field in key_fields.into_iter().skip(1) {
8572 diagnostics.push(Diagnostic {
8573 related: Vec::new(),
8574 span: field.span,
8575 message: format!(
8576 "class `{}` declares more than one `@key` field",
8577 class_decl.name.name
8578 ),
8579 suggestion: Some("a class has at most one `@key` natural key in v0".to_owned()),
8580 });
8581 }
8582 }
8583
8584 validate_presence_conditions(&class_decl.name.name, &class_decl.fields, diagnostics);
8585
8586 ir.schemas.push(IrSchema::Class(IrClass {
8587 name: class_decl.name.name,
8588 span: class_decl.span,
8589 fields: class_decl
8590 .fields
8591 .into_iter()
8592 .map(|field| IrClassField {
8593 name: field.name.name,
8594 ty: lower_type(field.ty),
8595 is_key: field.is_key,
8596 presence_condition: field.presence_condition,
8597 span: field.span,
8598 })
8599 .collect(),
8600 }));
8601}
8602
8603fn lower_table(
8604 table: TableDecl,
8605 semantic: &SemanticContext,
8606 workflow_contract_names: &WorkflowContractNames,
8607 ir: &mut IrProgram,
8608 diagnostics: &mut Vec<Diagnostic>,
8609) {
8610 if !semantic.schemas.class_exists(&table.schema.name) {
8611 diagnostics.push(Diagnostic {
8612 related: Vec::new(),
8613 span: table.schema.span,
8614 message: format!(
8615 "table `{}` targets unknown class `{}`",
8616 table.name.name, table.schema.name
8617 ),
8618 suggestion: Some("declare the class before seeding rows for it".to_owned()),
8619 });
8620 return;
8621 }
8622
8623 if table.rows.is_empty() {
8624 diagnostics.push(Diagnostic {
8625 related: Vec::new(),
8626 span: table.span,
8627 message: format!("table `{}` has no rows", table.name.name),
8628 suggestion: Some("add at least one `{ ... }` row".to_owned()),
8629 });
8630 return;
8631 }
8632
8633 let mut body = String::new();
8634 for row in &table.rows {
8635 push_line(&mut body, format!("record {} {{", table.schema.name));
8636 push_block_body(&row.body.text, &mut body);
8637 push_line(&mut body, "}");
8638 body.push('\n');
8639 }
8640 if body.ends_with('\n') {
8641 body.pop();
8642 }
8643
8644 let rule = RuleDecl {
8645 name: Ident {
8646 name: format!("table_{}", table.name.name),
8647 span: table.name.span,
8648 },
8649 tags: Vec::new(),
8650 description: None,
8651 whens: vec![WhenClause {
8652 text: "started".to_owned(),
8653 span: table.name.span,
8654 }],
8655 body: BlockSource {
8656 text: body,
8657 span: table.span,
8658 },
8659 span: table.span,
8660 };
8661
8662 let record_sources = table
8663 .rows
8664 .iter()
8665 .map(|row| IrRecordSource {
8666 schema: table.schema.name.clone(),
8667 construct: "table_row".to_owned(),
8668 span: row.span,
8669 })
8670 .collect::<Vec<_>>();
8671
8672 let rule_name = rule.name.name.clone();
8673 lower_rule(rule, semantic, workflow_contract_names, ir, diagnostics);
8674 if let Some(rule) = ir
8675 .rules
8676 .iter_mut()
8677 .rev()
8678 .find(|rule| rule.name == rule_name)
8679 {
8680 rule.metadata.record_sources = record_sources;
8681 }
8682}
8683
8684fn validate_coerce_body_fields(coerce: &CoerceDecl, diagnostics: &mut Vec<Diagnostic>) {
8689 let mut in_prompt = false;
8690 let mut awaiting_opener = false;
8691 for line in coerce.body.text.lines() {
8692 let trimmed = line.trim();
8693 if in_prompt {
8694 if trimmed.matches("\"\"\"").count() % 2 == 1 {
8696 in_prompt = false;
8697 }
8698 continue;
8699 }
8700 if awaiting_opener {
8701 if trimmed.is_empty() {
8704 continue;
8705 }
8706 awaiting_opener = false;
8707 if let Some(after_opener) = trimmed.strip_prefix("\"\"\"") {
8708 if after_opener.matches("\"\"\"").count() % 2 == 0 {
8709 in_prompt = true;
8710 }
8711 continue;
8712 }
8713 }
8715 if trimmed.is_empty() || trimmed.starts_with('#') {
8716 continue;
8717 }
8718 if trimmed == "prompt" {
8719 awaiting_opener = true;
8720 continue;
8721 }
8722 if let Some(rest) = trimmed.strip_prefix("prompt ") {
8723 let rest = rest.trim_start();
8724 if let Some(after_opener) = rest.strip_prefix("\"\"\"") {
8725 if after_opener.matches("\"\"\"").count() % 2 == 0 {
8728 in_prompt = true;
8729 }
8730 }
8731 continue;
8733 }
8734 if let Some(rest) = trimmed.strip_prefix("provider ") {
8735 if rest.split_whitespace().count() != 1 {
8736 diagnostics.push(Diagnostic {
8737 related: Vec::new(),
8738 span: coerce.name.span,
8739 message: format!(
8740 "coerce `{}` has a malformed `provider` clause: `{trimmed}`",
8741 coerce.name.name
8742 ),
8743 suggestion: Some("write `provider <name>`".to_owned()),
8744 });
8745 }
8746 continue;
8747 }
8748 let field = trimmed.split_whitespace().next().unwrap_or(trimmed);
8749 diagnostics.push(Diagnostic {
8750 related: Vec::new(),
8751 span: coerce.name.span,
8752 message: format!(
8753 "unknown coerce field `{field}` on coerce `{}`",
8754 coerce.name.name
8755 ),
8756 suggestion: Some("supported coerce fields are `prompt` and `provider`".to_owned()),
8757 });
8758 }
8759}
8760
8761fn lower_coerce(
8762 coerce: CoerceDecl,
8763 ir: &mut IrProgram,
8764 schema_names: &BTreeSet<String>,
8765 agent_names: &BTreeSet<String>,
8766 diagnostics: &mut Vec<Diagnostic>,
8767) {
8768 let mut params = BTreeSet::new();
8769 for param in &coerce.params {
8770 if !params.insert(param.name.name.clone()) {
8771 diagnostics.push(Diagnostic {
8772 related: Vec::new(),
8773 span: param.name.span,
8774 message: format!(
8775 "coerce `{}` declares parameter `{}` more than once",
8776 coerce.name.name, param.name.name
8777 ),
8778 suggestion: Some(
8779 "remove the duplicate parameter or give it a distinct name".to_owned(),
8780 ),
8781 });
8782 }
8783 validate_type_refs(¶m.ty, schema_names, agent_names, diagnostics);
8784 }
8785 validate_type_refs(&coerce.output, schema_names, agent_names, diagnostics);
8786 validate_coerce_prompt_content_type_annotations(&coerce, diagnostics);
8787 validate_coerce_body_fields(&coerce, diagnostics);
8788
8789 ir.coerces.push(IrCoerce {
8790 name: coerce.name.name,
8791 params: coerce
8792 .params
8793 .into_iter()
8794 .map(|param| IrParam {
8795 name: param.name.name,
8796 ty: lower_type(param.ty),
8797 })
8798 .collect(),
8799 output: lower_type(coerce.output),
8800 body: coerce.body.text,
8801 });
8802}
8803
8804fn validate_type_refs(
8805 ty: &TypeSyntax,
8806 schema_names: &BTreeSet<String>,
8807 agent_names: &BTreeSet<String>,
8808 diagnostics: &mut Vec<Diagnostic>,
8809) {
8810 match ty {
8811 TypeSyntax::Primitive { .. } | TypeSyntax::LiteralString { .. } => {}
8812 TypeSyntax::Ref { name } => {
8813 if !schema_names.contains(&name.name) && !is_builtin_schema_ref(&name.name) {
8814 diagnostics.push(Diagnostic {
8815 related: Vec::new(),
8816 span: name.span,
8817 message: format!("unknown schema reference `{}`", name.name),
8818 suggestion: Some(format!(
8819 "declare `class {}` or `enum {}` before using it",
8820 name.name, name.name
8821 )),
8822 });
8823 }
8824 }
8825 TypeSyntax::AgentRef { agents, .. } => {
8826 let mut seen = BTreeSet::new();
8827 for agent in agents {
8828 if !seen.insert(agent.name.clone()) {
8829 diagnostics.push(Diagnostic {
8830 related: Vec::new(),
8831 span: agent.span,
8832 message: format!("AgentRef lists agent `{}` more than once", agent.name),
8833 suggestion: Some(
8834 "remove the duplicate agent from the AgentRef domain".to_owned(),
8835 ),
8836 });
8837 }
8838 if !agent_names.contains(&agent.name) {
8839 diagnostics.push(Diagnostic {
8840 related: Vec::new(),
8841 span: agent.span,
8842 message: format!("AgentRef references unknown agent `{}`", agent.name),
8843 suggestion: Some(format!(
8844 "declare `agent {}` before using it in AgentRef",
8845 agent.name
8846 )),
8847 });
8848 }
8849 }
8850 }
8851 TypeSyntax::Optional { inner, .. }
8852 | TypeSyntax::Array { inner, .. }
8853 | TypeSyntax::Map { inner, .. } => {
8854 validate_type_refs(inner, schema_names, agent_names, diagnostics)
8855 }
8856 TypeSyntax::Union { variants, .. } => {
8857 for variant in variants {
8858 validate_type_refs(variant, schema_names, agent_names, diagnostics);
8859 }
8860 }
8861 }
8862}
8863
8864fn is_builtin_schema_ref(name: &str) -> bool {
8865 matches!(
8866 name,
8867 "AgentTurn"
8868 | "WorkItem"
8869 | "Evidence"
8870 | "TerminalFailed"
8871 | "TerminalTimedOut"
8872 | "TerminalCancelled"
8873 | "TerminalOutcome"
8874 )
8875}
8876
8877fn is_observer_only_schema(name: &str) -> bool {
8884 matches!(
8885 name,
8886 "TerminalFailed" | "TerminalTimedOut" | "TerminalCancelled" | "TerminalOutcome"
8887 )
8888}
8889
8890fn lower_rule(
8891 rule: RuleDecl,
8892 semantic: &SemanticContext,
8893 workflow_contract_names: &WorkflowContractNames,
8894 ir: &mut IrProgram,
8895 diagnostics: &mut Vec<Diagnostic>,
8896) {
8897 validate_canonical_rule_body_syntax(&rule, diagnostics);
8898 let metadata = analyze_rule(&rule, semantic, diagnostics);
8899 validate_workflow_terminal_actions(
8900 &rule,
8901 semantic,
8902 &binding_types_for_rule(&rule),
8903 &known_roots_for_rule(&rule),
8904 workflow_contract_names,
8905 diagnostics,
8906 );
8907 validate_effectful_self_trigger(&rule, &metadata, diagnostics);
8908 validate_send_channels(&rule, semantic, diagnostics);
8909 validate_message_from_channels(&rule, semantic, diagnostics);
8910 validate_evidence_fact_not_matched(&rule, diagnostics);
8911 validate_turn_access_grants(&rule, &metadata, diagnostics);
8912 ir.rules.push(IrRule {
8913 name: rule.name.name,
8914 whens: rule.whens.into_iter().map(lower_when_clause).collect(),
8915 body: rule.body.text,
8916 metadata,
8917 });
8918}
8919
8920fn lower_when_clause(when: WhenClause) -> IrWhen {
8921 let source = when.text;
8922 let (pattern, guard_source) = split_when_guard(&source);
8923 let pattern = pattern.to_owned();
8924 let guard = guard_source.and_then(|guard_source| {
8925 let guard_offset = source.find(guard_source).unwrap_or(0);
8926 lower_expression(
8927 guard_source,
8928 SourceSpan {
8929 start: when.span.start + guard_offset,
8930 end: when.span.start + guard_offset + guard_source.len(),
8931 },
8932 )
8933 });
8934 IrWhen {
8935 source,
8936 pattern,
8937 guard,
8938 span: when.span,
8939 }
8940}
8941
8942fn validate_canonical_rule_body_syntax(rule: &RuleDecl, diagnostics: &mut Vec<Diagnostic>) {
8943 for line in rule.body.text.lines().map(str::trim) {
8944 if line.starts_with("then ") {
8945 diagnostics.push(Diagnostic {
8946 related: Vec::new(),
8947 span: rule.body.span,
8948 message: format!(
8949 "rule `{}` uses unsupported `then` sequencing",
8950 rule.name.name
8951 ),
8952 suggestion: Some(
8953 "use `after <effect> succeeds { ... }` blocks for effect sequencing".to_owned(),
8954 ),
8955 });
8956 }
8957 if line.starts_with("after ") && line.contains("=>") {
8958 diagnostics.push(Diagnostic {
8959 related: Vec::new(),
8960 span: rule.body.span,
8961 message: format!(
8962 "rule `{}` uses unsupported `after ... =>` sequencing",
8963 rule.name.name
8964 ),
8965 suggestion: Some("write `after <effect> succeeds { ... }`".to_owned()),
8966 });
8967 }
8968 }
8969}
8970
8971fn build_rule_dependencies(rules: &[IrRule]) -> Vec<IrRuleDependency> {
8972 let mut dependencies = Vec::new();
8973 for producer in rules {
8974 for produced_fact in &producer.metadata.fact_writes {
8975 for consumer in rules {
8976 if consumer.metadata.fact_reads.contains(produced_fact) {
8977 dependencies.push(IrRuleDependency {
8978 producer: producer.name.clone(),
8979 consumer: consumer.name.clone(),
8980 fact: produced_fact.clone(),
8981 });
8982 }
8983 }
8984 }
8985 }
8986 dependencies.sort_by(|left, right| {
8987 (&left.producer, &left.consumer, &left.fact).cmp(&(
8988 &right.producer,
8989 &right.consumer,
8990 &right.fact,
8991 ))
8992 });
8993 dependencies
8994}
8995
8996fn validate_message_from_channels(
9003 rule: &RuleDecl,
9004 semantic: &SemanticContext,
9005 diagnostics: &mut Vec<Diagnostic>,
9006) {
9007 for when in &rule.whens {
9008 let (pattern, _) = split_when_guard(&when.text);
9009 let Some(rest) = pattern.trim_start().strip_prefix("message from ") else {
9010 continue;
9011 };
9012 let Some(channel) = rest.split_whitespace().next() else {
9013 continue;
9014 };
9015 if !semantic.channels.iter().any(|c| c.as_str() == channel) {
9016 diagnostics.push(Diagnostic {
9017 related: Vec::new(),
9018 span: when.span,
9019 message: format!("`when message from {channel}` names an unknown channel"),
9020 suggestion: Some(
9021 "declare it with `channel <name> { provider … }`, or correct the channel name"
9022 .to_owned(),
9023 ),
9024 });
9025 continue;
9026 }
9027 if let Some(report) = semantic
9034 .channel_providers
9035 .get(channel)
9036 .and_then(|provider| channel_provider_report(provider))
9037 {
9038 if report.direction == "outbound_only" {
9039 diagnostics.push(Diagnostic {
9040 related: Vec::new(),
9041 span: when.span,
9042 message: format!(
9043 "`when message from {channel}` observes a channel whose provider `{}` is outbound-only (its capability report cannot deliver inbound messages)",
9044 report.short_name
9045 ),
9046 suggestion: Some(
9047 "route inbound observation through an inbound-capable provider (`local`, `stdio`, `fixture`)"
9048 .to_owned(),
9049 ),
9050 });
9051 }
9052 }
9053 }
9054}
9055
9056fn validate_send_channels(
9057 rule: &RuleDecl,
9058 semantic: &SemanticContext,
9059 diagnostics: &mut Vec<Diagnostic>,
9060) {
9061 let (ast, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
9062 fn walk(
9063 statements: &[body::BodyStmt],
9064 semantic: &SemanticContext,
9065 diagnostics: &mut Vec<Diagnostic>,
9066 ) {
9067 for statement in statements {
9068 match statement {
9069 body::BodyStmt::Effect(effect) => {
9070 if let body::BodyEffectKind::ConstructCapabilityCall {
9071 keyword, fields, ..
9072 } = &effect.kind
9073 {
9074 if keyword == "send" {
9075 if let Some(channel) =
9076 fields.iter().find(|field| field.name == "channel")
9077 {
9078 if !semantic.channels.contains(&channel.source) {
9079 diagnostics.push(Diagnostic {
9080 related: Vec::new(),
9081 span: effect.span,
9082 message: format!(
9083 "`send via {}` names an unknown channel",
9084 channel.source
9085 ),
9086 suggestion: Some(
9087 "declare it with `channel <name> { provider … }`, or correct the channel name"
9088 .to_owned(),
9089 ),
9090 });
9091 } else if let Some(report) = semantic
9092 .channel_providers
9093 .get(&channel.source)
9094 .and_then(|provider| channel_provider_report(provider))
9095 {
9096 if report.direction == "inbound_only" {
9106 diagnostics.push(Diagnostic {
9107 related: Vec::new(),
9108 span: effect.span,
9109 message: format!(
9110 "`send via {}` targets a channel whose provider `{}` is inbound-only (its capability report cannot accept outbound sends)",
9111 channel.source, report.short_name
9112 ),
9113 suggestion: Some(
9114 "send through an outbound-capable provider (`local`, `desktop`, `stdio`, `fixture`)"
9115 .to_owned(),
9116 ),
9117 });
9118 }
9119 }
9120 }
9121 }
9122 if matches!(keyword.as_str(), "recall" | "learn" | "curate") {
9125 if let Some(pool) = fields.iter().find(|field| field.name == "pool") {
9126 if !semantic.memory_pools.contains(&pool.source) {
9127 diagnostics.push(Diagnostic {
9128 related: Vec::new(),
9129 span: effect.span,
9130 message: format!(
9131 "`{keyword}` names unknown memory pool `{}`",
9132 pool.source
9133 ),
9134 suggestion: Some(
9135 "declare it with `memory pool <name> { … }`, or correct the pool name"
9136 .to_owned(),
9137 ),
9138 });
9139 }
9140 }
9141 }
9142 }
9143 }
9144 body::BodyStmt::After(after) => walk(&after.body, semantic, diagnostics),
9145 body::BodyStmt::Case(case) => {
9146 for branch in &case.branches {
9147 walk(&branch.body, semantic, diagnostics);
9148 }
9149 }
9150 _ => {}
9151 }
9152 }
9153 }
9154 walk(&ast.statements, semantic, diagnostics);
9155}
9156
9157const EVIDENCE_ONLY_TURN_FACTS: [&str; 3] = [
9164 "agent.turn.streamed",
9165 "agent.turn.tool_requested",
9166 "agent.turn.artifact_captured",
9167];
9168
9169fn validate_turn_access_grants(
9176 rule: &RuleDecl,
9177 metadata: &IrRuleMetadata,
9178 diagnostics: &mut Vec<Diagnostic>,
9179) {
9180 for effect in &metadata.effects {
9181 if effect.access_grants.is_empty() {
9182 continue;
9183 }
9184 let mut seen = BTreeSet::new();
9185 for grant in &effect.access_grants {
9186 if grant.operations.is_empty() {
9187 diagnostics.push(Diagnostic {
9188 related: Vec::new(),
9189 span: effect.span,
9190 message: format!(
9191 "rule `{}` has a `with access to {}` grant that grants no operations",
9192 rule.name.name, grant.resource
9193 ),
9194 suggestion: Some(
9195 "list at least one operation in the grant block, or drop the grant"
9196 .to_owned(),
9197 ),
9198 });
9199 }
9200 if !seen.insert(grant.resource.clone()) {
9201 diagnostics.push(Diagnostic {
9202 related: Vec::new(),
9203 span: effect.span,
9204 message: format!(
9205 "rule `{}` lists access resource `{}` more than once on one effect",
9206 rule.name.name, grant.resource
9207 ),
9208 suggestion: Some(
9209 "merge the grant clauses for a resource into a single block".to_owned(),
9210 ),
9211 });
9212 }
9213 }
9214 }
9215}
9216
9217fn validate_evidence_fact_not_matched(rule: &RuleDecl, diagnostics: &mut Vec<Diagnostic>) {
9218 for when in &rule.whens {
9219 let (pattern, _) = split_when_guard(&when.text);
9220 let Some(name) = runtime_fact_name_for_pattern(pattern) else {
9221 continue;
9222 };
9223 if EVIDENCE_ONLY_TURN_FACTS.contains(&name.as_str()) {
9224 diagnostics.push(Diagnostic { related: Vec::new(),
9225 span: when.span,
9226 message: format!(
9227 "rule `{}` matches evidence-only fact `{name}`: in-turn observations are evidence, not rule-matchable facts",
9228 rule.name.name
9229 ),
9230 suggestion: Some(
9231 "match a lifecycle fact (`agent.turn.completed`/`failed`/`timed_out`/`cancelled`) and read in-turn detail from its evidence".to_owned(),
9232 ),
9233 });
9234 }
9235 }
9236}
9237
9238fn extract_rule_regions(
9245 items: &mut [Item],
9246 diagnostics: &mut Vec<Diagnostic>,
9247) -> BTreeMap<String, IrRegion> {
9248 let mut pending = BTreeMap::new();
9249 for item in items.iter_mut() {
9250 let Item::Rule(rule) = item else {
9251 continue;
9252 };
9253 let (ast, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
9254 let mut regions = Vec::new();
9255 collect_region_blocks(&ast.statements, &mut regions);
9256 if regions.is_empty() {
9257 continue;
9258 }
9259 if regions.len() > 1 {
9260 diagnostics.push(Diagnostic {
9261 related: Vec::new(),
9262 span: regions[1].span,
9263 message: format!(
9264 "rule `{}` declares more than one `during`/`until` region",
9265 rule.name.name
9266 ),
9267 suggestion: Some(
9268 "v1 supports one region per rule (including nested regions); split the \
9269 rule or merge the conditions"
9270 .to_owned(),
9271 ),
9272 });
9273 continue;
9274 }
9275 let region = regions[0].clone();
9276 if count_effect_statements(®ion.body) == 0 {
9277 diagnostics.push(Diagnostic {
9278 related: Vec::new(),
9279 span: region.span,
9280 message: format!(
9281 "the `{}` region in rule `{}` contains no progression",
9282 if region.until { "until" } else { "during" },
9283 rule.name.name
9284 ),
9285 suggestion: Some(
9286 "a region around purely-atomic actions commits with admission and can \
9287 never lapse between steps; it needs at least one effect with a \
9288 continuation"
9289 .to_owned(),
9290 ),
9291 });
9292 continue;
9293 }
9294 let mut region_bindings = BTreeSet::new();
9299 collect_all_binding_names(®ion.body, &mut region_bindings);
9300 if let Some(view) = ®ion.lapse_binding {
9301 region_bindings.remove(view);
9302 }
9303 let mut arm_roots = BTreeSet::new();
9304 collect_statement_roots(®ion.lapse_body, &mut arm_roots);
9305 for root in &arm_roots {
9306 if region_bindings.contains(root) {
9307 diagnostics.push(Diagnostic {
9308 related: Vec::new(),
9309 span: region.span,
9310 message: format!(
9311 "the `on lapse` arm of rule `{}` references `{root}`, a binding the \
9312 region introduces — it may not exist when the arm runs",
9313 rule.name.name
9314 ),
9315 suggestion: Some(
9316 "reference only bindings from before the region, or bind the \
9317 progress view (`on lapse as got`) and read `got.<binding>` — its \
9318 fields are present exactly if that step settled"
9319 .to_owned(),
9320 ),
9321 });
9322 }
9323 }
9324 let base = rule.body.span.start;
9326 let text = rule.body.text.clone();
9327 let clamp = |offset: usize| offset.saturating_sub(base).min(text.len());
9328 let (r_start, r_end) = (clamp(region.span.start), clamp(region.span.end));
9329 let (b_start, b_end) = (clamp(region.body_span.start), clamp(region.body_span.end));
9330 let (l_start, l_end) = (clamp(region.lapse_span.start), clamp(region.lapse_span.end));
9331 if !(r_start <= b_start
9332 && b_start <= b_end
9333 && b_end <= l_start
9334 && l_start <= l_end
9335 && l_end <= r_end)
9336 {
9337 diagnostics.push(Diagnostic {
9338 related: Vec::new(),
9339 span: region.span,
9340 message: format!(
9341 "internal: region span reconstruction failed for rule `{}`",
9342 rule.name.name
9343 ),
9344 suggestion: None,
9345 });
9346 continue;
9347 }
9348 let body_content = &text[b_start..b_end];
9349 let arm_content = &text[l_start..l_end];
9350 let variant_holds = format!("{}{}{}", &text[..r_start], body_content, &text[r_end..]);
9351 let variant_removed = format!("{}{}", &text[..r_start], &text[r_end..]);
9352 let variant_lapsed = format!("{}{}{}", &text[..r_start], arm_content, &text[r_end..]);
9353 let mut effect_bindings = BTreeSet::new();
9357 collect_effect_binding_names(®ion.body, &mut effect_bindings);
9358 let (holds_ast, _) = body::parse_rule_body(&variant_holds, 0);
9359 let mut region_effects = Vec::new();
9360 assign_region_effect_scopes(
9361 &holds_ast.statements,
9362 None,
9363 &effect_bindings,
9364 &mut region_effects,
9365 );
9366 pending.insert(
9367 rule.name.name.clone(),
9368 IrRegion {
9369 until: region.until,
9370 condition: region.condition.clone(),
9371 lapse_binding: region.lapse_binding.clone(),
9372 effects: region_effects,
9373 body_removed: variant_removed,
9374 body_lapsed: variant_lapsed,
9375 },
9376 );
9377 rule.body.text = variant_holds;
9378 }
9379 pending
9380}
9381
9382fn collect_region_blocks(statements: &[body::BodyStmt], out: &mut Vec<body::RegionBlock>) {
9383 for statement in statements {
9384 match statement {
9385 body::BodyStmt::Region(region) => {
9386 out.push(region.clone());
9387 collect_region_blocks(®ion.body, out);
9388 collect_region_blocks(®ion.lapse_body, out);
9389 }
9390 body::BodyStmt::After(after) => collect_region_blocks(&after.body, out),
9391 body::BodyStmt::Case(case) => {
9392 for branch in &case.branches {
9393 collect_region_blocks(&branch.body, out);
9394 }
9395 }
9396 _ => {}
9397 }
9398 }
9399}
9400
9401fn count_effect_statements(statements: &[body::BodyStmt]) -> usize {
9402 let mut count = 0;
9403 for statement in statements {
9404 match statement {
9405 body::BodyStmt::Effect(_) => count += 1,
9406 body::BodyStmt::After(after) => count += count_effect_statements(&after.body),
9407 body::BodyStmt::Case(case) => {
9408 for branch in &case.branches {
9409 count += count_effect_statements(&branch.body);
9410 }
9411 }
9412 body::BodyStmt::Region(region) => {
9413 count += count_effect_statements(®ion.body);
9414 }
9415 _ => {}
9416 }
9417 }
9418 count
9419}
9420
9421fn collect_effect_binding_names(statements: &[body::BodyStmt], out: &mut BTreeSet<String>) {
9422 for statement in statements {
9423 match statement {
9424 body::BodyStmt::Effect(effect) => {
9425 if let Some(binding) = &effect.binding {
9426 out.insert(binding.clone());
9427 }
9428 }
9429 body::BodyStmt::After(after) => collect_effect_binding_names(&after.body, out),
9430 body::BodyStmt::Case(case) => {
9431 for branch in &case.branches {
9432 collect_effect_binding_names(&branch.body, out);
9433 }
9434 }
9435 body::BodyStmt::Region(region) => {
9436 collect_effect_binding_names(®ion.body, out);
9437 }
9438 _ => {}
9439 }
9440 }
9441}
9442
9443fn assign_region_effect_scopes(
9447 statements: &[body::BodyStmt],
9448 level1: Option<&(String, String)>,
9449 region_bindings: &BTreeSet<String>,
9450 out: &mut Vec<IrRegionEffect>,
9451) {
9452 for statement in statements {
9453 match statement {
9454 body::BodyStmt::Effect(effect) => {
9455 if let Some(binding) = &effect.binding {
9456 if region_bindings.contains(binding)
9457 && !out.iter().any(|known| &known.binding == binding)
9458 {
9459 out.push(IrRegionEffect {
9460 binding: binding.clone(),
9461 scope: level1.cloned(),
9462 });
9463 }
9464 }
9465 }
9466 body::BodyStmt::After(after) => {
9467 let own = (
9468 after.binding.clone(),
9469 after.predicate.kernel_str().to_owned(),
9470 );
9471 let next = level1.cloned().unwrap_or(own);
9472 assign_region_effect_scopes(&after.body, Some(&next), region_bindings, out);
9473 }
9474 body::BodyStmt::Case(case) => {
9475 for branch in &case.branches {
9476 assign_region_effect_scopes(&branch.body, level1, region_bindings, out);
9477 }
9478 }
9479 body::BodyStmt::Region(region) => {
9480 assign_region_effect_scopes(®ion.body, level1, region_bindings, out);
9481 }
9482 _ => {}
9483 }
9484 }
9485}
9486
9487fn collect_statement_roots(statements: &[body::BodyStmt], out: &mut BTreeSet<String>) {
9491 fn roots_in_expr(source: &str, out: &mut BTreeSet<String>) {
9492 let bytes = source.as_bytes();
9493 let mut i = 0;
9494 let mut in_string = false;
9495 while i < bytes.len() {
9496 let c = bytes[i] as char;
9497 if c == '"' {
9498 in_string = !in_string;
9499 i += 1;
9500 continue;
9501 }
9502 if in_string {
9503 i += 1;
9504 continue;
9505 }
9506 if c.is_ascii_alphabetic() || c == '_' {
9507 let start = i;
9508 while i < bytes.len() {
9509 let cj = bytes[i] as char;
9510 if cj.is_ascii_alphanumeric() || cj == '_' {
9511 i += 1;
9512 } else {
9513 break;
9514 }
9515 }
9516 let preceded_by_dot = start > 0 && bytes[start - 1] as char == '.';
9517 if !preceded_by_dot {
9518 out.insert(source[start..i].to_owned());
9519 }
9520 continue;
9521 }
9522 i += 1;
9523 }
9524 }
9525 fn roots_in_fields(fields: &[body::FieldAssign], out: &mut BTreeSet<String>) {
9526 for field in fields {
9527 match &field.value {
9528 body::FieldValue::Expr { source, .. } => roots_in_expr(source, out),
9529 body::FieldValue::Nested { fields, .. } => roots_in_fields(fields, out),
9530 body::FieldValue::Shorthand => {
9531 out.insert(field.name.clone());
9532 }
9533 }
9534 }
9535 }
9536 fn roots_in_prompt(text: &str, out: &mut BTreeSet<String>) {
9537 let mut rest = text;
9538 while let Some(open) = rest.find("{{") {
9539 let tail = &rest[open + 2..];
9540 let Some(close) = tail.find("}}") else {
9541 break;
9542 };
9543 roots_in_expr(&tail[..close], out);
9544 rest = &tail[close + 2..];
9545 }
9546 }
9547 for statement in statements {
9548 match statement {
9549 body::BodyStmt::Record(record) => roots_in_fields(&record.fields, out),
9550 body::BodyStmt::Done {
9551 binding,
9552 replacement,
9553 ..
9554 } => {
9555 out.insert(binding.clone());
9556 if let Some(record) = replacement {
9557 roots_in_fields(&record.fields, out);
9558 }
9559 }
9560 body::BodyStmt::Cancel { binding, .. } => {
9561 out.insert(binding.clone());
9562 }
9563 body::BodyStmt::Effect(effect) => {
9564 if let Some(prompt) = &effect.prompt {
9565 roots_in_prompt(&prompt.text, out);
9566 }
9567 match &effect.kind {
9568 body::BodyEffectKind::Coerce { args, .. } => {
9569 for arg in args {
9570 roots_in_expr(arg, out);
9571 }
9572 }
9573 body::BodyEffectKind::TrackerFinish { item, fields } => {
9574 out.insert(item.clone());
9575 roots_in_fields(fields, out);
9576 }
9577 body::BodyEffectKind::TrackerRelease { item } => {
9578 out.insert(item.clone());
9579 }
9580 _ => {}
9581 }
9582 }
9583 body::BodyStmt::Terminal(terminal) => {
9584 roots_in_fields(&terminal.fields, out);
9585 if let Some(body::FieldValue::Expr { source, .. }) = &terminal.scalar {
9586 roots_in_expr(source, out);
9587 }
9588 }
9589 body::BodyStmt::Milestone { fields, .. } => roots_in_fields(fields, out),
9590 body::BodyStmt::After(after) => collect_statement_roots(&after.body, out),
9591 body::BodyStmt::Case(case) => {
9592 roots_in_expr(&case.scrutinee, out);
9593 for branch in &case.branches {
9594 collect_statement_roots(&branch.body, out);
9595 }
9596 }
9597 body::BodyStmt::Region(region) => {
9598 collect_statement_roots(®ion.body, out);
9599 collect_statement_roots(®ion.lapse_body, out);
9600 }
9601 body::BodyStmt::Redact { source, .. } => {
9602 out.insert(source.clone());
9603 }
9604 }
9605 }
9606}
9607
9608fn validate_effectful_self_trigger(
9609 rule: &RuleDecl,
9610 metadata: &IrRuleMetadata,
9611 diagnostics: &mut Vec<Diagnostic>,
9612) {
9613 if metadata.effects.is_empty() {
9614 return;
9615 }
9616
9617 for written_fact in &metadata.fact_writes {
9618 if metadata.fact_reads.contains(written_fact)
9619 && !metadata.fact_consumes.contains(written_fact)
9620 {
9621 diagnostics.push(Diagnostic { related: Vec::new(),
9622 span: rule.body.span,
9623 message: format!(
9624 "effectful rule `{}` preserves trigger fact `{written_fact}`",
9625 rule.name.name
9626 ),
9627 suggestion: Some(
9628 "consume or advance the triggering fact, or move the next effect behind an external completion event"
9629 .to_owned(),
9630 ),
9631 });
9632 }
9633 }
9634}
9635
9636fn binding_types_for_rule(rule: &RuleDecl) -> BTreeMap<String, String> {
9637 let mut binding_types = BTreeMap::new();
9638 for when in &rule.whens {
9639 if let Some((binding, schema)) = binding_from_when(&when.text) {
9640 binding_types.insert(binding, schema);
9641 }
9642 }
9643 binding_types
9644}
9645
9646fn validate_workflow_terminal_actions(
9647 rule: &RuleDecl,
9648 semantic: &SemanticContext,
9649 binding_types: &BTreeMap<String, String>,
9650 known_roots: &BTreeSet<String>,
9651 contracts: &WorkflowContractNames,
9652 diagnostics: &mut Vec<Diagnostic>,
9653) {
9654 for line in rule.body.text.lines().map(str::trim) {
9655 let terminal = line
9656 .strip_prefix("complete ")
9657 .map(|rest| ("complete", rest, &contracts.outputs))
9658 .or_else(|| {
9659 line.strip_prefix("fail ")
9660 .map(|rest| ("fail", rest, &contracts.failures))
9661 });
9662 let Some((action, rest, declared)) = terminal else {
9663 continue;
9664 };
9665 if !rest.contains('{') {
9670 let tokens: Vec<&str> = rest.split_whitespace().collect();
9671 let is_from = matches!(tokens.as_slice(), [_, "from", ..]) && action == "complete";
9672 if tokens.len() >= 2 && !is_from {
9673 let name = tokens[0];
9674 let value = rest.trim().get(name.len()..).unwrap_or("").trim();
9675 if !declared.contains_key(name) {
9676 diagnostics.push(Diagnostic {
9677 related: Vec::new(),
9678 span: rule.body.span,
9679 message: format!(
9680 "rule `{}` {action}s unknown workflow terminal `{name}`",
9681 rule.name.name
9682 ),
9683 suggestion: Some(format!(
9684 "declare `{kind} {name} Type` on the workflow first",
9685 kind = if action == "complete" {
9686 "output"
9687 } else {
9688 "failure"
9689 }
9690 )),
9691 });
9692 continue;
9693 }
9694 if let Some(contract_ty) = declared.get(name) {
9695 validate_scalar_terminal_payload(
9696 rule,
9697 action,
9698 name,
9699 value,
9700 contract_ty,
9701 semantic,
9702 binding_types,
9703 known_roots,
9704 diagnostics,
9705 );
9706 }
9707 continue;
9708 }
9709 }
9710 let Some(name) = rest.split('{').next().and_then(|header| {
9713 let mut parts = header.split_whitespace();
9714 match (parts.next(), parts.next(), parts.next()) {
9715 (Some(name), None, _) => Some(name),
9716 (Some(name), Some("from"), Some(binding))
9717 if action == "complete" && is_identifier(binding) =>
9718 {
9719 Some(name)
9720 }
9721 _ => None,
9722 }
9723 }) else {
9724 diagnostics.push(Diagnostic {
9725 related: Vec::new(),
9726 span: rule.body.span,
9727 message: format!("rule `{}` has malformed `{action}` action", rule.name.name),
9728 suggestion: Some(format!(
9729 "{action} a declared workflow terminal with a payload block"
9730 )),
9731 });
9732 continue;
9733 };
9734 if !declared.contains_key(name) {
9735 diagnostics.push(Diagnostic {
9736 related: Vec::new(),
9737 span: rule.body.span,
9738 message: format!(
9739 "rule `{}` {action}s unknown workflow terminal `{name}`",
9740 rule.name.name
9741 ),
9742 suggestion: Some(format!(
9743 "declare `{kind} {name} Type` on the workflow first",
9744 kind = if action == "complete" {
9745 "output"
9746 } else {
9747 "failure"
9748 }
9749 )),
9750 });
9751 continue;
9752 }
9753 let Some(contract_ty) = declared.get(name) else {
9754 continue;
9755 };
9756 validate_workflow_terminal_payload(
9757 rule,
9758 action,
9759 name,
9760 contract_ty,
9761 semantic,
9762 binding_types,
9763 known_roots,
9764 diagnostics,
9765 );
9766 }
9767}
9768
9769#[allow(clippy::too_many_arguments)]
9770fn validate_workflow_terminal_payload(
9771 rule: &RuleDecl,
9772 action: &str,
9773 terminal_name: &str,
9774 contract_ty: &TypeSyntax,
9775 semantic: &SemanticContext,
9776 binding_types: &BTreeMap<String, String>,
9777 known_roots: &BTreeSet<String>,
9778 diagnostics: &mut Vec<Diagnostic>,
9779) {
9780 let Some((_, _, body)) = workflow_terminal_blocks(&rule.body.text).into_iter().find(
9781 |(candidate_action, candidate_name, _)| {
9782 candidate_action == action && candidate_name == terminal_name
9783 },
9784 ) else {
9785 return;
9786 };
9787 let schema = match contract_ty {
9788 TypeSyntax::Ref { name } if semantic.schemas.class_exists(&name.name) => &name.name,
9789 TypeSyntax::Primitive { .. }
9790 | TypeSyntax::LiteralString { .. }
9791 | TypeSyntax::Union { .. } => {
9792 diagnostics.push(Diagnostic {
9795 related: Vec::new(),
9796 span: rule.body.span,
9797 message: format!(
9798 "workflow terminal `{terminal_name}` has a scalar payload contract but is given a field block"
9799 ),
9800 suggestion: Some(format!(
9801 "write a bare scalar value: `{action} {terminal_name} <value>`"
9802 )),
9803 });
9804 return;
9805 }
9806 _ => {
9807 diagnostics.push(Diagnostic { related: Vec::new(),
9808 span: rule.body.span,
9809 message: format!(
9810 "workflow terminal `{terminal_name}` uses an unsupported payload contract type"
9811 ),
9812 suggestion: Some(
9813 "declare the terminal payload as a class (field block) or a scalar type (number/string/bool)"
9814 .to_owned(),
9815 ),
9816 });
9817 return;
9818 }
9819 };
9820 for assignment in collect_field_assignments(&body) {
9821 let (field, value) = match assignment {
9822 RecordFieldAssignment::Value { field, value } => (field, value),
9823 RecordFieldAssignment::Shorthand { field } => (field.clone(), field),
9824 };
9825 let line = format!("{field} {value}");
9826 validate_record_field(
9827 rule,
9828 &line,
9829 schema,
9830 semantic,
9831 binding_types,
9832 known_roots,
9833 diagnostics,
9834 );
9835 }
9836 validate_required_terminal_fields(rule, schema, terminal_name, &body, semantic, diagnostics);
9837}
9838
9839#[allow(clippy::too_many_arguments)]
9845fn validate_scalar_terminal_payload(
9846 rule: &RuleDecl,
9847 action: &str,
9848 terminal_name: &str,
9849 value: &str,
9850 contract_ty: &TypeSyntax,
9851 semantic: &SemanticContext,
9852 binding_types: &BTreeMap<String, String>,
9853 known_roots: &BTreeSet<String>,
9854 diagnostics: &mut Vec<Diagnostic>,
9855) {
9856 if let TypeSyntax::Ref { name } = contract_ty {
9857 if semantic.schemas.class_exists(&name.name) {
9858 diagnostics.push(Diagnostic {
9859 related: Vec::new(),
9860 span: rule.body.span,
9861 message: format!(
9862 "workflow terminal `{terminal_name}` has a class payload contract `{}` but is given a bare scalar value",
9863 name.name
9864 ),
9865 suggestion: Some(format!("write a field block: `{action} {terminal_name} {{ … }}`")),
9866 });
9867 return;
9868 }
9869 }
9870 if value.is_empty() {
9871 diagnostics.push(Diagnostic {
9872 related: Vec::new(),
9873 span: rule.body.span,
9874 message: format!("workflow terminal `{terminal_name}` is missing its scalar value"),
9875 suggestion: Some(format!("write `{action} {terminal_name} <value>`")),
9876 });
9877 return;
9878 }
9879 validate_literal_assignment(
9882 rule,
9883 terminal_name,
9884 "value",
9885 contract_ty,
9886 value,
9887 semantic,
9888 diagnostics,
9889 );
9890 if let Some(root) = dangling_value_root(value, known_roots) {
9891 diagnostics.push(Diagnostic {
9892 related: Vec::new(),
9893 span: rule.body.span,
9894 message: format!(
9895 "rule `{}` has unknown binding `{root}` in `{action} {terminal_name}` value",
9896 rule.name.name
9897 ),
9898 suggestion: Some(
9899 "reference a binding from a `when ... as name` clause, an effect `as` binding, or a `case` pattern"
9900 .to_owned(),
9901 ),
9902 });
9903 } else if let Some((root, path)) = expression_path(value) {
9904 if let Some(schema) = binding_types.get(&root) {
9905 if semantic.schemas.class_exists(schema) {
9906 if let Err(message) = semantic.schemas.resolve_field_path(schema, &path) {
9907 diagnostics.push(Diagnostic {
9908 related: Vec::new(),
9909 span: rule.body.span,
9910 message: format!(
9911 "rule `{}` has invalid field path `{root}.{}`: {message}",
9912 rule.name.name,
9913 path.join(".")
9914 ),
9915 suggestion: Some(
9916 "use a field declared on the bound schema or add it to the class declaration"
9917 .to_owned(),
9918 ),
9919 });
9920 }
9921 }
9922 }
9923 }
9924}
9925
9926fn validate_required_terminal_fields(
9927 rule: &RuleDecl,
9928 schema: &str,
9929 terminal_name: &str,
9930 body: &str,
9931 semantic: &SemanticContext,
9932 diagnostics: &mut Vec<Diagnostic>,
9933) {
9934 let Some(schema_fields) = semantic.schemas.classes.get(schema) else {
9935 return;
9936 };
9937 let seen = collect_field_assignments(body)
9938 .into_iter()
9939 .map(|assignment| match assignment {
9940 RecordFieldAssignment::Value { field, .. }
9941 | RecordFieldAssignment::Shorthand { field } => field,
9942 })
9943 .collect::<BTreeSet<_>>();
9944 for (required, ty) in schema_fields {
9945 if seen.contains(required) || matches!(ty, TypeSyntax::Optional { .. }) {
9946 continue;
9947 }
9948 diagnostics.push(Diagnostic { related: Vec::new(),
9949 span: rule.body.span,
9950 message: format!(
9951 "workflow terminal `{terminal_name}` is missing required field `{schema}.{required}`"
9952 ),
9953 suggestion: Some(format!("add `{required}` to the `{terminal_name}` payload")),
9954 });
9955 }
9956}
9957
9958fn max_after_depth(statements: &[body::BodyStmt]) -> usize {
9964 use body::BodyStmt;
9965 statements
9966 .iter()
9967 .map(|statement| match statement {
9968 BodyStmt::After(after) => 1 + max_after_depth(&after.body),
9969 BodyStmt::Case(case) => case
9970 .branches
9971 .iter()
9972 .map(|branch| max_after_depth(&branch.body))
9973 .max()
9974 .unwrap_or(0),
9975 _ => 0,
9976 })
9977 .max()
9978 .unwrap_or(0)
9979}
9980
9981fn analyze_rule(
9982 rule: &RuleDecl,
9983 semantic: &SemanticContext,
9984 diagnostics: &mut Vec<Diagnostic>,
9985) -> IrRuleMetadata {
9986 let (body_ast, body_diagnostics) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
9990 diagnostics.extend(body_diagnostics);
9991 let mut metadata = IrRuleMetadata {
9992 fact_reads: rule
9993 .whens
9994 .iter()
9995 .map(|when| fact_read_from_when(&when.text))
9996 .collect(),
9997 max_after_depth: max_after_depth(&body_ast.statements),
9998 ..IrRuleMetadata::default()
9999 };
10000 let mut seen_bindings = BTreeSet::new();
10001 let mut binding_types = BTreeMap::new();
10002 for when in &rule.whens {
10003 let (pattern_text, _) = split_when_guard(&when.text);
10006 if binding_after_as(pattern_text).is_some()
10007 && binding_from_when(&when.text).is_none()
10008 && !pattern_text.ends_with(" is available")
10009 {
10010 diagnostics.push(Diagnostic {
10011 related: Vec::new(),
10012 span: when.span,
10013 message: format!(
10014 "rule `{}` has unknown readiness pattern `{pattern_text}`",
10015 rule.name.name
10016 ),
10017 suggestion: Some(
10018 "match a class (`when Class as x`) or a runtime fact (`when fact <name> as x`)"
10019 .to_owned(),
10020 ),
10021 });
10022 }
10023 if let Some((binding, schema)) = binding_from_when(&when.text) {
10024 validate_binding_name(rule, &binding, when.span, diagnostics);
10025 if !schema.contains('.') && !semantic.schemas.class_exists(&schema) {
10026 let suggestion = match closest_name(&schema, semantic.schemas.classes.keys()) {
10027 Some(candidate) => {
10028 format!("did you mean `{candidate}`? otherwise declare `class {schema}`")
10029 }
10030 None => format!("declare `class {schema}` before matching it"),
10031 };
10032 diagnostics.push(Diagnostic {
10033 related: Vec::new(),
10034 span: when.span,
10035 message: format!("rule `{}` matches unknown class `{schema}`", rule.name.name),
10036 suggestion: Some(suggestion),
10037 });
10038 }
10039 if schema.contains('.')
10043 && !pattern_text.trim_start().starts_with("fact ")
10044 && !semantic.schemas.events.contains(&schema)
10045 {
10046 diagnostics.push(Diagnostic { related: Vec::new(),
10047 span: when.span,
10048 message: format!(
10049 "rule `{}` reacts to undeclared signal `{schema}`",
10050 rule.name.name
10051 ),
10052 suggestion: Some(format!(
10053 "declare `signal {schema} {{ ... }}` for a typed reaction, or use `when fact {schema} as ...` for an untyped one"
10054 )),
10055 });
10056 }
10057 binding_types.insert(binding, schema);
10058 }
10059 }
10060 let mut effect_payload_types = collect_effect_payload_types(rule, semantic, diagnostics);
10061 collect_exec_payload_types(&body_ast.statements, semantic, &mut effect_payload_types);
10065 collect_decide_payload_types(
10069 &body_ast.statements,
10070 &rule.name.name,
10071 &mut effect_payload_types,
10072 );
10073 collect_prompt_payload_types(&body_ast.statements, &mut effect_payload_types);
10074 collect_redact_payload_types(
10078 &body_ast.statements,
10079 &rule.name.name,
10080 &mut effect_payload_types,
10081 );
10082 for (binding, payload_type) in &effect_payload_types {
10083 if let IrType::Ref(schema) = payload_type {
10084 binding_types.insert(binding.clone(), schema.clone());
10085 }
10086 }
10087 let mut effect_binding_kinds: BTreeMap<String, IrEffectKind> = rule
10093 .body
10094 .text
10095 .lines()
10096 .filter_map(|line| {
10097 let line = line.trim();
10098 if line.starts_with("exec ") {
10101 return Some((binding_after_as(line)?, IrEffectKind::ExecCommand));
10102 }
10103 let (kind, binding) = parse_effect_line(line)?;
10104 Some((binding?, kind))
10105 })
10106 .collect();
10107 for statement in effect_payload_statements(&rule.body.text) {
10108 if let Some((kind, Some(binding))) = parse_effect_line(statement.trim()) {
10109 effect_binding_kinds.insert(binding, kind);
10110 }
10111 }
10112 for line in rule.body.text.lines() {
10116 let Some(rest) = line.trim().strip_prefix("after ") else {
10117 continue;
10118 };
10119 let mut words = rest.split_whitespace();
10120 let Some(binding) = words.next() else {
10121 continue;
10122 };
10123 let Some(predicate) = words.next() else {
10124 continue;
10125 };
10126 if predicate == "succeeds" {
10133 match effect_binding_kinds.get(binding) {
10134 Some(IrEffectKind::LeaseAcquire) => {
10135 diagnostics.push(Diagnostic {
10136 related: Vec::new(),
10137 span: rule.body.span,
10138 message: format!(
10139 "rule `{}` observes acquire `{binding}` with `succeeds`, which also \
10140 matches a Contended outcome (the acquire op completes either way)",
10141 rule.name.name
10142 ),
10143 suggestion: Some(format!(
10144 "use `after {binding} held` / `after {binding} contended` for the \
10145 outcome variants, or `after {binding} completes` for any settled \
10146 outcome"
10147 )),
10148 });
10149 }
10150 Some(IrEffectKind::CounterConsume) => {
10151 diagnostics.push(Diagnostic {
10152 related: Vec::new(),
10153 span: rule.body.span,
10154 message: format!(
10155 "rule `{}` observes counter consume `{binding}` with `succeeds`, \
10156 which also matches an Over outcome (the consume op completes \
10157 either way)",
10158 rule.name.name
10159 ),
10160 suggestion: Some(format!(
10161 "use `after {binding} ok` / `after {binding} over` for the outcome \
10162 variants, or `after {binding} completes` for any settled outcome"
10163 )),
10164 });
10165 }
10166 _ => {}
10167 }
10168 }
10169 if predicate == "reaches" {
10173 let Some(quoted) = words.next() else {
10174 continue;
10175 };
10176 let milestone = quoted.trim_matches('"');
10177 let (Some("as"), Some(alias)) = (words.next(), words.next()) else {
10178 continue;
10179 };
10180 let alias = alias.trim_end_matches('{').trim();
10181 if alias.is_empty() {
10182 continue;
10183 }
10184 if let Some(class) = milestone_payload_class(rule, binding, milestone, semantic) {
10185 if !class.is_empty() {
10186 binding_types.insert(alias.to_owned(), class);
10187 }
10188 }
10189 continue;
10190 }
10191 if predicate == "times" && words.next() != Some("out") {
10194 continue;
10195 }
10196 let (Some(keyword), Some(alias)) = (words.next(), words.next()) else {
10197 continue;
10198 };
10199 if keyword != "as" {
10200 continue;
10201 }
10202 let alias = alias.trim_end_matches('{').trim();
10203 if alias.is_empty() {
10204 continue;
10205 }
10206 match predicate {
10212 "times" => {
10213 binding_types.insert(alias.to_owned(), "TerminalTimedOut".to_owned());
10214 }
10215 "cancelled" => {
10216 binding_types.insert(alias.to_owned(), "TerminalCancelled".to_owned());
10217 }
10218 "completes" => {
10224 binding_types.insert(alias.to_owned(), "TerminalOutcome".to_owned());
10225 }
10226 "fails" => {
10240 if let Some(class) = invoke_failure_class(rule, binding, semantic) {
10241 binding_types.insert(alias.to_owned(), class);
10242 } else {
10243 let schema = match effect_binding_kinds.get(binding) {
10244 Some(IrEffectKind::ExecCommand) => "TerminalFailedExec",
10245 Some(IrEffectKind::SchemaCoerce) => "TerminalFailedCoerce",
10246 Some(IrEffectKind::AgentTell) => "TerminalFailedTell",
10247 _ => "TerminalFailed",
10248 };
10249 binding_types.insert(alias.to_owned(), schema.to_owned());
10250 }
10251 }
10252 _ => {
10253 if let Some(IrType::Ref(schema)) = effect_payload_types.get(binding) {
10254 binding_types.insert(alias.to_owned(), schema.clone());
10255 } else if let Some(class) = invoke_output_class(rule, binding, semantic) {
10256 binding_types.insert(alias.to_owned(), class);
10262 }
10263 }
10264 }
10265 }
10266 for when in &rule.whens {
10267 if let (_, Some(guard)) = split_when_guard(&when.text) {
10268 validate_expression(rule, guard, semantic, &binding_types, "guard", diagnostics);
10269 validate_known_field_paths(rule, guard, semantic, &binding_types, diagnostics);
10270 if let Some(expr) = lower_expression(guard, when.span) {
10271 metadata
10272 .projection_reads
10273 .extend(collect_projection_reads(&expr.expr));
10274 }
10275 }
10276 validate_availability_when(rule, &when.text, semantic, &binding_types, diagnostics);
10277 }
10278 validate_case_blocks(rule, semantic, &binding_types, diagnostics);
10279 metadata.case_branches =
10280 collect_rule_case_metadata(rule, semantic, &binding_types, diagnostics);
10281 let terminal_metadata = collect_terminal_case_metadata(
10282 rule,
10283 semantic,
10284 &binding_types,
10285 &effect_payload_types,
10286 diagnostics,
10287 );
10288 let mut known_roots: BTreeSet<String> = binding_types.keys().cloned().collect();
10292 collect_all_binding_names(&body_ast.statements, &mut known_roots);
10293 validate_record_blocks(rule, semantic, &binding_types, &known_roots, diagnostics);
10294 validate_effect_payloads(rule, semantic, &binding_types, &known_roots, diagnostics);
10295 validate_effect_field_roots(rule, &body_ast.statements, &known_roots, diagnostics);
10296 validate_emit_signal_declarations(
10297 rule,
10298 &body_ast.statements,
10299 &semantic.schemas.events,
10300 diagnostics,
10301 );
10302 validate_workflow_invocations(rule, semantic, &binding_types, &known_roots, diagnostics);
10303 validate_milestone_statements(rule, semantic, diagnostics);
10304 let mut block_stack: Vec<BlockFrame> = Vec::new();
10305 let mut misplaced_effect_bindings = BTreeSet::new();
10306 seed_ast_only_effect_bindings(&body_ast.statements, &mut seen_bindings, &mut binding_types);
10307 validate_body_effect_operands(
10308 rule,
10309 &body_ast.statements,
10310 semantic,
10311 &binding_types,
10312 diagnostics,
10313 );
10314 validate_coordination_discipline(rule, &body_ast.statements, diagnostics);
10315 validate_redactions(
10318 rule,
10319 &body_ast.statements,
10320 semantic,
10321 &binding_types,
10322 diagnostics,
10323 );
10324 validate_conditioned_field_reads(
10327 rule,
10328 &body_ast.statements,
10329 semantic,
10330 &binding_types,
10331 &BTreeSet::new(),
10332 diagnostics,
10333 );
10334 let mut anonymous_effects = 0usize;
10335 let mut record_depth = 0i32;
10336
10337 for raw_line in rule.body.text.lines() {
10338 let line = raw_line.trim();
10339 if line.is_empty() {
10340 continue;
10341 }
10342
10343 if record_depth > 0 {
10344 record_depth += brace_delta(line);
10345 continue;
10346 }
10347
10348 if let Some(binding) = binding_after_multiline_string_end(line) {
10349 misplaced_effect_bindings.insert(binding.clone());
10350 diagnostics.push(Diagnostic { related: Vec::new(),
10351 span: rule.body.span,
10352 message: format!(
10353 "rule `{}` places effect binding `{binding}` after a multiline string delimiter",
10354 rule.name.name
10355 ),
10356 suggestion: Some(format!(
10357 "move `as {binding}` onto the effect line, before the multiline string body"
10358 )),
10359 });
10360 continue;
10361 }
10362 validate_rule_prompt_content_type_annotation(rule, line, diagnostics);
10363
10364 if line.starts_with('}') {
10365 block_stack.pop();
10366 continue;
10367 }
10368
10369 if line.starts_with("case ") || (!line.starts_with("after ") && is_case_branch_start(line))
10370 {
10371 validate_known_field_paths(rule, line, semantic, &binding_types, diagnostics);
10372 continue;
10373 }
10374
10375 let active_afters = after_scopes(&block_stack);
10376 validate_binding_uses(rule, line, &seen_bindings, &active_afters, diagnostics);
10377 validate_known_field_paths(rule, line, semantic, &binding_types, diagnostics);
10378
10379 if let Some(binding) = parse_consume_line(line) {
10380 match binding_types.get(&binding) {
10381 Some(schema) => metadata.fact_consumes.push(format!("schema:{schema}")),
10382 None => diagnostics.push(Diagnostic {
10383 related: Vec::new(),
10384 span: rule.body.span,
10385 message: format!(
10386 "rule `{}` consumes unknown fact binding `{binding}`",
10387 rule.name.name
10388 ),
10389 suggestion: Some(
10390 "consume a binding introduced by a `when Class as binding` clause"
10391 .to_owned(),
10392 ),
10393 }),
10394 }
10395 if !line.contains("->") {
10396 continue;
10397 }
10398 }
10399
10400 if line.starts_with("after ") {
10401 if let Some(alias) = binding_after_as(line) {
10402 validate_binding_name(rule, &alias, rule.body.span, diagnostics);
10403 }
10404 match parse_after_line(line) {
10405 Some((binding, predicate)) => {
10406 if !seen_bindings.contains(&binding) {
10407 let suggestion = if misplaced_effect_bindings.contains(&binding) {
10408 format!(
10409 "move `as {binding}` onto the effect line before the multiline string"
10410 )
10411 } else {
10412 format!("create an effect with `as {binding}` before the `after` block")
10413 };
10414 diagnostics.push(Diagnostic { related: Vec::new(),
10415 span: rule.body.span,
10416 message: format!(
10417 "rule `{}` has `after` block for unknown effect binding `{binding}`",
10418 rule.name.name
10419 ),
10420 suggestion: Some(suggestion),
10421 });
10422 }
10423 block_stack.push(BlockFrame::After { binding, predicate });
10424 }
10425 None => {
10426 diagnostics.push(Diagnostic { related: Vec::new(),
10427 span: rule.body.span,
10428 message: format!(
10429 "rule `{}` has unsupported `after` dependency predicate",
10430 rule.name.name
10431 ),
10432 suggestion: Some(
10433 "use `after name succeeds`, `after name fails`, `after name completes`, `after name times out`, or `after name cancelled`"
10434 .to_owned(),
10435 ),
10436 });
10437 }
10438 }
10439 continue;
10440 }
10441
10442 if let Some((schema, _)) = parse_record_start(line) {
10443 if is_observer_only_schema(&schema) {
10444 diagnostics.push(Diagnostic {
10445 related: Vec::new(),
10446 span: rule.body.span,
10447 message: format!(
10448 "rule `{}` cannot record kernel-owned terminal schema `{schema}`",
10449 rule.name.name
10450 ),
10451 suggestion: Some(
10452 "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`"
10453 .to_owned(),
10454 ),
10455 });
10456 } else if !semantic.schemas.class_exists(&schema) {
10457 diagnostics.push(Diagnostic {
10458 related: Vec::new(),
10459 span: rule.body.span,
10460 message: format!("rule `{}` records unknown class `{schema}`", rule.name.name),
10461 suggestion: Some(format!("declare `class {schema}` before recording it")),
10462 });
10463 }
10464 metadata.fact_writes.push(format!("schema:{schema}"));
10465 record_depth = brace_delta(line).max(1);
10466 continue;
10467 }
10468
10469 if let Some((kind, binding)) = parse_effect_line(line) {
10470 validate_agent_tell_target(
10471 rule,
10472 line,
10473 &kind,
10474 semantic,
10475 &binding_types,
10476 &known_roots,
10477 diagnostics,
10478 );
10479 anonymous_effects += 1;
10480 let id = binding
10481 .clone()
10482 .unwrap_or_else(|| format!("effect{anonymous_effects}"));
10483 if let Some(binding) = &binding {
10484 validate_binding_name(rule, binding, rule.body.span, diagnostics);
10485 seen_bindings.insert(binding.clone());
10486 if let Some(schema) = effect_binding_schema(line, &kind, semantic) {
10487 binding_types.insert(binding.clone(), schema);
10488 }
10489 }
10490 for (upstream, predicate) in after_scopes(&block_stack) {
10491 metadata.dependencies.push(IrEffectDependency {
10492 upstream,
10493 predicate,
10494 downstream: id.clone(),
10495 });
10496 }
10497 let idempotency_key = effect_idempotency_key(&rule.name.name, &id, &kind, &binding);
10498 metadata.effects.push(IrEffectNode {
10499 id,
10500 kind,
10501 binding,
10502 required_capabilities: parse_required_capabilities(line),
10503 construct_use: None,
10504 idempotency_key,
10505 span: rule.body.span,
10506 timeout_seconds: None,
10507 access_grants: Vec::new(),
10510 turn_skills: Vec::new(),
10511 resource: None,
10512 agent: None,
10513 workflow_target: None,
10514 endorsed: false,
10515 declassified: false,
10516 selected_by: None,
10517 exec_target: None,
10518 });
10519 }
10520 }
10521
10522 let (ast_effects, ast_dependencies) =
10523 collect_effects_from_ast(&body_ast.statements, &rule.name.name);
10524 metadata.effects = ast_effects;
10525 metadata.dependencies = ast_dependencies;
10526
10527 push_ingest_fact_writes(&body_ast.statements, &mut metadata.fact_writes);
10531
10532 metadata.fact_reads.sort();
10533 metadata.fact_reads.dedup();
10534 sort_projection_reads(&mut metadata.projection_reads);
10535 metadata.fact_writes.sort();
10536 metadata.fact_writes.dedup();
10537 metadata.fact_consumes.sort();
10538 metadata.fact_consumes.dedup();
10539 metadata.terminal_outputs = terminal_metadata.outputs;
10540 metadata.terminal_branches = terminal_metadata.branches;
10541 for branch in &metadata.case_branches {
10548 if let Some(guard) = &branch.guard {
10549 metadata
10550 .projection_reads
10551 .extend(collect_projection_reads(&guard.expr));
10552 }
10553 }
10554 for branch in &metadata.terminal_branches {
10555 if let Some(guard) = &branch.guard {
10556 metadata
10557 .projection_reads
10558 .extend(collect_projection_reads(&guard.expr));
10559 }
10560 }
10561 sort_projection_reads(&mut metadata.projection_reads);
10562 collect_terminal_complete_bindings(&body_ast.statements, &mut metadata.terminal_completes);
10563 metadata.terminal_completes.sort();
10564 metadata.terminal_completes.dedup();
10565 collect_redaction_metadata(
10566 &body_ast.statements,
10567 &binding_types,
10568 &mut metadata.redactions,
10569 );
10570 collect_bounded_egresses(
10571 &body_ast.statements,
10572 &binding_types,
10573 &mut metadata.bounded_egresses,
10574 );
10575 let mut egress_reads = Vec::new();
10576 collect_egress_payload_reads(&body_ast.statements, &mut egress_reads);
10577 for (sink, roots) in egress_reads {
10578 metadata
10579 .egress_payload_reads
10580 .entry(sink)
10581 .or_default()
10582 .extend(roots);
10583 }
10584 collect_complete_field_reads(&body_ast.statements, &mut metadata.complete_field_reads);
10585 collect_record_field_reads(&body_ast.statements, &mut metadata.record_field_reads);
10586 collect_milestone_field_reads(&body_ast.statements, &mut metadata.milestone_field_reads);
10587 collect_crossing_roots(
10588 &body_ast.statements,
10589 &mut metadata.declassified_roots,
10590 &mut metadata.endorsed_roots,
10591 &mut metadata.endorsed_claim_items,
10592 );
10593 collect_provenance_metadata(
10594 &body_ast.statements,
10595 &mut metadata.coerce_input_roots,
10596 &mut metadata.after_aliases,
10597 );
10598 collect_egress_case_influence(
10599 &body_ast.statements,
10600 &mut Vec::new(),
10601 &mut metadata.egress_case_influence,
10602 );
10603 loop {
10610 let mut changed = false;
10611 for redaction in &metadata.redactions {
10612 if metadata.declassified_roots.contains(&redaction.source)
10613 && metadata
10614 .declassified_roots
10615 .insert(redaction.binding.clone())
10616 {
10617 changed = true;
10618 }
10619 if metadata.endorsed_roots.contains(&redaction.source)
10620 && metadata.endorsed_roots.insert(redaction.binding.clone())
10621 {
10622 changed = true;
10623 }
10624 }
10625 if !changed {
10626 break;
10627 }
10628 }
10629 metadata
10630}
10631
10632fn collect_egress_case_influence(
10637 statements: &[body::BodyStmt],
10638 active: &mut Vec<BTreeSet<String>>,
10639 out: &mut BTreeMap<String, BTreeSet<String>>,
10640) {
10641 let record_sink = |sink: String,
10642 active: &[BTreeSet<String>],
10643 out: &mut BTreeMap<String, BTreeSet<String>>| {
10644 if active.is_empty() {
10645 return;
10646 }
10647 let entry = out.entry(sink).or_default();
10648 for roots in active {
10649 entry.extend(roots.iter().cloned());
10650 }
10651 };
10652 for statement in statements {
10653 match statement {
10654 body::BodyStmt::Terminal(terminal) if terminal.kind == body::TerminalKind::Complete => {
10655 record_sink(terminal.name.clone(), active, out);
10656 }
10657 body::BodyStmt::Record(record) => {
10658 record_sink(format!("fact:{}", record.schema), active, out);
10659 }
10660 body::BodyStmt::Done {
10661 replacement: Some(record),
10662 ..
10663 } => {
10664 record_sink(format!("fact:{}", record.schema), active, out);
10665 }
10666 body::BodyStmt::Milestone { name, .. } => {
10667 record_sink(format!("milestone:{name}"), active, out);
10668 }
10669 body::BodyStmt::Effect(effect) => match &effect.kind {
10670 body::BodyEffectKind::ConstructCapabilityCall {
10671 keyword, fields, ..
10672 } if keyword == "send" => {
10673 if let Some(channel) = fields
10674 .iter()
10675 .find(|field| field.name == "channel")
10676 .map(|field| field.source.clone())
10677 {
10678 record_sink(channel, active, out);
10679 }
10680 }
10681 body::BodyEffectKind::FileWrite { store, .. } => {
10682 record_sink(store.clone(), active, out);
10683 }
10684 _ => {}
10685 },
10686 body::BodyStmt::After(after) => {
10687 collect_egress_case_influence(&after.body, active, out);
10688 }
10689 body::BodyStmt::Case(case) => {
10690 let mut roots = BTreeSet::new();
10691 if let Ok(expr) = parse_expression(&case.scrutinee) {
10692 collect_expr_binding_roots(&expr, &mut roots);
10693 } else {
10694 collect_template_binding_roots(&case.scrutinee, &mut roots);
10695 }
10696 active.push(roots);
10697 for branch in &case.branches {
10698 collect_egress_case_influence(&branch.body, active, out);
10699 }
10700 active.pop();
10701 }
10702 _ => {}
10703 }
10704 }
10705}
10706
10707fn collect_provenance_metadata(
10712 statements: &[body::BodyStmt],
10713 coerce_input_roots: &mut BTreeMap<String, BTreeSet<String>>,
10714 after_aliases: &mut BTreeMap<String, String>,
10715) {
10716 for statement in statements {
10717 match statement {
10718 body::BodyStmt::Effect(effect) => {
10719 if let body::BodyEffectKind::Coerce { args, .. } = &effect.kind {
10720 if let Some(binding) = &effect.binding {
10721 let mut roots = BTreeSet::new();
10722 for arg in args {
10723 if let Ok(expr) = parse_expression(arg) {
10724 collect_expr_binding_roots(&expr, &mut roots);
10725 } else {
10726 collect_template_binding_roots(arg, &mut roots);
10727 }
10728 }
10729 coerce_input_roots
10730 .entry(binding.clone())
10731 .or_default()
10732 .extend(roots);
10733 }
10734 }
10735 }
10736 body::BodyStmt::After(after) => {
10737 if matches!(
10738 after.predicate,
10739 body::AfterPredicate::Succeeds | body::AfterPredicate::Completes
10740 ) {
10741 if let Some(alias) = &after.alias {
10742 after_aliases.insert(alias.clone(), after.binding.clone());
10743 }
10744 }
10745 collect_provenance_metadata(&after.body, coerce_input_roots, after_aliases);
10746 }
10747 body::BodyStmt::Case(case) => {
10748 for branch in &case.branches {
10749 collect_provenance_metadata(&branch.body, coerce_input_roots, after_aliases);
10750 }
10751 }
10752 _ => {}
10753 }
10754 }
10755}
10756
10757fn collect_crossing_roots(
10764 statements: &[body::BodyStmt],
10765 declassified: &mut BTreeSet<String>,
10766 endorsed: &mut BTreeSet<String>,
10767 claim_items: &mut BTreeSet<String>,
10768) {
10769 fn collect_marked(
10770 statements: &[body::BodyStmt],
10771 declassified: &mut BTreeSet<String>,
10772 endorsed: &mut BTreeSet<String>,
10773 claim_items: &mut BTreeSet<String>,
10774 ) {
10775 for statement in statements {
10776 match statement {
10777 body::BodyStmt::Effect(effect) => {
10778 if let body::BodyEffectKind::Coerce {
10779 declassified: is_declassified,
10780 endorsed: is_endorsed,
10781 ..
10782 } = &effect.kind
10783 {
10784 if let Some(binding) = &effect.binding {
10785 if *is_declassified {
10786 declassified.insert(binding.clone());
10787 }
10788 if *is_endorsed {
10789 endorsed.insert(binding.clone());
10790 }
10791 }
10792 }
10793 if let body::BodyEffectKind::TrackerClaim {
10798 endorsed: is_endorsed,
10799 item,
10800 ..
10801 } = &effect.kind
10802 {
10803 if *is_endorsed {
10804 endorsed.insert(item.clone());
10810 claim_items.insert(item.clone());
10811 }
10812 }
10813 }
10814 body::BodyStmt::After(after) => {
10815 collect_marked(&after.body, declassified, endorsed, claim_items)
10816 }
10817 body::BodyStmt::Case(case) => {
10818 for branch in &case.branches {
10819 collect_marked(&branch.body, declassified, endorsed, claim_items);
10820 }
10821 }
10822 _ => {}
10823 }
10824 }
10825 }
10826 fn collect_aliases(
10827 statements: &[body::BodyStmt],
10828 declassified: &mut BTreeSet<String>,
10829 endorsed: &mut BTreeSet<String>,
10830 ) {
10831 for statement in statements {
10832 match statement {
10833 body::BodyStmt::After(after) => {
10834 if matches!(
10835 after.predicate,
10836 body::AfterPredicate::Succeeds | body::AfterPredicate::Completes
10837 ) {
10838 if let Some(alias) = &after.alias {
10839 if declassified.contains(&after.binding) {
10840 declassified.insert(alias.clone());
10841 }
10842 if endorsed.contains(&after.binding) {
10843 endorsed.insert(alias.clone());
10844 }
10845 }
10846 }
10847 collect_aliases(&after.body, declassified, endorsed);
10848 }
10849 body::BodyStmt::Case(case) => {
10850 for branch in &case.branches {
10851 collect_aliases(&branch.body, declassified, endorsed);
10852 }
10853 }
10854 _ => {}
10855 }
10856 }
10857 }
10858 collect_marked(statements, declassified, endorsed, claim_items);
10859 loop {
10863 let before = (declassified.len(), endorsed.len());
10864 collect_aliases(statements, declassified, endorsed);
10865 if (declassified.len(), endorsed.len()) == before {
10866 break;
10867 }
10868 }
10869}
10870
10871fn collect_complete_field_reads(
10879 statements: &[body::BodyStmt],
10880 out: &mut BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
10881) {
10882 for statement in statements {
10883 match statement {
10884 body::BodyStmt::Terminal(terminal) if terminal.kind == body::TerminalKind::Complete => {
10885 let per_field = out.entry(terminal.name.clone()).or_default();
10886 for field in &terminal.fields {
10887 let mut roots = BTreeSet::new();
10888 match &field.value {
10889 body::FieldValue::Shorthand => {
10890 if let Some(root) = &terminal.from {
10891 roots.insert(root.clone());
10892 }
10893 }
10894 body::FieldValue::Expr { expr, .. } => {
10895 collect_expr_binding_roots(expr, &mut roots)
10896 }
10897 body::FieldValue::Nested { fields, .. } => collect_payload_field_roots(
10898 fields,
10899 terminal.from.as_deref(),
10900 &mut roots,
10901 ),
10902 }
10903 per_field
10904 .entry(field.name.clone())
10905 .or_default()
10906 .extend(roots);
10907 }
10908 }
10909 body::BodyStmt::After(after) => collect_complete_field_reads(&after.body, out),
10910 body::BodyStmt::Case(case) => {
10911 for branch in &case.branches {
10912 collect_complete_field_reads(&branch.body, out);
10913 }
10914 }
10915 _ => {}
10916 }
10917 }
10918}
10919
10920fn collect_milestone_field_reads(
10926 statements: &[body::BodyStmt],
10927 out: &mut BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
10928) {
10929 for statement in statements {
10930 match statement {
10931 body::BodyStmt::Milestone { name, fields, .. } => {
10932 let per_field = out.entry(name.clone()).or_default();
10933 for field in fields {
10934 let mut roots = BTreeSet::new();
10935 match &field.value {
10936 body::FieldValue::Shorthand => {}
10937 body::FieldValue::Expr { expr, .. } => {
10938 collect_expr_binding_roots(expr, &mut roots)
10939 }
10940 body::FieldValue::Nested { fields, .. } => {
10941 collect_payload_field_roots(fields, None, &mut roots)
10942 }
10943 }
10944 per_field
10945 .entry(field.name.clone())
10946 .or_default()
10947 .extend(roots);
10948 }
10949 }
10950 body::BodyStmt::After(after) => collect_milestone_field_reads(&after.body, out),
10951 body::BodyStmt::Case(case) => {
10952 for branch in &case.branches {
10953 collect_milestone_field_reads(&branch.body, out);
10954 }
10955 }
10956 _ => {}
10957 }
10958 }
10959}
10960
10961fn collect_redaction_metadata(
10968 statements: &[body::BodyStmt],
10969 binding_types: &BTreeMap<String, String>,
10970 out: &mut Vec<IrRedaction>,
10971) {
10972 let mut redacts = Vec::new();
10973 collect_redact_effects(statements, &mut redacts);
10974 for (source, keep, binding, _span) in redacts {
10975 out.push(IrRedaction {
10976 source: source.to_owned(),
10977 keep: keep.to_vec(),
10978 binding: binding.to_owned(),
10979 source_schema: binding_types.get(source).cloned(),
10980 });
10981 }
10982}
10983
10984fn push_bounded_projection(
10998 from: Option<&str>,
10999 fields: &[body::FieldAssign],
11000 sink: String,
11001 binding_types: &BTreeMap<String, String>,
11002 out: &mut Vec<IrBoundedEgress>,
11003) {
11004 let Some(source_schema) = from.and_then(|src| binding_types.get(src)) else {
11005 return;
11006 };
11007 if fields.is_empty()
11008 || !fields
11009 .iter()
11010 .all(|field| matches!(field.value, body::FieldValue::Shorthand))
11011 {
11012 return;
11013 }
11014 out.push(IrBoundedEgress {
11015 sink,
11016 source_schema: source_schema.clone(),
11017 keep: fields.iter().map(|field| field.name.clone()).collect(),
11018 });
11019}
11020
11021fn push_bounded_record(
11022 record: &body::RecordStmt,
11023 binding_types: &BTreeMap<String, String>,
11024 out: &mut Vec<IrBoundedEgress>,
11025) {
11026 push_bounded_projection(
11027 record.from.as_deref(),
11028 &record.fields,
11029 format!("fact:{}", record.schema),
11030 binding_types,
11031 out,
11032 );
11033}
11034
11035fn collect_bounded_egresses(
11036 statements: &[body::BodyStmt],
11037 binding_types: &BTreeMap<String, String>,
11038 out: &mut Vec<IrBoundedEgress>,
11039) {
11040 for statement in statements {
11041 match statement {
11042 body::BodyStmt::Record(record) => push_bounded_record(record, binding_types, out),
11043 body::BodyStmt::Done {
11044 replacement: Some(record),
11045 ..
11046 } => push_bounded_record(record, binding_types, out),
11047 body::BodyStmt::Terminal(terminal)
11050 if terminal.kind == body::TerminalKind::Complete && terminal.from.is_some() =>
11051 {
11052 push_bounded_projection(
11053 terminal.from.as_deref(),
11054 &terminal.fields,
11055 terminal.name.clone(),
11056 binding_types,
11057 out,
11058 );
11059 }
11060 body::BodyStmt::After(after) => {
11061 collect_bounded_egresses(&after.body, binding_types, out)
11062 }
11063 body::BodyStmt::Case(case) => {
11064 for branch in &case.branches {
11065 collect_bounded_egresses(&branch.body, binding_types, out);
11066 }
11067 }
11068 _ => {}
11069 }
11070 }
11071}
11072
11073fn collect_expr_binding_roots(expr: &Expr, out: &mut BTreeSet<String>) {
11081 match expr {
11082 Expr::Literal(ExprLiteral::String(text)) => collect_template_binding_roots(text, out),
11083 Expr::Literal(ExprLiteral::Ident(name)) => {
11084 out.insert(name.clone());
11085 }
11086 Expr::Literal(ExprLiteral::Number(_) | ExprLiteral::Bool(_) | ExprLiteral::Null) => {}
11087 Expr::Path(segments) => {
11088 if let Some(root) = segments.first() {
11089 out.insert(root.clone());
11090 }
11091 }
11092 Expr::Index { target, key } => {
11093 collect_expr_binding_roots(target, out);
11094 collect_expr_binding_roots(key, out);
11095 }
11096 Expr::Array(items) => {
11097 for item in items {
11098 collect_expr_binding_roots(item, out);
11099 }
11100 }
11101 Expr::Object(fields) => {
11102 for field in fields {
11103 collect_expr_binding_roots(&field.value, out);
11104 }
11105 }
11106 Expr::Unary { expr, .. } => collect_expr_binding_roots(expr, out),
11107 Expr::Binary { left, right, .. } => {
11108 collect_expr_binding_roots(left, out);
11109 collect_expr_binding_roots(right, out);
11110 }
11111 Expr::Call { args, .. } => {
11112 for arg in args {
11113 collect_expr_binding_roots(arg, out);
11114 }
11115 }
11116 Expr::Query { head, guard, .. } => {
11117 out.insert(head.clone());
11118 if let Some(guard) = guard {
11119 collect_expr_binding_roots(guard, out);
11120 }
11121 }
11122 }
11123}
11124
11125fn collect_template_binding_roots(text: &str, out: &mut BTreeSet<String>) {
11131 let mut rest = text;
11132 while let Some(open) = rest.find("{{") {
11133 let after_open = &rest[open + 2..];
11134 let Some(close) = after_open.find("}}") else {
11135 break;
11136 };
11137 let body = after_open[..close].trim();
11138 if let Ok(expr) = parse_expression(body) {
11139 collect_expr_binding_roots(&expr, out);
11140 } else {
11141 for token in body.split(|ch: char| !ch.is_alphanumeric() && ch != '_') {
11142 if token
11143 .as_bytes()
11144 .first()
11145 .is_some_and(|byte| is_ident_start(*byte))
11146 {
11147 out.insert(token.to_owned());
11148 }
11149 }
11150 }
11151 rest = &after_open[close + 2..];
11152 }
11153}
11154
11155fn collect_payload_field_roots(
11158 fields: &[body::FieldAssign],
11159 from_binding: Option<&str>,
11160 out: &mut BTreeSet<String>,
11161) {
11162 for field in fields {
11163 match &field.value {
11164 body::FieldValue::Shorthand => {
11165 if let Some(root) = from_binding {
11166 out.insert(root.to_owned());
11167 }
11168 }
11169 body::FieldValue::Expr { expr, .. } => collect_expr_binding_roots(expr, out),
11170 body::FieldValue::Nested { fields, .. } => {
11171 collect_payload_field_roots(fields, from_binding, out)
11172 }
11173 }
11174 }
11175}
11176
11177fn collect_egress_payload_reads(
11186 statements: &[body::BodyStmt],
11187 out: &mut Vec<(String, BTreeSet<String>)>,
11188) {
11189 for statement in statements {
11190 match statement {
11191 body::BodyStmt::Terminal(terminal) if terminal.kind == body::TerminalKind::Complete => {
11192 let mut roots = BTreeSet::new();
11193 collect_payload_field_roots(&terminal.fields, None, &mut roots);
11194 if let Some(body::FieldValue::Expr { expr, .. }) = &terminal.scalar {
11198 collect_expr_binding_roots(expr, &mut roots);
11199 }
11200 out.push((terminal.name.clone(), roots));
11201 }
11202 body::BodyStmt::Record(record) => out.push(record_payload_reads(record)),
11203 body::BodyStmt::Done {
11205 replacement: Some(record),
11206 ..
11207 } => out.push(record_payload_reads(record)),
11208 body::BodyStmt::Milestone { name, fields, .. } => {
11209 let mut roots = BTreeSet::new();
11210 collect_payload_field_roots(fields, None, &mut roots);
11211 out.push((format!("milestone:{name}"), roots));
11212 }
11213 body::BodyStmt::Effect(effect) => match &effect.kind {
11220 body::BodyEffectKind::ConstructCapabilityCall {
11221 keyword, fields, ..
11222 } if keyword == "send" => {
11223 if let Some(reads) = send_payload_reads(fields) {
11224 out.push(reads);
11225 }
11226 }
11227 body::BodyEffectKind::FileWrite {
11228 store, path, body, ..
11229 } => {
11230 let mut roots = BTreeSet::new();
11231 for source in [path, body] {
11232 if let Ok(expr) = parse_expression(source) {
11233 collect_expr_binding_roots(&expr, &mut roots);
11234 } else {
11235 collect_template_binding_roots(source, &mut roots);
11236 }
11237 }
11238 out.push((store.clone(), roots));
11239 }
11240 _ => {}
11241 },
11242 body::BodyStmt::After(after) => collect_egress_payload_reads(&after.body, out),
11243 body::BodyStmt::Case(case) => {
11244 for branch in &case.branches {
11245 collect_egress_payload_reads(&branch.body, out);
11246 }
11247 }
11248 _ => {}
11249 }
11250 }
11251}
11252
11253fn send_payload_reads(fields: &[body::ConstructUseField]) -> Option<(String, BTreeSet<String>)> {
11258 let channel = fields
11259 .iter()
11260 .find(|field| field.name == "channel")
11261 .map(|field| field.source.clone())?;
11262 let mut roots = BTreeSet::new();
11263 for field in fields.iter().filter(|field| field.name != "channel") {
11264 if let Ok(expr) = parse_expression(&field.source) {
11265 collect_expr_binding_roots(&expr, &mut roots);
11266 } else {
11267 collect_template_binding_roots(&field.source, &mut roots);
11269 }
11270 }
11271 Some((channel, roots))
11272}
11273
11274fn collect_record_field_reads(
11281 statements: &[body::BodyStmt],
11282 out: &mut BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
11283) {
11284 fn record_fields(
11285 record: &body::RecordStmt,
11286 out: &mut BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
11287 ) {
11288 let per_field = out.entry(format!("fact:{}", record.schema)).or_default();
11289 for field in &record.fields {
11290 let mut roots = BTreeSet::new();
11291 match &field.value {
11292 body::FieldValue::Shorthand => {
11293 if let Some(root) = &record.from {
11294 roots.insert(root.clone());
11295 }
11296 }
11297 body::FieldValue::Expr { expr, .. } => collect_expr_binding_roots(expr, &mut roots),
11298 body::FieldValue::Nested { fields, .. } => {
11299 collect_payload_field_roots(fields, record.from.as_deref(), &mut roots)
11300 }
11301 }
11302 per_field
11303 .entry(field.name.clone())
11304 .or_default()
11305 .extend(roots);
11306 }
11307 }
11308 for statement in statements {
11309 match statement {
11310 body::BodyStmt::Record(record) => record_fields(record, out),
11311 body::BodyStmt::Done {
11312 replacement: Some(record),
11313 ..
11314 } => record_fields(record, out),
11315 body::BodyStmt::After(after) => collect_record_field_reads(&after.body, out),
11316 body::BodyStmt::Case(case) => {
11317 for branch in &case.branches {
11318 collect_record_field_reads(&branch.body, out);
11319 }
11320 }
11321 _ => {}
11322 }
11323 }
11324}
11325
11326fn record_payload_reads(record: &body::RecordStmt) -> (String, BTreeSet<String>) {
11327 let mut roots = BTreeSet::new();
11328 if let Some(from) = &record.from {
11329 roots.insert(from.clone());
11330 }
11331 collect_payload_field_roots(&record.fields, record.from.as_deref(), &mut roots);
11332 (format!("fact:{}", record.schema), roots)
11333}
11334
11335#[derive(Clone, Debug, Default)]
11336struct TerminalMetadata {
11337 outputs: Vec<IrTerminalOutput>,
11338 branches: Vec<IrTerminalCaseBranch>,
11339}
11340
11341#[derive(Clone, Debug)]
11342struct TerminalBranchSource {
11343 scrutinee: String,
11344 pattern: String,
11345 guard: Option<String>,
11346 body: String,
11347 pattern_span: SourceSpan,
11348}
11349
11350#[derive(Clone, Debug)]
11351struct RuleCaseBranchSource {
11352 scrutinee: String,
11353 scrutinee_type: TypeSyntax,
11354 pattern: String,
11355 guard: Option<String>,
11356 body: String,
11357 pattern_span: SourceSpan,
11358}
11359
11360fn collect_effect_payload_types(
11361 rule: &RuleDecl,
11362 semantic: &SemanticContext,
11363 diagnostics: &mut Vec<Diagnostic>,
11364) -> BTreeMap<String, IrType> {
11365 let mut payloads = BTreeMap::new();
11366 for statement in effect_payload_statements(&rule.body.text) {
11367 let line = statement.trim();
11368 let Some((kind, Some(binding))) = parse_effect_line(line) else {
11369 continue;
11370 };
11371 let payload = terminal_completed_payload_type(line, &kind, semantic);
11372 match payloads.get(&binding) {
11377 Some(existing) if existing != &payload => {
11378 diagnostics.push(Diagnostic {
11379 related: Vec::new(),
11380 span: rule.body.span,
11381 message: format!(
11382 "rule `{}` reuses effect binding `{binding}` for effects with conflicting result types",
11383 rule.name.name
11384 ),
11385 suggestion: Some(format!(
11386 "give each effect a distinct binding — `as {binding}` is reused with a different result type, so `after {binding} …` is ambiguous"
11387 )),
11388 });
11389 }
11390 Some(_) => {}
11391 None => {
11392 payloads.insert(binding, payload);
11393 }
11394 }
11395 }
11396
11397 payloads
11398}
11399
11400fn terminal_completed_payload_type(
11401 line: &str,
11402 kind: &IrEffectKind,
11403 semantic: &SemanticContext,
11404) -> IrType {
11405 match kind {
11406 IrEffectKind::SchemaCoerce if line.starts_with("prompt ") => {
11407 IrType::Primitive(IrPrimitiveType::String)
11408 }
11409 IrEffectKind::SchemaCoerce => parse_coerce_call_name(line)
11410 .and_then(|name| semantic.coerce_outputs.get(name))
11411 .cloned()
11412 .map(lower_type)
11413 .unwrap_or_else(terminal_unknown_payload_type),
11414 IrEffectKind::AgentTell => IrType::Ref("AgentTurn".to_owned()),
11415 IrEffectKind::CapabilityCall
11416 | IrEffectKind::EventEmit
11417 | IrEffectKind::WorkflowInvoke
11418 | IrEffectKind::TimerWait
11419 | IrEffectKind::ExecCommand
11420 | IrEffectKind::TrackerFile
11421 | IrEffectKind::TrackerClaim
11422 | IrEffectKind::TrackerRenew
11423 | IrEffectKind::TrackerRelease
11424 | IrEffectKind::TrackerFinish
11425 | IrEffectKind::LeaseAcquire
11426 | IrEffectKind::LeaseRenew
11427 | IrEffectKind::LedgerAppend
11428 | IrEffectKind::CounterConsume
11429 | IrEffectKind::SignalEmit
11430 | IrEffectKind::FileRead
11431 | IrEffectKind::FileWrite
11432 | IrEffectKind::FileImport
11433 | IrEffectKind::FileExport => terminal_unknown_payload_type(),
11434 }
11435}
11436
11437fn collect_rule_case_metadata(
11438 rule: &RuleDecl,
11439 semantic: &SemanticContext,
11440 binding_types: &BTreeMap<String, String>,
11441 diagnostics: &mut Vec<Diagnostic>,
11442) -> Vec<IrRuleCaseBranch> {
11443 let mut branches = Vec::new();
11444 for branch in rule_case_branch_sources(rule, semantic, binding_types) {
11445 let mut branch_scope = binding_types.clone();
11446 if let Some((binding, schema)) =
11447 case_branch_payload_binding(&branch.pattern, &branch.scrutinee_type, semantic)
11448 {
11449 branch_scope.insert(binding, schema);
11450 }
11451 if let Some(guard) = &branch.guard {
11452 validate_expression(
11453 rule,
11454 guard,
11455 semantic,
11456 &branch_scope,
11457 "case guard",
11458 diagnostics,
11459 );
11460 validate_known_field_paths_at_span(
11461 rule,
11462 guard,
11463 branch.pattern_span,
11464 semantic,
11465 &branch_scope,
11466 diagnostics,
11467 );
11468 }
11469 validate_known_field_paths_at_span(
11470 rule,
11471 &branch.body,
11472 branch.pattern_span,
11473 semantic,
11474 &branch_scope,
11475 diagnostics,
11476 );
11477 if let Some(pattern) = lower_case_pattern(&branch.pattern, &branch.scrutinee_type, semantic)
11478 {
11479 branches.push(IrRuleCaseBranch {
11480 scrutinee: branch.scrutinee,
11481 scrutinee_type: lower_type(branch.scrutinee_type),
11482 pattern,
11483 guard: branch.guard.as_ref().and_then(|guard| {
11484 lower_expression(
11485 guard,
11486 SourceSpan {
11487 start: branch.pattern_span.start,
11488 end: branch.pattern_span.end,
11489 },
11490 )
11491 }),
11492 body_hash: stable_hash(&branch.body),
11493 pattern_span: branch.pattern_span,
11494 });
11495 }
11496 }
11497 branches.sort_by(|left, right| {
11498 (left.scrutinee.as_str(), left.pattern_span.start)
11499 .cmp(&(right.scrutinee.as_str(), right.pattern_span.start))
11500 });
11501 branches
11502}
11503
11504fn rule_case_branch_sources(
11505 rule: &RuleDecl,
11506 semantic: &SemanticContext,
11507 binding_types: &BTreeMap<String, String>,
11508) -> Vec<RuleCaseBranchSource> {
11509 let lines = rule
11510 .body
11511 .text
11512 .lines()
11513 .scan(0usize, |offset, line| {
11514 let current = *offset;
11515 *offset += line.len() + 1;
11516 Some((line, current))
11517 })
11518 .collect::<Vec<_>>();
11519 let text_lines = lines.iter().map(|(line, _)| *line).collect::<Vec<_>>();
11520 let mut branches = Vec::new();
11521 let mut index = 0usize;
11522 while index < lines.len() {
11523 let (line, _) = lines[index];
11524 let trimmed = line.trim();
11525 let Some(scrutinee) = case_scrutinee(trimmed) else {
11526 index += 1;
11527 continue;
11528 };
11529 if active_completes_binding_for_case(&text_lines, index, scrutinee) {
11530 index += 1;
11531 continue;
11532 }
11533 let Some(scrutinee_type) = expression_type(scrutinee, semantic, binding_types) else {
11534 index += 1;
11535 continue;
11536 };
11537 let mut depth = brace_delta(trimmed).max(1);
11538 index += 1;
11539 while index < lines.len() && depth > 0 {
11540 let (branch_line, branch_line_offset) = lines[index];
11541 let branch_trimmed = branch_line.trim();
11542 if depth == 1 {
11543 if let Some((pattern, guard, body_start)) = terminal_branch_header(branch_trimmed) {
11544 let pattern_column = case_pattern_column(branch_line, pattern);
11545 let pattern_span = SourceSpan {
11546 start: rule_body_text_start(rule) + branch_line_offset + pattern_column,
11547 end: rule_body_text_start(rule)
11548 + branch_line_offset
11549 + pattern_column
11550 + pattern.len(),
11551 };
11552 let mut body_lines = Vec::new();
11553 let mut branch_depth = brace_delta(body_start).max(1);
11554 index += 1;
11555 while index < lines.len() && branch_depth > 0 {
11556 let body_line = lines[index].0;
11557 let next_depth = branch_depth + brace_delta(body_line);
11558 if next_depth >= 1 {
11559 body_lines.push(body_line.to_owned());
11560 }
11561 branch_depth = next_depth;
11562 index += 1;
11563 }
11564 branches.push(RuleCaseBranchSource {
11565 scrutinee: scrutinee.to_owned(),
11566 scrutinee_type: scrutinee_type.clone(),
11567 pattern: pattern.to_owned(),
11568 guard,
11569 body: body_lines.join("\n"),
11570 pattern_span,
11571 });
11572 continue;
11573 }
11574 }
11575 depth += brace_delta(branch_trimmed);
11576 index += 1;
11577 }
11578 }
11579 branches
11580}
11581
11582fn lower_case_pattern(
11583 pattern: &str,
11584 scrutinee_type: &TypeSyntax,
11585 semantic: &SemanticContext,
11586) -> Option<IrCasePattern> {
11587 if is_fallback_pattern(pattern) {
11588 return Some(IrCasePattern::Wildcard);
11589 }
11590 if pattern == "None" {
11591 return Some(IrCasePattern::OptionalNone);
11592 }
11593 if let Some(binding) = pattern.strip_prefix("Some ").map(str::trim) {
11594 if !binding.is_empty() {
11595 return Some(IrCasePattern::OptionalSome {
11596 binding: binding.to_owned(),
11597 });
11598 }
11599 }
11600 match scrutinee_type {
11601 TypeSyntax::Ref { name } if semantic.schemas.enums.contains_key(&name.name) => {
11602 let (variant, _) = sum_case_pattern_parts(pattern);
11605 Some(IrCasePattern::EnumVariant(variant.to_owned()))
11606 }
11607 TypeSyntax::Union { .. } => parse_literal_expr(pattern).and_then(|literal| match literal {
11608 LiteralExpr::String(value) => Some(IrCasePattern::LiteralString(value.to_owned())),
11609 LiteralExpr::Ident(value) => Some(IrCasePattern::LiteralString(value.to_owned())),
11610 _ => None,
11611 }),
11612 TypeSyntax::AgentRef { .. } => {
11613 parse_literal_expr(pattern).and_then(|literal| match literal {
11614 LiteralExpr::String(value) | LiteralExpr::Ident(value) => {
11615 Some(IrCasePattern::Agent(value.to_owned()))
11616 }
11617 _ => None,
11618 })
11619 }
11620 TypeSyntax::Optional { inner, .. } => lower_case_pattern(pattern, inner, semantic),
11621 _ => None,
11622 }
11623}
11624
11625fn case_branch_payload_binding(
11626 pattern: &str,
11627 scrutinee_type: &TypeSyntax,
11628 semantic: &SemanticContext,
11629) -> Option<(String, String)> {
11630 if let TypeSyntax::Ref { name } = scrutinee_type {
11633 if semantic.schemas.enums.contains_key(&name.name) {
11634 let (variant, binding) = sum_case_pattern_parts(pattern);
11635 let binding = binding?;
11636 let generated = format!("{}.{variant}", name.name);
11637 if binding.is_empty() || !semantic.schemas.class_exists(&generated) {
11638 return None;
11639 }
11640 return Some((binding.to_owned(), generated));
11641 }
11642 }
11643 let binding = pattern.strip_prefix("Some ").map(str::trim)?;
11644 if binding.is_empty() {
11645 return None;
11646 }
11647 let TypeSyntax::Optional { inner, .. } = scrutinee_type else {
11648 return None;
11649 };
11650 let schema = match inner.as_ref() {
11651 TypeSyntax::Ref { name } if semantic.schemas.class_exists(&name.name) => {
11652 Some(name.name.clone())
11653 }
11654 _ => None,
11655 }?;
11656 Some((binding.to_owned(), schema))
11657}
11658
11659fn collect_terminal_case_metadata(
11660 rule: &RuleDecl,
11661 semantic: &SemanticContext,
11662 binding_types: &BTreeMap<String, String>,
11663 effect_payload_types: &BTreeMap<String, IrType>,
11664 diagnostics: &mut Vec<Diagnostic>,
11665) -> TerminalMetadata {
11666 let mut metadata = TerminalMetadata::default();
11667 let mut output_bindings = BTreeSet::new();
11668
11669 for branch in terminal_case_branch_sources(rule) {
11670 if output_bindings.insert(branch.scrutinee.clone()) {
11671 let completed_payload = effect_payload_types
11672 .get(&branch.scrutinee)
11673 .cloned()
11674 .unwrap_or_else(terminal_unknown_payload_type);
11675 metadata.outputs.push(IrTerminalOutput {
11676 binding: branch.scrutinee.clone(),
11677 alternatives: terminal_alternatives(completed_payload, branch.pattern_span),
11678 span: branch.pattern_span,
11679 });
11680 }
11681
11682 let (tag, binding) = parse_terminal_pattern_parts(&branch.pattern);
11683 let mut branch_scope = binding_types.clone();
11684 if let (Some(tag), Some(binding)) = (&tag, &binding) {
11685 if let Some(schema) =
11686 terminal_payload_schema_for_tag(tag, &branch.scrutinee, effect_payload_types)
11687 {
11688 branch_scope.insert(binding.clone(), schema);
11689 }
11690 }
11691 if let Some(guard) = &branch.guard {
11692 validate_expression(
11693 rule,
11694 guard,
11695 semantic,
11696 &branch_scope,
11697 "case guard",
11698 diagnostics,
11699 );
11700 validate_known_field_paths(rule, guard, semantic, &branch_scope, diagnostics);
11701 }
11702 validate_known_field_paths(rule, &branch.body, semantic, &branch_scope, diagnostics);
11703 metadata.branches.push(IrTerminalCaseBranch {
11704 scrutinee: branch.scrutinee,
11705 tag,
11706 binding,
11707 guard: branch.guard.as_ref().and_then(|guard| {
11708 lower_expression(
11709 guard,
11710 SourceSpan {
11711 start: branch.pattern_span.start,
11712 end: branch.pattern_span.end,
11713 },
11714 )
11715 }),
11716 body_hash: stable_hash(&branch.body),
11717 pattern_span: branch.pattern_span,
11718 });
11719 }
11720
11721 metadata
11722 .outputs
11723 .sort_by(|left, right| left.binding.cmp(&right.binding));
11724 metadata.branches.sort_by(|left, right| {
11725 (left.scrutinee.as_str(), left.pattern_span.start)
11726 .cmp(&(right.scrutinee.as_str(), right.pattern_span.start))
11727 });
11728 metadata
11729}
11730
11731fn terminal_case_branch_sources(rule: &RuleDecl) -> Vec<TerminalBranchSource> {
11732 let lines = rule
11733 .body
11734 .text
11735 .lines()
11736 .scan(0usize, |offset, line| {
11737 let current = *offset;
11738 *offset += line.len() + 1;
11739 Some((line, current))
11740 })
11741 .collect::<Vec<_>>();
11742 let text_lines = lines.iter().map(|(line, _)| *line).collect::<Vec<_>>();
11743 let mut branches = Vec::new();
11744 let mut index = 0usize;
11745 while index < lines.len() {
11746 let (line, line_offset) = lines[index];
11747 let trimmed = line.trim();
11748 let Some(scrutinee) = case_scrutinee(trimmed) else {
11749 index += 1;
11750 continue;
11751 };
11752 if !active_completes_binding_for_case(&text_lines, index, scrutinee) {
11753 index += 1;
11754 continue;
11755 }
11756 let mut depth = brace_delta(trimmed).max(1);
11757 index += 1;
11758 while index < lines.len() && depth > 0 {
11759 let (branch_line, branch_line_offset) = lines[index];
11760 let branch_trimmed = branch_line.trim();
11761 if depth == 1 {
11762 if let Some((pattern, guard, body_start)) = terminal_branch_header(branch_trimmed) {
11763 let pattern_column = case_pattern_column(branch_line, pattern);
11764 let pattern_span = SourceSpan {
11765 start: rule_body_text_start(rule) + branch_line_offset + pattern_column,
11766 end: rule_body_text_start(rule)
11767 + branch_line_offset
11768 + pattern_column
11769 + pattern.len(),
11770 };
11771 let mut body_lines = Vec::new();
11772 let mut branch_depth = brace_delta(body_start).max(1);
11773 index += 1;
11774 while index < lines.len() && branch_depth > 0 {
11775 let body_line = lines[index].0;
11776 let next_depth = branch_depth + brace_delta(body_line);
11777 if next_depth >= 1 {
11778 body_lines.push(body_line.to_owned());
11779 }
11780 branch_depth = next_depth;
11781 index += 1;
11782 }
11783 branches.push(TerminalBranchSource {
11784 scrutinee: scrutinee.to_owned(),
11785 pattern: pattern.to_owned(),
11786 guard,
11787 body: body_lines.join("\n"),
11788 pattern_span,
11789 });
11790 continue;
11791 }
11792 }
11793 depth += brace_delta(branch_trimmed);
11794 index += 1;
11795 }
11796 let _ = line_offset;
11797 }
11798 branches
11799}
11800
11801fn rule_body_text_start(rule: &RuleDecl) -> usize {
11802 rule.body.span.end.saturating_sub(2 + rule.body.text.len())
11803}
11804
11805fn terminal_branch_header(line: &str) -> Option<(&str, Option<String>, &str)> {
11806 let (head, body_start) = line.split_once("=>")?;
11807 let body_start = body_start.trim();
11808 if !body_start.starts_with('{') {
11809 return None;
11810 }
11811 let head = head.trim();
11812 let (pattern, guard) = match head.split_once(" where ") {
11813 Some((pattern, guard)) => (pattern.trim(), Some(guard.trim().to_owned())),
11814 None => (head, None),
11815 };
11816 Some((pattern, guard, body_start))
11817}
11818
11819fn case_pattern_column(line: &str, pattern: &str) -> usize {
11820 line.find(pattern).unwrap_or_else(|| {
11821 let indent = line.len().saturating_sub(line.trim_start().len());
11822 indent + line.trim_start().find(pattern).unwrap_or(0)
11823 })
11824}
11825
11826fn parse_terminal_pattern_parts(pattern: &str) -> (Option<String>, Option<String>) {
11827 if is_fallback_pattern(pattern) {
11828 return (None, None);
11829 }
11830 let mut parts = pattern.split_whitespace();
11831 let tag = parts.next().map(str::to_owned);
11832 let second = parts.next();
11834 let binding = match second {
11835 Some("as") => parts.next().map(str::to_owned),
11836 Some(_) => return (tag, None),
11837 None => None,
11838 };
11839 if parts.next().is_some() {
11840 return (tag, None);
11841 }
11842 (tag, binding)
11843}
11844
11845fn terminal_payload_schema_for_tag(
11846 tag: &str,
11847 scrutinee: &str,
11848 effect_payload_types: &BTreeMap<String, IrType>,
11849) -> Option<String> {
11850 match tag {
11851 "Completed" => match effect_payload_types.get(scrutinee) {
11852 Some(IrType::Ref(schema)) => Some(schema.clone()),
11853 _ => None,
11854 },
11855 "Failed" => Some("TerminalFailed".to_owned()),
11856 "TimedOut" => Some("TerminalTimedOut".to_owned()),
11857 "Cancelled" => Some("TerminalCancelled".to_owned()),
11858 _ => None,
11859 }
11860}
11861
11862fn terminal_alternatives(
11863 completed_payload: IrType,
11864 span: SourceSpan,
11865) -> Vec<IrTerminalAlternative> {
11866 [
11867 ("Completed", completed_payload),
11868 ("Failed", terminal_failure_payload_type()),
11869 ("TimedOut", terminal_timeout_payload_type()),
11870 ("Cancelled", terminal_cancelled_payload_type()),
11871 ]
11872 .into_iter()
11873 .map(|(tag, payload_type)| IrTerminalAlternative {
11874 tag: tag.to_owned(),
11875 payload_type,
11876 source_span: span,
11877 })
11878 .collect()
11879}
11880
11881fn terminal_failure_payload_type() -> IrType {
11882 IrType::Object(vec![
11883 ir_field("reason", IrType::Primitive(IrPrimitiveType::String)),
11884 ir_field("summary", IrType::Primitive(IrPrimitiveType::String)),
11885 ir_field("effect_id", IrType::Primitive(IrPrimitiveType::String)),
11886 ir_field("run_id", IrType::Primitive(IrPrimitiveType::String)),
11887 ])
11888}
11889
11890fn terminal_timeout_payload_type() -> IrType {
11891 IrType::Object(vec![
11892 ir_field("summary", IrType::Primitive(IrPrimitiveType::String)),
11893 ir_field("effect_id", IrType::Primitive(IrPrimitiveType::String)),
11894 ir_field("run_id", IrType::Primitive(IrPrimitiveType::String)),
11895 ])
11896}
11897
11898fn terminal_cancelled_payload_type() -> IrType {
11899 IrType::Object(vec![
11900 ir_field("summary", IrType::Primitive(IrPrimitiveType::String)),
11901 ir_field("effect_id", IrType::Primitive(IrPrimitiveType::String)),
11902 ir_field("run_id", IrType::Primitive(IrPrimitiveType::String)),
11903 ])
11904}
11905
11906fn terminal_unknown_payload_type() -> IrType {
11907 IrType::Object(vec![
11908 ir_field("summary", IrType::Primitive(IrPrimitiveType::String)),
11909 ir_field("effect_id", IrType::Primitive(IrPrimitiveType::String)),
11910 ir_field("run_id", IrType::Primitive(IrPrimitiveType::String)),
11911 ])
11912}
11913
11914fn ir_field(name: &str, ty: IrType) -> IrClassField {
11915 IrClassField {
11916 name: name.to_owned(),
11917 ty,
11918 is_key: false,
11919 presence_condition: None,
11920 span: SourceSpan { start: 0, end: 0 },
11921 }
11922}
11923
11924fn ir_access_grants_for_body(kind: &body::BodyEffectKind) -> Vec<IrAccessGrant> {
11927 match kind {
11928 body::BodyEffectKind::Tell { access_grants, .. }
11929 | body::BodyEffectKind::Invoke { access_grants, .. } => access_grants
11930 .iter()
11931 .map(|grant| IrAccessGrant {
11932 resource: grant.resource.clone(),
11933 operations: grant
11934 .operations
11935 .iter()
11936 .map(|op| IrAccessGrantOp {
11937 operation: op.operation.clone(),
11938 target: op.target.clone(),
11939 globs: op.globs.clone(),
11940 })
11941 .collect(),
11942 })
11943 .collect(),
11944 _ => Vec::new(),
11945 }
11946}
11947
11948fn ir_effect_kind_for_body(kind: &body::BodyEffectKind) -> IrEffectKind {
11949 match kind {
11950 body::BodyEffectKind::Tell { .. } => IrEffectKind::AgentTell,
11951 body::BodyEffectKind::Coerce { .. }
11952 | body::BodyEffectKind::Prompt { .. }
11953 | body::BodyEffectKind::Decide { .. } => IrEffectKind::SchemaCoerce,
11954 body::BodyEffectKind::Call { .. }
11955 | body::BodyEffectKind::ConstructCapabilityCall { .. } => IrEffectKind::CapabilityCall,
11956 body::BodyEffectKind::Invoke { .. } => IrEffectKind::WorkflowInvoke,
11957 body::BodyEffectKind::Timer { .. } => IrEffectKind::TimerWait,
11958 body::BodyEffectKind::Exec { .. } => IrEffectKind::ExecCommand,
11959 body::BodyEffectKind::TrackerFile { .. } => IrEffectKind::TrackerFile,
11960 body::BodyEffectKind::TrackerClaim { .. } => IrEffectKind::TrackerClaim,
11961 body::BodyEffectKind::TrackerRelease { .. } => IrEffectKind::TrackerRelease,
11962 body::BodyEffectKind::TrackerFinish { .. } => IrEffectKind::TrackerFinish,
11963 body::BodyEffectKind::LeaseAcquire { .. } => IrEffectKind::LeaseAcquire,
11964 body::BodyEffectKind::LeaseRenew { .. } => IrEffectKind::LeaseRenew,
11965 body::BodyEffectKind::LedgerAppend { .. } => IrEffectKind::LedgerAppend,
11966 body::BodyEffectKind::CounterConsume { .. } => IrEffectKind::CounterConsume,
11967 body::BodyEffectKind::Notify { .. } => IrEffectKind::SignalEmit,
11968 body::BodyEffectKind::FileRead { .. } => IrEffectKind::FileRead,
11969 body::BodyEffectKind::FileWrite { .. } => IrEffectKind::FileWrite,
11970 body::BodyEffectKind::FileImport { .. } => IrEffectKind::FileImport,
11971 body::BodyEffectKind::FileExport { .. } => IrEffectKind::FileExport,
11972 }
11973}
11974
11975fn agent_for_body(kind: &body::BodyEffectKind) -> Option<String> {
11978 match kind {
11979 body::BodyEffectKind::Tell { target, .. } => Some(target.clone()),
11980 _ => None,
11981 }
11982}
11983
11984fn turn_skills_for_body(kind: &body::BodyEffectKind) -> Vec<String> {
11986 match kind {
11987 body::BodyEffectKind::Tell { skills, .. } => skills.clone(),
11988 _ => Vec::new(),
11989 }
11990}
11991
11992fn workflow_target_for_body(kind: &body::BodyEffectKind) -> Option<String> {
11994 match kind {
11995 body::BodyEffectKind::Invoke { workflow, .. } => Some(workflow.clone()),
11996 _ => None,
11997 }
11998}
11999
12000fn exec_target_for_body(kind: &body::BodyEffectKind) -> Option<IrExecTarget> {
12003 match kind {
12004 body::BodyEffectKind::Exec { target, .. } => Some(match target {
12005 body::ExecTarget::RawCommand(_) => IrExecTarget::Raw,
12006 body::ExecTarget::Capability { name, .. } => {
12007 IrExecTarget::Capability { name: name.clone() }
12008 }
12009 }),
12010 _ => None,
12011 }
12012}
12013
12014fn endorsed_for_body(kind: &body::BodyEffectKind) -> bool {
12017 matches!(kind, body::BodyEffectKind::Coerce { endorsed: true, .. })
12018}
12019
12020fn declassified_for_body(kind: &body::BodyEffectKind) -> bool {
12023 matches!(
12024 kind,
12025 body::BodyEffectKind::Coerce {
12026 declassified: true,
12027 ..
12028 }
12029 )
12030}
12031
12032fn resource_for_body(kind: &body::BodyEffectKind) -> Option<String> {
12035 match kind {
12036 body::BodyEffectKind::FileRead { store, .. }
12037 | body::BodyEffectKind::FileWrite { store, .. }
12038 | body::BodyEffectKind::FileImport { store, .. }
12039 | body::BodyEffectKind::FileExport { store, .. } => Some(store.clone()),
12040 body::BodyEffectKind::ConstructCapabilityCall {
12042 keyword, fields, ..
12043 } if keyword == "send" => fields
12044 .iter()
12045 .find(|field| field.name == "channel")
12046 .map(|field| field.source.clone()),
12047 body::BodyEffectKind::Notify { event, .. } => Some(format!("signal:{event}")),
12051 body::BodyEffectKind::LeaseAcquire { resource, .. } => Some(format!("resource:{resource}")),
12054 body::BodyEffectKind::LedgerAppend { ledger, .. } => Some(format!("resource:{ledger}")),
12055 body::BodyEffectKind::CounterConsume { counter, .. } => Some(format!("resource:{counter}")),
12056 _ => None,
12057 }
12058}
12059
12060fn construct_use_for_body(kind: &body::BodyEffectKind) -> Option<IrConstructUse> {
12061 match kind {
12062 body::BodyEffectKind::ConstructCapabilityCall {
12063 keyword,
12064 target_capability,
12065 ..
12066 } => Some(IrConstructUse {
12067 keyword: keyword.clone(),
12068 scope: "rule_body".to_owned(),
12069 construct_family: "effect_operation".to_owned(),
12070 lowering_target: "capability_call".to_owned(),
12071 target_capability: target_capability.clone(),
12072 }),
12073 _ => None,
12074 }
12075}
12076
12077fn is_ast_only_effect_kind(kind: &body::BodyEffectKind) -> bool {
12078 if let body::BodyEffectKind::ConstructCapabilityCall { keyword, .. } = kind {
12083 return keyword == "send";
12084 }
12085 matches!(
12086 kind,
12087 body::BodyEffectKind::Prompt { .. }
12088 | body::BodyEffectKind::Timer { .. }
12089 | body::BodyEffectKind::Exec { .. }
12090 | body::BodyEffectKind::Decide { .. }
12091 | body::BodyEffectKind::TrackerFile { .. }
12092 | body::BodyEffectKind::TrackerClaim { .. }
12093 | body::BodyEffectKind::TrackerRelease { .. }
12094 | body::BodyEffectKind::TrackerFinish { .. }
12095 | body::BodyEffectKind::LeaseAcquire { .. }
12096 | body::BodyEffectKind::LeaseRenew { .. }
12097 | body::BodyEffectKind::LedgerAppend { .. }
12098 | body::BodyEffectKind::CounterConsume { .. }
12099 | body::BodyEffectKind::Notify { .. }
12100 | body::BodyEffectKind::Invoke { .. }
12103 | body::BodyEffectKind::FileWrite { .. }
12107 | body::BodyEffectKind::FileExport { .. }
12108 )
12109}
12110
12111fn seed_ast_only_effect_bindings(
12115 statements: &[body::BodyStmt],
12116 seen_bindings: &mut BTreeSet<String>,
12117 binding_types: &mut BTreeMap<String, String>,
12118) {
12119 for statement in statements {
12120 match statement {
12121 body::BodyStmt::Effect(effect) if is_ast_only_effect_kind(&effect.kind) => {
12122 if let Some(binding) = &effect.binding {
12123 seen_bindings.insert(binding.clone());
12124 let _ = binding_types;
12125 }
12126 }
12127 body::BodyStmt::After(after) => {
12128 seed_ast_only_effect_bindings(&after.body, seen_bindings, binding_types)
12129 }
12130 body::BodyStmt::Case(case) => {
12131 for branch in &case.branches {
12132 seed_ast_only_effect_bindings(&branch.body, seen_bindings, binding_types);
12133 }
12134 }
12135 _ => {}
12136 }
12137 }
12138}
12139
12140fn collect_terminal_complete_bindings(statements: &[body::BodyStmt], out: &mut Vec<String>) {
12149 for statement in statements {
12150 match statement {
12151 body::BodyStmt::Terminal(terminal) if terminal.kind == body::TerminalKind::Complete => {
12152 out.push(terminal.name.clone());
12153 }
12154 body::BodyStmt::After(after) => collect_terminal_complete_bindings(&after.body, out),
12155 body::BodyStmt::Case(case) => {
12156 for branch in &case.branches {
12157 collect_terminal_complete_bindings(&branch.body, out);
12158 }
12159 }
12160 _ => {}
12161 }
12162 }
12163}
12164
12165fn collect_effects_from_ast(
12166 statements: &[body::BodyStmt],
12167 rule_name: &str,
12168) -> (Vec<IrEffectNode>, Vec<IrEffectDependency>) {
12169 let mut effects = Vec::new();
12170 let mut dependencies = Vec::new();
12171 let mut counter = 0usize;
12172 let mut after_stack: Vec<(String, DependencyPredicate)> = Vec::new();
12173 let mut case_stack: Vec<(String, String)> = Vec::new();
12174 let claim_bindings = collect_claim_bindings(statements);
12180 walk_effects(
12181 statements,
12182 rule_name,
12183 &claim_bindings,
12184 &mut counter,
12185 &mut after_stack,
12186 &mut case_stack,
12187 &mut effects,
12188 &mut dependencies,
12189 );
12190 (effects, dependencies)
12191}
12192
12193fn collect_claim_bindings(statements: &[body::BodyStmt]) -> BTreeSet<String> {
12197 let mut bindings = BTreeSet::new();
12198 for_each_body(statements, &mut |stmt| {
12199 if let body::BodyStmt::Effect(effect) = stmt {
12200 if matches!(effect.kind, body::BodyEffectKind::TrackerClaim { .. }) {
12201 if let Some(binding) = &effect.binding {
12202 bindings.insert(binding.clone());
12203 }
12204 }
12205 }
12206 });
12207 bindings
12208}
12209
12210#[allow(clippy::too_many_arguments)]
12211fn walk_effects(
12212 statements: &[body::BodyStmt],
12213 rule_name: &str,
12214 claim_bindings: &BTreeSet<String>,
12215 counter: &mut usize,
12216 after_stack: &mut Vec<(String, DependencyPredicate)>,
12217 case_stack: &mut Vec<(String, String)>,
12218 effects: &mut Vec<IrEffectNode>,
12219 dependencies: &mut Vec<IrEffectDependency>,
12220) {
12221 for statement in statements {
12222 match statement {
12223 body::BodyStmt::Effect(effect) => {
12224 *counter += 1;
12225 let id = effect
12226 .binding
12227 .clone()
12228 .unwrap_or_else(|| format!("effect{counter}"));
12229 let kind = match &effect.kind {
12232 body::BodyEffectKind::LeaseRenew {
12233 acquire_binding, ..
12234 } if claim_bindings.contains(acquire_binding) => IrEffectKind::TrackerRenew,
12235 other => ir_effect_kind_for_body(other),
12236 };
12237 for (upstream, predicate) in after_stack.iter() {
12238 dependencies.push(IrEffectDependency {
12239 upstream: upstream.clone(),
12240 predicate: predicate.clone(),
12241 downstream: id.clone(),
12242 });
12243 }
12244 let idempotency_key =
12245 effect_idempotency_key(rule_name, &id, &kind, &effect.binding);
12246 let mut required_capabilities = effect.requires.clone();
12247 match &effect.kind {
12248 body::BodyEffectKind::Call { capability, .. } => {
12249 required_capabilities.push(capability.clone());
12250 }
12251 body::BodyEffectKind::ConstructCapabilityCall {
12252 target_capability, ..
12253 } => {
12254 required_capabilities.push(target_capability.clone());
12255 }
12256 _ => {}
12257 }
12258 required_capabilities.sort();
12259 required_capabilities.dedup();
12260 let construct_use = construct_use_for_body(&effect.kind);
12261 let access_grants = ir_access_grants_for_body(&effect.kind);
12262 let turn_skills = turn_skills_for_body(&effect.kind);
12263 let resource = resource_for_body(&effect.kind);
12264 let agent = agent_for_body(&effect.kind);
12265 let workflow_target = workflow_target_for_body(&effect.kind);
12266 let endorsed = endorsed_for_body(&effect.kind);
12267 let declassified = declassified_for_body(&effect.kind);
12268 let exec_target = exec_target_for_body(&effect.kind);
12269 effects.push(IrEffectNode {
12270 id,
12271 kind,
12272 binding: effect.binding.clone(),
12273 required_capabilities,
12274 construct_use,
12275 idempotency_key,
12276 span: effect.span,
12277 timeout_seconds: effect.timeout_seconds,
12278 access_grants,
12279 turn_skills,
12280 resource,
12281 agent,
12282 workflow_target,
12283 endorsed,
12284 declassified,
12285 selected_by: case_stack.last().cloned(),
12286 exec_target,
12287 });
12288 }
12289 body::BodyStmt::After(after) => {
12290 let predicate = match after.predicate {
12291 body::AfterPredicate::Succeeds => DependencyPredicate::Succeeds,
12292 body::AfterPredicate::Fails => DependencyPredicate::Fails,
12293 body::AfterPredicate::TimedOut => DependencyPredicate::TimedOut,
12298 body::AfterPredicate::Cancelled => DependencyPredicate::Cancelled,
12299 body::AfterPredicate::Completes
12303 | body::AfterPredicate::Held
12304 | body::AfterPredicate::Contended
12305 | body::AfterPredicate::Ok
12306 | body::AfterPredicate::Over => DependencyPredicate::Completes,
12307 body::AfterPredicate::Reaches => DependencyPredicate::Completes,
12314 };
12315 after_stack.push((after.binding.clone(), predicate));
12316 walk_effects(
12317 &after.body,
12318 rule_name,
12319 claim_bindings,
12320 counter,
12321 after_stack,
12322 case_stack,
12323 effects,
12324 dependencies,
12325 );
12326 after_stack.pop();
12327 }
12328 body::BodyStmt::Case(case) => {
12329 for branch in &case.branches {
12330 case_stack.push((case.scrutinee.clone(), branch.pattern.clone()));
12333 walk_effects(
12334 &branch.body,
12335 rule_name,
12336 claim_bindings,
12337 counter,
12338 after_stack,
12339 case_stack,
12340 effects,
12341 dependencies,
12342 );
12343 case_stack.pop();
12344 }
12345 }
12346 _ => {}
12347 }
12348 }
12349}
12350
12351fn effect_idempotency_key(
12352 rule_name: &str,
12353 effect_id: &str,
12354 kind: &IrEffectKind,
12355 binding: &Option<String>,
12356) -> String {
12357 stable_hash(&format!(
12358 "rule={rule_name};effect={effect_id};kind={};binding={}",
12359 kind.as_str(),
12360 binding.as_deref().unwrap_or("-")
12361 ))
12362}
12363
12364fn validate_coerce_call(
12365 rule: &RuleDecl,
12366 line: &str,
12367 semantic: &SemanticContext,
12368 binding_types: &BTreeMap<String, String>,
12369 known_roots: &BTreeSet<String>,
12370 diagnostics: &mut Vec<Diagnostic>,
12371) {
12372 let Some((function_name, args)) = parse_coerce_call(line) else {
12373 diagnostics.push(Diagnostic {
12374 related: Vec::new(),
12375 span: rule.body.span,
12376 message: format!("rule `{}` has malformed coerce call", rule.name.name),
12377 suggestion: Some("write `coerce functionName(arg, ...) as name`".to_owned()),
12378 });
12379 return;
12380 };
12381 let Some(params) = semantic.coerce_params.get(function_name) else {
12382 diagnostics.push(Diagnostic {
12383 related: Vec::new(),
12384 span: rule.body.span,
12385 message: format!(
12386 "rule `{}` calls unknown coerce function `{function_name}`",
12387 rule.name.name
12388 ),
12389 suggestion: Some(format!(
12390 "declare `coerce {function_name}(...) -> Output {{ ... }}` before using it"
12391 )),
12392 });
12393 return;
12394 };
12395 if args.len() != params.len() {
12396 diagnostics.push(Diagnostic {
12397 related: Vec::new(),
12398 span: rule.body.span,
12399 message: format!(
12400 "rule `{}` calls coerce `{function_name}` with {} argument(s), expected {}",
12401 rule.name.name,
12402 args.len(),
12403 params.len()
12404 ),
12405 suggestion: Some("pass one argument for each declared coerce parameter".to_owned()),
12406 });
12407 return;
12408 }
12409 let scope = ExprScope::from_bindings(binding_types);
12410 for (arg, param) in args.iter().zip(params) {
12411 if let Some(root) = dangling_value_root(arg, known_roots) {
12415 diagnostics.push(Diagnostic { related: Vec::new(),
12416 span: rule.body.span,
12417 message: format!(
12418 "rule `{}` has unknown binding `{root}` in coerce `{function_name}` argument",
12419 rule.name.name
12420 ),
12421 suggestion: Some(
12422 "reference a binding from a `when ... as name` clause, an effect `as` binding, or a `case` pattern"
12423 .to_owned(),
12424 ),
12425 });
12426 }
12427 validate_expr_source_against_type(
12428 rule,
12429 &format!("coerce `{function_name}`"),
12430 ¶m.name.name,
12431 ¶m.ty,
12432 arg,
12433 semantic,
12434 &scope,
12435 diagnostics,
12436 );
12437 }
12438}
12439
12440fn validate_effect_payloads(
12441 rule: &RuleDecl,
12442 semantic: &SemanticContext,
12443 binding_types: &BTreeMap<String, String>,
12444 known_roots: &BTreeSet<String>,
12445 diagnostics: &mut Vec<Diagnostic>,
12446) {
12447 for statement in effect_payload_statements(&rule.body.text) {
12448 let trimmed = statement.trim();
12449 if trimmed.starts_with("coerce ") {
12450 validate_coerce_call(
12451 rule,
12452 trimmed,
12453 semantic,
12454 binding_types,
12455 known_roots,
12456 diagnostics,
12457 );
12458 }
12459 }
12460}
12461
12462fn validate_workflow_invocations(
12463 rule: &RuleDecl,
12464 semantic: &SemanticContext,
12465 binding_types: &BTreeMap<String, String>,
12466 known_roots: &BTreeSet<String>,
12467 diagnostics: &mut Vec<Diagnostic>,
12468) {
12469 for statement in workflow_invoke_statements(&rule.body.text) {
12470 let Some((target, body)) = invoke_statement_parts(&statement) else {
12471 diagnostics.push(Diagnostic {
12472 related: Vec::new(),
12473 span: rule.body.span,
12474 message: format!(
12475 "rule `{}` has malformed workflow invocation",
12476 rule.name.name
12477 ),
12478 suggestion: Some("write `invoke Workflow { input value } as binding`".to_owned()),
12479 });
12480 continue;
12481 };
12482 if semantic.workflow.as_deref() == Some(target) {
12483 diagnostics.push(Diagnostic {
12484 related: Vec::new(),
12485 span: rule.body.span,
12486 message: format!(
12487 "rule `{}` recursively invokes workflow `{target}`",
12488 rule.name.name
12489 ),
12490 suggestion: Some(
12491 "split recursive orchestration into an explicit bounded scheduler workflow"
12492 .to_owned(),
12493 ),
12494 });
12495 continue;
12496 }
12497 let Some(surface) = semantic.workflow_inputs.get(target) else {
12498 diagnostics.push(Diagnostic {
12499 related: Vec::new(),
12500 span: rule.body.span,
12501 message: format!(
12502 "rule `{}` invokes unknown workflow `{target}`",
12503 rule.name.name
12504 ),
12505 suggestion: Some("invoke a workflow declared in this source bundle".to_owned()),
12506 });
12507 continue;
12508 };
12509
12510 let mut invocation_semantic = semantic.clone();
12511 invocation_semantic.schemas.merge(surface.schemas.clone());
12512 let assignments = collect_field_assignments(body);
12513 let mut seen = BTreeSet::new();
12514 for assignment in assignments {
12515 let (field, value) = match assignment {
12516 RecordFieldAssignment::Value { field, value } => (field, value),
12517 RecordFieldAssignment::Shorthand { field } => (field.clone(), field),
12518 };
12519 if !seen.insert(field.clone()) {
12520 diagnostics.push(Diagnostic {
12521 related: Vec::new(),
12522 span: rule.body.span,
12523 message: format!("workflow invocation `{target}` repeats input `{field}`"),
12524 suggestion: Some("remove the duplicate invocation input".to_owned()),
12525 });
12526 continue;
12527 }
12528 let Some(input_ty) = surface.inputs.get(&field) else {
12529 let known = surface
12530 .inputs
12531 .keys()
12532 .map(|input| format!("`{input}`"))
12533 .collect::<Vec<_>>()
12534 .join(", ");
12535 diagnostics.push(Diagnostic {
12536 related: Vec::new(),
12537 span: rule.body.span,
12538 message: format!("workflow `{target}` has no input `{field}`"),
12539 suggestion: Some(if known.is_empty() {
12540 "remove the invocation payload; the target declares no inputs".to_owned()
12541 } else {
12542 format!("pass one of: {known}")
12543 }),
12544 });
12545 continue;
12546 };
12547 if let Some(root) = dangling_value_root(&value, known_roots) {
12548 diagnostics.push(Diagnostic { related: Vec::new(),
12549 span: rule.body.span,
12550 message: format!(
12551 "rule `{}` has unknown binding `{root}` in `invoke {target}` input `{field}`",
12552 rule.name.name
12553 ),
12554 suggestion: Some(
12555 "reference a binding from a `when ... as name` clause, an effect `as` binding, or a `case` pattern"
12556 .to_owned(),
12557 ),
12558 });
12559 }
12560 validate_expr_source_against_type(
12561 rule,
12562 target,
12563 &field,
12564 input_ty,
12565 &value,
12566 &invocation_semantic,
12567 &ExprScope::from_bindings(binding_types),
12568 diagnostics,
12569 );
12570 }
12571 for input in surface.inputs.keys() {
12572 if seen.contains(input) {
12573 continue;
12574 }
12575 diagnostics.push(Diagnostic {
12576 related: Vec::new(),
12577 span: rule.body.span,
12578 message: format!("workflow invocation `{target}` is missing input `{input}`"),
12579 suggestion: Some(format!(
12580 "add `{input}` to the `{target}` invocation payload"
12581 )),
12582 });
12583 }
12584 }
12585}
12586
12587fn validate_agent_tell_target(
12588 rule: &RuleDecl,
12589 line: &str,
12590 kind: &IrEffectKind,
12591 semantic: &SemanticContext,
12592 binding_types: &BTreeMap<String, String>,
12593 known_roots: &BTreeSet<String>,
12594 diagnostics: &mut Vec<Diagnostic>,
12595) {
12596 if kind != &IrEffectKind::AgentTell {
12597 return;
12598 }
12599 let Some(target) = parse_tell_target(line) else {
12600 diagnostics.push(Diagnostic {
12601 related: Vec::new(),
12602 span: rule.body.span,
12603 message: format!("rule `{}` has malformed tell target", rule.name.name),
12604 suggestion: Some("write `tell agentName ...` or `tell task.agentRef ...`".to_owned()),
12605 });
12606 return;
12607 };
12608 if target.starts_with('"') {
12609 diagnostics.push(Diagnostic {
12610 related: Vec::new(),
12611 span: rule.body.span,
12612 message: format!(
12613 "rule `{}` uses a string literal as a tell target",
12614 rule.name.name
12615 ),
12616 suggestion: Some("use a declared agent name or an AgentRef field".to_owned()),
12617 });
12618 return;
12619 }
12620 let required_capabilities = parse_required_capabilities(line);
12621 if target.contains('.') {
12622 let Some(ty) = expression_type(target, semantic, binding_types) else {
12623 if let Some(root) = dangling_value_root(target, known_roots) {
12627 diagnostics.push(Diagnostic { related: Vec::new(),
12628 span: rule.body.span,
12629 message: format!(
12630 "rule `{}` has unknown binding `{root}` in tell target `{target}`",
12631 rule.name.name
12632 ),
12633 suggestion: Some(
12634 "reference a binding from a `when ... as name` clause or an effect `as` binding"
12635 .to_owned(),
12636 ),
12637 });
12638 }
12639 return;
12640 };
12641 if let TypeSyntax::AgentRef { agents, .. } = ty {
12642 for agent in agents {
12643 validate_agent_capabilities(
12644 rule,
12645 &agent.name,
12646 &required_capabilities,
12647 semantic,
12648 diagnostics,
12649 );
12650 }
12651 } else {
12652 diagnostics.push(Diagnostic {
12653 related: Vec::new(),
12654 span: rule.body.span,
12655 message: format!(
12656 "rule `{}` uses non-AgentRef dynamic tell target `{target}`",
12657 rule.name.name
12658 ),
12659 suggestion: Some(
12660 "declare the field as `AgentRef<...>` before using it as a tell target"
12661 .to_owned(),
12662 ),
12663 });
12664 }
12665 return;
12666 }
12667 if !semantic.agents.contains(target) {
12668 diagnostics.push(Diagnostic {
12669 related: Vec::new(),
12670 span: rule.body.span,
12671 message: format!("rule `{}` tells unknown agent `{target}`", rule.name.name),
12672 suggestion: Some("declare the target agent before telling it".to_owned()),
12673 });
12674 return;
12675 }
12676 validate_agent_capabilities(rule, target, &required_capabilities, semantic, diagnostics);
12677}
12678
12679fn validate_agent_capabilities(
12680 rule: &RuleDecl,
12681 agent: &str,
12682 required_capabilities: &[String],
12683 semantic: &SemanticContext,
12684 diagnostics: &mut Vec<Diagnostic>,
12685) {
12686 if required_capabilities.is_empty() {
12687 return;
12688 }
12689 let declared = semantic
12690 .agent_capabilities
12691 .get(agent)
12692 .cloned()
12693 .unwrap_or_default();
12694 for capability in required_capabilities {
12695 if !declared.contains(capability) {
12696 diagnostics.push(Diagnostic { related: Vec::new(),
12697 span: rule.body.span,
12698 message: format!(
12699 "rule `{}` tells agent `{agent}` requiring undeclared capability `{capability}`",
12700 rule.name.name
12701 ),
12702 suggestion: Some(format!(
12703 "add `{capability}` to agent `{agent}` capabilities or choose another AgentRef target"
12704 )),
12705 });
12706 }
12707 }
12708}
12709
12710fn validate_availability_when(
12711 rule: &RuleDecl,
12712 when: &str,
12713 semantic: &SemanticContext,
12714 binding_types: &BTreeMap<String, String>,
12715 diagnostics: &mut Vec<Diagnostic>,
12716) {
12717 let (pattern, _) = split_when_guard(when);
12718 let Some(target) = pattern.strip_suffix(" is available").map(str::trim) else {
12719 return;
12720 };
12721 if target.contains('.') {
12722 let Some(ty) = expression_type(target, semantic, binding_types) else {
12723 return;
12724 };
12725 if !matches!(ty, TypeSyntax::AgentRef { .. }) {
12726 diagnostics.push(Diagnostic {
12727 related: Vec::new(),
12728 span: rule.body.span,
12729 message: format!(
12730 "rule `{}` checks availability for non-AgentRef `{target}`",
12731 rule.name.name
12732 ),
12733 suggestion: Some(
12734 "availability checks must name a declared agent or an AgentRef field"
12735 .to_owned(),
12736 ),
12737 });
12738 }
12739 return;
12740 }
12741 if !semantic.agents.contains(target) {
12742 diagnostics.push(Diagnostic {
12743 related: Vec::new(),
12744 span: rule.body.span,
12745 message: format!("rule `{}` checks unknown agent `{target}`", rule.name.name),
12746 suggestion: Some("declare the target agent before checking availability".to_owned()),
12747 });
12748 }
12749}
12750
12751#[derive(Clone, Debug, Default)]
12752struct ExprScope {
12753 binding_types: BTreeMap<String, String>,
12754 implicit_schema: Option<String>,
12755}
12756
12757impl ExprScope {
12758 fn from_bindings(binding_types: &BTreeMap<String, String>) -> Self {
12759 Self {
12760 binding_types: binding_types.clone(),
12761 implicit_schema: None,
12762 }
12763 }
12764
12765 fn with_implicit_schema(&self, schema: String) -> Self {
12766 let mut scope = self.clone();
12767 scope.implicit_schema = Some(schema);
12768 scope
12769 }
12770}
12771
12772#[derive(Clone, Debug)]
12773struct ExprValidationContext {
12774 subject: String,
12775 span: SourceSpan,
12776}
12777
12778impl ExprValidationContext {
12779 fn rule(rule: &RuleDecl) -> Self {
12780 Self {
12781 subject: format!("rule `{}`", rule.name.name),
12782 span: rule.body.span,
12783 }
12784 }
12785
12786 fn assertion(span: SourceSpan) -> Self {
12787 Self {
12788 subject: "assertion".to_owned(),
12789 span,
12790 }
12791 }
12792}
12793
12794fn validate_expression(
12795 rule: &RuleDecl,
12796 expr: &str,
12797 semantic: &SemanticContext,
12798 binding_types: &BTreeMap<String, String>,
12799 label: &str,
12800 diagnostics: &mut Vec<Diagnostic>,
12801) {
12802 match parse_expression(expr) {
12803 Ok(expr) => {
12804 validate_parsed_expression(
12805 &expr,
12806 semantic,
12807 &ExprScope::from_bindings(binding_types),
12808 &ExprValidationContext::rule(rule),
12809 label,
12810 diagnostics,
12811 );
12812 }
12813 Err(message) => diagnostics.push(Diagnostic { related: Vec::new(),
12814 span: rule.body.span,
12815 message: format!("rule `{}` has invalid {label} expression: {message}", rule.name.name),
12816 suggestion: Some("use deterministic field paths, literals, boolean operators, comparisons, membership, count, or exists".to_owned()),
12817 }),
12818 }
12819}
12820
12821fn validate_parsed_expression(
12822 expr: &Expr,
12823 semantic: &SemanticContext,
12824 scope: &ExprScope,
12825 context: &ExprValidationContext,
12826 label: &str,
12827 diagnostics: &mut Vec<Diagnostic>,
12828) {
12829 let presence_proofs = BTreeSet::new();
12830 validate_expr_node(
12831 expr,
12832 semantic,
12833 scope,
12834 context,
12835 &presence_proofs,
12836 diagnostics,
12837 );
12838 let ty = infer_expr_type(expr, semantic, scope, context, diagnostics);
12839 if ty != ExprType::Bool && ty != ExprType::Unknown {
12840 diagnostics.push(Diagnostic {
12841 related: Vec::new(),
12842 span: context.span,
12843 message: format!("{} has non-boolean {label} expression", context.subject),
12844 suggestion: Some(format!("{label} expressions must evaluate to bool")),
12845 });
12846 }
12847}
12848
12849fn validate_expr_node(
12850 expr: &Expr,
12851 semantic: &SemanticContext,
12852 scope: &ExprScope,
12853 context: &ExprValidationContext,
12854 presence_proofs: &BTreeSet<String>,
12855 diagnostics: &mut Vec<Diagnostic>,
12856) {
12857 match expr {
12858 Expr::Path(path) => {
12859 if path.len() < 2 {
12860 return;
12861 }
12862 let root = &path[0];
12863 let Some(schema) = scope.binding_types.get(root) else {
12864 if let Some(schema) = &scope.implicit_schema {
12865 if let Err(message) =
12866 validate_optional_path_access(schema, path, semantic, presence_proofs)
12867 {
12868 diagnostics.push(Diagnostic {
12869 related: Vec::new(),
12870 span: context.span,
12871 message: format!(
12872 "{} has unsafe optional path `{}`: {message}",
12873 context.subject,
12874 path.join(".")
12875 ),
12876 suggestion: Some(
12877 "prove the optional value is present before reading through it"
12878 .to_owned(),
12879 ),
12880 });
12881 return;
12882 }
12883 if let Err(message) = semantic.schemas.resolve_field_path(schema, path) {
12884 diagnostics.push(Diagnostic {
12885 related: Vec::new(),
12886 span: context.span,
12887 message: format!(
12888 "{} has invalid expression path `{}`: {message}",
12889 context.subject,
12890 path.join(".")
12891 ),
12892 suggestion: Some(
12893 "use a field declared on the queried schema".to_owned(),
12894 ),
12895 });
12896 }
12897 return;
12898 }
12899 diagnostics.push(Diagnostic {
12900 related: Vec::new(),
12901 span: context.span,
12902 message: format!("{} has unknown expression root `{root}`", context.subject),
12903 suggestion: Some(
12904 "use a binding introduced by a `when ... as name` clause".to_owned(),
12905 ),
12906 });
12907 return;
12908 };
12909 if let Err(message) =
12910 validate_optional_path_access(schema, &path[1..], semantic, presence_proofs)
12911 {
12912 diagnostics.push(Diagnostic {
12913 related: Vec::new(),
12914 span: context.span,
12915 message: format!(
12916 "{} has unsafe optional path `{}`: {message}",
12917 context.subject,
12918 path.join(".")
12919 ),
12920 suggestion: Some(
12921 "prove the optional value is present before reading through it".to_owned(),
12922 ),
12923 });
12924 return;
12925 }
12926 if let Err(message) = semantic.schemas.resolve_field_path(schema, &path[1..]) {
12927 diagnostics.push(Diagnostic {
12928 related: Vec::new(),
12929 span: context.span,
12930 message: format!(
12931 "{} has invalid expression path `{}`: {message}",
12932 context.subject,
12933 path.join(".")
12934 ),
12935 suggestion: Some("use a field declared on the bound schema".to_owned()),
12936 });
12937 }
12938 }
12939 Expr::Index { target, key } => {
12940 validate_expr_node(
12941 target,
12942 semantic,
12943 scope,
12944 context,
12945 presence_proofs,
12946 diagnostics,
12947 );
12948 validate_expr_node(key, semantic, scope, context, presence_proofs, diagnostics);
12949 let key_ty = infer_expr_type(key, semantic, scope, context, diagnostics);
12950 if !matches!(key_ty, ExprType::String | ExprType::Unknown) {
12951 diagnostics.push(Diagnostic {
12952 related: Vec::new(),
12953 span: context.span,
12954 message: format!("{} indexes a map with a non-string key", context.subject),
12955 suggestion: Some(
12956 "use a string literal or string expression as the map key".to_owned(),
12957 ),
12958 });
12959 }
12960 }
12961 Expr::Array(items) => {
12962 for item in items {
12963 validate_expr_node(item, semantic, scope, context, presence_proofs, diagnostics);
12964 }
12965 }
12966 Expr::Object(fields) => {
12967 diagnostics.push(Diagnostic {
12968 related: Vec::new(),
12969 span: context.span,
12970 message: format!(
12971 "{} uses an object literal without an expected object or map type",
12972 context.subject
12973 ),
12974 suggestion: Some(
12975 "use object literals only in typed record fields or typed effect arguments"
12976 .to_owned(),
12977 ),
12978 });
12979 for field in fields {
12980 validate_expr_node(
12981 &field.value,
12982 semantic,
12983 scope,
12984 context,
12985 presence_proofs,
12986 diagnostics,
12987 );
12988 }
12989 }
12990 Expr::Unary { expr, .. } => {
12991 validate_expr_node(expr, semantic, scope, context, presence_proofs, diagnostics)
12992 }
12993 Expr::Binary {
12994 op: BinaryOp::And,
12995 left,
12996 right,
12997 } => {
12998 validate_expr_node(left, semantic, scope, context, presence_proofs, diagnostics);
12999 let mut right_proofs = presence_proofs.clone();
13000 collect_presence_proofs(left, &mut right_proofs);
13001 validate_expr_node(right, semantic, scope, context, &right_proofs, diagnostics);
13002 }
13003 Expr::Binary { op, left, right } => {
13004 validate_expr_node(left, semantic, scope, context, presence_proofs, diagnostics);
13005 validate_expr_node(
13006 right,
13007 semantic,
13008 scope,
13009 context,
13010 presence_proofs,
13011 diagnostics,
13012 );
13013 validate_unknown_implicit_idents(
13014 *op,
13015 left,
13016 right,
13017 semantic,
13018 scope,
13019 context,
13020 diagnostics,
13021 );
13022 validate_finite_domain_expr(*op, left, right, semantic, scope, context, diagnostics);
13023 }
13024 Expr::Call { name, args } => {
13025 validate_function_call(name, args, semantic, scope, context, diagnostics);
13026 for arg in args {
13027 validate_expr_node(arg, semantic, scope, context, presence_proofs, diagnostics);
13028 }
13029 }
13030 Expr::Query { guard, .. } => {
13031 validate_query_expr(expr, semantic, scope, context, diagnostics);
13032 if let Some(guard) = guard {
13033 let guard_scope = query_guard_scope(expr, semantic, scope);
13034 validate_expr_node(
13035 guard,
13036 semantic,
13037 &guard_scope,
13038 context,
13039 presence_proofs,
13040 diagnostics,
13041 );
13042 }
13043 }
13044 Expr::Literal(_) => {}
13045 }
13046}
13047
13048fn validate_unknown_implicit_idents(
13049 op: BinaryOp,
13050 left: &Expr,
13051 right: &Expr,
13052 semantic: &SemanticContext,
13053 scope: &ExprScope,
13054 context: &ExprValidationContext,
13055 diagnostics: &mut Vec<Diagnostic>,
13056) {
13057 if !matches!(
13058 op,
13059 BinaryOp::Eq | BinaryOp::Ne | BinaryOp::Lt | BinaryOp::Le | BinaryOp::Gt | BinaryOp::Ge
13060 ) {
13061 return;
13062 }
13063 validate_unknown_implicit_ident(left, right, semantic, scope, context, diagnostics);
13064 validate_unknown_implicit_ident(right, left, semantic, scope, context, diagnostics);
13065}
13066
13067fn validate_unknown_implicit_ident(
13068 expr: &Expr,
13069 other: &Expr,
13070 semantic: &SemanticContext,
13071 scope: &ExprScope,
13072 context: &ExprValidationContext,
13073 diagnostics: &mut Vec<Diagnostic>,
13074) {
13075 let Expr::Literal(ExprLiteral::Ident(name)) = expr else {
13076 return;
13077 };
13078 let Some(schema) = &scope.implicit_schema else {
13079 return;
13080 };
13081 let field_exists = semantic
13082 .schemas
13083 .classes
13084 .get(schema)
13085 .is_some_and(|fields| fields.contains_key(name));
13086 if field_exists
13087 || expr_domain(other, semantic, scope).is_some()
13088 || implicit_ident_field_exists(other, semantic, scope)
13089 {
13090 return;
13091 }
13092 diagnostics.push(Diagnostic {
13093 related: Vec::new(),
13094 span: context.span,
13095 message: format!(
13096 "{} fact query `{schema}` has unknown field `{name}`",
13097 context.subject
13098 ),
13099 suggestion: Some(format!(
13100 "use a field declared on `{schema}` inside the query `where` expression"
13101 )),
13102 });
13103}
13104
13105fn implicit_ident_field_exists(expr: &Expr, semantic: &SemanticContext, scope: &ExprScope) -> bool {
13106 let Expr::Literal(ExprLiteral::Ident(name)) = expr else {
13107 return false;
13108 };
13109 let Some(schema) = &scope.implicit_schema else {
13110 return false;
13111 };
13112 semantic
13113 .schemas
13114 .classes
13115 .get(schema)
13116 .is_some_and(|fields| fields.contains_key(name))
13117}
13118
13119fn validate_function_call(
13120 name: &str,
13121 args: &[Expr],
13122 semantic: &SemanticContext,
13123 scope: &ExprScope,
13124 context: &ExprValidationContext,
13125 diagnostics: &mut Vec<Diagnostic>,
13126) {
13127 match name {
13128 "count" => {
13129 if args.len() != 1 {
13130 diagnostics.push(Diagnostic { related: Vec::new(),
13131 span: context.span,
13132 message: format!(
13133 "{} calls `count` with {} arguments, expected 1",
13134 context.subject,
13135 args.len()
13136 ),
13137 suggestion: Some(
13138 "call `count` with exactly one array, map, fact query, or effect query argument"
13139 .to_owned(),
13140 ),
13141 });
13142 return;
13143 }
13144 let ty = infer_expr_type(&args[0], semantic, scope, context, diagnostics);
13145 if !is_countable_type(&ty) {
13146 diagnostics.push(Diagnostic {
13147 related: Vec::new(),
13148 span: context.span,
13149 message: format!(
13150 "{} calls `count` with unsupported argument type `{}`",
13151 context.subject,
13152 expr_type_label(&ty)
13153 ),
13154 suggestion: Some(
13155 "use `count` only with arrays, maps, fact queries, or effect queries"
13156 .to_owned(),
13157 ),
13158 });
13159 }
13160 }
13161 "exists" => {
13162 if args.len() != 1 {
13163 diagnostics.push(Diagnostic {
13164 related: Vec::new(),
13165 span: context.span,
13166 message: format!(
13167 "{} calls `exists` with {} arguments, expected 1",
13168 context.subject,
13169 args.len()
13170 ),
13171 suggestion: Some("call `exists` with exactly one argument".to_owned()),
13172 });
13173 return;
13174 }
13175 let ty = infer_expr_type(&args[0], semantic, scope, context, diagnostics);
13176 if !matches!(args[0], Expr::Index { .. }) && !is_exists_type(&ty) {
13177 diagnostics.push(Diagnostic { related: Vec::new(),
13178 span: context.span,
13179 message: format!(
13180 "{} calls `exists` with unsupported argument type `{}`",
13181 context.subject,
13182 expr_type_label(&ty)
13183 ),
13184 suggestion: Some(
13185 "use `exists path` for optional/map presence checks or pass an array, map, fact query, or effect query"
13186 .to_owned(),
13187 ),
13188 });
13189 }
13190 }
13191 "empty" => {
13192 if args.len() != 1 {
13193 diagnostics.push(Diagnostic {
13194 related: Vec::new(),
13195 span: context.span,
13196 message: format!(
13197 "{} calls `empty` with {} arguments, expected 1",
13198 context.subject,
13199 args.len()
13200 ),
13201 suggestion: Some(
13202 "call `empty` with exactly one array, map, string, fact query, or effect query argument"
13203 .to_owned(),
13204 ),
13205 });
13206 return;
13207 }
13208 let ty = infer_expr_type(&args[0], semantic, scope, context, diagnostics);
13209 if !is_emptiable_type(&ty) {
13210 let optional = matches!(ty, ExprType::Optional(_));
13214 diagnostics.push(Diagnostic {
13215 related: Vec::new(),
13216 span: context.span,
13217 message: format!(
13218 "{} calls `empty` with unsupported {}argument type `{}`",
13219 context.subject,
13220 if optional { "optional " } else { "" },
13221 expr_type_label(&ty)
13222 ),
13223 suggestion: Some(
13224 "use `empty` only with arrays, maps, strings, fact queries, effect queries, null, or supported optional values"
13225 .to_owned(),
13226 ),
13227 });
13228 }
13229 }
13230 _ => {}
13231 }
13232}
13233
13234fn validate_query_expr(
13235 expr: &Expr,
13236 semantic: &SemanticContext,
13237 scope: &ExprScope,
13238 context: &ExprValidationContext,
13239 diagnostics: &mut Vec<Diagnostic>,
13240) {
13241 let Expr::Query { kind, head, guard } = expr else {
13242 return;
13243 };
13244 if *kind == QueryKind::Fact {
13245 let Some(schema) = query_head_schema(head, semantic) else {
13246 diagnostics.push(Diagnostic {
13247 related: Vec::new(),
13248 span: context.span,
13249 message: format!(
13250 "{} queries unknown fact schema `{}`",
13251 context.subject,
13252 head.trim()
13253 ),
13254 suggestion: Some("use a declared class name in fact queries".to_owned()),
13255 });
13256 return;
13257 };
13258 if let Some(guard) = guard {
13259 let guard_scope = scope.with_implicit_schema(schema);
13260 let ty = infer_expr_type(guard, semantic, &guard_scope, context, diagnostics);
13261 if !matches!(ty, ExprType::Bool | ExprType::Unknown) {
13262 diagnostics.push(Diagnostic {
13263 related: Vec::new(),
13264 span: context.span,
13265 message: format!(
13266 "{} fact query `{}` has non-boolean `where` expression",
13267 context.subject,
13268 head.trim()
13269 ),
13270 suggestion: Some("query `where` expressions must evaluate to bool".to_owned()),
13271 });
13272 }
13273 }
13274 }
13275}
13276
13277fn validate_optional_path_access(
13278 root_schema: &str,
13279 path: &[String],
13280 semantic: &SemanticContext,
13281 presence_proofs: &BTreeSet<String>,
13282) -> Result<(), String> {
13283 let mut schema = root_schema.to_owned();
13284 let mut prefix = Vec::new();
13285 for (index, field) in path.iter().enumerate() {
13286 let Some(fields) = semantic.schemas.classes.get(&schema) else {
13287 return Ok(());
13288 };
13289 let Some(field_ty) = fields.get(field) else {
13290 return Ok(());
13291 };
13292 prefix.push(field.clone());
13293 if let TypeSyntax::Optional { inner, .. } = field_ty {
13294 if index + 1 < path.len() && !presence_proofs.contains(&prefix.join(".")) {
13295 return Err(format!(
13296 "`{}` must be proven present before accessing `{}`",
13297 prefix.join("."),
13298 path[index + 1..].join(".")
13299 ));
13300 }
13301 if let Some(next_schema) = schema_name_for_path(inner) {
13302 schema = next_schema;
13303 }
13304 continue;
13305 }
13306 if let Some(next_schema) = schema_name_for_path(field_ty) {
13307 schema = next_schema;
13308 }
13309 }
13310 Ok(())
13311}
13312
13313fn collect_presence_proofs(expr: &Expr, proofs: &mut BTreeSet<String>) {
13314 match expr {
13315 Expr::Binary {
13316 op: BinaryOp::Ne,
13317 left,
13318 right,
13319 } => {
13320 if matches!(**right, Expr::Literal(ExprLiteral::Null)) {
13321 if let Some(path) = expr_path_key(left) {
13322 proofs.insert(path);
13323 }
13324 }
13325 if matches!(**left, Expr::Literal(ExprLiteral::Null)) {
13326 if let Some(path) = expr_path_key(right) {
13327 proofs.insert(path);
13328 }
13329 }
13330 }
13331 Expr::Unary {
13332 op: UnaryOp::Not,
13333 expr,
13334 } => {
13335 if let Expr::Binary {
13336 op: BinaryOp::Eq,
13337 left,
13338 right,
13339 } = expr.as_ref()
13340 {
13341 if matches!(**right, Expr::Literal(ExprLiteral::Null)) {
13342 if let Some(path) = expr_path_key(left) {
13343 proofs.insert(path);
13344 }
13345 }
13346 if matches!(**left, Expr::Literal(ExprLiteral::Null)) {
13347 if let Some(path) = expr_path_key(right) {
13348 proofs.insert(path);
13349 }
13350 }
13351 }
13352 }
13353 Expr::Call { name, args } if name == "exists" && args.len() == 1 => {
13354 if let Some(path) = expr_path_key(&args[0]) {
13355 proofs.insert(path);
13356 }
13357 }
13358 Expr::Binary {
13359 op: BinaryOp::And,
13360 left,
13361 right,
13362 } => {
13363 collect_presence_proofs(left, proofs);
13364 collect_presence_proofs(right, proofs);
13365 }
13366 _ => {}
13367 }
13368}
13369
13370fn expr_path_key(expr: &Expr) -> Option<String> {
13371 match expr {
13372 Expr::Literal(ExprLiteral::Ident(name)) => Some(name.clone()),
13373 Expr::Path(path) if path.len() >= 2 => Some(path[1..].join(".")),
13374 Expr::Index { target, key } => {
13375 let target = expr_path_key(target)?;
13376 let key = match key.as_ref() {
13377 Expr::Literal(ExprLiteral::String(value) | ExprLiteral::Ident(value)) => value,
13378 _ => return None,
13379 };
13380 Some(format!("{target}[{key:?}]"))
13381 }
13382 _ => None,
13383 }
13384}
13385
13386fn query_guard_scope(expr: &Expr, semantic: &SemanticContext, scope: &ExprScope) -> ExprScope {
13387 let Expr::Query {
13388 kind: QueryKind::Fact,
13389 head,
13390 ..
13391 } = expr
13392 else {
13393 return scope.clone();
13394 };
13395 query_head_schema(head, semantic)
13396 .map(|schema| scope.with_implicit_schema(schema))
13397 .unwrap_or_else(|| scope.clone())
13398}
13399
13400fn query_head_schema(head: &str, semantic: &SemanticContext) -> Option<String> {
13401 let mut parts = head.split_whitespace();
13402 let schema = parts.next()?;
13403 if parts.next().is_some() {
13404 return None;
13405 }
13406 semantic
13407 .schemas
13408 .class_exists(schema)
13409 .then(|| schema.to_owned())
13410}
13411
13412fn implicit_field_type(
13413 name: &str,
13414 semantic: &SemanticContext,
13415 scope: &ExprScope,
13416) -> Option<TypeSyntax> {
13417 let schema = scope.implicit_schema.as_ref()?;
13418 semantic
13419 .schemas
13420 .resolve_field_path(schema, &[name.to_owned()])
13421 .ok()
13422}
13423
13424fn infer_expr_type(
13425 expr: &Expr,
13426 semantic: &SemanticContext,
13427 scope: &ExprScope,
13428 context: &ExprValidationContext,
13429 diagnostics: &mut Vec<Diagnostic>,
13430) -> ExprType {
13431 match expr {
13432 Expr::Literal(ExprLiteral::Ident(name)) => implicit_field_type(name, semantic, scope)
13433 .map(|ty| expr_type_from_type_syntax(&ty, semantic))
13434 .unwrap_or_else(|| expr_literal_type(&ExprLiteral::Ident(name.clone()))),
13435 Expr::Literal(literal) => expr_literal_type(literal),
13436 Expr::Path(path) => expr_path_type(path, semantic, scope).unwrap_or(ExprType::Unknown),
13437 Expr::Index { target, key } => {
13438 let target_ty = infer_expr_type(target, semantic, scope, context, diagnostics);
13439 let key_ty = infer_expr_type(key, semantic, scope, context, diagnostics);
13440 if !matches!(key_ty, ExprType::String | ExprType::Unknown) {
13441 diagnostics.push(Diagnostic {
13442 related: Vec::new(),
13443 span: context.span,
13444 message: format!("{} indexes a map with a non-string key", context.subject),
13445 suggestion: Some(
13446 "use a string literal or string expression as the map key".to_owned(),
13447 ),
13448 });
13449 }
13450 match target_ty {
13451 ExprType::Map(inner) => *inner,
13452 ExprType::Unknown => ExprType::Unknown,
13453 _ => {
13454 diagnostics.push(Diagnostic {
13455 related: Vec::new(),
13456 span: context.span,
13457 message: format!("{} indexes a non-map expression", context.subject),
13458 suggestion: Some("use indexing only on map values".to_owned()),
13459 });
13460 ExprType::Unknown
13461 }
13462 }
13463 }
13464 Expr::Array(items) => infer_array_type(items, semantic, scope, context, diagnostics),
13465 Expr::Object(fields) => {
13466 for field in fields {
13467 infer_expr_type(&field.value, semantic, scope, context, diagnostics);
13468 }
13469 ExprType::Object
13470 }
13471 Expr::Unary {
13472 op: UnaryOp::Not,
13473 expr,
13474 } => {
13475 let inner = infer_expr_type(expr, semantic, scope, context, diagnostics);
13476 if !matches!(inner, ExprType::Bool | ExprType::Unknown) {
13477 diagnostics.push(Diagnostic {
13478 related: Vec::new(),
13479 span: context.span,
13480 message: format!(
13481 "{} applies `!` to a non-boolean expression",
13482 context.subject
13483 ),
13484 suggestion: Some("use `!` only with boolean expressions".to_owned()),
13485 });
13486 }
13487 ExprType::Bool
13488 }
13489 Expr::Binary { op, left, right } => {
13490 infer_binary_type(*op, left, right, semantic, scope, context, diagnostics)
13491 }
13492 Expr::Call { name, args } => match name.as_str() {
13493 "count" => ExprType::Int,
13494 "exists" => ExprType::Bool,
13495 "empty" => ExprType::Bool,
13496 _ => {
13497 diagnostics.push(Diagnostic {
13498 related: Vec::new(),
13499 span: context.span,
13500 message: format!(
13501 "{} calls unsupported expression function `{name}`",
13502 context.subject
13503 ),
13504 suggestion: Some("use `count`, `exists`, or `empty`".to_owned()),
13505 });
13506 for arg in args {
13507 infer_expr_type(arg, semantic, scope, context, diagnostics);
13508 }
13509 ExprType::Unknown
13510 }
13511 },
13512 Expr::Query { guard, .. } => {
13513 if let Some(guard) = guard {
13514 let guard_scope = query_guard_scope(expr, semantic, scope);
13515 infer_expr_type(guard, semantic, &guard_scope, context, diagnostics);
13516 }
13517 ExprType::Collection
13518 }
13519 }
13520}
13521
13522fn infer_binary_type(
13523 op: BinaryOp,
13524 left: &Expr,
13525 right: &Expr,
13526 semantic: &SemanticContext,
13527 scope: &ExprScope,
13528 context: &ExprValidationContext,
13529 diagnostics: &mut Vec<Diagnostic>,
13530) -> ExprType {
13531 let left_ty = infer_expr_type(left, semantic, scope, context, diagnostics);
13532 let right_ty = infer_expr_type(right, semantic, scope, context, diagnostics);
13533 match op {
13534 BinaryOp::And | BinaryOp::Or => {
13535 for ty in [&left_ty, &right_ty] {
13536 if !matches!(ty, ExprType::Bool | ExprType::Unknown) {
13537 diagnostics.push(Diagnostic {
13538 related: Vec::new(),
13539 span: context.span,
13540 message: format!(
13541 "{} uses boolean operator with non-boolean operand",
13542 context.subject
13543 ),
13544 suggestion: Some(
13545 "use `&&` and `||` only with boolean expressions".to_owned(),
13546 ),
13547 });
13548 break;
13549 }
13550 }
13551 ExprType::Bool
13552 }
13553 BinaryOp::Eq | BinaryOp::Ne => {
13554 if !types_comparable(&left_ty, &right_ty) {
13555 diagnostics.push(Diagnostic {
13556 related: Vec::new(),
13557 span: context.span,
13558 message: format!("{} compares incompatible expression types", context.subject),
13559 suggestion: Some(
13560 "compare values with compatible scalar or finite-domain types".to_owned(),
13561 ),
13562 });
13563 }
13564 ExprType::Bool
13565 }
13566 BinaryOp::Lt | BinaryOp::Le | BinaryOp::Gt | BinaryOp::Ge => {
13567 if !is_orderable_pair(&left_ty, &right_ty) {
13568 diagnostics.push(Diagnostic {
13569 related: Vec::new(),
13570 span: context.span,
13571 message: format!("{} orders non-orderable expression values", context.subject),
13572 suggestion: Some(
13573 "use ordering only with int, float, duration, or time values".to_owned(),
13574 ),
13575 });
13576 }
13577 ExprType::Bool
13578 }
13579 BinaryOp::In | BinaryOp::NotIn => {
13580 match &right_ty {
13581 ExprType::Array(item_ty) => {
13582 if !types_comparable(&left_ty, item_ty) {
13583 diagnostics.push(Diagnostic {
13584 related: Vec::new(),
13585 span: context.span,
13586 message: format!(
13587 "{} uses membership with incompatible item type",
13588 context.subject
13589 ),
13590 suggestion: Some(
13591 "make the left value compatible with the array item type"
13592 .to_owned(),
13593 ),
13594 });
13595 }
13596 }
13597 ExprType::Map(_) => {
13598 if !is_string_like_key_type(&left_ty) {
13599 diagnostics.push(Diagnostic {
13600 related: Vec::new(),
13601 span: context.span,
13602 message: format!(
13603 "{} uses map membership with a non-string key",
13604 context.subject
13605 ),
13606 suggestion: Some(
13607 "use a string value on the left side of map membership".to_owned(),
13608 ),
13609 });
13610 }
13611 }
13612 ExprType::Unknown => {}
13613 _ => diagnostics.push(Diagnostic {
13614 related: Vec::new(),
13615 span: context.span,
13616 message: format!(
13617 "{} uses membership against a non-array/non-map expression",
13618 context.subject
13619 ),
13620 suggestion: Some(
13621 "use `in` with an array literal, array value, or map value".to_owned(),
13622 ),
13623 }),
13624 }
13625 ExprType::Bool
13626 }
13627 BinaryOp::Add | BinaryOp::Sub | BinaryOp::Mul | BinaryOp::Div => {
13628 for ty in [&left_ty, &right_ty] {
13629 if !matches!(ty, ExprType::Int | ExprType::Float | ExprType::Unknown) {
13630 diagnostics.push(Diagnostic {
13631 related: Vec::new(),
13632 span: context.span,
13633 message: format!(
13634 "{} uses arithmetic with a non-numeric operand",
13635 context.subject
13636 ),
13637 suggestion: Some("use `+ - * /` only with int or float values".to_owned()),
13638 });
13639 break;
13640 }
13641 }
13642 if matches!(left_ty, ExprType::Float) || matches!(right_ty, ExprType::Float) {
13643 ExprType::Float
13644 } else if matches!(left_ty, ExprType::Int) && matches!(right_ty, ExprType::Int) {
13645 ExprType::Int
13646 } else {
13647 ExprType::Unknown
13648 }
13649 }
13650 }
13651}
13652
13653fn infer_array_type(
13654 items: &[Expr],
13655 semantic: &SemanticContext,
13656 scope: &ExprScope,
13657 context: &ExprValidationContext,
13658 diagnostics: &mut Vec<Diagnostic>,
13659) -> ExprType {
13660 let mut item_ty: Option<ExprType> = None;
13661 for item in items {
13662 let ty = infer_expr_type(item, semantic, scope, context, diagnostics);
13663 if matches!(ty, ExprType::Unknown) {
13664 continue;
13665 }
13666 match &item_ty {
13667 None => item_ty = Some(ty),
13668 Some(existing) if types_comparable(existing, &ty) => {}
13669 Some(_) => {
13670 diagnostics.push(Diagnostic {
13671 related: Vec::new(),
13672 span: context.span,
13673 message: format!("{} has mixed-type array literal", context.subject),
13674 suggestion: Some("use array literals whose elements share one type".to_owned()),
13675 });
13676 return ExprType::Array(Box::new(ExprType::Unknown));
13677 }
13678 }
13679 }
13680 ExprType::Array(Box::new(item_ty.unwrap_or(ExprType::Unknown)))
13681}
13682
13683fn expr_path_type(
13684 path: &[String],
13685 semantic: &SemanticContext,
13686 scope: &ExprScope,
13687) -> Option<ExprType> {
13688 if path.len() < 2 {
13689 return None;
13690 }
13691 if let Some(schema) = scope.binding_types.get(&path[0]) {
13692 if schema.contains('.') {
13693 return Some(ExprType::Unknown);
13695 }
13696 return semantic
13697 .schemas
13698 .resolve_field_path(schema, &path[1..])
13699 .ok()
13700 .map(|ty| expr_type_from_type_syntax(&ty, semantic));
13701 }
13702 let schema = scope.implicit_schema.as_ref()?;
13703 if schema.contains('.') {
13704 return Some(ExprType::Unknown);
13705 }
13706 semantic
13707 .schemas
13708 .resolve_field_path(schema, path)
13709 .ok()
13710 .map(|ty| expr_type_from_type_syntax(&ty, semantic))
13711}
13712
13713fn expr_type_from_type_syntax(ty: &TypeSyntax, semantic: &SemanticContext) -> ExprType {
13714 match ty {
13715 TypeSyntax::Primitive { name, .. } => match name.as_str() {
13716 "bool" => ExprType::Bool,
13717 "int" => ExprType::Int,
13718 "float" => ExprType::Float,
13719 "string" => ExprType::String,
13720 "duration" => ExprType::Duration,
13721 "time" => ExprType::Time,
13722 _ => ExprType::Unknown,
13723 },
13724 TypeSyntax::LiteralString { value, .. } => ExprType::Finite {
13725 label: "literal".to_owned(),
13726 values: vec![value.clone()],
13727 },
13728 TypeSyntax::AgentRef { agents, .. } => ExprType::Finite {
13729 label: "AgentRef".to_owned(),
13730 values: agents.iter().map(|agent| agent.name.clone()).collect(),
13731 },
13732 TypeSyntax::Ref { name } => semantic
13733 .schemas
13734 .enums
13735 .get(&name.name)
13736 .map(|variants| ExprType::Finite {
13737 label: format!("enum `{}`", name.name),
13738 values: variants.iter().cloned().collect(),
13739 })
13740 .unwrap_or(ExprType::Object),
13741 TypeSyntax::Optional { inner, .. } => {
13742 ExprType::Optional(Box::new(expr_type_from_type_syntax(inner, semantic)))
13743 }
13744 TypeSyntax::Array { inner, .. } => {
13745 ExprType::Array(Box::new(expr_type_from_type_syntax(inner, semantic)))
13746 }
13747 TypeSyntax::Map { inner, .. } => {
13748 ExprType::Map(Box::new(expr_type_from_type_syntax(inner, semantic)))
13749 }
13750 TypeSyntax::Union { variants, .. } => {
13751 let values = variants
13752 .iter()
13753 .filter_map(|variant| match variant {
13754 TypeSyntax::LiteralString { value, .. } => Some(value.clone()),
13755 _ => None,
13756 })
13757 .collect::<Vec<_>>();
13758 if values.len() == variants.len() && !values.is_empty() {
13759 ExprType::Finite {
13760 label: "literal union".to_owned(),
13761 values,
13762 }
13763 } else {
13764 ExprType::Unknown
13765 }
13766 }
13767 }
13768}
13769
13770fn expr_literal_type(literal: &ExprLiteral) -> ExprType {
13771 match literal {
13772 ExprLiteral::String(_) | ExprLiteral::Ident(_) => ExprType::String,
13773 ExprLiteral::Number(value) if value.contains('.') => ExprType::Float,
13774 ExprLiteral::Number(_) => ExprType::Int,
13775 ExprLiteral::Bool(_) => ExprType::Bool,
13776 ExprLiteral::Null => ExprType::Null,
13777 }
13778}
13779
13780fn types_comparable(left: &ExprType, right: &ExprType) -> bool {
13781 if matches!(left, ExprType::Unknown) || matches!(right, ExprType::Unknown) {
13782 return true;
13783 }
13784 if matches!(left, ExprType::Null) || matches!(right, ExprType::Null) {
13785 return true;
13786 }
13787 if is_numeric_type(left) && is_numeric_type(right) {
13788 return true;
13789 }
13790 match (left, right) {
13791 (ExprType::Optional(left), right) | (right, ExprType::Optional(left)) => {
13792 types_comparable(left, right)
13793 }
13794 (ExprType::Finite { .. }, ExprType::String)
13795 | (ExprType::String, ExprType::Finite { .. })
13796 | (ExprType::Finite { .. }, ExprType::Finite { .. }) => true,
13797 _ => left == right,
13798 }
13799}
13800
13801fn is_numeric_type(ty: &ExprType) -> bool {
13802 matches!(ty, ExprType::Int | ExprType::Float)
13803}
13804
13805fn is_string_like_key_type(ty: &ExprType) -> bool {
13806 match ty {
13807 ExprType::String | ExprType::Unknown | ExprType::Finite { .. } => true,
13808 ExprType::Optional(inner) => is_string_like_key_type(inner),
13809 _ => false,
13810 }
13811}
13812
13813fn is_orderable_pair(left: &ExprType, right: &ExprType) -> bool {
13814 if matches!(left, ExprType::Unknown) || matches!(right, ExprType::Unknown) {
13815 return true;
13816 }
13817 (is_numeric_type(left) && is_numeric_type(right))
13818 || matches!(
13819 (left, right),
13820 (ExprType::Duration, ExprType::Duration)
13821 | (ExprType::Time, ExprType::Time)
13822 | (ExprType::Time, ExprType::String)
13825 | (ExprType::String, ExprType::Time)
13826 )
13827}
13828
13829fn is_countable_type(ty: &ExprType) -> bool {
13830 matches!(
13831 ty,
13832 ExprType::Array(_) | ExprType::Map(_) | ExprType::Collection | ExprType::Unknown
13833 )
13834}
13835
13836fn is_exists_type(ty: &ExprType) -> bool {
13837 matches!(
13838 ty,
13839 ExprType::Array(_)
13840 | ExprType::Map(_)
13841 | ExprType::Collection
13842 | ExprType::Optional(_)
13843 | ExprType::Unknown
13844 )
13845}
13846
13847fn is_emptiable_type(ty: &ExprType) -> bool {
13852 match ty {
13853 ExprType::Array(_)
13854 | ExprType::Map(_)
13855 | ExprType::String
13856 | ExprType::Collection
13857 | ExprType::Null
13858 | ExprType::Unknown => true,
13859 ExprType::Optional(inner) => is_emptiable_type(inner),
13860 _ => false,
13861 }
13862}
13863
13864fn expr_type_label(ty: &ExprType) -> String {
13865 match ty {
13866 ExprType::Bool => "bool".to_owned(),
13867 ExprType::Int => "int".to_owned(),
13868 ExprType::Float => "float".to_owned(),
13869 ExprType::String => "string".to_owned(),
13870 ExprType::Finite { label, values } => format!("{label}<{}>", values.join(" | ")),
13871 ExprType::Duration => "duration".to_owned(),
13872 ExprType::Time => "time".to_owned(),
13873 ExprType::Null => "null".to_owned(),
13874 ExprType::Object => "object".to_owned(),
13875 ExprType::Array(inner) => format!("{}[]", expr_type_label(inner)),
13876 ExprType::Map(inner) => format!("map<{}>", expr_type_label(inner)),
13877 ExprType::Optional(inner) => format!("{}?", expr_type_label(inner)),
13878 ExprType::Collection => "query".to_owned(),
13879 ExprType::Unknown => "unknown".to_owned(),
13880 }
13881}
13882
13883fn validate_finite_domain_expr(
13884 op: BinaryOp,
13885 left: &Expr,
13886 right: &Expr,
13887 semantic: &SemanticContext,
13888 scope: &ExprScope,
13889 context: &ExprValidationContext,
13890 diagnostics: &mut Vec<Diagnostic>,
13891) {
13892 if !matches!(
13893 op,
13894 BinaryOp::Eq | BinaryOp::Ne | BinaryOp::In | BinaryOp::NotIn
13895 ) {
13896 return;
13897 }
13898 let Some((domain, literals)) = finite_domain_comparison(left, right, semantic, scope)
13899 .or_else(|| finite_domain_comparison(right, left, semantic, scope))
13900 else {
13901 validate_finite_domain_relation(op, left, right, semantic, scope, context, diagnostics);
13902 return;
13903 };
13904 for literal in literals.into_iter().flatten() {
13905 if !domain.iter().any(|value| value == &literal) {
13906 diagnostics.push(Diagnostic {
13907 related: Vec::new(),
13908 span: context.span,
13909 message: format!(
13910 "{} compares finite-domain value to unknown `{literal}`",
13911 context.subject
13912 ),
13913 suggestion: Some(format!("use one of: {}", domain.join(", "))),
13914 });
13915 }
13916 }
13917 validate_finite_domain_relation(op, left, right, semantic, scope, context, diagnostics);
13918}
13919
13920fn validate_finite_domain_relation(
13921 op: BinaryOp,
13922 left: &Expr,
13923 right: &Expr,
13924 semantic: &SemanticContext,
13925 scope: &ExprScope,
13926 context: &ExprValidationContext,
13927 diagnostics: &mut Vec<Diagnostic>,
13928) {
13929 match op {
13930 BinaryOp::Eq => {
13931 let Some(left_domain) = expr_domain(left, semantic, scope) else {
13932 return;
13933 };
13934 let Some(right_domain) = expr_domain(right, semantic, scope) else {
13935 return;
13936 };
13937 if left_domain
13938 .iter()
13939 .all(|value| !right_domain.iter().any(|right| right == value))
13940 {
13941 diagnostics.push(Diagnostic {
13942 related: Vec::new(),
13943 span: context.span,
13944 message: format!(
13945 "{} has statically unsatisfiable finite-domain equality",
13946 context.subject
13947 ),
13948 suggestion: Some(format!(
13949 "compare domains with at least one shared value; left: {}, right: {}",
13950 left_domain.join(", "),
13951 right_domain.join(", ")
13952 )),
13953 });
13954 }
13955 }
13956 BinaryOp::In => {
13957 let Some(domain) = expr_domain(left, semantic, scope) else {
13958 return;
13959 };
13960 let Some(literals) = literal_array_values(right) else {
13961 return;
13962 };
13963 if literals
13964 .iter()
13965 .all(|literal| !domain.iter().any(|value| value == literal))
13966 {
13967 diagnostics.push(Diagnostic {
13968 related: Vec::new(),
13969 span: context.span,
13970 message: format!(
13971 "{} has statically unsatisfiable finite-domain membership",
13972 context.subject
13973 ),
13974 suggestion: Some(format!("use one of: {}", domain.join(", "))),
13975 });
13976 }
13977 }
13978 BinaryOp::NotIn => {
13979 let Some(domain) = expr_domain(left, semantic, scope) else {
13980 return;
13981 };
13982 let Some(literals) = literal_array_values(right) else {
13983 return;
13984 };
13985 if !domain.is_empty()
13986 && domain
13987 .iter()
13988 .all(|value| literals.iter().any(|literal| literal == value))
13989 {
13990 diagnostics.push(Diagnostic {
13991 related: Vec::new(),
13992 span: context.span,
13993 message: format!(
13994 "{} has statically unsatisfiable finite-domain exclusion",
13995 context.subject
13996 ),
13997 suggestion: Some(
13998 "leave at least one domain value outside the exclusion set".to_owned(),
13999 ),
14000 });
14001 }
14002 }
14003 _ => {}
14004 }
14005}
14006
14007fn finite_domain_comparison(
14008 domain_expr: &Expr,
14009 literal_expr: &Expr,
14010 semantic: &SemanticContext,
14011 scope: &ExprScope,
14012) -> Option<(Vec<String>, Vec<Option<String>>)> {
14013 let domain = expr_domain(domain_expr, semantic, scope)?;
14014 let literals = match literal_expr {
14015 Expr::Literal(literal) => vec![expr_literal_name(literal)],
14016 Expr::Array(items) => items
14017 .iter()
14018 .filter_map(|item| match item {
14019 Expr::Literal(literal) => Some(expr_literal_name(literal)),
14020 _ => None,
14021 })
14022 .collect(),
14023 _ => Vec::new(),
14024 };
14025 Some((domain, literals))
14026}
14027
14028fn expr_domain(expr: &Expr, semantic: &SemanticContext, scope: &ExprScope) -> Option<Vec<String>> {
14029 let ty = match expr {
14030 Expr::Path(path) => {
14031 let root = path.first()?;
14032 if let Some(schema) = scope.binding_types.get(root) {
14033 semantic
14034 .schemas
14035 .resolve_field_path(schema, path.get(1..)?)
14036 .ok()?
14037 } else {
14038 let schema = scope.implicit_schema.as_ref()?;
14039 semantic.schemas.resolve_field_path(schema, path).ok()?
14040 }
14041 }
14042 Expr::Literal(ExprLiteral::Ident(name)) => implicit_field_type(name, semantic, scope)?,
14043 _ => return None,
14044 };
14045 finite_expr_domain(&ty, semantic)
14046}
14047
14048fn finite_expr_domain(ty: &TypeSyntax, semantic: &SemanticContext) -> Option<Vec<String>> {
14049 match ty {
14050 TypeSyntax::Ref { name } => semantic
14051 .schemas
14052 .enums
14053 .get(&name.name)
14054 .map(|variants| variants.iter().cloned().collect()),
14055 TypeSyntax::Union { variants, .. } => {
14056 let values = variants
14057 .iter()
14058 .filter_map(|variant| match variant {
14059 TypeSyntax::LiteralString { value, .. } => Some(value.clone()),
14060 _ => None,
14061 })
14062 .collect::<Vec<_>>();
14063 (!values.is_empty()).then_some(values)
14064 }
14065 TypeSyntax::AgentRef { agents, .. } => {
14066 Some(agents.iter().map(|agent| agent.name.clone()).collect())
14067 }
14068 _ => None,
14069 }
14070}
14071
14072fn expr_literal_name(literal: &ExprLiteral) -> Option<String> {
14073 match literal {
14074 ExprLiteral::String(value) | ExprLiteral::Ident(value) => Some(value.clone()),
14075 _ => None,
14076 }
14077}
14078
14079fn literal_array_values(expr: &Expr) -> Option<Vec<String>> {
14080 let Expr::Array(items) = expr else {
14081 return None;
14082 };
14083 items
14084 .iter()
14085 .map(|item| match item {
14086 Expr::Literal(literal) => expr_literal_name(literal),
14087 _ => None,
14088 })
14089 .collect()
14090}
14091
14092fn parse_tell_target(line: &str) -> Option<&str> {
14093 line.strip_prefix("tell ")?
14094 .split_whitespace()
14095 .next()
14096 .filter(|target| !target.is_empty())
14097}
14098
14099fn parse_required_capabilities(line: &str) -> Vec<String> {
14100 let Some(rest) = line.split_once(" requires ") else {
14101 return Vec::new();
14102 };
14103 let Some(list) = rest.1.trim_start().strip_prefix('[') else {
14104 return Vec::new();
14105 };
14106 let Some((items, _)) = list.split_once(']') else {
14107 return Vec::new();
14108 };
14109 let mut capabilities = items
14110 .split(',')
14111 .filter_map(|item| {
14112 let value = item.trim().trim_matches('"');
14113 (!value.is_empty()).then(|| value.to_owned())
14114 })
14115 .collect::<Vec<_>>();
14116 capabilities.sort();
14117 capabilities.dedup();
14118 capabilities
14119}
14120
14121fn validate_case_blocks(
14122 rule: &RuleDecl,
14123 semantic: &SemanticContext,
14124 binding_types: &BTreeMap<String, String>,
14125 diagnostics: &mut Vec<Diagnostic>,
14126) {
14127 let lines = rule
14128 .body
14129 .text
14130 .lines()
14131 .scan(0usize, |offset, line| {
14132 let current = *offset;
14133 *offset += line.len() + 1;
14134 Some((line, current))
14135 })
14136 .collect::<Vec<_>>();
14137 let text_lines = lines.iter().map(|(line, _)| *line).collect::<Vec<_>>();
14138 let mut index = 0usize;
14139 while index < lines.len() {
14140 let trimmed = lines[index].0.trim();
14141 let Some(scrutinee) = case_scrutinee(trimmed) else {
14142 index += 1;
14143 continue;
14144 };
14145 let scrutinee_ty = expression_type(scrutinee, semantic, binding_types);
14146 let terminal_case = scrutinee_ty.is_none()
14147 && active_completes_binding_for_case(&text_lines, index, scrutinee);
14148 if scrutinee_ty.is_none() && !terminal_case {
14149 diagnostics.push(Diagnostic {
14150 related: Vec::new(),
14151 span: rule.body.span,
14152 message: format!(
14153 "rule `{}` has case scrutinee `{scrutinee}` that is not a typed path",
14154 rule.name.name
14155 ),
14156 suggestion: Some("match on a bound field such as `task.provider`".to_owned()),
14157 });
14158 }
14159 let mut depth = brace_delta(trimmed).max(1);
14160 let mut case_index = index + 1;
14161 let mut branches = Vec::new();
14162 while case_index < lines.len() && depth > 0 {
14163 let (raw_line, line_offset) = lines[case_index];
14164 let line = raw_line.trim();
14165 if depth == 1 {
14166 if let Some(branch) = parse_case_branch_head(line) {
14167 let pattern_column = case_pattern_column(raw_line, branch.pattern);
14168 let branch = SpanCaseBranchHead {
14169 pattern: branch.pattern,
14170 guard: branch.guard,
14171 pattern_span: SourceSpan {
14172 start: rule_body_text_start(rule) + line_offset + pattern_column,
14173 end: rule_body_text_start(rule)
14174 + line_offset
14175 + pattern_column
14176 + branch.pattern.len(),
14177 },
14178 };
14179 branches.push(branch);
14180 if terminal_case {
14181 validate_terminal_case_pattern(
14182 rule,
14183 branch.pattern,
14184 branch.pattern_span,
14185 diagnostics,
14186 );
14187 } else {
14188 validate_case_pattern(
14189 rule,
14190 branch.pattern,
14191 scrutinee_ty.as_ref(),
14192 branch.pattern_span,
14193 semantic,
14194 diagnostics,
14195 );
14196 }
14197 if let Some(guard) = branch.guard.filter(|_| !terminal_case) {
14204 let mut branch_scope = binding_types.clone();
14205 if let Some(scrutinee_ty) = scrutinee_ty.as_ref() {
14206 if let Some((binding, schema)) =
14207 case_branch_payload_binding(branch.pattern, scrutinee_ty, semantic)
14208 {
14209 branch_scope.insert(binding, schema);
14210 }
14211 }
14212 validate_expression(
14213 rule,
14214 guard,
14215 semantic,
14216 &branch_scope,
14217 "case guard",
14218 diagnostics,
14219 );
14220 validate_known_field_paths_at_span(
14221 rule,
14222 guard,
14223 branch.pattern_span,
14224 semantic,
14225 &branch_scope,
14226 diagnostics,
14227 );
14228 }
14229 }
14230 }
14231 depth += brace_delta(line);
14232 case_index += 1;
14233 }
14234 if terminal_case {
14235 validate_terminal_case_coverage(rule, &branches, diagnostics);
14236 } else {
14237 validate_case_coverage(
14238 rule,
14239 scrutinee_ty.as_ref(),
14240 &branches,
14241 semantic,
14242 diagnostics,
14243 );
14244 }
14245 index += 1;
14246 }
14247}
14248
14249fn active_completes_binding_for_case(lines: &[&str], case_index: usize, scrutinee: &str) -> bool {
14250 let mut scopes: Vec<(String, DependencyPredicate, i32)> = Vec::new();
14251 for line in lines.iter().take(case_index) {
14252 let trimmed = line.trim();
14253 if let Some((binding, predicate)) = parse_after_line(trimmed) {
14254 scopes.push((binding, predicate, brace_delta(trimmed).max(1)));
14255 } else {
14256 let delta = brace_delta(trimmed);
14257 for (_, _, depth) in &mut scopes {
14258 *depth += delta;
14259 }
14260 scopes.retain(|(_, _, depth)| *depth > 0);
14261 }
14262 }
14263 scopes.iter().any(|(binding, predicate, _)| {
14264 binding == scrutinee && predicate == &DependencyPredicate::Completes
14265 })
14266}
14267
14268fn brace_delta(line: &str) -> i32 {
14269 line.chars().fold(0, |depth, ch| match ch {
14270 '{' => depth + 1,
14271 '}' => depth - 1,
14272 _ => depth,
14273 })
14274}
14275
14276fn case_scrutinee(line: &str) -> Option<&str> {
14277 let rest = line.strip_prefix("case ")?;
14278 let expr = rest.strip_suffix('{').unwrap_or(rest).trim();
14279 (!expr.is_empty()).then_some(expr)
14280}
14281
14282fn is_case_branch_start(line: &str) -> bool {
14283 line.contains("=>")
14284}
14285
14286#[derive(Clone, Copy)]
14287struct CaseBranchHead<'a> {
14288 pattern: &'a str,
14289 guard: Option<&'a str>,
14290}
14291
14292#[derive(Clone, Copy)]
14293struct SpanCaseBranchHead<'a> {
14294 pattern: &'a str,
14295 guard: Option<&'a str>,
14296 pattern_span: SourceSpan,
14297}
14298
14299fn parse_case_branch_head(line: &str) -> Option<CaseBranchHead<'_>> {
14300 let (pattern, _) = line.split_once("=>")?;
14301 let pattern = pattern.trim();
14302 if pattern.is_empty() {
14303 return None;
14304 }
14305 match pattern.split_once(" where ") {
14306 Some((pattern, guard)) => Some(CaseBranchHead {
14307 pattern: pattern.trim(),
14308 guard: Some(guard.trim()),
14309 }),
14310 None => Some(CaseBranchHead {
14311 pattern,
14312 guard: None,
14313 }),
14314 }
14315}
14316
14317fn expression_type(
14318 expr: &str,
14319 semantic: &SemanticContext,
14320 binding_types: &BTreeMap<String, String>,
14321) -> Option<TypeSyntax> {
14322 let is_bare_ident = !expr.is_empty()
14326 && expr.chars().all(|ch| ch.is_alphanumeric() || ch == '_')
14327 && expr.chars().next().is_some_and(char::is_alphabetic);
14328 if is_bare_ident {
14329 let schema = binding_types.get(expr)?;
14330 if semantic.schemas.enums.contains_key(schema) {
14331 return Some(TypeSyntax::Ref {
14332 name: Ident {
14333 name: schema.clone(),
14334 span: zero_span(),
14335 },
14336 });
14337 }
14338 return None;
14339 }
14340 let (root, path) = expression_path(expr)?;
14341 let schema = binding_types.get(&root)?;
14342 semantic.schemas.resolve_field_path(schema, &path).ok()
14343}
14344
14345fn validate_case_pattern(
14346 rule: &RuleDecl,
14347 pattern: &str,
14348 scrutinee_ty: Option<&TypeSyntax>,
14349 span: SourceSpan,
14350 semantic: &SemanticContext,
14351 diagnostics: &mut Vec<Diagnostic>,
14352) {
14353 if matches!(pattern, "_" | "default") {
14354 return;
14355 }
14356 if pattern == "None" {
14357 if !matches!(scrutinee_ty, Some(TypeSyntax::Optional { .. })) {
14358 diagnostics.push(Diagnostic {
14359 related: Vec::new(),
14360 span,
14361 message: format!(
14362 "rule `{}` uses `None` for a non-optional case",
14363 rule.name.name
14364 ),
14365 suggestion: Some("use `None` only when matching an optional field".to_owned()),
14366 });
14367 }
14368 return;
14369 }
14370 if pattern.starts_with("Some ") {
14371 if !matches!(scrutinee_ty, Some(TypeSyntax::Optional { .. })) {
14372 diagnostics.push(Diagnostic {
14373 related: Vec::new(),
14374 span,
14375 message: format!(
14376 "rule `{}` uses `Some` for a non-optional case",
14377 rule.name.name
14378 ),
14379 suggestion: Some("use `Some name` only when matching an optional field".to_owned()),
14380 });
14381 }
14382 return;
14383 }
14384 let Some(scrutinee_ty) = scrutinee_ty else {
14385 return;
14386 };
14387 match scrutinee_ty {
14388 TypeSyntax::Ref { name } => {
14389 let Some(variants) = semantic.schemas.enums.get(&name.name) else {
14390 return;
14391 };
14392 let (variant, binding) = sum_case_pattern_parts(pattern);
14393 if !variants.contains(variant) {
14394 diagnostics.push(Diagnostic {
14395 related: Vec::new(),
14396 span,
14397 message: format!("enum `{}` has no variant `{variant}`", name.name),
14398 suggestion: Some(format!(
14399 "use one of: {}",
14400 variants.iter().cloned().collect::<Vec<_>>().join(", ")
14401 )),
14402 });
14403 return;
14404 }
14405 if binding.is_some()
14408 && !semantic
14409 .schemas
14410 .class_exists(&format!("{}.{variant}", name.name))
14411 {
14412 diagnostics.push(Diagnostic {
14413 related: Vec::new(),
14414 span,
14415 message: format!(
14416 "variant `{variant}` of enum `{}` carries no payload to bind",
14417 name.name
14418 ),
14419 suggestion: Some(format!("write `{variant} => {{ ... }}` without `as`")),
14420 });
14421 }
14422 }
14423 TypeSyntax::Union { variants, .. } => {
14424 let Some(literal) = parse_literal_expr(pattern) else {
14425 diagnostics.push(Diagnostic {
14426 related: Vec::new(),
14427 span,
14428 message: format!(
14429 "rule `{}` has unsupported case pattern `{pattern}`",
14430 rule.name.name
14431 ),
14432 suggestion: Some("use a literal branch value or `_`".to_owned()),
14433 });
14434 return;
14435 };
14436 validate_union_case_pattern(rule, variants, &literal, span, diagnostics);
14437 }
14438 TypeSyntax::AgentRef { agents, .. } => {
14439 let Some(literal) = parse_literal_expr(pattern) else {
14440 diagnostics.push(Diagnostic {
14441 related: Vec::new(),
14442 span,
14443 message: format!(
14444 "rule `{}` has unsupported AgentRef case pattern `{pattern}`",
14445 rule.name.name
14446 ),
14447 suggestion: Some(
14448 "use a declared agent name, a string literal, or `_`".to_owned(),
14449 ),
14450 });
14451 return;
14452 };
14453 validate_agent_ref_case_pattern(rule, agents, &literal, span, diagnostics);
14454 }
14455 TypeSyntax::Optional { inner, .. } => {
14456 validate_case_pattern(rule, pattern, Some(inner), span, semantic, diagnostics);
14457 }
14458 TypeSyntax::Primitive { name, .. } if name == "bool" => {
14461 if !matches!(pattern, "true" | "false") {
14462 diagnostics.push(Diagnostic {
14463 related: Vec::new(),
14464 span,
14465 message: format!(
14466 "rule `{}` has case pattern `{pattern}` that is not a `bool` value",
14467 rule.name.name
14468 ),
14469 suggestion: Some("match `true`, `false`, or `_`".to_owned()),
14470 });
14471 }
14472 }
14473 _ => {
14474 diagnostics.push(Diagnostic {
14475 related: Vec::new(),
14476 span,
14477 message: format!(
14478 "rule `{}` cannot pattern-match this scrutinee type",
14479 rule.name.name
14480 ),
14481 suggestion: Some(
14482 "match an enum, literal union, optional, or tagged output union".to_owned(),
14483 ),
14484 });
14485 }
14486 }
14487}
14488
14489fn terminal_case_tags() -> [&'static str; 4] {
14490 ["Completed", "Failed", "TimedOut", "Cancelled"]
14491}
14492
14493fn validate_terminal_case_pattern(
14494 rule: &RuleDecl,
14495 pattern: &str,
14496 span: SourceSpan,
14497 diagnostics: &mut Vec<Diagnostic>,
14498) {
14499 if is_fallback_pattern(pattern) {
14500 return;
14501 }
14502 let mut parts = pattern.split_whitespace();
14503 let Some(tag) = parts.next() else {
14504 return;
14505 };
14506 let second = parts.next();
14509 let binding = match second {
14510 Some("as") => parts.next(),
14511 other => other,
14512 };
14513 let uses_as = matches!(second, Some("as"));
14514 if parts.next().is_some() || binding.is_none() || !uses_as {
14515 diagnostics.push(Diagnostic { related: Vec::new(),
14516 span,
14517 message: format!(
14518 "rule `{}` has malformed terminal-output case pattern `{pattern}`",
14519 rule.name.name
14520 ),
14521 suggestion: Some("write `Completed as result`, `Failed as failure`, `TimedOut as timeout`, or `Cancelled as cancel` (the `as` is required)".to_owned()),
14522 });
14523 return;
14524 }
14525 let tags = terminal_case_tags();
14526 if !tags.contains(&tag) {
14527 diagnostics.push(Diagnostic {
14528 related: Vec::new(),
14529 span,
14530 message: format!(
14531 "rule `{}` terminal-output case pattern cannot be `{tag}`",
14532 rule.name.name
14533 ),
14534 suggestion: Some(format!("use one of: {}", tags.join(", "))),
14535 });
14536 }
14537}
14538
14539fn validate_terminal_case_coverage(
14540 rule: &RuleDecl,
14541 branches: &[SpanCaseBranchHead<'_>],
14542 diagnostics: &mut Vec<Diagnostic>,
14543) {
14544 validate_unreachable_after_fallback(rule, branches, diagnostics);
14545 if branches.is_empty()
14546 || branches
14547 .iter()
14548 .any(|branch| is_fallback_pattern(branch.pattern))
14549 {
14550 validate_duplicate_terminal_case_patterns(rule, branches, diagnostics);
14551 return;
14552 }
14553 validate_duplicate_terminal_case_patterns(rule, branches, diagnostics);
14554 let covered = branches
14555 .iter()
14556 .filter(|branch| branch.guard.is_none())
14557 .filter_map(|branch| normalized_terminal_case_pattern(branch.pattern))
14558 .collect::<BTreeSet<_>>();
14559 let missing = terminal_case_tags()
14560 .iter()
14561 .filter(|tag| !covered.contains(**tag))
14562 .copied()
14563 .collect::<Vec<_>>();
14564 if !missing.is_empty() {
14565 diagnostics.push(Diagnostic {
14566 related: Vec::new(),
14567 span: rule.body.span,
14568 message: format!(
14569 "rule `{}` has non-exhaustive terminal-output case; missing {}",
14570 rule.name.name,
14571 missing.join(", ")
14572 ),
14573 suggestion: Some(
14574 "add terminal branches for every value or add `_ => { ... }`".to_owned(),
14575 ),
14576 });
14577 }
14578}
14579
14580fn validate_duplicate_terminal_case_patterns(
14581 rule: &RuleDecl,
14582 branches: &[SpanCaseBranchHead<'_>],
14583 diagnostics: &mut Vec<Diagnostic>,
14584) {
14585 let mut seen = BTreeSet::new();
14586 for branch in branches.iter().filter(|branch| branch.guard.is_none()) {
14587 let Some(pattern) = normalized_terminal_case_pattern(branch.pattern) else {
14588 continue;
14589 };
14590 if !seen.insert(pattern.to_owned()) {
14591 diagnostics.push(Diagnostic {
14592 related: Vec::new(),
14593 span: branch.pattern_span,
14594 message: format!(
14595 "rule `{}` has duplicate unguarded terminal-output case pattern `{pattern}`",
14596 rule.name.name
14597 ),
14598 suggestion: Some(
14599 "remove the duplicate branch or add mutually exclusive `where` guards"
14600 .to_owned(),
14601 ),
14602 });
14603 }
14604 }
14605}
14606
14607fn validate_case_coverage(
14608 rule: &RuleDecl,
14609 scrutinee_ty: Option<&TypeSyntax>,
14610 branches: &[SpanCaseBranchHead<'_>],
14611 semantic: &SemanticContext,
14612 diagnostics: &mut Vec<Diagnostic>,
14613) {
14614 validate_unreachable_after_fallback(rule, branches, diagnostics);
14615 if branches.is_empty()
14616 || branches
14617 .iter()
14618 .any(|branch| is_fallback_pattern(branch.pattern))
14619 {
14620 validate_duplicate_case_patterns(rule, branches, diagnostics);
14621 return;
14622 }
14623 validate_duplicate_case_patterns(rule, branches, diagnostics);
14624
14625 let Some(domain) = finite_case_domain(scrutinee_ty, semantic) else {
14626 return;
14627 };
14628 let covered = branches
14629 .iter()
14630 .filter(|branch| branch.guard.is_none())
14631 .filter_map(|branch| normalized_case_pattern(branch.pattern))
14632 .collect::<BTreeSet<_>>();
14633 let missing = domain
14634 .iter()
14635 .filter(|value| !covered.contains(value.as_str()))
14636 .cloned()
14637 .collect::<Vec<_>>();
14638 if !missing.is_empty() {
14639 diagnostics.push(Diagnostic {
14640 related: Vec::new(),
14641 span: rule.body.span,
14642 message: format!(
14643 "rule `{}` has non-exhaustive case; missing {}",
14644 rule.name.name,
14645 missing.join(", ")
14646 ),
14647 suggestion: Some("add branches for every value or add `_ => { ... }`".to_owned()),
14648 });
14649 }
14650}
14651
14652fn validate_duplicate_case_patterns(
14653 rule: &RuleDecl,
14654 branches: &[SpanCaseBranchHead<'_>],
14655 diagnostics: &mut Vec<Diagnostic>,
14656) {
14657 let mut seen = BTreeSet::new();
14658 for branch in branches.iter().filter(|branch| branch.guard.is_none()) {
14659 let Some(pattern) = normalized_case_pattern(branch.pattern) else {
14660 continue;
14661 };
14662 if !seen.insert(pattern.to_owned()) {
14663 diagnostics.push(Diagnostic {
14664 related: Vec::new(),
14665 span: branch.pattern_span,
14666 message: format!(
14667 "rule `{}` has duplicate unguarded case pattern `{pattern}`",
14668 rule.name.name
14669 ),
14670 suggestion: Some(
14671 "remove the duplicate branch or add mutually exclusive `where` guards"
14672 .to_owned(),
14673 ),
14674 });
14675 }
14676 }
14677}
14678
14679fn validate_unreachable_after_fallback(
14685 rule: &RuleDecl,
14686 branches: &[SpanCaseBranchHead<'_>],
14687 diagnostics: &mut Vec<Diagnostic>,
14688) {
14689 let mut ordered: Vec<&SpanCaseBranchHead<'_>> = branches.iter().collect();
14690 ordered.sort_by_key(|branch| branch.pattern_span.start);
14691 let mut fallback_span: Option<SourceSpan> = None;
14692 for branch in ordered {
14693 if let Some(prior) = fallback_span {
14694 diagnostics.push(
14695 Diagnostic {
14696 related: Vec::new(),
14697 span: branch.pattern_span,
14698 message: format!(
14699 "rule `{}` has an unreachable case branch after the `_` wildcard",
14700 rule.name.name
14701 ),
14702 suggestion: Some(
14703 "move this branch before the wildcard, or remove it".to_owned(),
14704 ),
14705 }
14706 .with_related(
14707 prior,
14708 "this unguarded wildcard already matches every remaining value",
14709 ),
14710 );
14711 } else if branch.guard.is_none() && is_fallback_pattern(branch.pattern) {
14712 fallback_span = Some(branch.pattern_span);
14713 }
14714 }
14715}
14716
14717fn finite_case_domain(
14718 scrutinee_ty: Option<&TypeSyntax>,
14719 semantic: &SemanticContext,
14720) -> Option<Vec<String>> {
14721 match scrutinee_ty? {
14722 TypeSyntax::Ref { name } => semantic
14723 .schemas
14724 .enums
14725 .get(&name.name)
14726 .map(|variants| variants.iter().cloned().collect()),
14727 TypeSyntax::Union { variants, .. } => {
14728 let values = variants
14729 .iter()
14730 .filter_map(|variant| match variant {
14731 TypeSyntax::LiteralString { value, .. } => Some(value.clone()),
14732 _ => None,
14733 })
14734 .collect::<Vec<_>>();
14735 (!values.is_empty()).then_some(values)
14736 }
14737 TypeSyntax::Optional { .. } => Some(vec!["Some".to_owned(), "None".to_owned()]),
14738 TypeSyntax::AgentRef { agents, .. } => {
14739 Some(agents.iter().map(|agent| agent.name.clone()).collect())
14740 }
14741 TypeSyntax::Primitive { name, .. } if name == "bool" => {
14744 Some(vec!["true".to_owned(), "false".to_owned()])
14745 }
14746 _ => None,
14747 }
14748}
14749
14750fn sum_case_pattern_parts(pattern: &str) -> (&str, Option<&str>) {
14753 match pattern.split_once(" as ") {
14754 Some((variant, binding)) => (variant.trim(), Some(binding.trim())),
14755 None => (pattern.trim(), None),
14756 }
14757}
14758
14759fn normalized_case_pattern(pattern: &str) -> Option<&str> {
14760 if is_fallback_pattern(pattern) {
14761 return None;
14762 }
14763 if pattern.starts_with("Some ") {
14764 return Some("Some");
14765 }
14766 if pattern == "None" {
14767 return Some("None");
14768 }
14769 let (pattern, _) = sum_case_pattern_parts(pattern);
14771 if matches!(pattern, "true" | "false") {
14774 return Some(pattern);
14775 }
14776 parse_literal_expr(pattern).and_then(|literal| match literal {
14777 LiteralExpr::String(value) | LiteralExpr::Ident(value) => Some(value),
14778 _ => None,
14779 })
14780}
14781
14782fn normalized_terminal_case_pattern(pattern: &str) -> Option<&str> {
14783 if is_fallback_pattern(pattern) {
14784 return None;
14785 }
14786 pattern.split_whitespace().next()
14787}
14788
14789fn is_fallback_pattern(pattern: &str) -> bool {
14790 matches!(pattern, "_" | "default")
14791}
14792
14793fn validate_union_case_pattern(
14794 rule: &RuleDecl,
14795 variants: &[TypeSyntax],
14796 literal: &LiteralExpr<'_>,
14797 span: SourceSpan,
14798 diagnostics: &mut Vec<Diagnostic>,
14799) {
14800 let allowed = variants
14801 .iter()
14802 .filter_map(|variant| match variant {
14803 TypeSyntax::LiteralString { value, .. } => Some(value.as_str()),
14804 _ => None,
14805 })
14806 .collect::<Vec<_>>();
14807 if allowed.is_empty() {
14808 return;
14809 }
14810 let LiteralExpr::String(value) = literal else {
14811 diagnostics.push(Diagnostic {
14812 related: Vec::new(),
14813 span,
14814 message: format!(
14815 "rule `{}` case pattern must be one of its literal variants",
14816 rule.name.name
14817 ),
14818 suggestion: Some(format!("use one of: {}", allowed.join(", "))),
14819 });
14820 return;
14821 };
14822 if !allowed.contains(value) {
14823 diagnostics.push(Diagnostic {
14824 related: Vec::new(),
14825 span,
14826 message: format!("rule `{}` case pattern cannot be `{value}`", rule.name.name),
14827 suggestion: Some(format!("use one of: {}", allowed.join(", "))),
14828 });
14829 }
14830}
14831
14832fn validate_agent_ref_case_pattern(
14833 rule: &RuleDecl,
14834 agents: &[Ident],
14835 literal: &LiteralExpr<'_>,
14836 span: SourceSpan,
14837 diagnostics: &mut Vec<Diagnostic>,
14838) {
14839 let allowed = agents
14840 .iter()
14841 .map(|agent| agent.name.as_str())
14842 .collect::<Vec<_>>();
14843 let (LiteralExpr::String(value) | LiteralExpr::Ident(value)) = literal else {
14844 diagnostics.push(Diagnostic {
14845 related: Vec::new(),
14846 span,
14847 message: format!("rule `{}` has non-agent case pattern", rule.name.name),
14848 suggestion: Some(format!("use one of: {}", allowed.join(", "))),
14849 });
14850 return;
14851 };
14852 if !allowed.contains(value) {
14853 diagnostics.push(Diagnostic {
14854 related: Vec::new(),
14855 span,
14856 message: format!("AgentRef has no agent `{value}`"),
14857 suggestion: Some(format!("use one of: {}", allowed.join(", "))),
14858 });
14859 }
14860}
14861
14862fn validate_binding_uses(
14863 rule: &RuleDecl,
14864 line: &str,
14865 seen_bindings: &BTreeSet<String>,
14866 scope_stack: &[(String, DependencyPredicate)],
14867 diagnostics: &mut Vec<Diagnostic>,
14868) {
14869 for root in interpolation_roots(line) {
14870 if !seen_bindings.contains(&root) {
14871 continue;
14872 }
14873 if scope_stack.iter().any(|(binding, _)| binding == &root) {
14874 continue;
14875 }
14876
14877 diagnostics.push(Diagnostic { related: Vec::new(),
14878 span: rule.body.span,
14879 message: format!(
14880 "rule `{}` uses effect output `{root}` outside a matching `after {root} ...` block",
14881 rule.name.name
14882 ),
14883 suggestion: Some(format!(
14884 "move this use into `after {root} succeeds {{ ... }}` or another matching terminal branch"
14885 )),
14886 });
14887 }
14888}
14889
14890fn after_scopes(block_stack: &[BlockFrame]) -> Vec<(String, DependencyPredicate)> {
14891 block_stack
14892 .iter()
14893 .map(|frame| match frame {
14894 BlockFrame::After { binding, predicate } => (binding.clone(), predicate.clone()),
14895 })
14896 .collect()
14897}
14898
14899pub fn runtime_fact_name_for_pattern(pattern: &str) -> Option<String> {
14904 let pattern = pattern.trim();
14905 if let Some(rest) = pattern.strip_prefix("fact ") {
14906 let name = rest.split_whitespace().next()?;
14907 return Some(name.to_owned());
14908 }
14909 if let Some(rest) = pattern.strip_prefix("message from ") {
14912 if let Some(channel) = rest.split_whitespace().next() {
14913 return Some(format!("message.{channel}"));
14914 }
14915 }
14916 let mut words = pattern.split_whitespace();
14917 let first = words.next()?;
14918 if words.next() == Some("completed") && words.next() == Some("turn") {
14919 let _ = first;
14920 return Some("agent.turn.completed".to_owned());
14921 }
14922 {
14923 let mut words = pattern.split_whitespace();
14924 let _tracker = words.next();
14925 if words.next() == Some("has")
14926 && words.next() == Some("ready")
14927 && words.next() == Some("issue")
14928 {
14929 return Some("tracker.issue.ready".to_owned());
14930 }
14931 }
14932 if first.chars().next().is_some_and(char::is_uppercase) {
14933 return Some(first.to_owned());
14934 }
14935 None
14936}
14937
14938fn binding_from_when(when: &str) -> Option<(String, String)> {
14942 let (pattern, _) = split_when_guard(when);
14943 let binding = binding_after_as(pattern)?;
14944 let first = pattern.split_whitespace().next()?;
14945 let completed_turn = {
14946 let mut words = pattern.split_whitespace();
14947 words.next();
14948 words.next() == Some("completed") && words.next() == Some("turn")
14949 };
14950 let has_ready_issue = {
14951 let mut words = pattern.split_whitespace();
14952 words.next();
14953 words.next() == Some("has")
14954 && words.next() == Some("ready")
14955 && words.next() == Some("issue")
14956 };
14957 let schema = if let Some(rest) = pattern.strip_prefix("fact ") {
14958 rest.split_whitespace().next()?.to_owned()
14959 } else if first.chars().next().is_some_and(char::is_uppercase) {
14960 first.to_owned()
14961 } else if first.contains('.') {
14962 first.to_owned()
14966 } else if completed_turn {
14967 "AgentTurn".to_owned()
14968 } else if has_ready_issue {
14969 "WorkItem".to_owned()
14970 } else if pattern.starts_with("message from ") {
14971 "Message".to_owned()
14974 } else {
14975 return None;
14976 };
14977
14978 Some((binding, schema))
14979}
14980
14981fn split_when_guard(when: &str) -> (&str, Option<&str>) {
14982 match when.split_once(" where ") {
14983 Some((pattern, guard)) => (pattern.trim(), Some(guard.trim())),
14984 None => (when.trim(), None),
14985 }
14986}
14987
14988fn effect_binding_schema(
14989 line: &str,
14990 kind: &IrEffectKind,
14991 semantic: &SemanticContext,
14992) -> Option<String> {
14993 match kind {
14994 IrEffectKind::SchemaCoerce => parse_coerce_call_name(line).and_then(|name| {
14995 semantic
14996 .coerce_outputs
14997 .get(name)
14998 .and_then(schema_name_for_path)
14999 }),
15000 IrEffectKind::AgentTell
15001 | IrEffectKind::CapabilityCall
15002 | IrEffectKind::EventEmit
15003 | IrEffectKind::WorkflowInvoke
15004 | IrEffectKind::TimerWait
15005 | IrEffectKind::ExecCommand
15006 | IrEffectKind::TrackerFile
15007 | IrEffectKind::TrackerClaim
15008 | IrEffectKind::TrackerRenew
15009 | IrEffectKind::TrackerRelease
15010 | IrEffectKind::TrackerFinish
15011 | IrEffectKind::LeaseAcquire
15012 | IrEffectKind::LeaseRenew
15013 | IrEffectKind::LedgerAppend
15014 | IrEffectKind::CounterConsume
15015 | IrEffectKind::SignalEmit
15016 | IrEffectKind::FileRead
15017 | IrEffectKind::FileWrite
15018 | IrEffectKind::FileImport
15019 | IrEffectKind::FileExport => None,
15020 }
15021}
15022
15023fn parse_coerce_call_name(line: &str) -> Option<&str> {
15024 let rest = line.strip_prefix("coerce ")?;
15025 rest.split_once('(').map(|(name, _)| name.trim())
15026}
15027
15028fn parse_coerce_call(line: &str) -> Option<(&str, Vec<&str>)> {
15029 let rest = line.strip_prefix("coerce ")?;
15030 let call = rest.split(" as ").next().unwrap_or(rest).trim();
15031 let (name, tail) = call.split_once('(')?;
15032 let (args, _) = tail.rsplit_once(')')?;
15033 Some((name.trim(), split_expression_args(args)))
15034}
15035
15036fn split_expression_args(args: &str) -> Vec<&str> {
15037 let mut values = Vec::new();
15038 let mut start = 0usize;
15039 let mut depth = 0i32;
15040 let mut in_string = false;
15041 let mut previous = '\0';
15042 for (index, ch) in args.char_indices() {
15043 if ch == '"' && previous != '\\' {
15044 in_string = !in_string;
15045 } else if !in_string {
15046 match ch {
15047 '(' | '[' | '{' => depth += 1,
15048 ')' | ']' | '}' => depth -= 1,
15049 ',' if depth == 0 => {
15050 let value = args[start..index].trim();
15051 if !value.is_empty() {
15052 values.push(value);
15053 }
15054 start = index + ch.len_utf8();
15055 }
15056 _ => {}
15057 }
15058 }
15059 previous = ch;
15060 }
15061 let value = args[start..].trim();
15062 if !value.is_empty() {
15063 values.push(value);
15064 }
15065 values
15066}
15067
15068fn effect_payload_statements(body: &str) -> Vec<String> {
15069 collect_body_statements(body, effect_payload_statement_balance)
15070}
15071
15072fn workflow_invoke_statements(body: &str) -> Vec<String> {
15073 collect_body_statements(body, workflow_invoke_statement_balance)
15074}
15075
15076#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15077enum StatementBalance {
15078 None,
15079 Parens,
15080 Braces,
15081}
15082
15083fn collect_body_statements(
15084 body: &str,
15085 statement_balance: fn(&str) -> Option<StatementBalance>,
15086) -> Vec<String> {
15087 let lines = body.lines().collect::<Vec<_>>();
15088 let mut statements = Vec::new();
15089 let mut index = 0usize;
15090 let mut record_depth = 0i32;
15091 let mut multiline_string = false;
15092 while index < lines.len() {
15093 let trimmed = lines[index].trim();
15094 if trimmed.is_empty() {
15095 index += 1;
15096 continue;
15097 }
15098 if multiline_string {
15099 if trimmed.contains("\"\"\"") {
15100 multiline_string = false;
15101 }
15102 index += 1;
15103 continue;
15104 }
15105 if record_depth > 0 {
15106 record_depth += brace_delta(trimmed);
15107 index += 1;
15108 continue;
15109 }
15110 if parse_record_start(trimmed).is_some() {
15111 record_depth = brace_delta(trimmed).max(1);
15112 index += 1;
15113 continue;
15114 }
15115 if trimmed.contains("\"\"\"") {
15116 multiline_string = trimmed.matches("\"\"\"").count() % 2 == 1;
15117 index += 1;
15118 continue;
15119 }
15120 if let Some(balance) = statement_balance(trimmed) {
15121 match balance {
15122 StatementBalance::None => statements.push(trimmed.to_owned()),
15123 StatementBalance::Parens => {
15124 let (statement, next_index) =
15125 statement_until_balanced(&lines, index, trimmed, paren_delta);
15126 statements.push(statement);
15127 index = next_index + 1;
15128 continue;
15129 }
15130 StatementBalance::Braces => {
15131 let (statement, next_index) =
15132 statement_until_balanced(&lines, index, trimmed, brace_delta);
15133 statements.push(statement);
15134 index = next_index + 1;
15135 continue;
15136 }
15137 }
15138 }
15139 index += 1;
15140 }
15141 statements
15142}
15143
15144fn effect_payload_statement_balance(trimmed: &str) -> Option<StatementBalance> {
15145 if trimmed.starts_with("coerce ") {
15146 Some(StatementBalance::Parens)
15147 } else if trimmed.starts_with("claim ") {
15148 Some(StatementBalance::None)
15149 } else {
15150 None
15151 }
15152}
15153
15154fn workflow_invoke_statement_balance(trimmed: &str) -> Option<StatementBalance> {
15155 trimmed
15156 .starts_with("invoke ")
15157 .then_some(StatementBalance::Braces)
15158}
15159
15160fn invoke_statement_parts(statement: &str) -> Option<(&str, &str)> {
15161 let rest = statement.trim().strip_prefix("invoke ")?;
15162 let target = rest
15163 .split_whitespace()
15164 .next()
15165 .unwrap_or("")
15166 .trim_end_matches('{');
15167 if target.is_empty() {
15168 return None;
15169 }
15170 let open = statement.find('{')?;
15171 let mut depth = 0i32;
15172 let mut close = None;
15173 for (offset, ch) in statement[open..].char_indices() {
15174 match ch {
15175 '{' => depth += 1,
15176 '}' => {
15177 depth -= 1;
15178 if depth == 0 {
15179 close = Some(open + offset);
15180 break;
15181 }
15182 }
15183 _ => {}
15184 }
15185 }
15186 let close = close?;
15187 (close > open).then_some((target, statement[open + 1..close].trim()))
15188}
15189
15190fn statement_until_balanced(
15191 lines: &[&str],
15192 index: usize,
15193 trimmed: &str,
15194 delta: fn(&str) -> i32,
15195) -> (String, usize) {
15196 let mut statement = trimmed.to_owned();
15197 let mut depth = delta(trimmed);
15198 let mut cursor = index;
15199 while depth > 0 && cursor + 1 < lines.len() {
15200 cursor += 1;
15201 let next = lines[cursor].trim();
15202 statement.push(' ');
15203 statement.push_str(next);
15204 depth += delta(next);
15205 }
15206 (statement, cursor)
15207}
15208
15209fn paren_delta(line: &str) -> i32 {
15210 line.chars().fold(0, |depth, ch| match ch {
15211 '(' => depth + 1,
15212 ')' => depth - 1,
15213 _ => depth,
15214 })
15215}
15216
15217pub fn inline_decide_schema_name(rule: &str, binding: &str) -> String {
15225 format!("decide.{rule}.{binding}")
15226}
15227
15228fn decide_field_type_syntax(ty: &str, span: SourceSpan) -> TypeSyntax {
15232 if is_primitive_type(ty) {
15233 TypeSyntax::Primitive {
15234 name: ty.to_owned(),
15235 span,
15236 }
15237 } else {
15238 TypeSyntax::Ref {
15239 name: Ident {
15240 name: ty.to_owned(),
15241 span,
15242 },
15243 }
15244 }
15245}
15246
15247#[allow(clippy::type_complexity)]
15251fn collect_decide_effects<'a>(
15252 statements: &'a [body::BodyStmt],
15253 out: &mut Vec<(&'a str, &'a [(String, String)], SourceSpan)>,
15254) {
15255 for statement in statements {
15256 match statement {
15257 body::BodyStmt::Effect(effect) => {
15258 if let body::BodyEffectKind::Decide { result_fields } = &effect.kind {
15259 if let Some(binding) = &effect.binding {
15260 out.push((binding.as_str(), result_fields.as_slice(), effect.span));
15261 }
15262 }
15263 }
15264 body::BodyStmt::After(after) => collect_decide_effects(&after.body, out),
15265 body::BodyStmt::Case(case) => {
15266 for branch in &case.branches {
15267 collect_decide_effects(&branch.body, out);
15268 }
15269 }
15270 _ => {}
15271 }
15272 }
15273}
15274
15275fn collect_decide_payload_types(
15280 statements: &[body::BodyStmt],
15281 rule_name: &str,
15282 payloads: &mut BTreeMap<String, IrType>,
15283) {
15284 let mut decides = Vec::new();
15285 collect_decide_effects(statements, &mut decides);
15286 for (binding, _fields, _span) in decides {
15287 payloads.insert(
15288 binding.to_owned(),
15289 IrType::Ref(inline_decide_schema_name(rule_name, binding)),
15290 );
15291 }
15292}
15293
15294fn collect_prompt_payload_types(
15295 statements: &[body::BodyStmt],
15296 payloads: &mut BTreeMap<String, IrType>,
15297) {
15298 for statement in statements {
15299 match statement {
15300 body::BodyStmt::Effect(effect) => {
15301 if matches!(&effect.kind, body::BodyEffectKind::Prompt { .. }) {
15302 if let Some(binding) = &effect.binding {
15303 payloads
15304 .insert(binding.clone(), IrType::Primitive(IrPrimitiveType::String));
15305 }
15306 }
15307 }
15308 body::BodyStmt::After(after) => collect_prompt_payload_types(&after.body, payloads),
15309 body::BodyStmt::Case(case) => {
15310 for branch in &case.branches {
15311 collect_prompt_payload_types(&branch.body, payloads);
15312 }
15313 }
15314 _ => {}
15315 }
15316 }
15317}
15318
15319fn collect_inline_decide_schemas(
15325 items: &[Item],
15326 semantic: &mut SemanticContext,
15327 ir: &mut IrProgram,
15328) {
15329 for item in items {
15330 let Item::Rule(rule) = item else {
15331 continue;
15332 };
15333 let (body_ast, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
15334 let mut decides = Vec::new();
15335 collect_decide_effects(&body_ast.statements, &mut decides);
15336 for (binding, fields, span) in decides {
15337 let name = inline_decide_schema_name(&rule.name.name, binding);
15338 let mut syntax_fields: BTreeMap<String, TypeSyntax> = BTreeMap::new();
15341 let mut ir_fields = Vec::new();
15342 for (field_name, field_ty) in fields {
15343 let ty = decide_field_type_syntax(field_ty, span);
15344 ir_fields.push(IrClassField {
15345 name: field_name.clone(),
15346 ty: lower_type(ty.clone()),
15347 is_key: false,
15348 presence_condition: None,
15349 span,
15350 });
15351 syntax_fields.insert(field_name.clone(), ty);
15352 }
15353 semantic.schemas.classes.insert(name.clone(), syntax_fields);
15354 ir.schemas.push(IrSchema::Class(IrClass {
15355 name,
15356 fields: ir_fields,
15357 span,
15358 }));
15359 }
15360 }
15361}
15362
15363pub fn redact_schema_name(rule: &str, binding: &str) -> String {
15366 format!("redact.{rule}.{binding}")
15367}
15368
15369#[allow(clippy::type_complexity)]
15373fn collect_redact_effects<'a>(
15374 statements: &'a [body::BodyStmt],
15375 out: &mut Vec<(&'a str, &'a [String], &'a str, SourceSpan)>,
15376) {
15377 for statement in statements {
15378 match statement {
15379 body::BodyStmt::Redact {
15380 source,
15381 keep,
15382 binding,
15383 span,
15384 } => out.push((source.as_str(), keep.as_slice(), binding.as_str(), *span)),
15385 body::BodyStmt::After(after) => collect_redact_effects(&after.body, out),
15386 body::BodyStmt::Case(case) => {
15387 for branch in &case.branches {
15388 collect_redact_effects(&branch.body, out);
15389 }
15390 }
15391 _ => {}
15392 }
15393 }
15394}
15395
15396fn rule_binding_schemas(rule: &RuleDecl, semantic: &SemanticContext) -> BTreeMap<String, String> {
15404 let mut schemas = binding_types_for_rule(rule);
15405 let (body_ast, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
15406 let mut payloads = collect_effect_payload_types(rule, semantic, &mut Vec::new());
15407 collect_exec_payload_types(&body_ast.statements, semantic, &mut payloads);
15408 collect_decide_payload_types(&body_ast.statements, &rule.name.name, &mut payloads);
15409 collect_redact_payload_types(&body_ast.statements, &rule.name.name, &mut payloads);
15410 for line in rule.body.text.lines() {
15416 let Some(rest) = line.trim().strip_prefix("after ") else {
15417 continue;
15418 };
15419 let mut words = rest.split_whitespace();
15420 let Some(binding) = words.next() else {
15421 continue;
15422 };
15423 let Some(predicate) = words.next() else {
15424 continue;
15425 };
15426 if predicate == "times" && words.next() != Some("out") {
15427 continue;
15428 }
15429 let (Some("as"), Some(alias)) = (words.next(), words.next()) else {
15430 continue;
15431 };
15432 let alias = alias.trim_end_matches('{').trim();
15433 if alias.is_empty() {
15434 continue;
15435 }
15436 if let Some(IrType::Ref(schema)) = payloads.get(binding) {
15437 schemas.insert(alias.to_owned(), schema.clone());
15438 }
15439 }
15440 for (binding, ty) in payloads {
15441 if let IrType::Ref(schema) = ty {
15442 schemas.insert(binding, schema);
15443 }
15444 }
15445 schemas
15446}
15447
15448fn collect_redact_schemas(items: &[Item], semantic: &mut SemanticContext, ir: &mut IrProgram) {
15458 for item in items {
15459 let Item::Rule(rule) = item else {
15460 continue;
15461 };
15462 let (body_ast, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
15463 let mut redacts = Vec::new();
15464 collect_redact_effects(&body_ast.statements, &mut redacts);
15465 if redacts.is_empty() {
15466 continue;
15467 }
15468 let binding_schemas = rule_binding_schemas(rule, semantic);
15469 let mut local: BTreeMap<String, String> = BTreeMap::new();
15470 for (source, keep, binding, span) in redacts {
15471 let name = redact_schema_name(&rule.name.name, binding);
15472 let source_schema = binding_schemas
15473 .get(source)
15474 .cloned()
15475 .or_else(|| local.get(source).cloned());
15476 let projected: Vec<(String, TypeSyntax)> = source_schema
15479 .as_ref()
15480 .and_then(|schema| semantic.schemas.classes.get(schema))
15481 .map(|src_fields| {
15482 keep.iter()
15483 .filter_map(|field| {
15484 src_fields.get(field).map(|ty| (field.clone(), ty.clone()))
15485 })
15486 .collect()
15487 })
15488 .unwrap_or_default();
15489 let mut syntax_fields: BTreeMap<String, TypeSyntax> = BTreeMap::new();
15490 let mut ir_fields = Vec::new();
15491 for (field_name, ty) in &projected {
15492 syntax_fields.insert(field_name.clone(), ty.clone());
15493 ir_fields.push(IrClassField {
15494 name: field_name.clone(),
15495 ty: lower_type(ty.clone()),
15496 is_key: false,
15497 presence_condition: None,
15498 span,
15499 });
15500 }
15501 semantic.schemas.classes.insert(name.clone(), syntax_fields);
15502 ir.schemas.push(IrSchema::Class(IrClass {
15503 name: name.clone(),
15504 fields: ir_fields,
15505 span,
15506 }));
15507 local.insert(binding.to_owned(), name);
15508 }
15509 }
15510}
15511
15512fn collect_redact_payload_types(
15517 statements: &[body::BodyStmt],
15518 rule_name: &str,
15519 payloads: &mut BTreeMap<String, IrType>,
15520) {
15521 let mut redacts = Vec::new();
15522 collect_redact_effects(statements, &mut redacts);
15523 for (_source, _keep, binding, _span) in redacts {
15524 payloads.insert(
15525 binding.to_owned(),
15526 IrType::Ref(redact_schema_name(rule_name, binding)),
15527 );
15528 }
15529}
15530
15531fn validate_redactions(
15536 rule: &RuleDecl,
15537 statements: &[body::BodyStmt],
15538 semantic: &SemanticContext,
15539 binding_schemas: &BTreeMap<String, String>,
15540 diagnostics: &mut Vec<Diagnostic>,
15541) {
15542 let mut redacts = Vec::new();
15543 collect_redact_effects(statements, &mut redacts);
15544 let mut local: BTreeMap<String, String> = BTreeMap::new();
15545 for (source, keep, binding, span) in redacts {
15546 let source_schema = binding_schemas
15547 .get(source)
15548 .cloned()
15549 .or_else(|| local.get(source).cloned());
15550 local.insert(
15551 binding.to_owned(),
15552 redact_schema_name(&rule.name.name, binding),
15553 );
15554 let Some(schema) = source_schema else {
15555 diagnostics.push(Diagnostic {
15556 related: Vec::new(),
15557 span,
15558 message: format!(
15559 "rule `{}` redacts `{source}`, which has no known schema",
15560 rule.name.name
15561 ),
15562 suggestion: Some(
15563 "redact a binding with a known record type — a matched `when Class as x`, or a \
15564 coerce/decide/exec result"
15565 .to_owned(),
15566 ),
15567 });
15568 continue;
15569 };
15570 let Some(src_fields) = semantic.schemas.classes.get(&schema) else {
15571 continue;
15572 };
15573 for field in keep {
15574 if !src_fields.contains_key(field) {
15575 diagnostics.push(Diagnostic {
15576 related: Vec::new(),
15577 span,
15578 message: format!(
15579 "rule `{}` redacts `{source}` keeping unknown field `{field}` of `{schema}`",
15580 rule.name.name
15581 ),
15582 suggestion: Some(format!("keep a field declared on `{schema}`")),
15583 });
15584 }
15585 }
15586 }
15587}
15588
15589fn collect_exec_payload_types(
15595 statements: &[body::BodyStmt],
15596 semantic: &SemanticContext,
15597 payloads: &mut BTreeMap<String, IrType>,
15598) {
15599 for statement in statements {
15600 match statement {
15601 body::BodyStmt::Effect(effect) => {
15602 if let body::BodyEffectKind::Exec {
15603 parse_target: Some(parse),
15604 ..
15605 } = &effect.kind
15606 {
15607 if !parse.each {
15608 if let Some(binding) = &effect.binding {
15609 if semantic.schemas.class_exists(&parse.schema) {
15610 payloads.insert(binding.clone(), IrType::Ref(parse.schema.clone()));
15611 }
15612 }
15613 }
15614 }
15615 }
15616 body::BodyStmt::After(after) => {
15617 collect_exec_payload_types(&after.body, semantic, payloads)
15618 }
15619 body::BodyStmt::Case(case) => {
15620 for branch in &case.branches {
15621 collect_exec_payload_types(&branch.body, semantic, payloads);
15622 }
15623 }
15624 _ => {}
15625 }
15626 }
15627}
15628
15629fn push_ingest_fact_writes(statements: &[body::BodyStmt], fact_writes: &mut Vec<String>) {
15631 for statement in statements {
15632 match statement {
15633 body::BodyStmt::Effect(effect) => {
15634 match &effect.kind {
15635 body::BodyEffectKind::Exec {
15636 parse_target: Some(parse),
15637 ..
15638 } if parse.each => {
15639 fact_writes.push(format!("schema:{}", parse.schema));
15640 }
15641 body::BodyEffectKind::FileImport { schema, .. } => {
15645 fact_writes.push(format!("schema:{schema}"));
15646 }
15647 _ => {}
15648 }
15649 }
15650 body::BodyStmt::After(after) => push_ingest_fact_writes(&after.body, fact_writes),
15651 body::BodyStmt::Case(case) => {
15652 for branch in &case.branches {
15653 push_ingest_fact_writes(&branch.body, fact_writes);
15654 }
15655 }
15656 _ => {}
15657 }
15658 }
15659}
15660
15661fn validate_coordination_discipline(
15675 rule: &RuleDecl,
15676 statements: &[body::BodyStmt],
15677 diagnostics: &mut Vec<Diagnostic>,
15678) {
15679 let mut acquires = Vec::new();
15680 let mut consumes = Vec::new();
15681 let mut claims = Vec::new();
15682 collect_coordination_effects(statements, &mut acquires, &mut consumes, &mut claims);
15683
15684 let claim_bindings = collect_claim_bindings(statements);
15695 let renewable: BTreeSet<&str> = acquires
15696 .iter()
15697 .map(|(b, _, _)| b.as_str())
15698 .chain(claim_bindings.iter().map(String::as_str))
15699 .collect();
15700 for_each_body(statements, &mut |stmt| {
15701 if let body::BodyStmt::Effect(effect) = stmt {
15702 if let body::BodyEffectKind::LeaseRenew {
15703 acquire_binding, ..
15704 } = &effect.kind
15705 {
15706 if !renewable.contains(acquire_binding.as_str()) {
15707 diagnostics.push(Diagnostic {
15708 related: Vec::new(),
15709 span: effect.span,
15710 message: format!(
15711 "rule `{}` renews unbound coordination binding `{}`",
15712 rule.name.name, acquire_binding
15713 ),
15714 suggestion: Some(format!(
15715 "`renew {acquire_binding}` must name a lease acquired here (`acquire ... as {acquire_binding}`) or an issue claimed here (`claim ... as {acquire_binding}`)"
15716 )),
15717 });
15718 }
15719 }
15720 }
15721 });
15722
15723 let work_items: Vec<String> = rule
15734 .whens
15735 .iter()
15736 .filter_map(|when| when_has_ready_binding(&when.text))
15737 .collect();
15738 let releasable: BTreeSet<&str> = acquires
15739 .iter()
15740 .map(|(b, _, _)| b.as_str())
15741 .chain(claims.iter().map(|(item, _)| item.as_str()))
15742 .chain(work_items.iter().map(String::as_str))
15743 .collect();
15744 for_each_body(statements, &mut |stmt| {
15745 if let body::BodyStmt::Effect(effect) = stmt {
15746 if let body::BodyEffectKind::TrackerRelease { item } = &effect.kind {
15747 if !releasable.contains(item.as_str()) {
15748 diagnostics.push(Diagnostic {
15749 related: Vec::new(),
15750 span: effect.span,
15751 message: format!(
15752 "rule `{}` releases unbound coordination item `{}`",
15753 rule.name.name, item
15754 ),
15755 suggestion: Some(format!(
15756 "`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"
15757 )),
15758 });
15759 }
15760 }
15761 }
15762 });
15763
15764 if acquires.len() > 1 {
15765 diagnostics.push(Diagnostic { related: Vec::new(),
15766 span: acquires[1].2,
15767 message: format!(
15768 "rule `{}` acquires more than one lease in a single progression",
15769 rule.name.name
15770 ),
15771 suggestion: Some(
15772 "the hard default is at most one held lease per progression (it breaks hold-and-wait); restructure into separate rules"
15773 .to_owned(),
15774 ),
15775 });
15776 }
15777 for (binding, until_ttl, span) in &acquires {
15778 if *until_ttl {
15779 continue;
15780 }
15781 let mut predicates = BTreeSet::new();
15782 collect_after_predicates(statements, binding, &mut predicates);
15783 for required in ["held", "contended"] {
15784 if !predicates.contains(required) {
15785 diagnostics.push(Diagnostic { related: Vec::new(),
15786 span: *span,
15787 message: format!(
15788 "rule `{}` does not handle the `{required}` outcome of lease `{binding}`",
15789 rule.name.name
15790 ),
15791 suggestion: Some(format!(
15792 "coordination outcomes are exhaustive: add `after {binding} {required} {{ ... }}`"
15793 )),
15794 });
15795 }
15796 }
15797 if let Some(held_body) = find_after_body(statements, binding, body::AfterPredicate::Held) {
15798 if !releases_or_terminates(held_body, binding) {
15799 diagnostics.push(Diagnostic { related: Vec::new(),
15800 span: *span,
15801 message: format!(
15802 "rule `{}` can hold lease `{binding}` forever: the `held` branch neither releases it nor reaches a workflow terminal",
15803 rule.name.name
15804 ),
15805 suggestion: Some(format!(
15806 "add `release {binding}` on every non-terminal path, or use `acquire ... until ttl` for fire-and-forget"
15807 )),
15808 });
15809 }
15810 }
15811 }
15812 for (binding, span) in &consumes {
15813 let mut predicates = BTreeSet::new();
15814 collect_after_predicates(statements, binding, &mut predicates);
15815 for required in ["ok", "over"] {
15816 if !predicates.contains(required) {
15817 diagnostics.push(Diagnostic { related: Vec::new(),
15818 span: *span,
15819 message: format!(
15820 "rule `{}` does not handle the `{required}` outcome of counter consume `{binding}`",
15821 rule.name.name
15822 ),
15823 suggestion: Some(format!(
15824 "coordination outcomes are exhaustive: add `after {binding} {required} {{ ... }}`"
15825 )),
15826 });
15827 }
15828 }
15829 }
15830}
15831
15832fn collect_coordination_effects(
15833 statements: &[body::BodyStmt],
15834 acquires: &mut Vec<(String, bool, SourceSpan)>,
15835 consumes: &mut Vec<(String, SourceSpan)>,
15836 claims: &mut Vec<(String, SourceSpan)>,
15837) {
15838 for_each_body(statements, &mut |stmt| {
15839 if let body::BodyStmt::Effect(effect) = stmt {
15840 match &effect.kind {
15841 body::BodyEffectKind::LeaseAcquire { until_ttl, .. } => {
15842 if let Some(binding) = &effect.binding {
15843 acquires.push((binding.clone(), *until_ttl, effect.span));
15844 }
15845 }
15846 body::BodyEffectKind::CounterConsume { .. } => {
15847 if let Some(binding) = &effect.binding {
15848 consumes.push((binding.clone(), effect.span));
15849 }
15850 }
15851 body::BodyEffectKind::TrackerClaim { item, .. } => {
15855 claims.push((item.clone(), effect.span));
15856 }
15857 _ => {}
15858 }
15859 }
15860 });
15861}
15862
15863fn when_has_ready_binding(when: &str) -> Option<String> {
15869 let (pattern, _) = split_when_guard(when);
15870 let mut words = pattern.split_whitespace();
15871 let _queue = words.next()?;
15872 if words.next() == Some("has") && words.next() == Some("ready") {
15873 return binding_after_as(pattern);
15874 }
15875 None
15876}
15877
15878fn collect_after_predicates(
15879 statements: &[body::BodyStmt],
15880 binding: &str,
15881 predicates: &mut BTreeSet<&'static str>,
15882) {
15883 for_each_body(statements, &mut |stmt| {
15884 if let body::BodyStmt::After(after) = stmt {
15885 if after.binding == binding {
15886 predicates.insert(after.predicate.as_str());
15887 }
15888 }
15889 });
15890}
15891
15892fn find_after_body<'a>(
15893 statements: &'a [body::BodyStmt],
15894 binding: &str,
15895 predicate: body::AfterPredicate,
15896) -> Option<&'a [body::BodyStmt]> {
15897 for statement in statements {
15898 match statement {
15899 body::BodyStmt::After(after) => {
15900 if after.binding == binding && after.predicate == predicate {
15901 return Some(&after.body);
15902 }
15903 if let Some(found) = find_after_body(&after.body, binding, predicate) {
15904 return Some(found);
15905 }
15906 }
15907 body::BodyStmt::Case(case) => {
15908 for branch in &case.branches {
15909 if let Some(found) = find_after_body(&branch.body, binding, predicate) {
15910 return Some(found);
15911 }
15912 }
15913 }
15914 _ => {}
15915 }
15916 }
15917 None
15918}
15919
15920fn releases_or_terminates(statements: &[body::BodyStmt], binding: &str) -> bool {
15925 statements.iter().any(|statement| match statement {
15926 body::BodyStmt::Effect(effect) => matches!(
15927 &effect.kind,
15928 body::BodyEffectKind::TrackerRelease { item } if item == binding
15929 ),
15930 body::BodyStmt::Terminal(_) => true,
15931 body::BodyStmt::After(after) => releases_or_terminates(&after.body, binding),
15932 body::BodyStmt::Case(case) => {
15933 !case.branches.is_empty()
15934 && case
15935 .branches
15936 .iter()
15937 .all(|branch| releases_or_terminates(&branch.body, binding))
15938 }
15939 _ => false,
15940 })
15941}
15942
15943fn for_each_body(statements: &[body::BodyStmt], visit: &mut impl FnMut(&body::BodyStmt)) {
15944 for statement in statements {
15945 visit(statement);
15946 match statement {
15947 body::BodyStmt::After(after) => for_each_body(&after.body, visit),
15948 body::BodyStmt::Case(case) => {
15949 for branch in &case.branches {
15950 for_each_body(&branch.body, visit);
15951 }
15952 }
15953 _ => {}
15954 }
15955 }
15956}
15957
15958fn family_b_arm_allowed(
15963 scrutinee: &str,
15964 pattern: &str,
15965 binding_types: &BTreeMap<String, String>,
15966 semantic: &SemanticContext,
15967) -> BTreeSet<(String, String)> {
15968 let mut allowed = BTreeSet::new();
15969 let Some((root, disc)) = scrutinee.split_once('.') else {
15970 return allowed;
15971 };
15972 if disc.contains('.') {
15973 return allowed;
15974 }
15975 let trimmed = pattern.trim();
15976 if trimmed == "_" || trimmed == "default" {
15977 return allowed;
15978 }
15979 let literal = trimmed.trim_matches('"');
15980 if literal.is_empty() {
15981 return allowed;
15982 }
15983 let Some(schema) = binding_types.get(root) else {
15984 return allowed;
15985 };
15986 if let Some(conditions) = semantic.schemas.presence.get(schema) {
15987 for (field, (cond_disc, cond_literal)) in conditions {
15988 if cond_disc == disc && cond_literal == literal {
15989 allowed.insert((root.to_owned(), field.clone()));
15990 }
15991 }
15992 }
15993 allowed
15994}
15995
15996fn check_conditioned_reads_in_text(
16000 rule: &RuleDecl,
16001 text: &str,
16002 span: SourceSpan,
16003 semantic: &SemanticContext,
16004 binding_types: &BTreeMap<String, String>,
16005 allowed: &BTreeSet<(String, String)>,
16006 diagnostics: &mut Vec<Diagnostic>,
16007) {
16008 for (root, path) in dotted_paths(text) {
16009 let Some(first) = path.first() else {
16010 continue;
16011 };
16012 let Some(schema) = binding_types.get(&root) else {
16013 continue;
16014 };
16015 if let Some((disc, _literal)) = semantic.schemas.field_presence(schema, first) {
16016 if !allowed.contains(&(root.clone(), first.clone())) {
16017 diagnostics.push(Diagnostic {
16018 related: Vec::new(),
16019 span,
16020 message: format!(
16021 "rule `{}` reads conditional field `{root}.{first}` outside a matching `case {root}.{disc}` arm",
16022 rule.name.name
16023 ),
16024 suggestion: Some(format!(
16025 "read `{root}.{first}` inside `case {root}.{disc} {{ \"...\" => ... }}` — it is present only for a specific `{disc}`"
16026 )),
16027 });
16028 }
16029 }
16030 }
16031}
16032
16033fn check_conditioned_reads_in_fields(
16034 rule: &RuleDecl,
16035 fields: &[body::FieldAssign],
16036 semantic: &SemanticContext,
16037 binding_types: &BTreeMap<String, String>,
16038 allowed: &BTreeSet<(String, String)>,
16039 diagnostics: &mut Vec<Diagnostic>,
16040) {
16041 for field in fields {
16042 match &field.value {
16043 body::FieldValue::Expr { source, .. } => check_conditioned_reads_in_text(
16044 rule,
16045 source,
16046 field.span,
16047 semantic,
16048 binding_types,
16049 allowed,
16050 diagnostics,
16051 ),
16052 body::FieldValue::Nested { fields, .. } => check_conditioned_reads_in_fields(
16053 rule,
16054 fields,
16055 semantic,
16056 binding_types,
16057 allowed,
16058 diagnostics,
16059 ),
16060 body::FieldValue::Shorthand => {}
16061 }
16062 }
16063}
16064
16065fn validate_conditioned_field_reads(
16072 rule: &RuleDecl,
16073 statements: &[body::BodyStmt],
16074 semantic: &SemanticContext,
16075 binding_types: &BTreeMap<String, String>,
16076 allowed: &BTreeSet<(String, String)>,
16077 diagnostics: &mut Vec<Diagnostic>,
16078) {
16079 for statement in statements {
16080 match statement {
16081 body::BodyStmt::Record(record) => check_conditioned_reads_in_fields(
16082 rule,
16083 &record.fields,
16084 semantic,
16085 binding_types,
16086 allowed,
16087 diagnostics,
16088 ),
16089 body::BodyStmt::Terminal(terminal) => {
16090 check_conditioned_reads_in_fields(
16091 rule,
16092 &terminal.fields,
16093 semantic,
16094 binding_types,
16095 allowed,
16096 diagnostics,
16097 );
16098 if let Some(body::FieldValue::Expr { source, .. }) = &terminal.scalar {
16100 check_conditioned_reads_in_text(
16101 rule,
16102 source,
16103 terminal.span,
16104 semantic,
16105 binding_types,
16106 allowed,
16107 diagnostics,
16108 );
16109 }
16110 }
16111 body::BodyStmt::Done {
16112 replacement: Some(record),
16113 ..
16114 } => check_conditioned_reads_in_fields(
16115 rule,
16116 &record.fields,
16117 semantic,
16118 binding_types,
16119 allowed,
16120 diagnostics,
16121 ),
16122 body::BodyStmt::Milestone { fields, .. } => check_conditioned_reads_in_fields(
16123 rule,
16124 fields,
16125 semantic,
16126 binding_types,
16127 allowed,
16128 diagnostics,
16129 ),
16130 body::BodyStmt::Done { .. }
16131 | body::BodyStmt::Cancel { .. }
16132 | body::BodyStmt::Redact { .. }
16133 | body::BodyStmt::Effect(_) => {}
16134 body::BodyStmt::After(after) => validate_conditioned_field_reads(
16135 rule,
16136 &after.body,
16137 semantic,
16138 binding_types,
16139 allowed,
16140 diagnostics,
16141 ),
16142 body::BodyStmt::Region(region) => {
16143 validate_conditioned_field_reads(
16144 rule,
16145 ®ion.body,
16146 semantic,
16147 binding_types,
16148 allowed,
16149 diagnostics,
16150 );
16151 validate_conditioned_field_reads(
16152 rule,
16153 ®ion.lapse_body,
16154 semantic,
16155 binding_types,
16156 allowed,
16157 diagnostics,
16158 );
16159 }
16160 body::BodyStmt::Case(case) => {
16161 for arm in &case.branches {
16162 let mut arm_allowed = allowed.clone();
16163 arm_allowed.extend(family_b_arm_allowed(
16164 &case.scrutinee,
16165 &arm.pattern,
16166 binding_types,
16167 semantic,
16168 ));
16169 if let Some(guard) = &arm.guard {
16170 check_conditioned_reads_in_text(
16171 rule,
16172 guard,
16173 arm.span,
16174 semantic,
16175 binding_types,
16176 &arm_allowed,
16177 diagnostics,
16178 );
16179 }
16180 validate_conditioned_field_reads(
16181 rule,
16182 &arm.body,
16183 semantic,
16184 binding_types,
16185 &arm_allowed,
16186 diagnostics,
16187 );
16188 }
16189 }
16190 }
16191 }
16192}
16193
16194fn validate_body_effect_operands(
16195 rule: &RuleDecl,
16196 statements: &[body::BodyStmt],
16197 semantic: &SemanticContext,
16198 binding_types: &BTreeMap<String, String>,
16199 diagnostics: &mut Vec<Diagnostic>,
16200) {
16201 for statement in statements {
16202 match statement {
16203 body::BodyStmt::Effect(effect) => {
16204 match &effect.kind {
16205 body::BodyEffectKind::LeaseAcquire { resource, .. }
16206 if !semantic.leases.contains(resource) =>
16207 {
16208 diagnostics.push(Diagnostic { related: Vec::new(),
16209 span: effect.span,
16210 message: format!(
16211 "rule `{}` acquires undeclared lease `{resource}`",
16212 rule.name.name
16213 ),
16214 suggestion: Some(format!(
16215 "declare `lease {resource} {{ key <Type> slots <N> ttl <duration> }}`"
16216 )),
16217 });
16218 }
16219 body::BodyEffectKind::LedgerAppend { ledger, schema, .. } => {
16220 if !semantic.ledgers.contains(ledger) {
16221 diagnostics.push(Diagnostic { related: Vec::new(),
16222 span: effect.span,
16223 message: format!(
16224 "rule `{}` appends to undeclared ledger `{ledger}`",
16225 rule.name.name
16226 ),
16227 suggestion: Some(format!(
16228 "declare `ledger {ledger} {{ entry <Type> partition by <field> retain <duration> }}`"
16229 )),
16230 });
16231 }
16232 if !semantic.schemas.class_exists(schema) {
16233 diagnostics.push(Diagnostic {
16234 related: Vec::new(),
16235 span: effect.span,
16236 message: format!(
16237 "rule `{}` appends unknown entry class `{schema}`",
16238 rule.name.name
16239 ),
16240 suggestion: Some(format!("declare `class {schema}` first")),
16241 });
16242 }
16243 }
16244 body::BodyEffectKind::CounterConsume { counter, .. }
16245 if !semantic.counters.contains(counter) =>
16246 {
16247 diagnostics.push(Diagnostic { related: Vec::new(),
16248 span: effect.span,
16249 message: format!(
16250 "rule `{}` consumes undeclared counter `{counter}`",
16251 rule.name.name
16252 ),
16253 suggestion: Some(format!(
16254 "declare `counter {counter} {{ key <Type> cap <N> reset <period> }}`"
16255 )),
16256 });
16257 }
16258 _ => {}
16259 }
16260 if let body::BodyEffectKind::Exec {
16265 target:
16266 body::ExecTarget::Capability {
16267 name,
16268 stdin_binding,
16269 },
16270 ..
16271 } = &effect.kind
16272 {
16273 match binding_types.get(stdin_binding) {
16274 None => {
16275 diagnostics.push(Diagnostic {
16276 related: Vec::new(),
16277 span: effect.span,
16278 message: format!(
16279 "rule `{}` uses unknown binding `{stdin_binding}` in `exec {name} with {stdin_binding}` — `with` requires a typed record binding",
16280 rule.name.name
16281 ),
16282 suggestion: Some(format!(
16283 "bind a typed record first (e.g. `when <Class> as {stdin_binding}` or `coerce ... -> <Class> as {stdin_binding}`) and pass that binding to `with`"
16284 )),
16285 });
16286 }
16287 Some(schema)
16293 if schema.contains('.') && !semantic.schemas.class_exists(schema) =>
16294 {
16295 diagnostics.push(Diagnostic {
16296 related: Vec::new(),
16297 span: effect.span,
16298 message: format!(
16299 "rule `{}` passes untyped fact binding `{stdin_binding}` to `exec {name} with` — `with` requires a typed record binding",
16300 rule.name.name
16301 ),
16302 suggestion: Some(format!(
16303 "declare `signal {schema} {{ ... }}` for a typed reaction, or bind a declared class and pass that to `with`"
16304 )),
16305 });
16306 }
16307 Some(_) => {}
16308 }
16309 }
16310 if let body::BodyEffectKind::Exec {
16311 parse_target: Some(parse),
16312 ..
16313 } = &effect.kind
16314 {
16315 if !semantic.schemas.class_exists(&parse.schema) {
16316 let suggestion =
16317 match closest_name(&parse.schema, semantic.schemas.classes.keys()) {
16318 Some(candidate) => format!(
16319 "did you mean `{candidate}`? otherwise declare `class {}`",
16320 parse.schema
16321 ),
16322 None => format!(
16323 "declare `class {}` before parsing into it",
16324 parse.schema
16325 ),
16326 };
16327 diagnostics.push(Diagnostic {
16328 related: Vec::new(),
16329 span: effect.span,
16330 message: format!(
16331 "rule `{}` parses exec output into unknown schema `{}`",
16332 rule.name.name, parse.schema
16333 ),
16334 suggestion: Some(suggestion),
16335 });
16336 }
16337 }
16338 let body::BodyEffectKind::Timer {
16339 until: Some(until), ..
16340 } = &effect.kind
16341 else {
16342 continue;
16343 };
16344 if body::is_iso8601_instant(until) {
16345 continue;
16346 }
16347 let mut segments = until.split('.');
16348 let root = segments.next().unwrap_or_default();
16349 let path = segments.map(str::to_owned).collect::<Vec<_>>();
16350 let Some(schema) = binding_types.get(root) else {
16351 diagnostics.push(Diagnostic { related: Vec::new(),
16352 span: effect.span,
16353 message: format!(
16354 "rule `{}` uses unknown binding `{root}` in `timer until {until}`",
16355 rule.name.name
16356 ),
16357 suggestion: Some(
16358 "bind a fact in `when` and reference a `time` field on it, or use an ISO-8601 literal"
16359 .to_owned(),
16360 ),
16361 });
16362 continue;
16363 };
16364 if schema.contains('.') {
16367 continue;
16368 }
16369 let resolved = if path.is_empty() {
16370 Err(format!(
16371 "`{root}` is a `{schema}` record, not a `time` value"
16372 ))
16373 } else {
16374 semantic.schemas.resolve_field_path(schema, &path)
16375 };
16376 match resolved {
16377 Ok(TypeSyntax::Primitive { ref name, .. }) if name == "time" => {}
16378 Ok(_) => {
16379 diagnostics.push(Diagnostic { related: Vec::new(),
16380 span: effect.span,
16381 message: format!(
16382 "rule `{}` uses non-time operand `{until}` in `timer until`",
16383 rule.name.name
16384 ),
16385 suggestion: Some(format!(
16386 "declare the field as `time` on `{schema}` or use an ISO-8601 literal"
16387 )),
16388 });
16389 }
16390 Err(message) => {
16391 diagnostics.push(Diagnostic { related: Vec::new(),
16392 span: effect.span,
16393 message: format!(
16394 "rule `{}` has invalid `timer until` operand `{until}`: {message}",
16395 rule.name.name
16396 ),
16397 suggestion: Some(
16398 "reference a `time`-typed field on a bound fact, or use an ISO-8601 literal"
16399 .to_owned(),
16400 ),
16401 });
16402 }
16403 }
16404 }
16405 body::BodyStmt::After(after) => {
16406 validate_body_effect_operands(
16407 rule,
16408 &after.body,
16409 semantic,
16410 binding_types,
16411 diagnostics,
16412 );
16413 }
16414 body::BodyStmt::Case(case) => {
16415 for branch in &case.branches {
16416 validate_body_effect_operands(
16417 rule,
16418 &branch.body,
16419 semantic,
16420 binding_types,
16421 diagnostics,
16422 );
16423 }
16424 }
16425 _ => {}
16426 }
16427 }
16428}
16429
16430fn validate_known_field_paths(
16431 rule: &RuleDecl,
16432 line: &str,
16433 semantic: &SemanticContext,
16434 binding_types: &BTreeMap<String, String>,
16435 diagnostics: &mut Vec<Diagnostic>,
16436) {
16437 validate_known_field_paths_at_span(
16438 rule,
16439 line,
16440 rule.body.span,
16441 semantic,
16442 binding_types,
16443 diagnostics,
16444 );
16445}
16446
16447fn validate_known_field_paths_at_span(
16448 rule: &RuleDecl,
16449 line: &str,
16450 span: SourceSpan,
16451 semantic: &SemanticContext,
16452 binding_types: &BTreeMap<String, String>,
16453 diagnostics: &mut Vec<Diagnostic>,
16454) {
16455 for (root, path) in dotted_paths(line) {
16456 let Some(schema) = binding_types.get(&root) else {
16457 continue;
16458 };
16459 if !semantic.schemas.class_exists(schema) {
16460 continue;
16461 }
16462 if let Err(message) = semantic.schemas.resolve_field_path(schema, &path) {
16463 diagnostics.push(Diagnostic {
16464 related: Vec::new(),
16465 span,
16466 message: format!(
16467 "rule `{}` has invalid field path `{root}.{}`: {message}",
16468 rule.name.name,
16469 path.join(".")
16470 ),
16471 suggestion: Some(
16472 "use a field declared on the bound schema or add it to the class declaration"
16473 .to_owned(),
16474 ),
16475 });
16476 }
16477 }
16478}
16479
16480fn dotted_paths(line: &str) -> Vec<(String, Vec<String>)> {
16481 let bytes = line.as_bytes();
16482 let mut paths = Vec::new();
16483 let mut index = 0;
16484
16485 while index < bytes.len() {
16486 if !is_ident_start(bytes[index]) {
16487 index += 1;
16488 continue;
16489 }
16490
16491 let root_start = index;
16492 index += 1;
16493 while index < bytes.len() && is_ident_continue(bytes[index]) {
16494 index += 1;
16495 }
16496 let root = &line[root_start..index];
16497 let mut fields = Vec::new();
16498
16499 while bytes.get(index) == Some(&b'.')
16500 && bytes
16501 .get(index + 1)
16502 .is_some_and(|byte| is_ident_start(*byte))
16503 {
16504 index += 1;
16505 let field_start = index;
16506 index += 1;
16507 while index < bytes.len() && is_ident_continue(bytes[index]) {
16508 index += 1;
16509 }
16510 fields.push(line[field_start..index].to_owned());
16511 }
16512
16513 if !fields.is_empty() {
16514 paths.push((root.to_owned(), fields));
16515 }
16516 }
16517
16518 paths
16519}
16520
16521fn interpolation_roots(line: &str) -> Vec<String> {
16522 let mut roots = Vec::new();
16523 let mut rest = line;
16524
16525 while let Some(open) = rest.find("{{") {
16526 let after_open = &rest[open + 2..];
16527 let Some(close) = after_open.find("}}") else {
16528 break;
16529 };
16530 let expr = after_open[..close].trim();
16531 if let Some(root) = expr
16532 .split(|ch: char| !ch.is_alphanumeric() && ch != '_')
16533 .find(|part| !part.is_empty())
16534 {
16535 roots.push(root.to_owned());
16536 }
16537 rest = &after_open[close + 2..];
16538 }
16539
16540 roots
16541}
16542
16543const RESERVED_BINDING_KEYWORDS: &[&str] = &[
16546 "after", "call", "case", "coerce", "complete", "consume", "done", "emit", "fail", "invoke",
16547 "record", "tell", "when", "where",
16548];
16549
16550fn validate_binding_name(
16551 rule: &RuleDecl,
16552 binding: &str,
16553 span: SourceSpan,
16554 diagnostics: &mut Vec<Diagnostic>,
16555) {
16556 if RESERVED_BINDING_KEYWORDS.contains(&binding) {
16557 diagnostics.push(Diagnostic {
16558 related: Vec::new(),
16559 span,
16560 message: format!(
16561 "rule `{}` binds reserved keyword `{binding}`",
16562 rule.name.name
16563 ),
16564 suggestion: Some(format!(
16565 "`{binding}` is a rule body keyword; choose another binding name"
16566 )),
16567 });
16568 }
16569}
16570
16571fn closest_name<'a>(target: &str, candidates: impl Iterator<Item = &'a String>) -> Option<String> {
16572 let target_lower = target.to_lowercase();
16573 candidates
16574 .map(|candidate| {
16575 let distance = edit_distance(&target_lower, &candidate.to_lowercase());
16576 (distance, candidate)
16577 })
16578 .filter(|(distance, candidate)| {
16579 *distance <= 2 && *distance < target.len().min(candidate.len())
16580 })
16581 .min_by_key(|(distance, candidate)| (*distance, candidate.as_str().to_owned()))
16582 .map(|(_, candidate)| candidate.clone())
16583}
16584
16585fn edit_distance(a: &str, b: &str) -> usize {
16586 let a: Vec<char> = a.chars().collect();
16587 let b: Vec<char> = b.chars().collect();
16588 let mut previous: Vec<usize> = (0..=b.len()).collect();
16589 let mut current = vec![0usize; b.len() + 1];
16590 for (i, a_char) in a.iter().enumerate() {
16591 current[0] = i + 1;
16592 for (j, b_char) in b.iter().enumerate() {
16593 let substitution = previous[j] + usize::from(a_char != b_char);
16594 current[j + 1] = substitution.min(previous[j + 1] + 1).min(current[j] + 1);
16595 }
16596 std::mem::swap(&mut previous, &mut current);
16597 }
16598 previous[b.len()]
16599}
16600
16601fn fact_read_from_when(when: &str) -> String {
16602 let (pattern, _) = split_when_guard(when);
16603 let first = pattern.split_whitespace().next().unwrap_or("<empty>");
16604 if first.chars().next().is_some_and(char::is_uppercase) {
16605 format!("schema:{first}")
16606 } else {
16607 format!("pattern:{pattern}")
16608 }
16609}
16610
16611fn parse_record_start(line: &str) -> Option<(String, Option<String>)> {
16612 let rest = line.strip_prefix("record ").or_else(|| {
16613 line.strip_prefix("done ")
16614 .and_then(|rest| rest.split_once("->"))
16615 .map(|(_, record)| record.trim())
16616 .and_then(|record| record.strip_prefix("record "))
16617 })?;
16618 let before_brace = rest.split('{').next().unwrap_or(rest).trim();
16619 let mut parts = before_brace.split_whitespace();
16620 let schema = parts.next()?.to_owned();
16621 let from_binding = match (parts.next(), parts.next(), parts.next()) {
16622 (None, None, None) => None,
16623 (Some("from"), Some(binding), None) => Some(binding.to_owned()),
16624 _ => return None,
16625 };
16626 Some((schema, from_binding))
16627}
16628
16629fn validate_record_field(
16630 rule: &RuleDecl,
16631 line: &str,
16632 record_schema: &str,
16633 semantic: &SemanticContext,
16634 binding_types: &BTreeMap<String, String>,
16635 known_roots: &BTreeSet<String>,
16636 diagnostics: &mut Vec<Diagnostic>,
16637) {
16638 let Some((field, expr)) = record_field_assignment(line) else {
16639 diagnostics.push(Diagnostic {
16640 related: Vec::new(),
16641 span: rule.body.span,
16642 message: format!(
16643 "rule `{}` has malformed field assignment in `record {record_schema}`",
16644 rule.name.name
16645 ),
16646 suggestion: Some("write record fields as `field value`".to_owned()),
16647 });
16648 return;
16649 };
16650
16651 let Some(fields) = semantic.schemas.classes.get(record_schema) else {
16652 return;
16653 };
16654 let Some(field_ty) = fields.get(field) else {
16655 diagnostics.push(Diagnostic {
16656 related: Vec::new(),
16657 span: rule.body.span,
16658 message: format!("class `{record_schema}` has no field `{field}`"),
16659 suggestion: Some(format!(
16660 "add `{field}` to `class {record_schema}` or record an existing field"
16661 )),
16662 });
16663 return;
16664 };
16665
16666 if let Some((root, path)) = expression_path(expr) {
16667 if let Some(schema) = binding_types.get(&root) {
16668 if !semantic.schemas.class_exists(schema) {
16669 return;
16670 }
16671 if let Err(message) = semantic.schemas.resolve_field_path(schema, &path) {
16672 diagnostics.push(Diagnostic { related: Vec::new(),
16673 span: rule.body.span,
16674 message: format!(
16675 "rule `{}` has invalid field path `{root}.{}`: {message}",
16676 rule.name.name,
16677 path.join(".")
16678 ),
16679 suggestion: Some(
16680 "use a field declared on the bound schema or add it to the class declaration"
16681 .to_owned(),
16682 ),
16683 });
16684 }
16685 } else if let Some(root) = dangling_value_root(expr, known_roots) {
16686 diagnostics.push(Diagnostic { related: Vec::new(),
16689 span: rule.body.span,
16690 message: format!(
16691 "rule `{}` has unknown binding `{root}` in `record {record_schema}` field `{field}`",
16692 rule.name.name
16693 ),
16694 suggestion: Some(
16695 "reference a binding from a `when ... as name` clause, an effect `as` binding, or a `case` pattern"
16696 .to_owned(),
16697 ),
16698 });
16699 }
16700 }
16701
16702 validate_literal_assignment(
16703 rule,
16704 record_schema,
16705 field,
16706 field_ty,
16707 expr,
16708 semantic,
16709 diagnostics,
16710 );
16711 validate_expected_assignment(
16712 rule,
16713 record_schema,
16714 field,
16715 field_ty,
16716 expr,
16717 semantic,
16718 binding_types,
16719 diagnostics,
16720 );
16721}
16722
16723fn record_field_assignment(line: &str) -> Option<(&str, &str)> {
16724 let field_end = line.find(char::is_whitespace)?;
16725 let field = &line[..field_end];
16726 let expr = line[field_end..].trim();
16727 (!field.is_empty() && !expr.is_empty()).then_some((field, expr))
16728}
16729
16730const SPECIAL_VALUE_ROOTS: &[&str] = &["external", "ctx"];
16734
16735fn collect_all_binding_names(statements: &[body::BodyStmt], out: &mut BTreeSet<String>) {
16741 for statement in statements {
16742 match statement {
16743 body::BodyStmt::Effect(effect) => {
16744 if let Some(binding) = &effect.binding {
16745 out.insert(binding.clone());
16746 }
16747 }
16748 body::BodyStmt::Region(region) => {
16749 if let Some(view) = ®ion.lapse_binding {
16750 out.insert(view.clone());
16751 }
16752 collect_all_binding_names(®ion.body, out);
16753 collect_all_binding_names(®ion.lapse_body, out);
16754 }
16755 body::BodyStmt::After(after) => {
16756 if let Some(alias) = &after.alias {
16757 out.insert(alias.clone());
16758 }
16759 collect_all_binding_names(&after.body, out);
16760 }
16761 body::BodyStmt::Case(case) => {
16762 for branch in &case.branches {
16763 if let Some(binding) = &branch.binding {
16764 out.insert(binding.clone());
16765 }
16766 collect_all_binding_names(&branch.body, out);
16767 }
16768 }
16769 body::BodyStmt::Redact { binding, .. } => {
16771 out.insert(binding.clone());
16772 }
16773 body::BodyStmt::Record(_)
16774 | body::BodyStmt::Done { .. }
16775 | body::BodyStmt::Terminal(_)
16776 | body::BodyStmt::Milestone { .. }
16777 | body::BodyStmt::Cancel { .. } => {}
16778 }
16779 }
16780}
16781
16782fn validate_source_emit_signal_declared(
16791 source: &SourceDecl,
16792 declared_signals: &BTreeSet<String>,
16793 diagnostics: &mut Vec<Diagnostic>,
16794) {
16795 let signal = &source.emit.signal;
16796 if signal.contains('.') && !declared_signals.contains(signal) {
16797 let suggestion = match closest_name(signal, declared_signals.iter()) {
16798 Some(candidate) => {
16799 format!("did you mean `{candidate}`? otherwise declare `signal {signal} {{ ... }}`")
16800 }
16801 None => format!("declare `signal {signal} {{ ... }}` so rules can react to it"),
16802 };
16803 diagnostics.push(Diagnostic {
16804 related: Vec::new(),
16805 span: source.emit.signal_span,
16806 message: format!(
16807 "source `{}` emits undeclared signal `{}`",
16808 source.name.name, signal
16809 ),
16810 suggestion: Some(suggestion),
16811 });
16812 }
16813
16814 let observation_fields: Option<&[&str]> = match source.provider.name.as_str() {
16822 "clock" => Some(&[
16823 "scheduled_at",
16824 "observed_at",
16825 "occurrence_id",
16826 "missed_count",
16827 "schedule_name",
16828 ]),
16829 "file" if source.watch.is_some() => Some(&["path", "content_hash", "watch"]),
16833 "file" => Some(&["line", "line_index", "path"]),
16834 "http" => Some(&["item", "item_index", "url"]),
16835 _ => None,
16836 };
16837 if let (Some(fields), Some(SourceValue::Path { segments, .. })) =
16840 (observation_fields, &source.dedup)
16841 {
16842 if let [field] = segments.as_slice() {
16843 if !fields.contains(&field.name.as_str()) {
16844 diagnostics.push(Diagnostic {
16845 related: Vec::new(),
16846 span: field.span,
16847 message: format!(
16848 "source `{}` `dedup` reads `{}.{}`, but a `{}` source's observation has no field `{}`",
16849 source.name.name,
16850 source.observe_binding.name,
16851 field.name,
16852 source.provider.name,
16853 field.name
16854 ),
16855 suggestion: Some(format!(
16856 "available observation fields: {}",
16857 fields.join(", ")
16858 )),
16859 });
16860 }
16861 }
16862 }
16863 if let Some(fields) = observation_fields {
16864 let observe = &source.observe_binding.name;
16865 for emit_field in &source.emit.fields {
16866 let SourceValue::Path {
16867 binding,
16868 segments,
16869 span,
16870 } = &emit_field.value
16871 else {
16872 continue;
16873 };
16874 if &binding.name != observe {
16875 diagnostics.push(Diagnostic {
16876 related: Vec::new(),
16877 span: *span,
16878 message: format!(
16879 "source `{}` emit reads unknown binding `{}`",
16880 source.name.name, binding.name
16881 ),
16882 suggestion: Some(format!(
16883 "the source's observation binding is `{observe}` (declared by `observe as {observe}`)"
16884 )),
16885 });
16886 continue;
16887 }
16888 if let Some(obs_field) = segments.first() {
16889 if !fields.contains(&obs_field.name.as_str()) {
16890 diagnostics.push(Diagnostic {
16891 related: Vec::new(),
16892 span: obs_field.span,
16893 message: format!(
16894 "source `{}` emit reads `{}.{}`, but a `{}` source's observation has no field `{}`",
16895 source.name.name, observe, obs_field.name, source.provider.name, obs_field.name
16896 ),
16897 suggestion: Some(format!(
16898 "available observation fields: {}",
16899 fields.join(", ")
16900 )),
16901 });
16902 }
16903 }
16904 }
16905 }
16906}
16907
16908fn validate_emit_signal_declarations(
16917 rule: &RuleDecl,
16918 statements: &[body::BodyStmt],
16919 declared_signals: &BTreeSet<String>,
16920 diagnostics: &mut Vec<Diagnostic>,
16921) {
16922 for statement in statements {
16923 match statement {
16924 body::BodyStmt::Effect(effect) => {
16925 if let body::BodyEffectKind::Notify { event, .. } = &effect.kind {
16926 if !declared_signals.contains(event) {
16927 diagnostics.push(Diagnostic {
16928 related: Vec::new(),
16929 span: effect.span,
16930 message: format!(
16931 "rule `{}` emits undeclared signal `{event}`",
16932 rule.name.name
16933 ),
16934 suggestion: Some(format!(
16935 "declare `signal {event} {{ ... }}` so the emitted payload is typed and admissible, \
16936 or check the signal name"
16937 )),
16938 });
16939 }
16940 }
16941 }
16942 body::BodyStmt::After(after) => {
16943 validate_emit_signal_declarations(rule, &after.body, declared_signals, diagnostics)
16944 }
16945 body::BodyStmt::Case(case) => {
16946 for branch in &case.branches {
16947 validate_emit_signal_declarations(
16948 rule,
16949 &branch.body,
16950 declared_signals,
16951 diagnostics,
16952 );
16953 }
16954 }
16955 _ => {}
16956 }
16957 }
16958}
16959
16960fn validate_effect_field_roots(
16965 rule: &RuleDecl,
16966 statements: &[body::BodyStmt],
16967 known_roots: &BTreeSet<String>,
16968 diagnostics: &mut Vec<Diagnostic>,
16969) {
16970 for statement in statements {
16971 match statement {
16972 body::BodyStmt::Effect(effect) => match &effect.kind {
16973 body::BodyEffectKind::Notify {
16974 target_expr,
16975 event,
16976 from,
16977 fields,
16978 } => {
16979 if let Some(from) = from {
16980 check_operand_root(
16981 rule,
16982 &format!("emit `{event}` from"),
16983 from,
16984 known_roots,
16985 diagnostics,
16986 );
16987 }
16988 check_operand_root(
16989 rule,
16990 &format!("emit `{event}` target"),
16991 target_expr,
16992 known_roots,
16993 diagnostics,
16994 );
16995 check_field_value_roots(
16996 rule,
16997 &format!("emit `{event}`"),
16998 fields,
16999 known_roots,
17000 diagnostics,
17001 );
17002 }
17003 body::BodyEffectKind::TrackerFile { queue, fields } => {
17004 check_field_value_roots(
17005 rule,
17006 &format!("file into `{queue}`"),
17007 fields,
17008 known_roots,
17009 diagnostics,
17010 );
17011 }
17012 body::BodyEffectKind::TrackerFinish { item, fields } => {
17013 check_operand_root(rule, "finish item", item, known_roots, diagnostics);
17014 check_field_value_roots(rule, "finish", fields, known_roots, diagnostics);
17015 }
17016 body::BodyEffectKind::LedgerAppend { ledger, fields, .. } => {
17017 check_field_value_roots(
17018 rule,
17019 &format!("append to `{ledger}`"),
17020 fields,
17021 known_roots,
17022 diagnostics,
17023 );
17024 }
17025 body::BodyEffectKind::LeaseAcquire {
17026 resource, key_expr, ..
17027 } => {
17028 check_operand_root(
17029 rule,
17030 &format!("acquire `{resource}` key"),
17031 key_expr,
17032 known_roots,
17033 diagnostics,
17034 );
17035 }
17036 body::BodyEffectKind::CounterConsume {
17037 counter,
17038 key_expr,
17039 amount_expr,
17040 } => {
17041 check_operand_root(
17042 rule,
17043 &format!("consume `{counter}` key"),
17044 key_expr,
17045 known_roots,
17046 diagnostics,
17047 );
17048 check_operand_root(
17049 rule,
17050 &format!("consume `{counter}` amount"),
17051 amount_expr,
17052 known_roots,
17053 diagnostics,
17054 );
17055 }
17056 _ => {}
17057 },
17058 body::BodyStmt::After(after) => {
17059 validate_effect_field_roots(rule, &after.body, known_roots, diagnostics)
17060 }
17061 body::BodyStmt::Case(case) => {
17062 for branch in &case.branches {
17063 validate_effect_field_roots(rule, &branch.body, known_roots, diagnostics);
17064 }
17065 }
17066 _ => {}
17067 }
17068 }
17069}
17070
17071fn dangling_value_root(value: &str, known_roots: &BTreeSet<String>) -> Option<String> {
17079 let (root, path) = expression_path(value)?;
17080 if !path.is_empty()
17081 && !value.contains('"')
17082 && !known_roots.contains(&root)
17083 && !SPECIAL_VALUE_ROOTS.contains(&root.as_str())
17084 {
17085 Some(root)
17086 } else {
17087 None
17088 }
17089}
17090
17091fn check_operand_root(
17095 rule: &RuleDecl,
17096 context: &str,
17097 operand: &str,
17098 known_roots: &BTreeSet<String>,
17099 diagnostics: &mut Vec<Diagnostic>,
17100) {
17101 if let Some(root) = dangling_value_root(operand, known_roots) {
17102 diagnostics.push(Diagnostic { related: Vec::new(),
17103 span: rule.body.span,
17104 message: format!(
17105 "rule `{}` has unknown binding `{root}` in {context} `{operand}`",
17106 rule.name.name
17107 ),
17108 suggestion: Some(
17109 "reference a binding from a `when ... as name` clause, an effect `as` binding, or a `case` pattern"
17110 .to_owned(),
17111 ),
17112 });
17113 }
17114}
17115
17116fn check_field_value_roots(
17117 rule: &RuleDecl,
17118 context: &str,
17119 fields: &[body::FieldAssign],
17120 known_roots: &BTreeSet<String>,
17121 diagnostics: &mut Vec<Diagnostic>,
17122) {
17123 for field in fields {
17124 match &field.value {
17125 body::FieldValue::Expr { source, .. } => {
17126 if let Some(root) = dangling_value_root(source, known_roots) {
17127 diagnostics.push(Diagnostic { related: Vec::new(),
17128 span: rule.body.span,
17129 message: format!(
17130 "rule `{}` has unknown binding `{root}` in {context} field `{}`",
17131 rule.name.name, field.name
17132 ),
17133 suggestion: Some(
17134 "reference a binding from a `when ... as name` clause, an effect `as` binding, or a `case` pattern"
17135 .to_owned(),
17136 ),
17137 });
17138 }
17139 }
17140 body::FieldValue::Nested { fields, .. } => {
17141 check_field_value_roots(rule, context, fields, known_roots, diagnostics)
17142 }
17143 body::FieldValue::Shorthand => {}
17144 }
17145 }
17146}
17147
17148fn known_roots_for_rule(rule: &RuleDecl) -> BTreeSet<String> {
17151 let mut roots: BTreeSet<String> = binding_types_for_rule(rule).into_keys().collect();
17152 let (body_ast, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
17153 collect_all_binding_names(&body_ast.statements, &mut roots);
17154 roots
17155}
17156
17157fn validate_record_blocks(
17158 rule: &RuleDecl,
17159 semantic: &SemanticContext,
17160 binding_types: &BTreeMap<String, String>,
17161 known_roots: &BTreeSet<String>,
17162 diagnostics: &mut Vec<Diagnostic>,
17163) {
17164 for (schema, from_binding, body) in record_blocks(&rule.body.text) {
17165 for assignment in collect_field_assignments(&body) {
17166 let (field, value) = match assignment {
17167 RecordFieldAssignment::Value { field, value } => (field, value),
17168 RecordFieldAssignment::Shorthand { field } => {
17169 let value = from_binding
17170 .as_ref()
17171 .map(|binding| format!("{binding}.{field}"))
17172 .unwrap_or_else(|| field.clone());
17173 (field, value)
17174 }
17175 };
17176 let line = format!("{field} {value}");
17177 validate_record_field(
17178 rule,
17179 &line,
17180 &schema,
17181 semantic,
17182 binding_types,
17183 known_roots,
17184 diagnostics,
17185 );
17186 }
17187 }
17188}
17189
17190fn record_blocks(body: &str) -> Vec<(String, Option<String>, String)> {
17191 let mut blocks = Vec::new();
17192 let lines = body.lines().collect::<Vec<_>>();
17193 let mut index = 0usize;
17194 while index < lines.len() {
17195 let trimmed = lines[index].trim();
17196 let Some((schema, from_binding)) = parse_record_start(trimmed) else {
17197 index += 1;
17198 continue;
17199 };
17200 if brace_delta(trimmed) == 0 && trimmed.contains('{') {
17204 if let (Some(open), Some(close)) = (trimmed.find('{'), trimmed.rfind('}')) {
17205 if close > open {
17206 blocks.push((
17207 schema,
17208 from_binding,
17209 trimmed[open + 1..close].trim().to_owned(),
17210 ));
17211 }
17212 }
17213 index += 1;
17214 continue;
17215 }
17216 let mut depth = brace_delta(trimmed);
17217 let mut record_lines = Vec::new();
17218 index += 1;
17219 while index < lines.len() && depth > 0 {
17220 let line = lines[index];
17221 let before = depth;
17222 depth += brace_delta(line);
17223 if !(before == 1 && depth == 0 && line.trim() == "}") {
17224 record_lines.push(line.to_owned());
17225 }
17226 index += 1;
17227 }
17228 blocks.push((schema, from_binding, record_lines.join("\n")));
17229 }
17230 blocks
17231}
17232
17233fn workflow_terminal_blocks(body: &str) -> Vec<(String, String, String)> {
17234 let mut blocks = Vec::new();
17235 let lines = body.lines().collect::<Vec<_>>();
17236 let mut index = 0usize;
17237 while index < lines.len() {
17238 let trimmed = lines[index].trim();
17239 let terminal = trimmed
17240 .strip_prefix("complete ")
17241 .map(|rest| ("complete", rest))
17242 .or_else(|| trimmed.strip_prefix("fail ").map(|rest| ("fail", rest)));
17243 let Some((action, rest)) = terminal else {
17244 index += 1;
17245 continue;
17246 };
17247 let Some(name) = rest.split('{').next().and_then(|header| {
17248 let mut parts = header.split_whitespace();
17249 match (parts.next(), parts.next()) {
17250 (Some(name), None) => Some(name.to_owned()),
17251 _ => None,
17252 }
17253 }) else {
17254 index += 1;
17255 continue;
17256 };
17257 let mut depth = brace_delta(trimmed);
17258 let mut terminal_lines = Vec::new();
17259 if depth == 0 && trimmed.contains('{') {
17260 if let (Some(open), Some(close)) = (trimmed.find('{'), trimmed.rfind('}')) {
17264 if close > open {
17265 let inner = trimmed[open + 1..close].trim();
17266 if !inner.is_empty() {
17267 terminal_lines.push(inner.to_owned());
17268 }
17269 }
17270 }
17271 index += 1;
17272 } else {
17273 index += 1;
17274 while index < lines.len() && depth > 0 {
17275 let line = lines[index];
17276 let before = depth;
17277 depth += brace_delta(line);
17278 if !(before == 1 && depth == 0 && line.trim() == "}") {
17279 terminal_lines.push(line.to_owned());
17280 }
17281 index += 1;
17282 }
17283 }
17284 blocks.push((action.to_owned(), name, terminal_lines.join("\n")));
17285 }
17286 blocks
17287}
17288
17289#[derive(Clone, Debug, Eq, PartialEq)]
17290enum RecordFieldAssignment {
17291 Value { field: String, value: String },
17292 Shorthand { field: String },
17293}
17294
17295fn collect_field_assignments(body: &str) -> Vec<RecordFieldAssignment> {
17296 body::split_field_assignments(body)
17301 .into_iter()
17302 .map(|assignment| match assignment.value {
17303 Some(value) => RecordFieldAssignment::Value {
17304 field: assignment.name,
17305 value,
17306 },
17307 None => RecordFieldAssignment::Shorthand {
17308 field: assignment.name,
17309 },
17310 })
17311 .collect()
17312}
17313
17314fn expression_path(expr: &str) -> Option<(String, Vec<String>)> {
17315 let mut paths = dotted_paths(expr);
17316 if paths.len() != 1 {
17317 return None;
17318 }
17319 Some(paths.remove(0))
17320}
17321
17322fn validate_literal_assignment(
17323 rule: &RuleDecl,
17324 record_schema: &str,
17325 field: &str,
17326 field_ty: &TypeSyntax,
17327 expr: &str,
17328 semantic: &SemanticContext,
17329 diagnostics: &mut Vec<Diagnostic>,
17330) {
17331 let Some(literal) = parse_literal_expr(expr) else {
17332 return;
17333 };
17334
17335 match field_ty {
17336 TypeSyntax::Primitive { name, .. } => {
17337 validate_primitive_literal(rule, record_schema, field, name, &literal, diagnostics)
17338 }
17339 TypeSyntax::LiteralString { value, .. } => {
17340 if literal != LiteralExpr::String(value.as_str()) {
17341 diagnostics.push(Diagnostic {
17342 related: Vec::new(),
17343 span: rule.body.span,
17344 message: format!(
17345 "field `{record_schema}.{field}` expects literal string `{value}`"
17346 ),
17347 suggestion: Some(format!("record `{field} {value:?}`")),
17348 });
17349 }
17350 }
17351 TypeSyntax::Ref { name } => {
17352 validate_enum_literal(
17353 rule,
17354 record_schema,
17355 field,
17356 &name.name,
17357 &literal,
17358 semantic,
17359 diagnostics,
17360 );
17361 }
17362 TypeSyntax::Union { variants, .. } => {
17363 validate_union_literal(rule, record_schema, field, variants, &literal, diagnostics);
17364 }
17365 TypeSyntax::AgentRef { agents, .. } => {
17366 validate_agent_ref_literal(rule, record_schema, field, agents, &literal, diagnostics);
17367 }
17368 TypeSyntax::Optional { inner, .. } => {
17369 if literal != LiteralExpr::Null {
17370 validate_literal_assignment(
17371 rule,
17372 record_schema,
17373 field,
17374 inner,
17375 expr,
17376 semantic,
17377 diagnostics,
17378 );
17379 }
17380 }
17381 TypeSyntax::Array { .. } | TypeSyntax::Map { .. } => {}
17382 }
17383}
17384
17385#[allow(clippy::too_many_arguments)]
17386fn validate_expected_assignment(
17387 rule: &RuleDecl,
17388 record_schema: &str,
17389 field: &str,
17390 field_ty: &TypeSyntax,
17391 expr: &str,
17392 semantic: &SemanticContext,
17393 binding_types: &BTreeMap<String, String>,
17394 diagnostics: &mut Vec<Diagnostic>,
17395) {
17396 if !(expr.trim_start().starts_with('{') || expr.trim_start().starts_with('[')) {
17397 return;
17398 }
17399 validate_expr_source_against_type(
17400 rule,
17401 record_schema,
17402 field,
17403 field_ty,
17404 expr,
17405 semantic,
17406 &ExprScope::from_bindings(binding_types),
17407 diagnostics,
17408 );
17409}
17410
17411#[allow(clippy::too_many_arguments)]
17412fn validate_expr_source_against_type(
17413 rule: &RuleDecl,
17414 record_schema: &str,
17415 field: &str,
17416 expected_ty: &TypeSyntax,
17417 expr: &str,
17418 semantic: &SemanticContext,
17419 scope: &ExprScope,
17420 diagnostics: &mut Vec<Diagnostic>,
17421) {
17422 match expected_ty {
17423 TypeSyntax::Map { inner, .. } => {
17424 let parsed = match parse_expression(expr) {
17425 Ok(Expr::Object(fields)) => fields,
17426 Ok(_) => {
17427 diagnostics.push(Diagnostic {
17428 related: Vec::new(),
17429 span: rule.body.span,
17430 message: format!("field `{record_schema}.{field}` expects a map literal"),
17431 suggestion: Some(format!("record `{field} {{ key value }}`")),
17432 });
17433 return;
17434 }
17435 Err(message) => {
17436 diagnostics.push(Diagnostic {
17437 related: Vec::new(),
17438 span: rule.body.span,
17439 message: format!(
17440 "field `{record_schema}.{field}` expects a map literal: {message}"
17441 ),
17442 suggestion: Some(format!("record `{field} {{ key value }}`")),
17443 });
17444 return;
17445 }
17446 };
17447 for map_field in &parsed {
17448 validate_expr_against_type(
17449 rule,
17450 record_schema,
17451 field,
17452 inner,
17453 &map_field.value,
17454 semantic,
17455 scope,
17456 diagnostics,
17457 );
17458 }
17459 }
17460 TypeSyntax::Array { inner, .. } => match parse_expression(expr) {
17461 Ok(Expr::Array(items)) => {
17462 for item in items {
17463 validate_expr_against_type(
17464 rule,
17465 record_schema,
17466 field,
17467 inner,
17468 &item,
17469 semantic,
17470 scope,
17471 diagnostics,
17472 );
17473 }
17474 }
17475 Ok(expr) => validate_inferred_assignment_type(
17476 rule,
17477 record_schema,
17478 field,
17479 expected_ty,
17480 &expr,
17481 semantic,
17482 scope,
17483 diagnostics,
17484 ),
17485 Err(message) => {
17486 push_invalid_assignment_expr(rule, record_schema, field, message, diagnostics)
17487 }
17488 },
17489 TypeSyntax::Optional { inner, .. } => {
17490 if expr.trim() != "null" {
17491 validate_expr_source_against_type(
17492 rule,
17493 record_schema,
17494 field,
17495 inner,
17496 expr,
17497 semantic,
17498 scope,
17499 diagnostics,
17500 );
17501 }
17502 }
17503 TypeSyntax::Ref { name } if semantic.schemas.class_exists(&name.name) => {
17504 let parsed = match parse_expression(expr) {
17505 Ok(Expr::Object(fields)) => fields,
17506 Ok(expr) => {
17507 validate_inferred_assignment_type(
17508 rule,
17509 record_schema,
17510 field,
17511 expected_ty,
17512 &expr,
17513 semantic,
17514 scope,
17515 diagnostics,
17516 );
17517 return;
17518 }
17519 Err(message) => {
17520 push_invalid_assignment_expr(rule, record_schema, field, message, diagnostics);
17521 return;
17522 }
17523 };
17524 validate_object_literal_fields(
17525 rule,
17526 record_schema,
17527 field,
17528 &name.name,
17529 &parsed,
17530 semantic,
17531 scope,
17532 diagnostics,
17533 );
17534 }
17535 _ => match parse_expression(expr) {
17536 Ok(expr) => validate_inferred_assignment_type(
17537 rule,
17538 record_schema,
17539 field,
17540 expected_ty,
17541 &expr,
17542 semantic,
17543 scope,
17544 diagnostics,
17545 ),
17546 Err(message) => {
17547 push_invalid_assignment_expr(rule, record_schema, field, message, diagnostics)
17548 }
17549 },
17550 }
17551}
17552
17553#[allow(clippy::too_many_arguments)]
17554fn validate_expr_against_type(
17555 rule: &RuleDecl,
17556 record_schema: &str,
17557 field: &str,
17558 expected_ty: &TypeSyntax,
17559 expr: &Expr,
17560 semantic: &SemanticContext,
17561 scope: &ExprScope,
17562 diagnostics: &mut Vec<Diagnostic>,
17563) {
17564 match expr {
17565 Expr::Array(items) if matches!(expected_ty, TypeSyntax::Array { .. }) => {
17566 if let TypeSyntax::Array { inner, .. } = expected_ty {
17567 for item in items {
17568 validate_expr_against_type(
17569 rule,
17570 record_schema,
17571 field,
17572 inner,
17573 item,
17574 semantic,
17575 scope,
17576 diagnostics,
17577 );
17578 }
17579 }
17580 }
17581 Expr::Object(fields) => match expected_ty {
17582 TypeSyntax::Map { inner, .. } => {
17583 for field in fields {
17584 validate_expr_against_type(
17585 rule,
17586 record_schema,
17587 field.key.as_str(),
17588 inner,
17589 &field.value,
17590 semantic,
17591 scope,
17592 diagnostics,
17593 );
17594 }
17595 }
17596 TypeSyntax::Ref { name } if semantic.schemas.class_exists(&name.name) => {
17597 validate_object_literal_fields(
17598 rule,
17599 record_schema,
17600 field,
17601 &name.name,
17602 fields,
17603 semantic,
17604 scope,
17605 diagnostics,
17606 );
17607 }
17608 _ => validate_inferred_assignment_type(
17609 rule,
17610 record_schema,
17611 field,
17612 expected_ty,
17613 expr,
17614 semantic,
17615 scope,
17616 diagnostics,
17617 ),
17618 },
17619 _ => validate_inferred_assignment_type(
17620 rule,
17621 record_schema,
17622 field,
17623 expected_ty,
17624 expr,
17625 semantic,
17626 scope,
17627 diagnostics,
17628 ),
17629 }
17630}
17631
17632fn push_invalid_assignment_expr(
17633 rule: &RuleDecl,
17634 record_schema: &str,
17635 field: &str,
17636 message: String,
17637 diagnostics: &mut Vec<Diagnostic>,
17638) {
17639 diagnostics.push(Diagnostic {
17640 related: Vec::new(),
17641 span: rule.body.span,
17642 message: format!(
17643 "rule `{}` has invalid expression for field `{record_schema}.{field}`: {message}",
17644 rule.name.name
17645 ),
17646 suggestion: Some(
17647 "use array literals or expected-schema object literals for collection fields"
17648 .to_owned(),
17649 ),
17650 });
17651}
17652
17653#[allow(clippy::too_many_arguments)]
17654fn validate_object_literal_fields(
17655 rule: &RuleDecl,
17656 record_schema: &str,
17657 field: &str,
17658 object_schema: &str,
17659 object_fields: &[ExprObjectField],
17660 semantic: &SemanticContext,
17661 scope: &ExprScope,
17662 diagnostics: &mut Vec<Diagnostic>,
17663) {
17664 let Some(schema_fields) = semantic.schemas.classes.get(object_schema) else {
17665 return;
17666 };
17667 let mut seen = BTreeSet::new();
17668 for object_field in object_fields {
17669 if !seen.insert(object_field.key.clone()) {
17670 diagnostics.push(Diagnostic {
17671 related: Vec::new(),
17672 span: rule.body.span,
17673 message: format!(
17674 "field `{record_schema}.{field}` repeats object field `{}`",
17675 object_field.key
17676 ),
17677 suggestion: Some("remove the duplicate object field".to_owned()),
17678 });
17679 continue;
17680 }
17681 let Some(field_ty) = schema_fields.get(&object_field.key) else {
17682 diagnostics.push(Diagnostic {
17683 related: Vec::new(),
17684 span: rule.body.span,
17685 message: format!(
17686 "class `{object_schema}` has no field `{}`",
17687 object_field.key
17688 ),
17689 suggestion: Some(format!(
17690 "add `{}` to `class {object_schema}` or use an existing field",
17691 object_field.key
17692 )),
17693 });
17694 continue;
17695 };
17696 validate_expr_against_type(
17697 rule,
17698 object_schema,
17699 &object_field.key,
17700 field_ty,
17701 &object_field.value,
17702 semantic,
17703 scope,
17704 diagnostics,
17705 );
17706 }
17707 for (required, ty) in schema_fields {
17708 if seen.contains(required) || matches!(ty, TypeSyntax::Optional { .. }) {
17709 continue;
17710 }
17711 diagnostics.push(Diagnostic { related: Vec::new(),
17712 span: rule.body.span,
17713 message: format!(
17714 "field `{record_schema}.{field}` is missing required object field `{object_schema}.{required}`"
17715 ),
17716 suggestion: Some(format!("add `{required}` to the `{field}` object literal")),
17717 });
17718 }
17719}
17720
17721#[allow(clippy::too_many_arguments)]
17722fn validate_inferred_assignment_type(
17723 rule: &RuleDecl,
17724 record_schema: &str,
17725 field: &str,
17726 expected_ty: &TypeSyntax,
17727 expr: &Expr,
17728 semantic: &SemanticContext,
17729 scope: &ExprScope,
17730 diagnostics: &mut Vec<Diagnostic>,
17731) {
17732 let literal = expr_literal_as_literal_expr(expr);
17733 if let Some(literal) = literal {
17734 validate_literal_against_type(
17735 rule,
17736 record_schema,
17737 field,
17738 expected_ty,
17739 &literal,
17740 semantic,
17741 diagnostics,
17742 );
17743 return;
17744 }
17745
17746 let context = ExprValidationContext::rule(rule);
17747 let mut local_diagnostics = Vec::new();
17748 let actual_ty = infer_expr_type(expr, semantic, scope, &context, &mut local_diagnostics);
17749 diagnostics.extend(local_diagnostics);
17750 let expected_expr_ty = expr_type_from_type_syntax(expected_ty, semantic);
17751 if !types_comparable(&actual_ty, &expected_expr_ty) {
17752 diagnostics.push(Diagnostic {
17753 related: Vec::new(),
17754 span: rule.body.span,
17755 message: format!(
17756 "field `{record_schema}.{field}` receives incompatible expression type"
17757 ),
17758 suggestion: Some(format!(
17759 "record a value compatible with `{}`",
17760 expected_ty.to_source()
17761 )),
17762 });
17763 }
17764}
17765
17766fn validate_literal_against_type(
17767 rule: &RuleDecl,
17768 record_schema: &str,
17769 field: &str,
17770 field_ty: &TypeSyntax,
17771 literal: &LiteralExpr<'_>,
17772 semantic: &SemanticContext,
17773 diagnostics: &mut Vec<Diagnostic>,
17774) {
17775 match field_ty {
17776 TypeSyntax::Primitive { name, .. } => {
17777 validate_primitive_literal(rule, record_schema, field, name, literal, diagnostics)
17778 }
17779 TypeSyntax::LiteralString { value, .. } => {
17780 if literal != &LiteralExpr::String(value.as_str()) {
17781 diagnostics.push(Diagnostic {
17782 related: Vec::new(),
17783 span: rule.body.span,
17784 message: format!(
17785 "field `{record_schema}.{field}` expects literal string `{value}`"
17786 ),
17787 suggestion: Some(format!("record `{field} {value:?}`")),
17788 });
17789 }
17790 }
17791 TypeSyntax::Ref { name } => {
17792 validate_enum_literal(
17793 rule,
17794 record_schema,
17795 field,
17796 &name.name,
17797 literal,
17798 semantic,
17799 diagnostics,
17800 );
17801 }
17802 TypeSyntax::Union { variants, .. } => {
17803 validate_union_literal(rule, record_schema, field, variants, literal, diagnostics);
17804 }
17805 TypeSyntax::AgentRef { agents, .. } => {
17806 validate_agent_ref_literal(rule, record_schema, field, agents, literal, diagnostics);
17807 }
17808 TypeSyntax::Optional { inner, .. } => {
17809 if literal != &LiteralExpr::Null {
17810 validate_literal_against_type(
17811 rule,
17812 record_schema,
17813 field,
17814 inner,
17815 literal,
17816 semantic,
17817 diagnostics,
17818 );
17819 }
17820 }
17821 TypeSyntax::Array { .. } | TypeSyntax::Map { .. } => {}
17822 }
17823}
17824
17825fn expr_literal_as_literal_expr(expr: &Expr) -> Option<LiteralExpr<'_>> {
17826 match expr {
17827 Expr::Literal(ExprLiteral::String(value)) => Some(LiteralExpr::String(value)),
17828 Expr::Literal(ExprLiteral::Number(value)) => Some(LiteralExpr::Number(value)),
17829 Expr::Literal(ExprLiteral::Bool(_)) => Some(LiteralExpr::Bool),
17830 Expr::Literal(ExprLiteral::Null) => Some(LiteralExpr::Null),
17831 Expr::Literal(ExprLiteral::Ident(value)) => Some(LiteralExpr::Ident(value)),
17832 _ => None,
17833 }
17834}
17835
17836fn validate_agent_ref_literal(
17837 rule: &RuleDecl,
17838 record_schema: &str,
17839 field: &str,
17840 agents: &[Ident],
17841 literal: &LiteralExpr<'_>,
17842 diagnostics: &mut Vec<Diagnostic>,
17843) {
17844 let allowed = agents
17845 .iter()
17846 .map(|agent| agent.name.as_str())
17847 .collect::<Vec<_>>();
17848 if let LiteralExpr::String(value) = literal {
17849 diagnostics.push(Diagnostic {
17850 related: Vec::new(),
17851 span: rule.body.span,
17852 message: format!(
17853 "field `{record_schema}.{field}` expects an AgentRef value, not string `{value}`"
17854 ),
17855 suggestion: Some(format!(
17856 "use an unquoted declared agent name: {}",
17857 allowed.join(", ")
17858 )),
17859 });
17860 return;
17861 }
17862 let LiteralExpr::Ident(value) = literal else {
17863 diagnostics.push(Diagnostic {
17864 related: Vec::new(),
17865 span: rule.body.span,
17866 message: format!("field `{record_schema}.{field}` expects an AgentRef value"),
17867 suggestion: Some(format!("use one of: {}", allowed.join(", "))),
17868 });
17869 return;
17870 };
17871 if !allowed.contains(value) {
17872 diagnostics.push(Diagnostic {
17873 related: Vec::new(),
17874 span: rule.body.span,
17875 message: format!("field `{record_schema}.{field}` cannot reference agent `{value}`"),
17876 suggestion: Some(format!("use one of: {}", allowed.join(", "))),
17877 });
17878 }
17879}
17880
17881fn parse_literal_expr(expr: &str) -> Option<LiteralExpr<'_>> {
17882 let expr = expr.trim().trim_end_matches(',');
17883 if let Some(value) = expr
17884 .strip_prefix('"')
17885 .and_then(|rest| rest.strip_suffix('"'))
17886 {
17887 return Some(LiteralExpr::String(value));
17888 }
17889 if expr.chars().all(|ch| ch.is_ascii_digit() || ch == '.')
17890 && expr.chars().any(|ch| ch.is_ascii_digit())
17891 {
17892 return Some(LiteralExpr::Number(expr));
17893 }
17894 match expr {
17895 "true" => Some(LiteralExpr::Bool),
17896 "false" => Some(LiteralExpr::Bool),
17897 "null" => Some(LiteralExpr::Null),
17898 value if value.chars().all(|ch| ch.is_alphanumeric() || ch == '_') => {
17899 Some(LiteralExpr::Ident(value))
17900 }
17901 _ => None,
17902 }
17903}
17904
17905struct ExprParser<'a> {
17906 source: &'a str,
17907 tokens: Vec<ExprToken>,
17908 pos: usize,
17909 depth: usize,
17910}
17911
17912const MAX_EXPR_DEPTH: usize = 256;
17918
17919#[derive(Clone, Debug, Eq, PartialEq)]
17920struct ExprToken {
17921 kind: ExprTokenKind,
17922}
17923
17924#[derive(Clone, Debug, Eq, PartialEq)]
17925enum ExprTokenKind {
17926 Ident(String),
17927 String(String),
17928 Number(String),
17929 Symbol(char),
17930 Op(&'static str),
17931}
17932
17933impl<'a> ExprParser<'a> {
17934 fn new(source: &'a str) -> Self {
17935 Self {
17936 source,
17937 tokens: lex_expr(source),
17938 pos: 0,
17939 depth: 0,
17940 }
17941 }
17942
17943 fn parse(mut self) -> Result<Expr, String> {
17944 let expr = self.parse_or()?;
17945 if self.peek().is_some() {
17946 return Err(format!(
17947 "unexpected token in expression `{}`",
17948 self.source.trim()
17949 ));
17950 }
17951 Ok(expr)
17952 }
17953
17954 fn parse_or(&mut self) -> Result<Expr, String> {
17955 let mut expr = self.parse_and()?;
17956 while self.consume_op("||") || self.consume_ident("or") {
17957 let right = self.parse_and()?;
17958 expr = Expr::Binary {
17959 op: BinaryOp::Or,
17960 left: Box::new(expr),
17961 right: Box::new(right),
17962 };
17963 }
17964 Ok(expr)
17965 }
17966
17967 fn parse_and(&mut self) -> Result<Expr, String> {
17968 let mut expr = self.parse_comparison()?;
17969 while self.consume_op("&&") || self.consume_ident("and") {
17970 let right = self.parse_comparison()?;
17971 expr = Expr::Binary {
17972 op: BinaryOp::And,
17973 left: Box::new(expr),
17974 right: Box::new(right),
17975 };
17976 }
17977 Ok(expr)
17978 }
17979
17980 fn parse_comparison(&mut self) -> Result<Expr, String> {
17981 let mut expr = self.parse_additive()?;
17982 loop {
17983 let op = if self.consume_op("==") {
17984 Some(BinaryOp::Eq)
17985 } else if self.consume_op("!=") {
17986 Some(BinaryOp::Ne)
17987 } else if self.consume_op("<=") {
17988 Some(BinaryOp::Le)
17989 } else if self.consume_op(">=") {
17990 Some(BinaryOp::Ge)
17991 } else if self.consume_symbol('<') {
17992 Some(BinaryOp::Lt)
17993 } else if self.consume_symbol('>') {
17994 Some(BinaryOp::Gt)
17995 } else if self.consume_ident("not") {
17996 if !self.consume_ident("in") {
17997 return Err("expected `in` after `not`".to_owned());
17998 }
17999 Some(BinaryOp::NotIn)
18000 } else if self.consume_ident("in") {
18001 Some(BinaryOp::In)
18002 } else {
18003 None
18004 };
18005 let Some(op) = op else {
18006 return Ok(expr);
18007 };
18008 let right = self.parse_additive()?;
18009 expr = Expr::Binary {
18010 op,
18011 left: Box::new(expr),
18012 right: Box::new(right),
18013 };
18014 }
18015 }
18016
18017 fn parse_additive(&mut self) -> Result<Expr, String> {
18018 let mut expr = self.parse_multiplicative()?;
18019 loop {
18020 let op = if self.consume_symbol('+') {
18021 Some(BinaryOp::Add)
18022 } else if self.consume_symbol('-') {
18023 Some(BinaryOp::Sub)
18024 } else {
18025 None
18026 };
18027 let Some(op) = op else {
18028 return Ok(expr);
18029 };
18030 let right = self.parse_multiplicative()?;
18031 expr = Expr::Binary {
18032 op,
18033 left: Box::new(expr),
18034 right: Box::new(right),
18035 };
18036 }
18037 }
18038
18039 fn parse_multiplicative(&mut self) -> Result<Expr, String> {
18040 let mut expr = self.parse_unary()?;
18041 loop {
18042 let op = if self.consume_symbol('*') {
18043 Some(BinaryOp::Mul)
18044 } else if self.consume_symbol('/') {
18045 Some(BinaryOp::Div)
18046 } else {
18047 None
18048 };
18049 let Some(op) = op else {
18050 return Ok(expr);
18051 };
18052 let right = self.parse_unary()?;
18053 expr = Expr::Binary {
18054 op,
18055 left: Box::new(expr),
18056 right: Box::new(right),
18057 };
18058 }
18059 }
18060
18061 fn parse_unary(&mut self) -> Result<Expr, String> {
18062 self.depth += 1;
18065 if self.depth > MAX_EXPR_DEPTH {
18066 self.depth -= 1;
18067 return Err(format!(
18068 "expression in `{}` is nested too deeply (limit {MAX_EXPR_DEPTH})",
18069 self.source.trim()
18070 ));
18071 }
18072 let result = self.parse_unary_inner();
18073 self.depth -= 1;
18074 result
18075 }
18076
18077 fn parse_unary_inner(&mut self) -> Result<Expr, String> {
18078 if self.consume_symbol('!') {
18079 return Ok(Expr::Unary {
18080 op: UnaryOp::Not,
18081 expr: Box::new(self.parse_unary()?),
18082 });
18083 }
18084 if self.consume_ident("not") {
18088 return Ok(Expr::Unary {
18089 op: UnaryOp::Not,
18090 expr: Box::new(self.parse_comparison()?),
18091 });
18092 }
18093 self.parse_postfix()
18094 }
18095
18096 fn parse_postfix(&mut self) -> Result<Expr, String> {
18097 let mut expr = self.parse_primary()?;
18098 loop {
18099 if self.consume_symbol('[') {
18100 let key = self.parse_or()?;
18101 self.expect_symbol(']')?;
18102 expr = Expr::Index {
18103 target: Box::new(expr),
18104 key: Box::new(key),
18105 };
18106 continue;
18107 }
18108 return Ok(expr);
18109 }
18110 }
18111
18112 fn parse_primary(&mut self) -> Result<Expr, String> {
18113 if self.consume_symbol('(') {
18114 let expr = self.parse_or()?;
18115 self.expect_symbol(')')?;
18116 return Ok(expr);
18117 }
18118 if self.consume_symbol('[') {
18119 let mut items = Vec::new();
18120 if self.consume_symbol(']') {
18121 return Ok(Expr::Array(items));
18122 }
18123 loop {
18124 items.push(self.parse_or()?);
18125 if self.consume_symbol(']') {
18126 break;
18127 }
18128 self.expect_symbol(',')?;
18129 }
18130 return Ok(Expr::Array(items));
18131 }
18132 if self.consume_symbol('{') {
18133 let mut fields = Vec::new();
18134 if self.consume_symbol('}') {
18135 return Ok(Expr::Object(fields));
18136 }
18137 loop {
18138 let key = match self.advance().map(|token| token.kind.clone()) {
18139 Some(ExprTokenKind::Ident(value) | ExprTokenKind::String(value)) => value,
18140 _ => return Err("expected object field name".to_owned()),
18141 };
18142 let value = self.parse_or()?;
18143 fields.push(ExprObjectField { key, value });
18144 if self.consume_symbol('}') {
18145 break;
18146 }
18147 let _ = self.consume_symbol(',');
18148 }
18149 return Ok(Expr::Object(fields));
18150 }
18151 match self.advance().map(|token| token.kind.clone()) {
18152 Some(ExprTokenKind::String(value)) => Ok(Expr::Literal(ExprLiteral::String(value))),
18153 Some(ExprTokenKind::Number(value)) => Ok(Expr::Literal(ExprLiteral::Number(value))),
18154 Some(ExprTokenKind::Ident(value)) if value == "true" => {
18155 Ok(Expr::Literal(ExprLiteral::Bool(true)))
18156 }
18157 Some(ExprTokenKind::Ident(value)) if value == "false" => {
18158 Ok(Expr::Literal(ExprLiteral::Bool(false)))
18159 }
18160 Some(ExprTokenKind::Ident(value)) if value == "null" => {
18161 Ok(Expr::Literal(ExprLiteral::Null))
18162 }
18163 Some(ExprTokenKind::Ident(value)) if value == "exists" && !self.at_symbol('(') => {
18164 let arg = match self.parse_postfix()? {
18165 Expr::Literal(ExprLiteral::Ident(path)) => Expr::Path(vec![path]),
18166 expr => expr,
18167 };
18168 Ok(Expr::Call {
18169 name: value,
18170 args: vec![arg],
18171 })
18172 }
18173 Some(ExprTokenKind::Ident(value))
18174 if matches!(value.as_str(), "count" | "exists" | "empty")
18175 && self.at_symbol('(') =>
18176 {
18177 self.expect_symbol('(')?;
18178 if let Some(query) = self.try_parse_query()? {
18179 self.expect_symbol(')')?;
18180 Ok(Expr::Call {
18181 name: value,
18182 args: vec![query],
18183 })
18184 } else {
18185 let mut args = Vec::new();
18186 if self.consume_symbol(')') {
18187 return Ok(Expr::Call { name: value, args });
18188 }
18189 loop {
18190 args.push(self.parse_or()?);
18191 if self.consume_symbol(')') {
18192 break;
18193 }
18194 self.expect_symbol(',')?;
18195 }
18196 Ok(Expr::Call { name: value, args })
18197 }
18198 }
18199 Some(ExprTokenKind::Ident(value)) => {
18200 let mut path = vec![value];
18201 while self.consume_symbol('.') {
18202 let Some(ExprTokenKind::Ident(field)) =
18203 self.advance().map(|token| token.kind.clone())
18204 else {
18205 return Err("expected field name after `.`".to_owned());
18206 };
18207 path.push(field);
18208 }
18209 if path.len() == 1 {
18210 Ok(Expr::Literal(ExprLiteral::Ident(path.remove(0))))
18211 } else {
18212 Ok(Expr::Path(path))
18213 }
18214 }
18215 _ => Err(format!("expected expression in `{}`", self.source.trim())),
18216 }
18217 }
18218
18219 fn try_parse_query(&mut self) -> Result<Option<Expr>, String> {
18220 let checkpoint = self.pos;
18221 let kind = if self.consume_ident("effect") {
18222 QueryKind::Effect
18223 } else if matches!(
18224 self.peek().map(|token| &token.kind),
18225 Some(ExprTokenKind::Ident(value)) if value.chars().next().is_some_and(char::is_uppercase)
18226 ) {
18227 QueryKind::Fact
18228 } else {
18229 return Ok(None);
18230 };
18231 let mut head = Vec::new();
18232 while let Some(token) = self.peek() {
18233 if self.at_symbol(')') || self.at_ident("where") {
18234 break;
18235 }
18236 head.push(self.token_text(token));
18237 self.pos += 1;
18238 }
18239 if head.is_empty() {
18240 self.pos = checkpoint;
18241 return Ok(None);
18242 }
18243 let guard = if self.consume_ident("where") {
18244 Some(Box::new(self.parse_or()?))
18245 } else {
18246 None
18247 };
18248 Ok(Some(Expr::Query {
18249 kind,
18250 head: join_query_head(&head),
18251 guard,
18252 }))
18253 }
18254
18255 fn token_text(&self, token: &ExprToken) -> String {
18256 match &token.kind {
18257 ExprTokenKind::Ident(value) | ExprTokenKind::Number(value) => value.clone(),
18258 ExprTokenKind::String(value) => format!("{value:?}"),
18259 ExprTokenKind::Symbol(value) => value.to_string(),
18260 ExprTokenKind::Op(value) => value.to_string(),
18261 }
18262 }
18263
18264 fn peek(&self) -> Option<&ExprToken> {
18265 self.tokens.get(self.pos)
18266 }
18267
18268 fn advance(&mut self) -> Option<&ExprToken> {
18269 let token = self.tokens.get(self.pos)?;
18270 self.pos += 1;
18271 Some(token)
18272 }
18273
18274 fn at_symbol(&self, symbol: char) -> bool {
18275 matches!(
18276 self.peek().map(|token| &token.kind),
18277 Some(ExprTokenKind::Symbol(value)) if *value == symbol
18278 )
18279 }
18280
18281 fn consume_symbol(&mut self, symbol: char) -> bool {
18282 if self.at_symbol(symbol) {
18283 self.pos += 1;
18284 true
18285 } else {
18286 false
18287 }
18288 }
18289
18290 fn expect_symbol(&mut self, symbol: char) -> Result<(), String> {
18291 if self.consume_symbol(symbol) {
18292 Ok(())
18293 } else {
18294 Err(format!("expected `{symbol}`"))
18295 }
18296 }
18297
18298 fn at_ident(&self, ident: &str) -> bool {
18299 matches!(
18300 self.peek().map(|token| &token.kind),
18301 Some(ExprTokenKind::Ident(value)) if value == ident
18302 )
18303 }
18304
18305 fn consume_ident(&mut self, ident: &str) -> bool {
18306 if self.at_ident(ident) {
18307 self.pos += 1;
18308 true
18309 } else {
18310 false
18311 }
18312 }
18313
18314 fn consume_op(&mut self, op: &'static str) -> bool {
18315 if matches!(
18316 self.peek().map(|token| &token.kind),
18317 Some(ExprTokenKind::Op(value)) if *value == op
18318 ) {
18319 self.pos += 1;
18320 true
18321 } else {
18322 false
18323 }
18324 }
18325}
18326
18327fn join_query_head(tokens: &[String]) -> String {
18328 let mut head = String::new();
18329 for token in tokens {
18330 if token == "." {
18331 head.push('.');
18332 } else if head.ends_with('.') || head.is_empty() {
18333 head.push_str(token);
18334 } else {
18335 head.push(' ');
18336 head.push_str(token);
18337 }
18338 }
18339 head
18340}
18341
18342fn lex_expr(source: &str) -> Vec<ExprToken> {
18343 let bytes = source.as_bytes();
18344 let mut tokens = Vec::new();
18345 let mut index = 0usize;
18346 while index < bytes.len() {
18347 let byte = bytes[index];
18348 if byte.is_ascii_whitespace() {
18349 index += 1;
18350 continue;
18351 }
18352 if is_ident_start(byte) {
18353 let start = index;
18354 index += 1;
18355 while index < bytes.len() && is_ident_continue(bytes[index]) {
18356 index += 1;
18357 }
18358 tokens.push(ExprToken {
18359 kind: ExprTokenKind::Ident(source[start..index].to_owned()),
18360 });
18361 continue;
18362 }
18363 if byte.is_ascii_digit() {
18364 let start = index;
18365 index += 1;
18366 while index < bytes.len() && (bytes[index].is_ascii_digit() || bytes[index] == b'.') {
18367 index += 1;
18368 }
18369 tokens.push(ExprToken {
18370 kind: ExprTokenKind::Number(source[start..index].to_owned()),
18371 });
18372 continue;
18373 }
18374 if byte == b'"' {
18375 let start = index + 1;
18376 index += 1;
18377 while index < bytes.len() && bytes[index] != b'"' {
18378 index += 1;
18379 }
18380 let value = source[start..index.min(bytes.len())].to_owned();
18381 index = (index + 1).min(bytes.len());
18382 tokens.push(ExprToken {
18383 kind: ExprTokenKind::String(value),
18384 });
18385 continue;
18386 }
18387 let rest = &source[index..];
18388 if rest.starts_with("&&") {
18389 tokens.push(ExprToken {
18390 kind: ExprTokenKind::Op("&&"),
18391 });
18392 index += 2;
18393 } else if rest.starts_with("||") {
18394 tokens.push(ExprToken {
18395 kind: ExprTokenKind::Op("||"),
18396 });
18397 index += 2;
18398 } else if rest.starts_with("==") {
18399 tokens.push(ExprToken {
18400 kind: ExprTokenKind::Op("=="),
18401 });
18402 index += 2;
18403 } else if rest.starts_with("!=") {
18404 tokens.push(ExprToken {
18405 kind: ExprTokenKind::Op("!="),
18406 });
18407 index += 2;
18408 } else if rest.starts_with("<=") {
18409 tokens.push(ExprToken {
18410 kind: ExprTokenKind::Op("<="),
18411 });
18412 index += 2;
18413 } else if rest.starts_with(">=") {
18414 tokens.push(ExprToken {
18415 kind: ExprTokenKind::Op(">="),
18416 });
18417 index += 2;
18418 } else {
18419 tokens.push(ExprToken {
18420 kind: ExprTokenKind::Symbol(byte as char),
18421 });
18422 index += 1;
18423 }
18424 }
18425 tokens
18426}
18427
18428fn validate_primitive_literal(
18429 rule: &RuleDecl,
18430 record_schema: &str,
18431 field: &str,
18432 primitive: &str,
18433 literal: &LiteralExpr<'_>,
18434 diagnostics: &mut Vec<Diagnostic>,
18435) {
18436 let valid = matches!(
18437 (primitive, literal),
18438 ("string", LiteralExpr::String(_))
18439 | ("string", LiteralExpr::Ident(_))
18440 | ("int", LiteralExpr::Number(_))
18441 | ("float", LiteralExpr::Number(_))
18442 | ("bool", LiteralExpr::Bool)
18443 | ("null", LiteralExpr::Null)
18444 | ("duration", LiteralExpr::String(_))
18445 | ("time", LiteralExpr::String(_))
18446 );
18447 if !valid {
18448 diagnostics.push(Diagnostic {
18449 related: Vec::new(),
18450 span: rule.body.span,
18451 message: format!("field `{record_schema}.{field}` expects `{primitive}`"),
18452 suggestion: Some(format!("record a value compatible with `{primitive}`")),
18453 });
18454 return;
18455 }
18456 match (primitive, literal) {
18457 ("duration", LiteralExpr::String(value)) if parse_duration_seconds(value).is_none() => {
18458 diagnostics.push(Diagnostic {
18459 related: Vec::new(),
18460 span: rule.body.span,
18461 message: format!("field `{record_schema}.{field}` has invalid duration literal"),
18462 suggestion: Some("use an ISO-8601 duration such as `\"PT30M\"`".to_owned()),
18463 });
18464 }
18465 ("time", LiteralExpr::String(value)) if parse_time_epoch_seconds(value).is_none() => {
18466 diagnostics.push(Diagnostic {
18467 related: Vec::new(),
18468 span: rule.body.span,
18469 message: format!("field `{record_schema}.{field}` has invalid time literal"),
18470 suggestion: Some(
18471 "use an RFC3339 timestamp such as `\"2026-05-29T10:00:00Z\"`".to_owned(),
18472 ),
18473 });
18474 }
18475 _ => {}
18476 }
18477}
18478
18479fn validate_enum_literal(
18480 rule: &RuleDecl,
18481 record_schema: &str,
18482 field: &str,
18483 schema: &str,
18484 literal: &LiteralExpr<'_>,
18485 semantic: &SemanticContext,
18486 diagnostics: &mut Vec<Diagnostic>,
18487) {
18488 let Some(variants) = semantic.schemas.enums.get(schema) else {
18489 return;
18490 };
18491 let LiteralExpr::Ident(variant) = literal else {
18492 diagnostics.push(Diagnostic {
18493 related: Vec::new(),
18494 span: rule.body.span,
18495 message: format!("field `{record_schema}.{field}` expects enum `{schema}`"),
18496 suggestion: Some(format!(
18497 "use one of: {}",
18498 variants.iter().cloned().collect::<Vec<_>>().join(", ")
18499 )),
18500 });
18501 return;
18502 };
18503 if !variants.contains(*variant) {
18504 diagnostics.push(Diagnostic {
18505 related: Vec::new(),
18506 span: rule.body.span,
18507 message: format!("enum `{schema}` has no variant `{variant}`"),
18508 suggestion: Some(format!(
18509 "use one of: {}",
18510 variants.iter().cloned().collect::<Vec<_>>().join(", ")
18511 )),
18512 });
18513 }
18514}
18515
18516fn validate_union_literal(
18517 rule: &RuleDecl,
18518 record_schema: &str,
18519 field: &str,
18520 variants: &[TypeSyntax],
18521 literal: &LiteralExpr<'_>,
18522 diagnostics: &mut Vec<Diagnostic>,
18523) {
18524 let allowed = variants
18525 .iter()
18526 .filter_map(|variant| match variant {
18527 TypeSyntax::LiteralString { value, .. } => Some(value.as_str()),
18528 _ => None,
18529 })
18530 .collect::<Vec<_>>();
18531 if allowed.is_empty() {
18532 return;
18533 }
18534 let LiteralExpr::String(value) = literal else {
18535 diagnostics.push(Diagnostic {
18536 related: Vec::new(),
18537 span: rule.body.span,
18538 message: format!("field `{record_schema}.{field}` expects one of its literal variants"),
18539 suggestion: Some(format!("use one of: {}", allowed.join(", "))),
18540 });
18541 return;
18542 };
18543 if !allowed.contains(value) {
18544 diagnostics.push(Diagnostic {
18545 related: Vec::new(),
18546 span: rule.body.span,
18547 message: format!("field `{record_schema}.{field}` cannot be `{value}`"),
18548 suggestion: Some(format!("use one of: {}", allowed.join(", "))),
18549 });
18550 }
18551}
18552
18553fn parse_effect_line(line: &str) -> Option<(IrEffectKind, Option<String>)> {
18554 let kind = if line.starts_with("tell ") {
18555 IrEffectKind::AgentTell
18556 } else if line.starts_with("coerce ") || line.starts_with("prompt ") {
18557 IrEffectKind::SchemaCoerce
18558 } else if line.starts_with("claim ") {
18559 IrEffectKind::TrackerClaim
18560 } else if line.starts_with("call ")
18561 || line.starts_with("recall ")
18562 || line.starts_with("learn ")
18563 || line.starts_with("curate ")
18564 {
18565 IrEffectKind::CapabilityCall
18566 } else if line.starts_with("emit ") {
18567 IrEffectKind::EventEmit
18568 } else if line.starts_with("invoke ") {
18569 IrEffectKind::WorkflowInvoke
18570 } else if line.starts_with("read ") {
18571 IrEffectKind::FileRead
18572 } else if line.starts_with("write ") {
18573 IrEffectKind::FileWrite
18574 } else if line.starts_with("import ") {
18575 IrEffectKind::FileImport
18576 } else if line.starts_with("export ") {
18577 IrEffectKind::FileExport
18578 } else if line.starts_with("acquire ") {
18579 IrEffectKind::LeaseAcquire
18580 } else if line.starts_with("renew ") {
18581 IrEffectKind::LeaseRenew
18582 } else if line.starts_with("append ") {
18583 IrEffectKind::LedgerAppend
18584 } else if line.starts_with("consume ") && line.contains(" for ") {
18585 IrEffectKind::CounterConsume
18589 } else {
18590 return None;
18591 };
18592
18593 Some((kind, binding_after_as(line)))
18594}
18595
18596fn parse_consume_line(line: &str) -> Option<String> {
18597 let binding = line
18601 .trim()
18602 .trim_end_matches(';')
18603 .strip_prefix("done ")?
18604 .split("->")
18605 .next()
18606 .unwrap_or_default()
18607 .trim();
18608 let mut chars = binding.chars();
18609 let first = chars.next()?;
18610 if !(first.is_ascii_alphabetic() || first == '_') {
18611 return None;
18612 }
18613 chars
18614 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
18615 .then(|| binding.to_owned())
18616}
18617
18618fn binding_after_multiline_string_end(line: &str) -> Option<String> {
18619 line.strip_prefix("\"\"\"")
18620 .and_then(|rest| rest.trim().strip_prefix("as "))
18621 .and_then(|rest| rest.split_whitespace().next())
18622 .map(|binding| binding.trim_matches(|ch: char| !ch.is_alphanumeric() && ch != '_'))
18623 .filter(|binding| !binding.is_empty())
18624 .map(str::to_owned)
18625}
18626
18627fn validate_rule_prompt_content_type_annotation(
18628 rule: &RuleDecl,
18629 line: &str,
18630 diagnostics: &mut Vec<Diagnostic>,
18631) {
18632 if !(line.starts_with("tell ") || line.starts_with("coerce ")) {
18633 return;
18634 }
18635 let Some(annotation) = malformed_prompt_content_type_annotation(line) else {
18636 return;
18637 };
18638 diagnostics.push(Diagnostic {
18639 related: Vec::new(),
18640 span: rule.body.span,
18641 message: format!(
18642 "rule `{}` has malformed multiline prompt content type `{annotation}`",
18643 rule.name.name
18644 ),
18645 suggestion: Some(
18646 "write a supported token such as `\"\"\"markdown` or put prompt text on the next line"
18647 .to_owned(),
18648 ),
18649 });
18650}
18651
18652fn validate_coerce_prompt_content_type_annotations(
18653 coerce: &CoerceDecl,
18654 diagnostics: &mut Vec<Diagnostic>,
18655) {
18656 for line in coerce.body.text.lines().map(str::trim) {
18657 if !line.starts_with("prompt ") {
18658 continue;
18659 }
18660 let Some(annotation) = malformed_prompt_content_type_annotation(line) else {
18661 continue;
18662 };
18663 diagnostics.push(Diagnostic { related: Vec::new(),
18664 span: coerce.body.span,
18665 message: format!(
18666 "coerce `{}` has malformed multiline prompt content type `{annotation}`",
18667 coerce.name.name
18668 ),
18669 suggestion: Some(
18670 "write a supported token such as `\"\"\"markdown` or put prompt text on the next line"
18671 .to_owned(),
18672 ),
18673 });
18674 }
18675}
18676
18677fn malformed_prompt_content_type_annotation(line: &str) -> Option<String> {
18678 let (_, tail) = line.split_once("\"\"\"")?;
18679 let candidate = tail.trim();
18680 if candidate.is_empty() || candidate.contains("\"\"\"") {
18681 return None;
18682 }
18683 let mut parts = candidate.split_whitespace();
18684 let first = parts.next()?;
18685 let has_extra_text = parts.next().is_some();
18686 let first_is_supported = is_supported_prompt_content_type(first);
18687 let first_is_annotation_shaped = first_is_supported || first.contains('/');
18688 if has_extra_text && first_is_annotation_shaped {
18689 return Some(candidate.to_owned());
18690 }
18691 if first.contains('/') && !first_is_supported {
18692 return Some(first.to_owned());
18693 }
18694 None
18695}
18696
18697fn is_supported_prompt_content_type(candidate: &str) -> bool {
18698 if !is_prompt_content_type_token(candidate) {
18699 return false;
18700 }
18701 let normalized = candidate.to_ascii_lowercase();
18702 normalized.contains('/')
18703 || matches!(
18704 normalized.as_str(),
18705 "markdown" | "json" | "text" | "plain" | "html" | "xml" | "yaml" | "yml"
18706 )
18707}
18708
18709fn is_prompt_content_type_token(candidate: &str) -> bool {
18710 let mut chars = candidate.chars();
18711 let Some(first) = chars.next() else {
18712 return false;
18713 };
18714 first.is_ascii_alphanumeric()
18715 && chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '+' | '-' | '_'))
18716}
18717
18718fn binding_after_as(line: &str) -> Option<String> {
18719 let mut tokens = line.split_whitespace();
18720 while let Some(token) = tokens.next() {
18721 if token == "as" {
18722 return tokens
18723 .next()
18724 .map(|binding| binding.trim_matches(|ch: char| !ch.is_alphanumeric() && ch != '_'))
18725 .filter(|binding| !binding.is_empty())
18726 .map(str::to_owned);
18727 }
18728 }
18729 None
18730}
18731
18732fn parse_after_line(line: &str) -> Option<(String, DependencyPredicate)> {
18733 let rest = line.strip_prefix("after ")?;
18734 if rest.contains("=>") {
18735 return None;
18736 }
18737 let before_body = rest.split('{').next().unwrap_or(rest).trim();
18738 let mut parts = before_body.split_whitespace();
18739 let binding = parts.next()?.to_owned();
18740 let predicate = match parts.next()? {
18741 "succeeds" => DependencyPredicate::Succeeds,
18742 "fails" => DependencyPredicate::Fails,
18743 "cancelled" => DependencyPredicate::Cancelled,
18746 "times" => {
18747 if parts.next()? != "out" {
18748 return None;
18749 }
18750 DependencyPredicate::TimedOut
18751 }
18752 "completes" | "held" | "contended" | "ok" | "over" => DependencyPredicate::Completes,
18755 "reaches" => {
18760 let rest = before_body.trim().strip_prefix(&binding)?.trim_start();
18761 let after_kw = rest.strip_prefix("reaches")?.trim_start();
18762 let quoted = after_kw.strip_prefix('"')?;
18763 let close = quoted.find('"')?;
18764 let tail = "ed[close + 1..];
18765 let mut tail_parts = tail.split_whitespace();
18766 match (tail_parts.next(), tail_parts.next(), tail_parts.next()) {
18767 (None, None, None) => {}
18768 (Some("as"), Some(alias), None) if is_identifier(alias) => {}
18769 _ => return None,
18770 }
18771 return Some((binding, DependencyPredicate::Completes));
18772 }
18773 _ => return None,
18774 };
18775 match (parts.next(), parts.next(), parts.next()) {
18776 (None, None, None) => {}
18777 (Some("as"), Some(alias), None) if is_identifier(alias) => {}
18778 _ => return None,
18779 }
18780 Some((binding, predicate))
18781}
18782
18783pub(crate) fn is_identifier(value: &str) -> bool {
18784 let mut chars = value.chars();
18785 let Some(first) = chars.next() else {
18786 return false;
18787 };
18788 (first.is_ascii_alphabetic() || first == '_')
18789 && chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
18790}
18791
18792fn lower_type(ty: TypeSyntax) -> IrType {
18793 match ty {
18794 TypeSyntax::Primitive { name, .. } => IrType::Primitive(lower_primitive_type(&name)),
18795 TypeSyntax::LiteralString { value, .. } => IrType::LiteralString(value),
18796 TypeSyntax::Ref { name } => IrType::Ref(name.name),
18797 TypeSyntax::AgentRef { agents, .. } => {
18798 IrType::AgentRef(agents.into_iter().map(|agent| agent.name).collect())
18799 }
18800 TypeSyntax::Optional { inner, .. } => IrType::Optional(Box::new(lower_type(*inner))),
18801 TypeSyntax::Array { inner, .. } => IrType::Array(Box::new(lower_type(*inner))),
18802 TypeSyntax::Map { inner, .. } => IrType::Map(Box::new(lower_type(*inner))),
18803 TypeSyntax::Union { variants, .. } => {
18804 IrType::Union(variants.into_iter().map(lower_type).collect())
18805 }
18806 }
18807}
18808
18809fn lower_primitive_type(name: &str) -> IrPrimitiveType {
18810 match name {
18811 "string" => IrPrimitiveType::String,
18812 "int" => IrPrimitiveType::Int,
18813 "float" => IrPrimitiveType::Float,
18814 "bool" => IrPrimitiveType::Bool,
18815 "null" => IrPrimitiveType::Null,
18816 "duration" => IrPrimitiveType::Duration,
18817 "time" => IrPrimitiveType::Time,
18818 "image" => IrPrimitiveType::Image,
18819 "audio" => IrPrimitiveType::Audio,
18820 "pdf" => IrPrimitiveType::Pdf,
18821 "video" => IrPrimitiveType::Video,
18822 _ => IrPrimitiveType::String,
18823 }
18824}
18825
18826fn push_line(snapshot: &mut String, line: impl AsRef<str>) {
18827 snapshot.push_str(line.as_ref());
18828 snapshot.push('\n');
18829}
18830
18831fn stable_hash(value: &str) -> String {
18832 use sha2::Digest;
18837 let digest = sha2::Sha256::digest(value.as_bytes());
18838 let mut hex = String::with_capacity(32);
18839 for byte in &digest[..16] {
18840 hex.push_str(&format!("{byte:02x}"));
18841 }
18842 hex
18843}
18844
18845pub fn parse_duration_seconds(value: &str) -> Option<f64> {
18846 let value = value.strip_prefix('P')?;
18847 let mut rest = value;
18848 let mut seconds = 0.0;
18849 let mut consumed = false;
18850 let mut in_time = false;
18851
18852 while !rest.is_empty() {
18853 if let Some(next) = rest.strip_prefix('T') {
18854 if in_time {
18855 return None;
18856 }
18857 in_time = true;
18858 rest = next;
18859 continue;
18860 }
18861
18862 let number_len = rest
18863 .char_indices()
18864 .take_while(|(_, ch)| ch.is_ascii_digit() || *ch == '.')
18865 .map(|(index, ch)| index + ch.len_utf8())
18866 .last()?;
18867 let number = rest[..number_len].parse::<f64>().ok()?;
18868 if !number.is_finite() {
18869 return None;
18870 }
18871 let unit = rest[number_len..].chars().next()?;
18872 rest = &rest[number_len + unit.len_utf8()..];
18873 let multiplier = match (in_time, unit) {
18874 (false, 'D') => 86_400.0,
18875 (true, 'H') => 3_600.0,
18876 (true, 'M') => 60.0,
18877 (true, 'S') => 1.0,
18878 _ => return None,
18879 };
18880 seconds += number * multiplier;
18881 consumed = true;
18882 }
18883
18884 consumed.then_some(seconds)
18885}
18886
18887pub fn parse_time_epoch_seconds(value: &str) -> Option<f64> {
18888 if value.len() < 20 {
18889 return None;
18890 }
18891 let year = parse_fixed_i32(value, 0, 4)?;
18892 require_byte(value, 4, b'-')?;
18893 let month = parse_fixed_u32(value, 5, 2)?;
18894 require_byte(value, 7, b'-')?;
18895 let day = parse_fixed_u32(value, 8, 2)?;
18896 require_byte(value, 10, b'T')?;
18897 let hour = parse_fixed_u32(value, 11, 2)?;
18898 require_byte(value, 13, b':')?;
18899 let minute = parse_fixed_u32(value, 14, 2)?;
18900 require_byte(value, 16, b':')?;
18901 let second = parse_fixed_u32(value, 17, 2)?;
18902 let mut offset_start = 19;
18903 let mut fractional_second = 0.0;
18904 if value.as_bytes().get(offset_start).copied() == Some(b'.') {
18905 let fraction_start = offset_start + 1;
18906 let fraction_len = value[fraction_start..]
18907 .char_indices()
18908 .take_while(|(_, ch)| ch.is_ascii_digit())
18909 .map(|(index, ch)| index + ch.len_utf8())
18910 .last()?;
18911 let fraction = &value[fraction_start..fraction_start + fraction_len];
18912 let scale = 10_f64.powi(i32::try_from(fraction.len()).ok()?);
18913 fractional_second = fraction.parse::<f64>().ok()? / scale;
18914 offset_start = fraction_start + fraction_len;
18915 }
18916 if !(1..=12).contains(&month)
18917 || !(1..=days_in_month(year, month)).contains(&day)
18918 || hour > 23
18919 || minute > 59
18920 || second > 60
18921 {
18922 return None;
18923 }
18924
18925 let offset_seconds = match value.as_bytes().get(offset_start).copied()? {
18926 b'Z' if value.len() == offset_start + 1 => 0,
18927 b'+' | b'-' if value.len() == offset_start + 6 => {
18928 let sign = if value.as_bytes()[offset_start] == b'+' {
18929 1
18930 } else {
18931 -1
18932 };
18933 let offset_hour = parse_fixed_i32(value, offset_start + 1, 2)?;
18934 require_byte(value, offset_start + 3, b':')?;
18935 let offset_minute = parse_fixed_i32(value, offset_start + 4, 2)?;
18936 if offset_hour > 23 || offset_minute > 59 {
18937 return None;
18938 }
18939 sign * (offset_hour * 3_600 + offset_minute * 60)
18940 }
18941 _ => return None,
18942 };
18943
18944 let days = days_from_civil(year, month, day);
18945 let local_seconds = days * 86_400 + i64::from(hour * 3_600 + minute * 60 + second.min(59));
18946 Some((local_seconds - i64::from(offset_seconds)) as f64 + fractional_second)
18947}
18948
18949fn parse_fixed_i32(value: &str, start: usize, len: usize) -> Option<i32> {
18950 value.get(start..start + len)?.parse::<i32>().ok()
18951}
18952
18953fn parse_fixed_u32(value: &str, start: usize, len: usize) -> Option<u32> {
18954 value.get(start..start + len)?.parse::<u32>().ok()
18955}
18956
18957fn require_byte(value: &str, index: usize, expected: u8) -> Option<()> {
18958 (value.as_bytes().get(index).copied()? == expected).then_some(())
18959}
18960
18961fn days_in_month(year: i32, month: u32) -> u32 {
18962 match month {
18963 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
18964 4 | 6 | 9 | 11 => 30,
18965 2 if is_leap_year(year) => 29,
18966 2 => 28,
18967 _ => 0,
18968 }
18969}
18970
18971fn is_leap_year(year: i32) -> bool {
18972 (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
18973}
18974
18975fn days_from_civil(year: i32, month: u32, day: u32) -> i64 {
18976 let year = year - i32::from(month <= 2);
18977 let era = if year >= 0 { year } else { year - 399 } / 400;
18978 let year_of_era = year - era * 400;
18979 let month = month as i32;
18980 let day = day as i32;
18981 let day_of_year = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day - 1;
18982 let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
18983 i64::from(era * 146_097 + day_of_era - 719_468)
18984}
18985
18986fn format_syntax(program: Program) -> String {
18987 let mut formatted = String::new();
18988 if let Some(workflow) = program.workflow {
18989 format_tags(&program.workflow_tags, &mut formatted);
18990 format_description(program.workflow_description.as_ref(), &mut formatted);
18991 push_line(&mut formatted, format!("workflow {}", workflow.name));
18992 formatted.push('\n');
18993 }
18994
18995 let mut top_level_items = Vec::new();
18996 top_level_items.extend(program.patterns.into_iter().map(Item::Pattern));
18997 top_level_items.extend(program.items);
18998 format_items(top_level_items, &mut formatted);
18999
19000 if !formatted.is_empty() && !program.workflows.is_empty() {
19001 formatted.push('\n');
19002 }
19003 let workflow_count = program.workflows.len();
19004 for (index, workflow) in program.workflows.into_iter().enumerate() {
19005 format_workflow(workflow, &mut formatted);
19006 if index + 1 < workflow_count {
19007 formatted.push('\n');
19008 }
19009 }
19010
19011 formatted
19012}
19013
19014fn format_items(items: Vec<Item>, formatted: &mut String) {
19015 let item_count = items.len();
19016 for (index, item) in items.into_iter().enumerate() {
19017 format_item(item, formatted);
19018 if index + 1 < item_count {
19019 formatted.push('\n');
19020 }
19021 }
19022}
19023
19024fn format_item(item: Item, formatted: &mut String) {
19025 match item {
19026 Item::Include(include) => {
19027 push_line(formatted, format!("include {:?}", include.path.value));
19028 }
19029 Item::Use(use_decl) => {
19030 push_line(formatted, format!("use {}", use_decl.name.value));
19031 }
19032 Item::Tracker(queue) => {
19033 push_line(formatted, format!("tracker {} {{", queue.name.name));
19034 push_line(formatted, format!(" provider {}", queue.provider.name));
19035 push_line(formatted, "}");
19036 }
19037 Item::Mark(mark) => {
19038 push_line(
19039 formatted,
19040 format!("mark {:?} after {}", mark.name.value, mark.site),
19041 );
19042 }
19043 Item::Gauge(gauge) => {
19044 let mut header = format!("gauge {}", gauge.name.name);
19045 if let Some(site) = &gauge.site {
19046 header.push_str(&format!(" on {site}"));
19047 }
19048 header.push_str(" {");
19049 push_line(formatted, header);
19050 let judge = match &gauge.judge {
19051 GaugeJudge::Coerce(target, args) if args.is_empty() => {
19052 format!("coerce {}", target.name)
19053 }
19054 GaugeJudge::Coerce(target, args) => {
19055 format!("coerce {}({})", target.name, args.join(", "))
19056 }
19057 GaugeJudge::Prompt(template) => format!("prompt {:?}", template.value),
19058 GaugeJudge::Exec(command) => format!("exec {:?}", command.value),
19059 GaugeJudge::Labels(source) => format!("labels {:?}", source.value),
19060 };
19061 push_line(formatted, format!(" judge via {judge}"));
19062 if let Some(bar) = &gauge.expect {
19063 let subject = match &bar.subject {
19064 GaugeBarSubject::Chance { field } => format!("P({})", field.name),
19065 GaugeBarSubject::Stat { stat } => stat.name.clone(),
19066 };
19067 let direction = if bar.at_least { "at least" } else { "at most" };
19068 push_line(
19069 formatted,
19070 format!(" expect {subject} {direction} {}", bar.threshold),
19071 );
19072 }
19073 if !gauge.inputs.is_empty() {
19074 let names = gauge
19075 .inputs
19076 .iter()
19077 .map(|input| input.name.as_str())
19078 .collect::<Vec<_>>()
19079 .join(", ");
19080 push_line(formatted, format!(" inputs {names}"));
19081 }
19082 push_line(formatted, "}");
19083 }
19084 Item::Campaign(campaign) => {
19085 push_line(formatted, format!("campaign {} {{", campaign.name.name));
19086 if !campaign.ascend.is_empty() {
19087 let names = campaign
19088 .ascend
19089 .iter()
19090 .map(|gauge| gauge.name.as_str())
19091 .collect::<Vec<_>>()
19092 .join(", ");
19093 push_line(formatted, format!(" ascend {names}"));
19094 }
19095 for reach in &campaign.reach {
19096 let direction = if reach.at_least {
19097 "at least"
19098 } else {
19099 "at most"
19100 };
19101 let unit = reach.unit.as_deref().unwrap_or("");
19102 push_line(
19103 formatted,
19104 format!(
19105 " reach {} {direction} {}{unit}",
19106 reach.gauge.name, reach.threshold
19107 ),
19108 );
19109 }
19110 for guard in &campaign.guard {
19111 push_line(
19112 formatted,
19113 format!(
19114 " guard {} within {} percent",
19115 guard.gauge.name, guard.band_percent
19116 ),
19117 );
19118 }
19119 if !campaign.sacrifice.is_empty() {
19120 let names = campaign
19121 .sacrifice
19122 .iter()
19123 .map(|gauge| gauge.name.as_str())
19124 .collect::<Vec<_>>()
19125 .join(", ");
19126 push_line(formatted, format!(" sacrifice {names}"));
19127 }
19128 if campaign.proposer_redacted {
19129 push_line(formatted, " proposer redacted");
19130 }
19131 push_line(formatted, "}");
19132 }
19133 Item::Channel(channel) => {
19134 push_line(formatted, format!("channel {} {{", channel.name.name));
19135 push_line(formatted, format!(" provider {}", channel.provider.name));
19136 if let Some(workspace) = &channel.workspace {
19137 push_line(formatted, format!(" workspace {}", workspace.name));
19138 }
19139 if let Some(destination) = &channel.destination {
19140 push_line(formatted, format!(" destination {:?}", destination.value));
19141 }
19142 push_line(formatted, "}");
19143 }
19144 Item::FileStore(file_store) => {
19145 push_line(formatted, format!("file store {} {{", file_store.name.name));
19146 push_line(formatted, format!(" root {:?}", file_store.root));
19147 let format_globs = |formatted: &mut String, direction: &str, globs: &[String]| {
19148 if !globs.is_empty() {
19149 let rendered = globs
19150 .iter()
19151 .map(|glob| format!("{glob:?}"))
19152 .collect::<Vec<_>>()
19153 .join(", ");
19154 push_line(formatted, format!(" allow {direction} [{rendered}]"));
19155 }
19156 };
19157 format_globs(formatted, "read", &file_store.read_globs);
19158 format_globs(formatted, "write", &file_store.write_globs);
19159 if let Some(provider) = &file_store.provider {
19160 push_line(formatted, format!(" provider {}", provider.name));
19161 }
19162 push_line(formatted, "}");
19163 }
19164 Item::MemoryPool(pool) => {
19165 push_line(formatted, format!("memory pool {} {{", pool.name.name));
19166 if let Some(limit) = pool.context_limit {
19167 push_line(formatted, format!(" context limit {limit}"));
19168 }
19169 push_line(formatted, "}");
19170 }
19171 Item::Action(action) => {
19172 let params = action
19173 .params
19174 .iter()
19175 .map(|param| format!("{} {}", param.name.name, param.ty.to_source()))
19176 .collect::<Vec<_>>()
19177 .join(", ");
19178 push_line(
19179 formatted,
19180 format!("action {}({params}) {{", action.name.name),
19181 );
19182 for line in action.body.text.lines() {
19183 if line.trim().is_empty() {
19184 push_line(formatted, "");
19185 } else {
19186 push_line(formatted, line.trim_end());
19187 }
19188 }
19189 push_line(formatted, "}");
19190 }
19191 Item::Pattern(pattern) => format_pattern(pattern, formatted),
19192 Item::Apply(apply) => format_apply(apply, formatted),
19193 Item::WorkflowContract(contract) => {
19194 push_line(
19195 formatted,
19196 format!(
19197 "{} {} {}",
19198 contract.kind.as_str(),
19199 contract.name.name,
19200 contract.ty.to_source()
19201 ),
19202 );
19203 }
19204 Item::Harness(harness) => format_harness(harness, formatted),
19205 Item::Agent(agent) => format_agent(agent, formatted),
19206 Item::Enum(enum_decl) => format_enum(enum_decl, formatted),
19207 Item::Event(event) => format_event(event, formatted),
19208 Item::Source(source) => format_source(*source, formatted),
19209 Item::Test(test) => format_test(test, formatted),
19210 Item::Lease(lease) => {
19211 push_line(formatted, format!("lease {} {{", lease.name.name));
19212 if lease.shared {
19213 push_line(formatted, " shared");
19214 }
19215 push_line(formatted, format!(" key {}", lease.key_type.name));
19216 push_line(formatted, format!(" slots {}", lease.slots));
19217 push_line(formatted, format!(" ttl {}s", lease.ttl_seconds));
19218 push_line(formatted, "}");
19219 }
19220 Item::Ledger(ledger) => {
19221 push_line(formatted, format!("ledger {} {{", ledger.name.name));
19222 if ledger.shared {
19223 push_line(formatted, " shared");
19224 }
19225 push_line(formatted, format!(" entry {}", ledger.entry_schema.name));
19226 push_line(
19227 formatted,
19228 format!(" partition by {}", ledger.partition_field.name),
19229 );
19230 push_line(formatted, format!(" retain {}s", ledger.retain_seconds));
19231 push_line(formatted, "}");
19232 }
19233 Item::Counter(counter) => {
19234 push_line(formatted, format!("counter {} {{", counter.name.name));
19235 if counter.shared {
19236 push_line(formatted, " shared");
19237 }
19238 push_line(formatted, format!(" key {}", counter.key_type.name));
19239 push_line(formatted, format!(" cap {}", counter.cap));
19240 push_line(formatted, format!(" reset {}", counter.reset));
19241 push_line(formatted, "}");
19242 }
19243 Item::Class(class_decl) => format_class(class_decl, formatted),
19244 Item::Table(table) => format_table(table, formatted),
19245 Item::Coerce(coerce) => format_coerce(coerce, formatted),
19246 Item::Assert(assertion) => {
19247 format_tags(&assertion.tags, formatted);
19248 format_description(assertion.description.as_ref(), formatted);
19249 push_line(formatted, format!("assert {}", assertion.expr));
19250 }
19251 Item::Rule(rule) => format_rule(rule, formatted),
19252 }
19253}
19254
19255fn format_tags(tags: &[TagDecl], formatted: &mut String) {
19256 for tag in tags {
19257 push_line(formatted, format!("@{}", tag.name));
19258 }
19259}
19260
19261fn format_description(description: Option<&StringLiteral>, formatted: &mut String) {
19262 if let Some(description) = description {
19263 push_line(formatted, format!("description {:?}", description.value));
19264 }
19265}
19266
19267fn format_pattern(pattern: PatternDecl, formatted: &mut String) {
19268 let params = if pattern.type_params.is_empty() {
19269 String::new()
19270 } else {
19271 format!(
19272 "<{}>",
19273 pattern
19274 .type_params
19275 .iter()
19276 .map(|param| param.name.as_str())
19277 .collect::<Vec<_>>()
19278 .join(", ")
19279 )
19280 };
19281 push_line(
19282 formatted,
19283 format!("pattern {}{} {{", pattern.name.name, params),
19284 );
19285 let mut inner = String::new();
19286 format_items(pattern.items, &mut inner);
19287 for line in inner.lines() {
19288 if line.is_empty() {
19289 formatted.push('\n');
19290 } else {
19291 push_line(formatted, format!(" {line}"));
19292 }
19293 }
19294 push_line(formatted, "}");
19295}
19296
19297fn format_apply(apply: ApplyDecl, formatted: &mut String) {
19298 let args = if apply.type_args.is_empty() {
19299 String::new()
19300 } else {
19301 format!(
19302 "<{}>",
19303 apply
19304 .type_args
19305 .iter()
19306 .map(TypeSyntax::to_source)
19307 .collect::<Vec<_>>()
19308 .join(", ")
19309 )
19310 };
19311 push_line(
19312 formatted,
19313 format!(
19314 "apply {}{} as {} {{",
19315 apply.pattern.name, args, apply.alias.name
19316 ),
19317 );
19318 format_block_body(&apply.body.text, formatted);
19319 push_line(formatted, "}");
19320}
19321
19322fn format_workflow(workflow: WorkflowDecl, formatted: &mut String) {
19323 format_tags(&workflow.tags, formatted);
19324 format_description(workflow.description.as_ref(), formatted);
19325 push_line(formatted, format!("workflow {} {{", workflow.name.name));
19326 let mut inner = String::new();
19327 format_items(workflow.items, &mut inner);
19328 for line in inner.lines() {
19329 if line.is_empty() {
19330 formatted.push('\n');
19331 } else {
19332 push_line(formatted, format!(" {line}"));
19333 }
19334 }
19335 push_line(formatted, "}");
19336}
19337
19338fn format_harness(harness: HarnessDecl, formatted: &mut String) {
19339 push_line(
19340 formatted,
19341 format!("harness {}: {}", harness.name.name, harness.kind.name),
19342 );
19343}
19344
19345fn format_agent(agent: AgentDecl, formatted: &mut String) {
19346 let harness = agent
19347 .harness
19348 .as_ref()
19349 .map(|harness| format!(" using {}", harness.name))
19350 .or_else(|| {
19351 agent
19352 .delegated_to
19353 .as_ref()
19354 .map(|delegate| format!(" delegated to {}", delegate.name))
19355 })
19356 .unwrap_or_default();
19357 push_line(
19358 formatted,
19359 format!("agent {}{} {{", agent.name.name, harness),
19360 );
19361 for field in agent.fields {
19362 match field {
19363 AgentField::Provider(provider) => {
19364 push_line(formatted, format!(" provider {}", provider.name));
19365 }
19366 AgentField::Profile(profile) => {
19367 push_line(formatted, format!(" profile {:?}", profile.value));
19368 }
19369 AgentField::Capacity(capacity, _) => {
19370 push_line(formatted, format!(" capacity {capacity}"));
19371 }
19372 AgentField::Skills(skills, _) => {
19373 let skills = skills
19374 .into_iter()
19375 .map(|skill| format!("{:?}", skill.value))
19376 .collect::<Vec<_>>()
19377 .join(", ");
19378 push_line(formatted, format!(" skills [{skills}]"));
19379 }
19380 AgentField::Capabilities(capabilities, _) => {
19381 let capabilities = capabilities
19382 .into_iter()
19383 .map(|capability| format!("{:?}", capability.value))
19384 .collect::<Vec<_>>()
19385 .join(", ");
19386 push_line(formatted, format!(" capabilities [{capabilities}]"));
19387 }
19388 AgentField::Requires(classes, _) => {
19389 let classes = classes
19390 .into_iter()
19391 .map(|class| class.name)
19392 .collect::<Vec<_>>()
19393 .join(", ");
19394 push_line(formatted, format!(" requires [{classes}]"));
19395 }
19396 AgentField::Tools(tools, _) => {
19397 let tools = tools
19398 .into_iter()
19399 .map(|tool| tool.name)
19400 .collect::<Vec<_>>()
19401 .join(", ");
19402 push_line(formatted, format!(" tools [{tools}]"));
19403 }
19404 AgentField::Compaction(strategy) => {
19405 push_line(formatted, format!(" compaction {}", strategy.name));
19406 }
19407 AgentField::Thread(mode) => {
19408 push_line(formatted, format!(" thread {}", mode.name));
19409 }
19410 AgentField::Settings(sources) => {
19411 push_line(formatted, format!(" settings {}", sources.name));
19412 }
19413 AgentField::Unknown { name, .. } => {
19414 push_line(formatted, format!(" {}", name.name));
19415 }
19416 }
19417 }
19418 push_line(formatted, "}");
19419}
19420
19421fn format_enum(enum_decl: EnumDecl, formatted: &mut String) {
19422 push_line(formatted, format!("enum {} {{", enum_decl.name.name));
19423 for variant in enum_decl.variants {
19424 if variant.fields.is_empty() {
19425 push_line(formatted, format!(" {}", variant.name.name));
19426 continue;
19427 }
19428 push_line(formatted, format!(" {} {{", variant.name.name));
19429 for field in variant.fields {
19430 push_line(
19431 formatted,
19432 format!(" {} {}", field.name.name, field.ty.to_source()),
19433 );
19434 }
19435 push_line(formatted, " }");
19436 }
19437 push_line(formatted, "}");
19438}
19439
19440fn format_time_of_day(time: TimeOfDay) -> String {
19441 format!("{:02}:{:02}", time.hour, time.minute)
19442}
19443
19444fn format_weekday(day: Weekday) -> &'static str {
19445 match day {
19446 Weekday::Monday => "monday",
19447 Weekday::Tuesday => "tuesday",
19448 Weekday::Wednesday => "wednesday",
19449 Weekday::Thursday => "thursday",
19450 Weekday::Friday => "friday",
19451 Weekday::Saturday => "saturday",
19452 Weekday::Sunday => "sunday",
19453 }
19454}
19455
19456fn format_recurrence(recurrence: &Recurrence) -> String {
19457 match recurrence {
19458 Recurrence::At { time, .. } => format!("at {}", format_time_of_day(*time)),
19459 Recurrence::EveryDuration { source, .. } => format!("every {source}"),
19460 Recurrence::EveryCalendar { pattern, time, .. } => {
19461 let pattern = match pattern {
19462 CalendarPattern::Day => "day".to_owned(),
19463 CalendarPattern::Weekday => "weekday".to_owned(),
19464 CalendarPattern::Weekly(day) => format_weekday(*day).to_owned(),
19465 };
19466 format!("every {pattern} at {}", format_time_of_day(*time))
19467 }
19468 }
19469}
19470
19471fn format_source_value(value: &SourceValue) -> String {
19472 match value {
19473 SourceValue::Path {
19474 binding, segments, ..
19475 } => {
19476 let mut text = binding.name.clone();
19477 for segment in segments {
19478 text.push('.');
19479 text.push_str(&segment.name);
19480 }
19481 text
19482 }
19483 SourceValue::String(literal) => format!("{:?}", literal.value),
19484 SourceValue::Number(number, _) => number.clone(),
19485 }
19486}
19487
19488fn format_test_fields(fields: &[TestField], formatted: &mut String) {
19489 for field in fields {
19490 push_line(
19491 formatted,
19492 format!(" {} {}", field.name.name, field.value),
19493 );
19494 }
19495}
19496
19497fn format_test(test: TestDecl, formatted: &mut String) {
19498 push_line(formatted, format!("test {:?} {{", test.name.value));
19499 if let Some(workflow) = &test.workflow {
19500 push_line(formatted, format!(" workflow {}", workflow.name));
19501 }
19502 for clause in &test.clauses {
19503 match clause {
19504 TestClause::Given(given) => match given {
19505 GivenClause::Input { fields, .. } => {
19506 push_line(formatted, " given input {");
19507 format_test_fields(fields, formatted);
19508 push_line(formatted, " }");
19509 }
19510 GivenClause::Fact { ty, fields, .. } => {
19511 push_line(formatted, format!(" given fact {} {{", ty.name));
19512 format_test_fields(fields, formatted);
19513 push_line(formatted, " }");
19514 }
19515 GivenClause::Signal { name, fields, .. } => {
19516 push_line(formatted, format!(" given signal {name} {{"));
19517 format_test_fields(fields, formatted);
19518 push_line(formatted, " }");
19519 }
19520 GivenClause::Clock { at, .. } => {
19521 push_line(formatted, format!(" given clock at {:?}", at.value));
19522 }
19523 GivenClause::Tracker {
19524 tracker, fields, ..
19525 } => {
19526 push_line(formatted, format!(" given tracker {tracker} issue {{"));
19527 format_test_fields(fields, formatted);
19528 push_line(formatted, " }");
19529 }
19530 GivenClause::File {
19531 store,
19532 path,
19533 content,
19534 ..
19535 } => {
19536 push_line(
19537 formatted,
19538 format!(
19539 " given file {store} at {:?} {:?}",
19540 path.value, content.value
19541 ),
19542 );
19543 }
19544 },
19545 TestClause::Stub(stub) => {
19546 let surface = stub.surface.join(" ");
19547 match &stub.payload {
19548 Some(StubPayload::Message(message)) => push_line(
19549 formatted,
19550 format!(" stub {surface} {} {:?}", stub.outcome, message.value),
19551 ),
19552 Some(StubPayload::Record(fields)) => {
19553 push_line(formatted, format!(" stub {surface} {} {{", stub.outcome));
19554 format_test_fields(fields, formatted);
19555 push_line(formatted, " }");
19556 }
19557 None => push_line(formatted, format!(" stub {surface} {}", stub.outcome)),
19558 }
19559 }
19560 TestClause::Run(run) => {
19561 let text = match &run.kind {
19562 RunKind::UntilIdle => "run until idle".to_owned(),
19563 RunKind::UntilWorkflowCompleted => "run until workflow completed".to_owned(),
19564 RunKind::UntilWorkflowFailed => "run until workflow failed".to_owned(),
19565 RunKind::ForSteps(steps) => format!("run for {steps} steps"),
19566 };
19567 push_line(formatted, format!(" {text}"));
19568 }
19569 TestClause::Expect(expect) => {
19570 push_line(
19571 formatted,
19572 format!(" {}", format_expect_target(&expect.target)),
19573 );
19574 }
19575 }
19576 }
19577 push_line(formatted, "}");
19578}
19579
19580fn format_expect_target(target: &ExpectTarget) -> String {
19581 match target {
19582 ExpectTarget::WorkflowCompleted => "expect workflow completed".to_owned(),
19583 ExpectTarget::WorkflowFailed { failure: None } => "expect workflow failed".to_owned(),
19584 ExpectTarget::WorkflowFailed {
19585 failure: Some(failure),
19586 } => format!("expect workflow failed with {}", failure.name),
19587 ExpectTarget::Rule { name, status } => {
19588 let status = match status {
19589 RuleStatus::Fired => "fired".to_owned(),
19590 RuleStatus::FiredTimes(count) => format!("fired {count} times"),
19591 RuleStatus::DidNotFire => "did not fire".to_owned(),
19592 };
19593 format!("expect rule {} {status}", name.name)
19594 }
19595 ExpectTarget::Effect { name, status } => {
19596 let status = match status {
19597 EffectStatus::Requested => "requested",
19598 EffectStatus::Completed => "completed",
19599 EffectStatus::Failed => "failed",
19600 };
19601 format!("expect effect {name} {status}")
19602 }
19603 ExpectTarget::Diagnostic { code } => format!("expect diagnostic {code}"),
19604 ExpectTarget::NoEffect { name } => format!("expect no {name}"),
19605 ExpectTarget::Projection(query) => format!("expect {}", format_proj_query(query)),
19606 }
19607}
19608
19609fn format_proj_query(query: &ProjQuery) -> String {
19610 match &query.kind {
19611 ProjQueryKind::Exists => format!("{} exists", query.noun),
19612 ProjQueryKind::Count { predicate, count } => {
19613 format!("{} count where {predicate} is {count}", query.noun)
19614 }
19615 ProjQueryKind::Where { predicate } => {
19616 format!("{} where {predicate}", query.noun)
19617 }
19618 }
19619}
19620
19621fn format_source(source: SourceDecl, formatted: &mut String) {
19622 push_line(
19623 formatted,
19624 format!("source {} as {} {{", source.provider.name, source.name.name),
19625 );
19626 if let Some(clock) = &source.clock {
19627 push_line(
19628 formatted,
19629 format!(" {}", format_recurrence(&clock.recurrence)),
19630 );
19631 if let Some(timezone) = &clock.timezone {
19632 push_line(formatted, format!(" timezone {:?}", timezone.value));
19633 }
19634 match clock.missed {
19635 Some(MissedPolicy::Skip) => push_line(formatted, " missed skip"),
19636 Some(MissedPolicy::Coalesce) => push_line(formatted, " missed coalesce"),
19637 Some(MissedPolicy::CatchUp { limit }) => {
19638 push_line(formatted, format!(" missed catch_up limit {limit}"))
19639 }
19640 None => {}
19641 }
19642 }
19643 if let Some(path) = &source.path {
19644 push_line(formatted, format!(" path {:?}", path.value));
19645 }
19646 if let Some(watch) = &source.watch {
19647 push_line(formatted, format!(" watch {:?}", watch.value));
19648 }
19649 if let Some(url) = &source.url {
19650 push_line(formatted, format!(" url {:?}", url.value));
19651 }
19652 if let Some(dedup) = &source.dedup {
19653 push_line(formatted, format!(" dedup {}", format_source_value(dedup)));
19654 }
19655 push_line(
19656 formatted,
19657 format!(" observe as {}", source.observe_binding.name),
19658 );
19659 let from = source
19660 .emit
19661 .from
19662 .as_ref()
19663 .map(|ident| format!(" from {}", ident.name))
19664 .unwrap_or_default();
19665 if source.emit.fields.is_empty() && source.emit.from.is_some() {
19666 push_line(formatted, format!(" emit {}{from}", source.emit.signal));
19667 } else {
19668 push_line(formatted, format!(" emit {}{from} {{", source.emit.signal));
19669 for field in &source.emit.fields {
19670 push_line(
19671 formatted,
19672 format!(
19673 " {} {}",
19674 field.name.name,
19675 format_source_value(&field.value)
19676 ),
19677 );
19678 }
19679 push_line(formatted, " }");
19680 }
19681 push_line(formatted, "}");
19682}
19683
19684fn format_event(event: EventDecl, formatted: &mut String) {
19685 push_line(formatted, format!("signal {} {{", event.name));
19686 for field in event.fields {
19687 push_line(
19688 formatted,
19689 format!(" {} {}", field.name.name, field.ty.to_source()),
19690 );
19691 }
19692 push_line(formatted, "}");
19693}
19694
19695fn format_class(class_decl: ClassDecl, formatted: &mut String) {
19696 push_line(formatted, format!("class {} {{", class_decl.name.name));
19697 for field in class_decl.fields {
19698 let key = if field.is_key { " @key" } else { "" };
19699 push_line(
19700 formatted,
19701 format!(" {} {}{key}", field.name.name, field.ty.to_source()),
19702 );
19703 }
19704 push_line(formatted, "}");
19705}
19706
19707fn format_table(table: TableDecl, formatted: &mut String) {
19708 format_tags(&table.tags, formatted);
19709 format_description(table.description.as_ref(), formatted);
19710 push_line(
19711 formatted,
19712 format!("table {} as {} [", table.name.name, table.schema.name),
19713 );
19714 for row in table.rows {
19715 push_line(formatted, " {");
19716 for line in row.body.text.lines() {
19717 if line.trim().is_empty() {
19718 formatted.push('\n');
19719 } else {
19720 push_line(formatted, format!(" {}", line.trim()));
19725 }
19726 }
19727 push_line(formatted, " }");
19728 }
19729 push_line(formatted, "]");
19730}
19731
19732fn format_coerce(coerce: CoerceDecl, formatted: &mut String) {
19733 let params = coerce
19734 .params
19735 .into_iter()
19736 .map(|param| format!("{} {}", param.name.name, param.ty.to_source()))
19737 .collect::<Vec<_>>()
19738 .join(", ");
19739 push_line(
19740 formatted,
19741 format!(
19742 "coerce {}({}) -> {} {{",
19743 coerce.name.name,
19744 params,
19745 coerce.output.to_source()
19746 ),
19747 );
19748 format_block_body(&coerce.body.text, formatted);
19749 push_line(formatted, "}");
19750}
19751
19752fn format_rule(rule: RuleDecl, formatted: &mut String) {
19753 format_tags(&rule.tags, formatted);
19754 format_description(rule.description.as_ref(), formatted);
19755 push_line(formatted, format!("rule {}", rule.name.name));
19756 for when in rule.whens {
19757 push_line(formatted, format!(" when {}", when.text));
19758 }
19759 push_line(formatted, "=> {");
19760 format_block_body(&rule.body.text, formatted);
19761 push_line(formatted, "}");
19762}
19763
19764fn push_block_body(body: &str, formatted: &mut String) {
19769 if body.is_empty() {
19770 return;
19771 }
19772 for line in body.lines() {
19773 if line.trim().is_empty() {
19774 formatted.push('\n');
19775 } else {
19776 push_line(formatted, format!(" {}", line.trim_end()));
19777 }
19778 }
19779}
19780
19781fn format_block_body(body: &str, formatted: &mut String) {
19791 if body.trim().is_empty() {
19792 return;
19793 }
19794 let lines: Vec<&str> = body.lines().collect();
19795 let mut index = 0;
19796 let mut depth: i32 = 1;
19797 while index < lines.len() {
19798 let trimmed = lines[index].trim();
19799 if trimmed.is_empty() {
19800 formatted.push('\n');
19801 index += 1;
19802 continue;
19803 }
19804 let opens_with_closer = trimmed
19805 .chars()
19806 .next()
19807 .is_some_and(|ch| matches!(ch, '}' | ']' | ')'));
19808 let line_depth = if opens_with_closer {
19809 (depth - 1).max(0)
19810 } else {
19811 depth
19812 };
19813 let prefix = " ".repeat(line_depth as usize);
19814 let (delta, opens_triple) = scan_braces(trimmed);
19815 push_line(formatted, format!("{prefix}{trimmed}"));
19816 if opens_triple {
19817 let mut end = index + 1;
19819 while end < lines.len() && lines[end].matches("\"\"\"").count().is_multiple_of(2) {
19820 end += 1;
19821 }
19822 let content = &lines[index + 1..end];
19823 let common = content
19824 .iter()
19825 .filter(|line| !line.trim().is_empty())
19826 .map(|line| line.len() - line.trim_start().len())
19827 .min()
19828 .unwrap_or(0);
19829 for line in content {
19830 if line.trim().is_empty() {
19831 formatted.push('\n');
19832 } else {
19833 push_line(formatted, format!("{prefix}{}", &line[common..]));
19834 }
19835 }
19836 if end < lines.len() {
19837 push_line(formatted, format!("{prefix}{}", lines[end].trim()));
19839 }
19840 index = end + 1;
19841 } else {
19842 index += 1;
19843 }
19844 depth = (depth + delta).max(0);
19845 }
19846}
19847
19848fn scan_braces(line: &str) -> (i32, bool) {
19854 let bytes = line.as_bytes();
19855 let mut index = 0;
19856 let mut delta = 0i32;
19857 let mut in_string = false;
19858 while index < bytes.len() {
19859 if in_string {
19860 match bytes[index] {
19861 b'\\' => index += 1,
19862 b'"' => in_string = false,
19863 _ => {}
19864 }
19865 index += 1;
19866 continue;
19867 }
19868 if line[index..].starts_with("\"\"\"") {
19869 match line[index + 3..].find("\"\"\"") {
19870 Some(offset) => index += 3 + offset + 3,
19871 None => return (delta, true),
19872 }
19873 continue;
19874 }
19875 match bytes[index] {
19876 b'"' => in_string = true,
19877 b'{' | b'[' | b'(' => delta += 1,
19878 b'}' | b']' | b')' => delta -= 1,
19879 _ => {}
19880 }
19881 index += 1;
19882 }
19883 (delta, false)
19884}
19885
19886impl TypeSyntax {
19887 fn to_source(&self) -> String {
19888 match self {
19889 Self::Primitive { name, .. } => name.clone(),
19890 Self::LiteralString { value, .. } => format!("{value:?}"),
19891 Self::Ref { name } => name.name.clone(),
19892 Self::AgentRef { agents, .. } => {
19893 let agents = agents
19894 .iter()
19895 .map(|agent| agent.name.as_str())
19896 .collect::<Vec<_>>()
19897 .join(" | ");
19898 format!("AgentRef<{agents}>")
19899 }
19900 Self::Optional { inner, .. } => format!("{}?", inner.to_source()),
19901 Self::Array { inner, .. } => format!("{}[]", inner.to_source()),
19902 Self::Map { inner, .. } => format!("map<{}>", inner.to_source()),
19903 Self::Union { variants, .. } => variants
19904 .iter()
19905 .map(Self::to_source)
19906 .collect::<Vec<_>>()
19907 .join(" | "),
19908 }
19909 }
19910}
19911
19912pub fn parser_stage() -> &'static str {
19914 whipplescript_core::IMPLEMENTATION_STAGE
19915}
19916
19917#[derive(Clone, Debug, Eq, PartialEq)]
19918struct Lexed {
19919 tokens: Vec<Token>,
19920 diagnostics: Vec<Diagnostic>,
19921 comments: Vec<Comment>,
19922}
19923
19924#[derive(Clone, Debug, Eq, PartialEq)]
19925struct Token {
19926 kind: TokenKind,
19927 span: SourceSpan,
19928}
19929
19930#[derive(Clone, Debug, Eq, PartialEq)]
19931enum TokenKind {
19932 Ident(String),
19933 String(String),
19934 Number(String),
19935 Arrow,
19936 ThinArrow,
19937 Symbol(char),
19938}
19939
19940impl TokenKind {
19941 fn label(&self) -> String {
19942 match self {
19943 Self::Ident(value) => format!("identifier `{value}`"),
19944 Self::String(_) => "string literal".to_owned(),
19945 Self::Number(_) => "number literal".to_owned(),
19946 Self::Arrow => "`=>`".to_owned(),
19947 Self::ThinArrow => "`->`".to_owned(),
19948 Self::Symbol(value) => format!("`{value}`"),
19949 }
19950 }
19951}
19952
19953fn lex(source: &str) -> Lexed {
19954 let bytes = source.as_bytes();
19955 let mut tokens = Vec::new();
19956 let mut diagnostics = Vec::new();
19957 let mut comments = Vec::new();
19958 let mut index = 0;
19959
19960 while index < bytes.len() {
19961 let byte = bytes[index];
19962 if byte.is_ascii_whitespace() {
19963 index += 1;
19964 continue;
19965 }
19966
19967 if byte == b'#' {
19968 let end = skip_line(bytes, index + 1);
19969 comments.push(Comment {
19970 marker: CommentMarker::Hash,
19971 text: source[index + 1..end].trim().to_owned(),
19972 span: SourceSpan { start: index, end },
19973 });
19974 index = end;
19975 continue;
19976 }
19977
19978 if byte == b'/' && bytes.get(index + 1) == Some(&b'/') {
19979 let end = skip_line(bytes, index + 2);
19980 comments.push(Comment {
19981 marker: CommentMarker::Slash,
19982 text: source[index + 2..end].trim().to_owned(),
19983 span: SourceSpan { start: index, end },
19984 });
19985 index = end;
19986 continue;
19987 }
19988
19989 if is_ident_start(byte) {
19990 let start = index;
19991 index += 1;
19992 while index < bytes.len() && is_ident_continue(bytes[index]) {
19993 index += 1;
19994 }
19995 tokens.push(Token {
19996 kind: TokenKind::Ident(source[start..index].to_owned()),
19997 span: SourceSpan { start, end: index },
19998 });
19999 continue;
20000 }
20001
20002 if byte.is_ascii_digit() {
20003 let start = index;
20004 index += 1;
20005 while index < bytes.len() && bytes[index].is_ascii_digit() {
20006 index += 1;
20007 }
20008 tokens.push(Token {
20009 kind: TokenKind::Number(source[start..index].to_owned()),
20010 span: SourceSpan { start, end: index },
20011 });
20012 continue;
20013 }
20014
20015 if byte == b'"' {
20016 let (token, next, diagnostic) = lex_string(source, index);
20017 tokens.push(token);
20018 if let Some(diagnostic) = diagnostic {
20019 diagnostics.push(diagnostic);
20020 }
20021 index = next;
20022 continue;
20023 }
20024
20025 if byte == b'=' && bytes.get(index + 1) == Some(&b'>') {
20026 tokens.push(Token {
20027 kind: TokenKind::Arrow,
20028 span: SourceSpan {
20029 start: index,
20030 end: index + 2,
20031 },
20032 });
20033 index += 2;
20034 continue;
20035 }
20036
20037 if byte == b'=' && bytes.get(index + 1) == Some(&b'=') {
20038 index += 2;
20039 continue;
20040 }
20041
20042 if byte == b'!' && bytes.get(index + 1) == Some(&b'=') {
20043 index += 2;
20044 continue;
20045 }
20046
20047 if matches!(byte, b'<' | b'>') && bytes.get(index + 1) == Some(&b'=') {
20048 index += 2;
20049 continue;
20050 }
20051
20052 if matches!(byte, b'&' | b'|') && bytes.get(index + 1) == Some(&byte) {
20053 index += 2;
20054 continue;
20055 }
20056
20057 if byte == b'-' && bytes.get(index + 1) == Some(&b'>') {
20058 tokens.push(Token {
20059 kind: TokenKind::ThinArrow,
20060 span: SourceSpan {
20061 start: index,
20062 end: index + 2,
20063 },
20064 });
20065 index += 2;
20066 continue;
20067 }
20068
20069 if matches!(byte, b'*' | b'/' | b'-') {
20073 index += 1;
20074 continue;
20075 }
20076
20077 if b"{}[]()<>,?|.+!:@".contains(&byte) {
20078 tokens.push(Token {
20079 kind: TokenKind::Symbol(byte as char),
20080 span: SourceSpan {
20081 start: index,
20082 end: index + 1,
20083 },
20084 });
20085 index += 1;
20086 continue;
20087 }
20088
20089 diagnostics.push(Diagnostic {
20090 related: Vec::new(),
20091 span: SourceSpan {
20092 start: index,
20093 end: index + 1,
20094 },
20095 message: format!("unexpected character `{}`", byte as char),
20096 suggestion: None,
20097 });
20098 index += 1;
20099 }
20100
20101 Lexed {
20102 tokens,
20103 diagnostics,
20104 comments,
20105 }
20106}
20107
20108pub fn lex_comments(source: &str) -> Vec<Comment> {
20112 lex(source).comments
20113}
20114
20115pub fn string_and_comment_spans(source: &str) -> Vec<SourceSpan> {
20120 let lexed = lex(source);
20121 let mut spans: Vec<SourceSpan> = lexed
20122 .tokens
20123 .iter()
20124 .filter(|token| matches!(token.kind, TokenKind::String(_)))
20125 .map(|token| token.span)
20126 .collect();
20127 spans.extend(lexed.comments.iter().map(|comment| comment.span));
20128 spans
20129}
20130
20131fn skip_line(bytes: &[u8], mut index: usize) -> usize {
20132 while index < bytes.len() && bytes[index] != b'\n' {
20133 index += 1;
20134 }
20135 index
20136}
20137
20138fn is_ident_start(byte: u8) -> bool {
20139 byte.is_ascii_alphabetic() || byte == b'_'
20140}
20141
20142fn is_ident_continue(byte: u8) -> bool {
20143 is_ident_start(byte) || byte.is_ascii_digit() || byte == b'-'
20144}
20145
20146fn lex_string(source: &str, start: usize) -> (Token, usize, Option<Diagnostic>) {
20147 let bytes = source.as_bytes();
20148 let triple = bytes.get(start..start + 3) == Some(b"\"\"\"");
20149 let content_start = if triple { start + 3 } else { start + 1 };
20150 let mut index = content_start;
20151
20152 while index < bytes.len() {
20153 if triple && bytes.get(index..index + 3) == Some(b"\"\"\"") {
20154 let end = index + 3;
20155 return (
20156 Token {
20157 kind: TokenKind::String(source[content_start..index].to_owned()),
20158 span: SourceSpan { start, end },
20159 },
20160 end,
20161 None,
20162 );
20163 }
20164
20165 if !triple && bytes[index] == b'"' {
20166 let end = index + 1;
20167 return (
20168 Token {
20169 kind: TokenKind::String(source[content_start..index].to_owned()),
20170 span: SourceSpan { start, end },
20171 },
20172 end,
20173 None,
20174 );
20175 }
20176
20177 if !triple && bytes[index] == b'\\' && index + 1 < bytes.len() {
20178 index += 2;
20179 } else {
20180 index += 1;
20181 }
20182 }
20183
20184 (
20185 Token {
20186 kind: TokenKind::String(source[content_start..].to_owned()),
20187 span: SourceSpan {
20188 start,
20189 end: source.len(),
20190 },
20191 },
20192 source.len(),
20193 Some(Diagnostic {
20194 related: Vec::new(),
20195 span: SourceSpan {
20196 start,
20197 end: source.len(),
20198 },
20199 message: "unterminated string literal".to_owned(),
20200 suggestion: Some("close the string literal".to_owned()),
20201 }),
20202 )
20203}
20204
20205#[derive(Clone, Copy, Debug)]
20210#[allow(dead_code)]
20211enum ClauseKind {
20212 Identifier,
20213 Expression,
20214 Duration,
20215 Glob,
20216 Schema,
20217 Scalar,
20218 Flag,
20219}
20220
20221#[derive(Clone, Copy, Debug)]
20227#[allow(dead_code)]
20228enum DeclAstKind {
20229 Tracker,
20230 Channel,
20231 Counter,
20232 Lease,
20233 Ledger,
20234 MemoryPool,
20235 FileStore,
20236}
20237
20238#[derive(Clone, Copy, Debug)]
20253struct ClauseSpec {
20254 name: &'static str,
20255 words: &'static [&'static str],
20256 connective: Option<&'static str>,
20257 kind: ClauseKind,
20258 list: bool,
20259 unknown_hint: &'static str,
20260}
20261
20262#[derive(Clone, Copy, Debug)]
20267#[allow(dead_code)]
20268struct DeclarationBlockSpec {
20269 keyword: &'static str,
20270 keyword_words: &'static [&'static str],
20271 ast_kind: DeclAstKind,
20272 clauses: &'static [ClauseSpec],
20273}
20274
20275include!(concat!(env!("OUT_DIR"), "/declaration_block_grammar.rs"));
20282
20283#[derive(Clone, Debug)]
20291enum ClauseValue {
20292 Ident(Ident),
20293 Duration(u64),
20294 Number(u32),
20295 Str(StringLiteral),
20296 Globs(Vec<String>),
20297 Flag,
20298 Missing,
20299}
20300
20301struct ClauseBag {
20308 records: Vec<(&'static str, SourceSpan, ClauseValue)>,
20309}
20310
20311impl ClauseBag {
20312 fn new() -> Self {
20313 ClauseBag {
20314 records: Vec::new(),
20315 }
20316 }
20317
20318 fn record(&mut self, name: &'static str, first_word_span: SourceSpan, value: ClauseValue) {
20319 self.records.push((name, first_word_span, value));
20320 }
20321
20322 fn get(&self, name: &str) -> Option<&(&'static str, SourceSpan, ClauseValue)> {
20323 self.records
20324 .iter()
20325 .rev()
20326 .find(|(clause, _, _)| *clause == name)
20327 }
20328
20329 fn ident(&self, name: &str) -> Option<Ident> {
20330 match self.get(name) {
20331 Some((_, _, ClauseValue::Ident(ident))) => Some(ident.clone()),
20332 _ => None,
20333 }
20334 }
20335
20336 fn duration(&self, name: &str) -> Option<u64> {
20337 match self.get(name) {
20338 Some((_, _, ClauseValue::Duration(seconds))) => Some(*seconds),
20339 _ => None,
20340 }
20341 }
20342
20343 fn number(&self, name: &str) -> Option<u32> {
20344 match self.get(name) {
20345 Some((_, _, ClauseValue::Number(value))) => Some(*value),
20346 _ => None,
20347 }
20348 }
20349
20350 fn text(&self, name: &str) -> Option<String> {
20351 self.text_literal(name).map(|literal| literal.value)
20352 }
20353
20354 fn text_literal(&self, name: &str) -> Option<StringLiteral> {
20355 match self.get(name) {
20356 Some((_, _, ClauseValue::Str(literal))) => Some(literal.clone()),
20357 _ => None,
20358 }
20359 }
20360
20361 fn globs(&self, name: &str) -> Vec<String> {
20362 match self.get(name) {
20363 Some((_, _, ClauseValue::Globs(values))) => values.clone(),
20364 _ => Vec::new(),
20365 }
20366 }
20367
20368 fn flag(&self, name: &str) -> bool {
20369 matches!(self.get(name), Some((_, _, ClauseValue::Flag)))
20370 }
20371
20372 fn span(&self, name: &str) -> Option<SourceSpan> {
20373 self.get(name).map(|(_, span, _)| *span)
20374 }
20375}
20376
20377struct Parser<'a> {
20378 source: &'a str,
20379 tokens: Vec<Token>,
20380 pos: usize,
20381 diagnostics: Vec<Diagnostic>,
20382 pending_contract_classes: Vec<ClassDecl>,
20385}
20386
20387struct ParsedWorkflow {
20388 decl: WorkflowDecl,
20389 explicit_body: bool,
20390}
20391
20392impl Parser<'_> {
20393 fn parse_program(&mut self) -> Program {
20394 let mut workflow = None;
20395 let mut workflow_tags = Vec::new();
20396 let mut workflow_description = None;
20397 let mut explicit_workflow_body = false;
20398 let mut workflows = Vec::new();
20399 let mut patterns = Vec::new();
20400 let mut items = Vec::new();
20401 let mut pending_tags = Vec::new();
20402 let mut pending_description = None;
20403
20404 while !self.is_at_end() {
20405 if self.at_symbol('@') {
20406 if let Some(tag) = self.parse_tag() {
20407 pending_tags.push(tag);
20408 }
20409 } else if self.at_ident("description") {
20410 self.parse_pending_description(&mut pending_description);
20411 } else if self.at_ident("workflow") {
20412 if let Some(parsed_workflow) = self.parse_workflow(
20413 std::mem::take(&mut pending_tags),
20414 pending_description.take(),
20415 ) {
20416 if parsed_workflow.explicit_body {
20417 workflows.push(parsed_workflow.decl);
20418 } else {
20419 if workflow.is_some() {
20420 self.diagnostics.push(Diagnostic { related: Vec::new(),
20421 span: parsed_workflow.decl.name.span,
20422 message: "multiple implicit workflow headers are not supported"
20423 .to_owned(),
20424 suggestion: Some(
20425 "use explicit `workflow Name { ... }` declarations with `--root`"
20426 .to_owned(),
20427 ),
20428 });
20429 }
20430 workflow_tags = parsed_workflow.decl.tags;
20431 workflow_description = parsed_workflow.decl.description;
20432 items.extend(parsed_workflow.decl.items);
20436 workflow = Some(parsed_workflow.decl.name);
20437 explicit_workflow_body = false;
20438 }
20439 }
20440 } else if self.at_ident("pattern") {
20441 self.reject_pending_tags(&mut pending_tags, "pattern");
20442 self.reject_pending_description(&mut pending_description, "pattern");
20443 if let Some(pattern) = self.parse_pattern() {
20444 patterns.push(pattern);
20445 }
20446 } else if let Some(item) =
20447 self.parse_declaration_item(&mut pending_tags, &mut pending_description)
20448 {
20449 items.push(item);
20450 } else if self.reject_gherkin_misuse() {
20451 continue;
20452 } else {
20453 if self.is_at_end() {
20454 break;
20455 }
20456 self.unexpected("top-level declaration");
20457 if !self.is_at_end() {
20458 self.advance();
20459 }
20460 }
20461 }
20462
20463 items.extend(
20466 std::mem::take(&mut self.pending_contract_classes)
20467 .into_iter()
20468 .map(Item::Class),
20469 );
20470
20471 Program {
20472 workflow,
20473 workflow_tags,
20474 workflow_description,
20475 explicit_workflow_body,
20476 workflows,
20477 patterns,
20478 items,
20479 }
20480 }
20481
20482 fn parse_workflow(
20483 &mut self,
20484 tags: Vec<TagDecl>,
20485 description: Option<StringLiteral>,
20486 ) -> Option<ParsedWorkflow> {
20487 let start = self.expect_keyword("workflow")?.span.start;
20488 let name = self.expect_ident("workflow name")?;
20489 let mut explicit_body = false;
20490 let mut items = Vec::new();
20491 let mut end = name.span.end;
20492 if self.at_symbol('(') {
20498 if let Some((contracts, signature_end)) = self.parse_compact_contract_signature() {
20499 end = signature_end;
20500 items.extend(contracts.into_iter().map(Item::WorkflowContract));
20501 }
20502 }
20503 if self.at_symbol('{') {
20504 explicit_body = true;
20505 self.expect_symbol('{')?;
20506 let mut pending_tags = Vec::new();
20507 let mut pending_description = None;
20508 while !self.is_at_end() && !self.at_symbol('}') {
20509 if self.at_symbol('@') {
20510 if let Some(tag) = self.parse_tag() {
20511 pending_tags.push(tag);
20512 }
20513 continue;
20514 }
20515 if self.at_ident("description") {
20516 self.parse_pending_description(&mut pending_description);
20517 continue;
20518 }
20519 if self.at_ident("workflow") || self.at_ident("pattern") {
20520 self.reject_pending_tags(&mut pending_tags, "workflow body declaration");
20521 self.reject_pending_description(
20522 &mut pending_description,
20523 "workflow body declaration",
20524 );
20525 self.unexpected("workflow body declaration");
20526 self.advance();
20527 continue;
20528 }
20529 if let Some(item) =
20530 self.parse_declaration_item(&mut pending_tags, &mut pending_description)
20531 {
20532 items.push(item);
20533 } else if self.reject_gherkin_misuse() {
20534 continue;
20535 } else {
20536 if self.is_at_end() {
20537 break;
20538 }
20539 self.reject_pending_tags(&mut pending_tags, "workflow body declaration");
20540 self.reject_pending_description(
20541 &mut pending_description,
20542 "workflow body declaration",
20543 );
20544 self.unexpected("workflow body declaration");
20545 if !self.is_at_end() {
20546 self.advance();
20547 }
20548 }
20549 }
20550 if let Some(close) = self.expect_symbol('}') {
20551 end = close.span.end;
20552 }
20553 }
20554 items.extend(
20559 std::mem::take(&mut self.pending_contract_classes)
20560 .into_iter()
20561 .map(Item::Class),
20562 );
20563 Some(ParsedWorkflow {
20564 decl: WorkflowDecl {
20565 name,
20566 tags,
20567 description,
20568 items,
20569 span: SourceSpan { start, end },
20570 },
20571 explicit_body,
20572 })
20573 }
20574
20575 fn parse_compact_contract_signature(&mut self) -> Option<(Vec<WorkflowContractDecl>, usize)> {
20581 self.expect_symbol('(')?;
20582 let mut contracts = Vec::new();
20583 while !self.is_at_end() && !self.at_symbol(')') {
20584 let name = self.expect_ident("workflow input name")?;
20585 self.expect_symbol(':')?;
20586 let ty = self.parse_type()?;
20587 let span = name.span.join(ty.span());
20588 contracts.push(WorkflowContractDecl {
20589 kind: WorkflowContractKind::Input,
20590 name,
20591 ty,
20592 span,
20593 });
20594 if self.at_symbol(',') {
20595 self.advance();
20596 } else if !self.at_symbol(')') {
20597 self.unexpected("`,` or `)`");
20598 while !self.is_at_end() && !self.at_symbol(')') && !self.at_symbol(',') {
20599 self.advance();
20600 }
20601 }
20602 }
20603 self.expect_symbol(')')?;
20604 self.expect_thin_arrow()?;
20605 let output_ty = self.parse_type()?;
20606 let output_span = output_ty.span();
20607 let mut end = output_span.end;
20608 contracts.push(WorkflowContractDecl {
20609 kind: WorkflowContractKind::Output,
20610 name: Ident {
20611 name: "result".to_owned(),
20612 span: output_span,
20613 },
20614 ty: output_ty,
20615 span: output_span,
20616 });
20617 if self.at_symbol('!') {
20618 self.advance();
20619 let failure_ty = self.parse_type()?;
20620 let failure_span = failure_ty.span();
20621 end = failure_span.end;
20622 contracts.push(WorkflowContractDecl {
20623 kind: WorkflowContractKind::Failure,
20624 name: Ident {
20625 name: "error".to_owned(),
20626 span: failure_span,
20627 },
20628 ty: failure_ty,
20629 span: failure_span,
20630 });
20631 }
20632 Some((contracts, end))
20633 }
20634
20635 fn parse_tag(&mut self) -> Option<TagDecl> {
20636 let at = self.expect_symbol('@')?;
20637 let name_start = at.span.end;
20638 let mut name_end = name_start;
20639 for (offset, ch) in self.source[name_start..].char_indices() {
20640 if ch.is_whitespace() {
20641 break;
20642 }
20643 name_end = name_start + offset + ch.len_utf8();
20644 }
20645 let name = self.source[name_start..name_end].to_owned();
20646 while !self.is_at_end() && self.peek().is_some_and(|token| token.span.start < name_end) {
20647 self.advance();
20648 }
20649 let span = SourceSpan {
20650 start: at.span.start,
20651 end: name_end,
20652 };
20653 if name.is_empty() {
20654 self.diagnostics.push(Diagnostic {
20655 related: Vec::new(),
20656 span,
20657 message: "tag is missing a name".to_owned(),
20658 suggestion: Some("write a tag such as `@fixture`".to_owned()),
20659 });
20660 return None;
20661 }
20662 if !name
20663 .chars()
20664 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | ':' | '.'))
20665 {
20666 self.diagnostics.push(Diagnostic {
20667 related: Vec::new(),
20668 span,
20669 message: format!("tag `@{name}` contains unsupported characters"),
20670 suggestion: Some(
20671 "use letters, digits, `_`, `-`, `.`, or `:` in tag names".to_owned(),
20672 ),
20673 });
20674 return None;
20675 }
20676 Some(TagDecl { name, span })
20677 }
20678
20679 fn reject_pending_tags(&mut self, pending_tags: &mut Vec<TagDecl>, target: &str) {
20680 for tag in pending_tags.drain(..) {
20681 self.diagnostics.push(Diagnostic {
20682 related: Vec::new(),
20683 span: tag.span,
20684 message: format!("tag `@{}` cannot be attached to {target}", tag.name),
20685 suggestion: Some(
20686 "place tags on workflows, matrices, assertions, or rules".to_owned(),
20687 ),
20688 });
20689 }
20690 }
20691
20692 fn parse_pending_description(&mut self, pending_description: &mut Option<StringLiteral>) {
20693 let Some(description) = self.parse_description() else {
20694 return;
20695 };
20696 if let Some(previous) = pending_description.replace(description) {
20697 self.diagnostics.push(Diagnostic { related: Vec::new(),
20698 span: previous.span,
20699 message: "description is not attached to a declaration".to_owned(),
20700 suggestion: Some(
20701 "place only one `description \"...\"` immediately before the target declaration"
20702 .to_owned(),
20703 ),
20704 });
20705 }
20706 }
20707
20708 fn parse_description(&mut self) -> Option<StringLiteral> {
20709 let description = self.expect_keyword("description")?;
20710 let Some(value) = self.expect_string("description string") else {
20711 return Some(StringLiteral {
20712 value: String::new(),
20713 span: description.span,
20714 });
20715 };
20716 Some(value)
20717 }
20718
20719 fn reject_pending_description(
20720 &mut self,
20721 pending_description: &mut Option<StringLiteral>,
20722 target: &str,
20723 ) {
20724 if let Some(description) = pending_description.take() {
20725 self.diagnostics.push(Diagnostic {
20726 related: Vec::new(),
20727 span: description.span,
20728 message: format!("description cannot be attached to {target}"),
20729 suggestion: Some(
20730 "place descriptions on workflows, matrices, assertions, or rules".to_owned(),
20731 ),
20732 });
20733 }
20734 }
20735
20736 fn reject_gherkin_misuse(&mut self) -> bool {
20737 let Some(token) = self.peek() else {
20738 return false;
20739 };
20740 let TokenKind::Ident(keyword) = &token.kind else {
20741 return false;
20742 };
20743 if !is_gherkin_keyword(keyword) {
20744 return false;
20745 }
20746 let span = token.span;
20747 self.diagnostics.push(Diagnostic { related: Vec::new(),
20748 span,
20749 message: format!(
20750 "Gherkin keyword `{keyword}` is not WhippleScript workflow syntax"
20751 ),
20752 suggestion: Some(
20753 "use `workflow`, `table`, `rule ... when ... => { ... }`, and `assert` instead of free-text Given/When/Then steps"
20754 .to_owned(),
20755 ),
20756 });
20757 self.advance_to_line_end(span.start);
20758 true
20759 }
20760
20761 fn advance_to_line_end(&mut self, line_start: usize) {
20762 let line_end = self.source[line_start..]
20763 .find('\n')
20764 .map(|offset| line_start + offset)
20765 .unwrap_or(self.source.len());
20766 while self.peek().is_some_and(|token| token.span.start < line_end) {
20767 self.advance();
20768 }
20769 }
20770
20771 fn parse_declaration_item(
20772 &mut self,
20773 pending_tags: &mut Vec<TagDecl>,
20774 pending_description: &mut Option<StringLiteral>,
20775 ) -> Option<Item> {
20776 if let Some(spec) = self.declaration_block_spec_at() {
20783 self.reject_pending_tags(pending_tags, spec.keyword);
20784 self.reject_pending_description(pending_description, spec.keyword);
20785 return self.parse_declaration_block(spec);
20786 }
20787 if self.at_ident("include") {
20788 self.reject_pending_tags(pending_tags, "include");
20789 self.reject_pending_description(pending_description, "include");
20790 self.parse_include().map(Item::Include)
20791 } else if self.at_ident("use") {
20792 self.reject_pending_tags(pending_tags, "use");
20793 self.reject_pending_description(pending_description, "use");
20794 self.parse_use().map(Item::Use)
20795 } else if self.at_ident("pattern") {
20796 self.reject_pending_tags(pending_tags, "pattern");
20797 self.reject_pending_description(pending_description, "pattern");
20798 self.parse_pattern().map(Item::Pattern)
20799 } else if self.at_ident("apply") {
20800 self.reject_pending_tags(pending_tags, "apply");
20801 self.reject_pending_description(pending_description, "apply");
20802 self.parse_apply().map(Item::Apply)
20803 } else if self.at_ident("input") || self.at_ident("output") || self.at_ident("failure") {
20804 self.reject_pending_tags(pending_tags, "workflow contract");
20805 self.reject_pending_description(pending_description, "workflow contract");
20806 self.parse_workflow_contract().map(Item::WorkflowContract)
20807 } else if self.at_ident("flow") {
20808 let span = self
20812 .peek()
20813 .map(|token| token.span)
20814 .unwrap_or(SourceSpan { start: 0, end: 0 });
20815 self.diagnostics.push(Diagnostic {
20816 related: Vec::new(),
20817 span,
20818 message: "the `flow` declaration was removed".to_owned(),
20819 suggestion: Some(
20820 "write a `rule` and chain sequential steps with `then <binding> <- <effect>`"
20821 .to_owned(),
20822 ),
20823 });
20824 let mut depth = 0usize;
20828 while !self.is_at_end() {
20829 let token = self.advance();
20830 match &token.kind {
20831 TokenKind::Symbol('{') => depth += 1,
20832 TokenKind::Symbol('}') => {
20833 depth = depth.saturating_sub(1);
20834 if depth == 0 {
20835 break;
20836 }
20837 }
20838 _ => {}
20839 }
20840 }
20841 None
20842 } else if self.at_ident("action") {
20843 self.reject_pending_tags(pending_tags, "action");
20844 self.reject_pending_description(pending_description, "action");
20845 self.parse_action().map(Item::Action)
20846 } else if self.at_ident("harness") {
20847 self.reject_pending_tags(pending_tags, "harness");
20848 self.reject_pending_description(pending_description, "harness");
20849 self.parse_harness().map(Item::Harness)
20850 } else if self.at_ident("agent") {
20851 self.reject_pending_tags(pending_tags, "agent");
20852 self.reject_pending_description(pending_description, "agent");
20853 self.parse_agent().map(Item::Agent)
20854 } else if self.at_ident("enum") {
20855 self.reject_pending_tags(pending_tags, "enum");
20856 self.reject_pending_description(pending_description, "enum");
20857 self.parse_enum().map(Item::Enum)
20858 } else if self.at_ident("signal") {
20859 self.reject_pending_tags(pending_tags, "signal");
20860 self.reject_pending_description(pending_description, "signal");
20861 self.parse_event().map(Item::Event)
20862 } else if self.at_ident("gauge") {
20863 self.reject_pending_tags(pending_tags, "gauge");
20864 self.reject_pending_description(pending_description, "gauge");
20865 self.parse_gauge().map(Item::Gauge)
20866 } else if self.at_ident("campaign") {
20867 self.reject_pending_tags(pending_tags, "campaign");
20868 self.reject_pending_description(pending_description, "campaign");
20869 self.parse_campaign().map(Item::Campaign)
20870 } else if self.at_ident("mark") {
20871 self.reject_pending_tags(pending_tags, "mark");
20872 self.reject_pending_description(pending_description, "mark");
20873 self.parse_mark().map(Item::Mark)
20874 } else if self.at_ident("source") {
20875 self.reject_pending_tags(pending_tags, "source");
20876 self.reject_pending_description(pending_description, "source");
20877 self.parse_source()
20878 .map(|source| Item::Source(Box::new(source)))
20879 } else if self.at_ident("test") {
20880 self.reject_pending_tags(pending_tags, "test");
20881 self.reject_pending_description(pending_description, "test");
20882 self.parse_test().map(Item::Test)
20883 } else if self.at_ident("class") {
20884 self.reject_pending_tags(pending_tags, "class");
20885 self.reject_pending_description(pending_description, "class");
20886 self.parse_class().map(Item::Class)
20887 } else if self.at_ident("table") {
20888 self.parse_table(std::mem::take(pending_tags), pending_description.take())
20889 .map(Item::Table)
20890 } else if self.at_ident("coerce") {
20891 self.reject_pending_tags(pending_tags, "coerce");
20892 self.reject_pending_description(pending_description, "coerce");
20893 self.parse_coerce().map(Item::Coerce)
20894 } else if self.at_ident("assert") {
20895 self.parse_assert(std::mem::take(pending_tags), pending_description.take())
20896 .map(Item::Assert)
20897 } else if self.at_ident("rule") {
20898 self.parse_rule(std::mem::take(pending_tags), pending_description.take())
20899 .map(Item::Rule)
20900 } else {
20901 None
20902 }
20903 }
20904
20905 fn declaration_block_spec_at(&self) -> Option<&'static DeclarationBlockSpec> {
20911 let head = match self.peek().map(|token| &token.kind) {
20912 Some(TokenKind::Ident(value)) => value.as_str(),
20913 _ => return None,
20914 };
20915 DECLARATION_BLOCK_GRAMMAR
20916 .iter()
20917 .find(|spec| spec.keyword_words.first() == Some(&head))
20918 }
20919
20920 fn parse_declaration_block(&mut self, spec: &'static DeclarationBlockSpec) -> Option<Item> {
20928 let head = *spec.keyword_words.first()?;
20929 let start = self.expect_keyword(head)?.span.start;
20930 for tail in &spec.keyword_words[1..] {
20931 if !self.consume_ident(tail) {
20932 self.expected(format!("`{tail}` after `{head}`"));
20933 return None;
20934 }
20935 }
20936 let name = self.expect_ident(&format!("{} name", spec.keyword))?;
20937 if !self.at_symbol('{') {
20943 let span = SourceSpan {
20944 start,
20945 end: name.span.end,
20946 };
20947 let bag = ClauseBag::new();
20948 return self.item_from_decl_ast(spec.ast_kind, &bag, name, span);
20949 }
20950 self.expect_symbol('{')?;
20951 let field_label = format!("{} field", spec.keyword);
20952 let mut bag = ClauseBag::new();
20953 while !self.is_at_end() && !self.at_symbol('}') {
20954 let Some(field) = self.expect_ident(&field_label) else {
20955 self.synchronize_to_block_item();
20956 continue;
20957 };
20958 let first_word_span = field.span;
20962 let mut words = vec![field.name];
20963 let matched = loop {
20964 let depth = words.len();
20965 if let Some(clause) = spec.clauses.iter().find(|clause| {
20966 clause.words.len() == depth
20967 && clause
20968 .words
20969 .iter()
20970 .zip(&words)
20971 .all(|(a, b)| *a == b.as_str())
20972 }) {
20973 break Some(clause);
20974 }
20975 let extendable = spec.clauses.iter().any(|clause| {
20976 clause.words.len() > depth
20977 && clause
20978 .words
20979 .iter()
20980 .zip(&words)
20981 .all(|(a, b)| *a == b.as_str())
20982 });
20983 if !extendable {
20984 break None;
20985 }
20986 let Some(next) = self.expect_ident("clause name") else {
20987 break None;
20988 };
20989 words.push(next.name);
20990 };
20991 let Some(clause) = matched else {
20992 let hint = spec.clauses.first().map(|clause| clause.unknown_hint);
20993 self.diagnostics.push(Diagnostic {
20994 related: Vec::new(),
20995 span: first_word_span,
20996 message: format!("unknown {} field `{}`", spec.keyword, words.join(" ")),
20997 suggestion: hint.map(str::to_owned),
20998 });
20999 self.synchronize_to_block_item();
21000 continue;
21001 };
21002 if let Some(connective) = clause.connective {
21005 if !self.consume_ident(connective) {
21006 self.diagnostics.push(Diagnostic {
21007 related: Vec::new(),
21008 span: first_word_span,
21009 message: format!("expected `{connective}` after `{}`", clause.name),
21010 suggestion: Some(format!("write `{} {connective} <field>`", clause.name)),
21011 });
21012 self.synchronize_to_block_item();
21013 continue;
21014 }
21015 }
21016 let value = self.parse_clause_value(clause);
21017 bag.record(clause.name, first_word_span, value);
21018 }
21019 let close = self.expect_symbol('}')?;
21020 let span = SourceSpan {
21021 start,
21022 end: close.span.end,
21023 };
21024 self.item_from_decl_ast(spec.ast_kind, &bag, name, span)
21025 }
21026
21027 fn parse_clause_value(&mut self, clause: &ClauseSpec) -> ClauseValue {
21031 match clause.kind {
21032 ClauseKind::Identifier | ClauseKind::Schema => self
21033 .expect_ident(&format!("{} value", clause.name))
21034 .map_or(ClauseValue::Missing, ClauseValue::Ident),
21035 ClauseKind::Duration => self
21036 .parse_decl_duration_seconds(&format!("{} duration", clause.name))
21037 .map_or(ClauseValue::Missing, ClauseValue::Duration),
21038 ClauseKind::Scalar => match self.peek().map(|token| &token.kind) {
21039 Some(TokenKind::String(_)) => self
21040 .expect_string(&format!("{} value", clause.name))
21041 .map_or(ClauseValue::Missing, ClauseValue::Str),
21042 _ => self
21043 .expect_u32(&format!("{} value", clause.name))
21044 .map_or(ClauseValue::Missing, |(value, _)| {
21045 ClauseValue::Number(value)
21046 }),
21047 },
21048 ClauseKind::Glob if clause.list => {
21049 let globs = self
21053 .parse_string_list()
21054 .map(|(literals, _)| literals.into_iter().map(|l| l.value).collect())
21055 .unwrap_or_default();
21056 ClauseValue::Globs(globs)
21057 }
21058 ClauseKind::Glob => self
21059 .expect_string(&format!("{} value", clause.name))
21060 .map_or(ClauseValue::Missing, ClauseValue::Str),
21061 ClauseKind::Flag => ClauseValue::Flag,
21062 ClauseKind::Expression => ClauseValue::Missing,
21064 }
21065 }
21066
21067 fn item_from_decl_ast(
21073 &mut self,
21074 ast_kind: DeclAstKind,
21075 bag: &ClauseBag,
21076 name: Ident,
21077 span: SourceSpan,
21078 ) -> Option<Item> {
21079 match ast_kind {
21080 DeclAstKind::Tracker => {
21081 let provider = bag.ident("provider").unwrap_or_else(|| Ident {
21084 name: "builtin".to_owned(),
21085 span,
21086 });
21087 Some(Item::Tracker(TrackerDecl {
21088 name,
21089 provider,
21090 span,
21091 }))
21092 }
21093 DeclAstKind::Channel => {
21094 let provider = bag.ident("provider").unwrap_or_else(|| Ident {
21096 name: "local".to_owned(),
21097 span,
21098 });
21099 Some(Item::Channel(ChannelDecl {
21100 name,
21101 provider,
21102 workspace: bag.ident("workspace"),
21103 destination: bag.text_literal("destination"),
21104 span,
21105 }))
21106 }
21107 DeclAstKind::Counter => {
21108 let key_type = bag.ident("key");
21109 let cap = bag.number("cap").map(i64::from);
21110 let reset = bag.ident("reset").map(|period| {
21113 if !matches!(
21114 period.name.as_str(),
21115 "hourly" | "daily" | "weekly" | "monthly"
21116 ) {
21117 self.diagnostics.push(Diagnostic {
21118 related: Vec::new(),
21119 span: period.span,
21120 message: format!("unknown reset period `{}`", period.name),
21121 suggestion: Some(
21122 "use `hourly`, `daily`, `weekly`, or `monthly`".to_owned(),
21123 ),
21124 });
21125 }
21126 period.name
21127 });
21128 let shared = bag.flag("shared");
21129 let (Some(key_type), Some(cap), Some(reset)) = (key_type, cap, reset) else {
21130 self.diagnostics.push(Diagnostic {
21131 related: Vec::new(),
21132 span,
21133 message: format!(
21134 "counter `{}` must declare `key`, `cap`, and `reset`",
21135 name.name
21136 ),
21137 suggestion: Some(
21138 "every counter is bounded: declare all three fields".to_owned(),
21139 ),
21140 });
21141 return None;
21142 };
21143 Some(Item::Counter(CounterDecl {
21144 name,
21145 key_type,
21146 cap,
21147 reset,
21148 timezone: bag.text("timezone"),
21149 shared,
21150 span,
21151 }))
21152 }
21153 DeclAstKind::Lease => {
21154 let key_type = bag.ident("key");
21155 let slots = bag.number("slots").unwrap_or(1);
21156 let ttl_seconds = bag.duration("ttl");
21157 let shared = bag.flag("shared");
21158 let (Some(key_type), Some(ttl_seconds)) = (key_type, ttl_seconds) else {
21159 self.diagnostics.push(Diagnostic {
21160 related: Vec::new(),
21161 span,
21162 message: format!(
21163 "lease `{}` must declare a `key` type and a `ttl` backstop",
21164 name.name
21165 ),
21166 suggestion: Some(
21167 "every lease is bounded: declare `key <Type>` and `ttl <duration>`"
21168 .to_owned(),
21169 ),
21170 });
21171 return None;
21172 };
21173 Some(Item::Lease(LeaseDecl {
21174 name,
21175 key_type,
21176 slots,
21177 ttl_seconds,
21178 shared,
21179 span,
21180 }))
21181 }
21182 DeclAstKind::Ledger => {
21183 let entry_schema = bag.ident("entry");
21184 let partition_field = bag.ident("partition");
21185 let retain_seconds = bag.duration("retain");
21186 let shared = bag.flag("shared");
21187 let (Some(entry_schema), Some(partition_field), Some(retain_seconds)) =
21188 (entry_schema, partition_field, retain_seconds)
21189 else {
21190 self.diagnostics.push(Diagnostic {
21191 related: Vec::new(),
21192 span,
21193 message: format!(
21194 "ledger `{}` must declare `entry`, `partition by`, and `retain`",
21195 name.name
21196 ),
21197 suggestion: Some(
21198 "every ledger is bounded and partitioned: declare all three fields"
21199 .to_owned(),
21200 ),
21201 });
21202 return None;
21203 };
21204 Some(Item::Ledger(LedgerDecl {
21205 name,
21206 entry_schema,
21207 partition_field,
21208 retain_seconds,
21209 shared,
21210 span,
21211 }))
21212 }
21213 DeclAstKind::FileStore => {
21214 let root_span = bag.span("root");
21215 let read_span = bag.span("allow read");
21216 let write_span = bag.span("allow write");
21217 let provider_span = bag.span("provider");
21218 let read_globs = bag.globs("allow read");
21219 let write_globs = bag.globs("allow write");
21220 let provider = bag.ident("provider");
21221 let Some(root) = bag.text("root") else {
21222 self.diagnostics.push(Diagnostic {
21223 related: Vec::new(),
21224 span,
21225 message: format!("file store `{}` is missing a root", name.name),
21226 suggestion: Some(
21227 "add `root \"<dir>\"` inside the file store block".to_owned(),
21228 ),
21229 });
21230 return None;
21231 };
21232 Some(Item::FileStore(FileStoreDecl {
21233 name,
21234 root,
21235 read_globs,
21236 write_globs,
21237 provider,
21238 root_span,
21239 read_span,
21240 write_span,
21241 provider_span,
21242 span,
21243 }))
21244 }
21245 DeclAstKind::MemoryPool => {
21246 let context_limit = bag.number("context limit").map(u64::from);
21247 let context_limit_span = context_limit.and(bag.span("context limit"));
21250 Some(Item::MemoryPool(MemoryPoolDecl {
21251 name,
21252 context_limit,
21253 context_limit_span,
21254 span,
21255 }))
21256 }
21257 }
21258 }
21259
21260 fn parse_pattern(&mut self) -> Option<PatternDecl> {
21261 let start = self.expect_keyword("pattern")?.span.start;
21262 let name = self.expect_ident("pattern name")?;
21263 let type_params = self.parse_type_param_list().unwrap_or_default();
21264 let open = self.expect_symbol('{')?;
21265 let mut items = Vec::new();
21266 let mut pending_tags = Vec::new();
21267 let mut pending_description = None;
21268 while !self.is_at_end() && !self.at_symbol('}') {
21269 if self.at_symbol('@') {
21270 if let Some(tag) = self.parse_tag() {
21271 pending_tags.push(tag);
21272 }
21273 continue;
21274 }
21275 if self.at_ident("description") {
21276 self.parse_pending_description(&mut pending_description);
21277 continue;
21278 }
21279 if self.at_ident("workflow") || self.at_ident("pattern") {
21280 self.reject_pending_tags(&mut pending_tags, "pattern body declaration");
21281 self.reject_pending_description(
21282 &mut pending_description,
21283 "pattern body declaration",
21284 );
21285 self.unexpected("pattern body declaration");
21286 self.advance();
21287 continue;
21288 }
21289 if let Some(item) =
21290 self.parse_declaration_item(&mut pending_tags, &mut pending_description)
21291 {
21292 items.push(item);
21293 } else if self.reject_gherkin_misuse() {
21294 continue;
21295 } else {
21296 if self.is_at_end() {
21297 break;
21298 }
21299 self.reject_pending_tags(&mut pending_tags, "pattern body declaration");
21300 self.reject_pending_description(
21301 &mut pending_description,
21302 "pattern body declaration",
21303 );
21304 self.unexpected("pattern body declaration");
21305 self.advance();
21306 }
21307 }
21308 let end = self
21309 .expect_symbol('}')
21310 .map(|token| token.span.end)
21311 .unwrap_or(open.span.end);
21312 Some(PatternDecl {
21313 name,
21314 type_params,
21315 items,
21316 span: SourceSpan { start, end },
21317 })
21318 }
21319
21320 fn parse_type_param_list(&mut self) -> Option<Vec<Ident>> {
21321 if !self.at_symbol('<') {
21322 return Some(Vec::new());
21323 }
21324 self.expect_symbol('<')?;
21325 let mut params = Vec::new();
21326 while !self.is_at_end() && !self.at_symbol('>') {
21327 params.push(self.expect_ident("type parameter")?);
21328 if self.at_symbol(',') {
21329 self.advance();
21330 } else if !self.at_symbol('>') {
21331 self.unexpected("`,` or `>`");
21332 while !self.is_at_end() && !self.at_symbol('>') && !self.at_symbol(',') {
21333 self.advance();
21334 }
21335 }
21336 }
21337 self.expect_symbol('>')?;
21338 Some(params)
21339 }
21340
21341 fn parse_type_arg_list(&mut self) -> Option<Vec<TypeSyntax>> {
21342 if !self.at_symbol('<') {
21343 return Some(Vec::new());
21344 }
21345 self.expect_symbol('<')?;
21346 let mut args = Vec::new();
21347 while !self.is_at_end() && !self.at_symbol('>') {
21348 args.push(self.parse_type()?);
21349 if self.at_symbol(',') {
21350 self.advance();
21351 } else if !self.at_symbol('>') {
21352 self.unexpected("`,` or `>`");
21353 while !self.is_at_end() && !self.at_symbol('>') && !self.at_symbol(',') {
21354 self.advance();
21355 }
21356 }
21357 }
21358 self.expect_symbol('>')?;
21359 Some(args)
21360 }
21361
21362 fn parse_apply(&mut self) -> Option<ApplyDecl> {
21363 let start = self.expect_keyword("apply")?.span.start;
21364 let pattern = self.expect_ident("pattern name")?;
21365 let type_args = self.parse_type_arg_list().unwrap_or_default();
21366 self.expect_keyword("as")?;
21367 let alias = self.expect_ident("pattern application alias")?;
21368 let body = self.parse_block_source()?;
21369 let span = SourceSpan {
21370 start,
21371 end: body.span.end,
21372 };
21373 Some(ApplyDecl {
21374 pattern,
21375 type_args,
21376 alias,
21377 body,
21378 span,
21379 })
21380 }
21381
21382 fn parse_include(&mut self) -> Option<IncludeDecl> {
21383 self.expect_keyword("include")?;
21384 Some(IncludeDecl {
21385 path: self.expect_string("include path")?,
21386 })
21387 }
21388
21389 fn parse_workflow_contract(&mut self) -> Option<WorkflowContractDecl> {
21390 let keyword = self.advance().clone();
21391 let kind = match &keyword.kind {
21392 TokenKind::Ident(value) if value == "input" => WorkflowContractKind::Input,
21393 TokenKind::Ident(value) if value == "output" => WorkflowContractKind::Output,
21394 TokenKind::Ident(value) if value == "failure" => WorkflowContractKind::Failure,
21395 _ => return None,
21396 };
21397 let name = self.expect_ident("workflow contract name")?;
21398 if self.at_symbol('{') {
21404 self.advance();
21405 let mut fields = Vec::new();
21406 while !self.is_at_end() && !self.at_symbol('}') {
21407 let Some(field_name) = self.expect_ident("contract payload field name") else {
21408 self.synchronize_to_block_item();
21409 continue;
21410 };
21411 let Some(field_ty) = self.parse_type() else {
21412 self.synchronize_to_block_item();
21413 continue;
21414 };
21415 let field_span = field_name.span.join(field_ty.span());
21416 fields.push(ClassField {
21417 name: field_name,
21418 ty: field_ty,
21419 is_key: false,
21420 presence_condition: None,
21421 span: field_span,
21422 });
21423 }
21424 let close_span = self.peek().map(|token| token.span);
21425 self.expect_symbol('}')?;
21426 let contract_keyword = match kind {
21427 WorkflowContractKind::Input => "input",
21428 WorkflowContractKind::Output => "output",
21429 WorkflowContractKind::Failure => "failure",
21430 };
21431 let class_name = format!("{contract_keyword}.{}", name.name);
21432 let end = close_span.map(|span| span.end).unwrap_or(name.span.end);
21433 let span = SourceSpan {
21434 start: keyword.span.start,
21435 end,
21436 };
21437 self.pending_contract_classes.push(ClassDecl {
21438 name: Ident {
21439 name: class_name.clone(),
21440 span,
21441 },
21442 fields,
21443 span,
21444 });
21445 return Some(WorkflowContractDecl {
21446 kind,
21447 name,
21448 ty: TypeSyntax::Ref {
21449 name: Ident {
21450 name: class_name,
21451 span,
21452 },
21453 },
21454 span,
21455 });
21456 }
21457 let ty = self.parse_type()?;
21458 let span = keyword.span.join(ty.span());
21459 Some(WorkflowContractDecl {
21460 kind,
21461 name,
21462 ty,
21463 span,
21464 })
21465 }
21466
21467 fn parse_use(&mut self) -> Option<UseDecl> {
21468 self.expect_keyword("use")?;
21469 if self.at_ident("plugin") || self.at_ident("skill") {
21470 let removed_kind = self.advance().clone();
21471 let removed_label = match &removed_kind.kind {
21472 TokenKind::Ident(value) => value.as_str(),
21473 _ => "",
21474 };
21475 self.diagnostics.push(Diagnostic { related: Vec::new(),
21476 span: removed_kind.span,
21477 message: format!("`use {removed_label}` is no longer supported"),
21478 suggestion: Some(
21479 "write `use std.memory` for package libraries; attach skills with `agent { skills [...] }`"
21480 .to_owned(),
21481 ),
21482 });
21483 }
21484 Some(UseDecl {
21485 name: self.expect_use_name("package library name")?,
21486 })
21487 }
21488
21489 fn parse_decl_duration_seconds(&mut self, label: &str) -> Option<u64> {
21492 let (value, span) = self.expect_u32(label)?;
21493 let unit = self.expect_ident(label)?;
21494 match body::parse_short_duration_seconds(&format!("{value}{}", unit.name)) {
21495 Some(seconds) if seconds > 0 => Some(seconds),
21496 _ => {
21497 self.diagnostics.push(Diagnostic {
21498 related: Vec::new(),
21499 span: span.join(unit.span),
21500 message: format!("invalid duration `{value}{}`", unit.name),
21501 suggestion: Some("use `<n><unit>` with unit s, m, h, or d".to_owned()),
21502 });
21503 None
21504 }
21505 }
21506 }
21507
21508 fn parse_harness(&mut self) -> Option<HarnessDecl> {
21509 let start = self.expect_keyword("harness")?.span.start;
21510 let name = self.expect_ident("harness name")?;
21511 self.expect_symbol(':')?;
21512 let kind = self.expect_ident("harness kind")?;
21513 let span = SourceSpan {
21514 start,
21515 end: kind.span.end,
21516 };
21517 Some(HarnessDecl { name, kind, span })
21518 }
21519
21520 fn parse_agent(&mut self) -> Option<AgentDecl> {
21521 let start = self.expect_keyword("agent")?.span.start;
21522 let name = self.expect_ident("agent name")?;
21523 let harness = if self.at_ident("using") {
21524 self.advance();
21525 Some(self.expect_ident("harness name")?)
21526 } else {
21527 None
21528 };
21529 let delegated_to = if harness.is_none() && self.at_ident("delegated") {
21530 self.advance();
21531 self.expect_keyword("to")?;
21532 Some(self.expect_ident("delegate provider")?)
21533 } else {
21534 None
21535 };
21536 if !self.at_symbol('{') {
21539 let end = delegated_to
21540 .as_ref()
21541 .map(|ident| ident.span.end)
21542 .or_else(|| harness.as_ref().map(|ident| ident.span.end))
21543 .unwrap_or(name.span.end);
21544 return Some(AgentDecl {
21545 name,
21546 harness,
21547 delegated_to,
21548 fields: Vec::new(),
21549 span: SourceSpan { start, end },
21550 });
21551 }
21552 let open = self.expect_symbol('{')?;
21553 let mut fields = Vec::new();
21554
21555 while !self.is_at_end() && !self.at_symbol('}') {
21556 let Some(field_name) = self.expect_ident("agent field") else {
21557 self.synchronize_to_block_item();
21558 continue;
21559 };
21560
21561 match field_name.name.as_str() {
21562 "provider" => {
21563 if let Some(provider) = self.expect_ident("provider name") {
21564 fields.push(AgentField::Provider(provider));
21565 } else {
21566 self.synchronize_to_block_item();
21567 }
21568 }
21569 "profile" => {
21570 if let Some(value) = self.expect_string("profile string") {
21571 fields.push(AgentField::Profile(value));
21572 } else {
21573 self.synchronize_to_block_item();
21574 }
21575 }
21576 "capacity" => {
21577 if let Some((value, span)) = self.expect_u32("capacity value") {
21578 fields.push(AgentField::Capacity(value, span));
21579 } else {
21580 self.synchronize_to_block_item();
21581 }
21582 }
21583 "skills" => {
21584 if let Some((skills, span)) = self.parse_string_list() {
21585 fields.push(AgentField::Skills(skills, span));
21586 } else {
21587 self.synchronize_to_block_item();
21588 }
21589 }
21590 "capabilities" => {
21591 if let Some((capabilities, span)) = self.parse_string_list() {
21592 fields.push(AgentField::Capabilities(capabilities, span));
21593 } else {
21594 self.synchronize_to_block_item();
21595 }
21596 }
21597 "requires" => {
21598 if let Some((classes, span)) = self.parse_feature_class_list() {
21599 fields.push(AgentField::Requires(classes, span));
21600 } else {
21601 self.synchronize_to_block_item();
21602 }
21603 }
21604 "tools" => {
21605 if let Some((tools, span)) = self.parse_ident_list() {
21606 fields.push(AgentField::Tools(tools, span));
21607 } else {
21608 self.synchronize_to_block_item();
21609 }
21610 }
21611 "compaction" => {
21612 if let Some(strategy) = self.expect_ident("compaction strategy") {
21613 fields.push(AgentField::Compaction(strategy));
21614 } else {
21615 self.synchronize_to_block_item();
21616 }
21617 }
21618 "thread" => {
21619 if let Some(mode) = self.expect_ident("thread mode") {
21620 fields.push(AgentField::Thread(mode));
21621 } else {
21622 self.synchronize_to_block_item();
21623 }
21624 }
21625 "settings" => {
21626 if let Some(sources) = self.expect_ident("settings source") {
21627 fields.push(AgentField::Settings(sources));
21628 } else {
21629 self.synchronize_to_block_item();
21630 }
21631 }
21632 _ => {
21633 let span = field_name.span;
21634 fields.push(AgentField::Unknown {
21635 name: field_name,
21636 span,
21637 });
21638 self.synchronize_to_block_item();
21639 }
21640 }
21641 }
21642
21643 let end = self
21644 .expect_symbol('}')
21645 .map(|token| token.span.end)
21646 .unwrap_or(open.span.end);
21647
21648 Some(AgentDecl {
21649 name,
21650 harness,
21651 delegated_to,
21652 fields,
21653 span: SourceSpan { start, end },
21654 })
21655 }
21656
21657 fn parse_enum(&mut self) -> Option<EnumDecl> {
21658 let start = self.expect_keyword("enum")?.span.start;
21659 let name = self.expect_ident("enum name")?;
21660 let open = self.expect_symbol('{')?;
21661 let mut variants = Vec::new();
21662
21663 let mut previous_variant_end: Option<usize> = None;
21664 while !self.is_at_end() && !self.at_symbol('}') {
21665 let Some(variant) = self.expect_ident("enum variant") else {
21666 self.synchronize_to_block_item();
21667 continue;
21668 };
21669 if let Some(previous_end) = previous_variant_end {
21674 let line_of = |offset: usize| {
21675 self.source[..offset.min(self.source.len())]
21676 .bytes()
21677 .filter(|byte| *byte == b'\n')
21678 .count()
21679 };
21680 if line_of(previous_end) == line_of(variant.span.start) {
21681 self.diagnostics.push(Diagnostic {
21682 related: Vec::new(),
21683 span: variant.span,
21684 message: format!(
21685 "enum `{}` declares variant `{}` on the same line as the previous variant",
21686 name.name, variant.name
21687 ),
21688 suggestion: Some("write one enum variant per line".to_owned()),
21689 });
21690 }
21691 }
21692 let mut fields = Vec::new();
21695 let mut end = variant.span.end;
21696 if self.at_symbol('{') {
21697 self.expect_symbol('{');
21698 while !self.is_at_end() && !self.at_symbol('}') {
21699 let Some(field_name) = self.expect_ident("variant field name") else {
21700 self.synchronize_to_block_item();
21701 continue;
21702 };
21703 let Some(ty) = self.parse_type() else {
21704 self.synchronize_to_block_item();
21705 continue;
21706 };
21707 fields.push(ClassField {
21708 span: field_name.span.join(ty.span()),
21709 name: field_name,
21710 ty,
21711 is_key: false,
21712 presence_condition: None,
21713 });
21714 }
21715 if let Some(close) = self.expect_symbol('}') {
21716 end = close.span.end;
21717 }
21718 }
21719 let span = SourceSpan {
21720 start: variant.span.start,
21721 end,
21722 };
21723 previous_variant_end = Some(end);
21724 variants.push(EnumVariantDecl {
21725 name: variant,
21726 fields,
21727 span,
21728 });
21729 }
21730
21731 let end = self
21732 .expect_symbol('}')
21733 .map(|token| token.span.end)
21734 .unwrap_or(open.span.end);
21735
21736 Some(EnumDecl {
21737 name,
21738 variants,
21739 span: SourceSpan { start, end },
21740 })
21741 }
21742
21743 fn parse_event(&mut self) -> Option<EventDecl> {
21744 let start = self.expect_keyword("signal")?.span.start;
21745 let first = self.expect_ident("signal name")?;
21748 let mut name = first.name.clone();
21749 let mut name_span = first.span;
21750 while self.at_symbol('.') {
21751 self.expect_symbol('.');
21752 let segment = self.expect_ident("signal name segment")?;
21753 name.push('.');
21754 name.push_str(&segment.name);
21755 name_span = name_span.join(segment.span);
21756 }
21757 if !name.contains('.')
21758 || name
21759 .split('.')
21760 .any(|segment| segment.chars().next().is_some_and(char::is_uppercase))
21761 {
21762 self.diagnostics.push(Diagnostic {
21763 related: Vec::new(),
21764 span: name_span,
21765 message: format!("signal name `{name}` must be dotted lowercase"),
21766 suggestion: Some(
21767 "use a dotted lowercase name such as `deploy.finished`".to_owned(),
21768 ),
21769 });
21770 }
21771 let open = self.expect_symbol('{')?;
21772 let mut fields = Vec::new();
21773 while !self.is_at_end() && !self.at_symbol('}') {
21774 let Some(field_name) = self.expect_ident("signal field name") else {
21775 self.synchronize_to_block_item();
21776 continue;
21777 };
21778 let Some(ty) = self.parse_type() else {
21779 self.synchronize_to_block_item();
21780 continue;
21781 };
21782 let presence_condition = self.parse_field_presence_condition();
21783 fields.push(ClassField {
21784 span: field_name.span.join(ty.span()),
21785 name: field_name,
21786 ty,
21787 is_key: false,
21788 presence_condition,
21789 });
21790 }
21791 let end = self
21792 .expect_symbol('}')
21793 .map(|token| token.span.end)
21794 .unwrap_or(open.span.end);
21795 Some(EventDecl {
21796 name,
21797 name_span,
21798 fields,
21799 span: SourceSpan { start, end },
21800 })
21801 }
21802
21803 fn parse_dotted_name_spanned(&mut self, label: &str) -> Option<(String, SourceSpan)> {
21805 let first = self.expect_ident(label)?;
21806 let mut name = first.name.clone();
21807 let mut span = first.span;
21808 while self.at_symbol('.') {
21809 self.expect_symbol('.');
21810 let segment = self.expect_ident(label)?;
21811 name.push('.');
21812 name.push_str(&segment.name);
21813 span = span.join(segment.span);
21814 }
21815 Some((name, span))
21816 }
21817
21818 fn parse_gauge_ref(&mut self, label: &str) -> Option<GaugeRef> {
21819 let (name, span) = self.parse_dotted_name_spanned(label)?;
21820 Some(GaugeRef { name, span })
21821 }
21822
21823 fn parse_gauge_ref_list(&mut self, label: &str, into: &mut Vec<GaugeRef>) -> Option<()> {
21825 into.push(self.parse_gauge_ref(label)?);
21826 while self.at_symbol(',') {
21827 self.expect_symbol(',');
21828 into.push(self.parse_gauge_ref(label)?);
21829 }
21830 Some(())
21831 }
21832
21833 fn parse_decl_number_text(&mut self, label: &str) -> Option<(String, SourceSpan)> {
21837 let token = self.peek()?;
21838 let TokenKind::Number(whole) = token.kind.clone() else {
21839 self.expected(label);
21840 return None;
21841 };
21842 let mut span = token.span;
21843 let mut text = whole;
21844 self.advance();
21845 if self.at_symbol('.') {
21846 self.expect_symbol('.');
21847 let token = self.peek()?;
21848 let TokenKind::Number(fraction) = token.kind.clone() else {
21849 self.expected(format!("{label} fraction digits"));
21850 return None;
21851 };
21852 text.push('.');
21853 text.push_str(&fraction);
21854 span = span.join(token.span);
21855 self.advance();
21856 }
21857 Some((text, span))
21858 }
21859
21860 fn parse_bar_direction(&mut self, label: &str) -> Option<bool> {
21866 if self.consume_ident("at") {
21867 if self.consume_ident("least") {
21868 return Some(true);
21869 }
21870 if self.consume_ident("most") {
21871 return Some(false);
21872 }
21873 self.expected(format!("`least` or `most` after `at` in {label}"));
21874 return None;
21875 }
21876 let gap_start = self.last_span_end();
21877 let gap_end = self
21878 .peek()
21879 .map(|token| token.span.start)
21880 .unwrap_or(gap_start);
21881 let gap = &self.source[gap_start..gap_end.max(gap_start)];
21882 let suggestion = if gap.contains(">=") {
21883 Some("write `at least` (the declaration grammar uses words, not `>=`)".to_owned())
21884 } else if gap.contains("<=") {
21885 Some("write `at most` (the declaration grammar uses words, not `<=`)".to_owned())
21886 } else {
21887 Some("write `at least <n>` or `at most <n>`".to_owned())
21888 };
21889 self.diagnostics.push(Diagnostic {
21890 related: Vec::new(),
21891 span: SourceSpan {
21892 start: gap_start,
21893 end: gap_end.max(gap_start),
21894 },
21895 message: format!("expected `at least` or `at most` in {label}"),
21896 suggestion,
21897 });
21898 None
21899 }
21900
21901 fn parse_mark(&mut self) -> Option<MarkDecl> {
21903 let start = self.expect_keyword("mark")?.span.start;
21904 let name = self.expect_string("mark name")?;
21905 if !self.consume_ident("after") {
21906 self.expected("`after` and a committing site in the mark declaration");
21907 return None;
21908 }
21909 let (site, site_span) = self.parse_dotted_name_spanned("mark site")?;
21910 Some(MarkDecl {
21911 name,
21912 site,
21913 site_span,
21914 span: SourceSpan {
21915 start,
21916 end: site_span.end,
21917 },
21918 })
21919 }
21920
21921 fn parse_gauge(&mut self) -> Option<GaugeDecl> {
21924 let start = self.expect_keyword("gauge")?.span.start;
21925 let name = self.expect_ident("gauge name")?;
21926 let (site, site_span) = if self.consume_ident("on") {
21927 let (site, span) = self.parse_dotted_name_spanned("gauge site")?;
21928 (Some(site), Some(span))
21929 } else {
21930 (None, None)
21931 };
21932 let open = self.expect_symbol('{')?;
21933 let mut judge: Option<GaugeJudge> = None;
21934 let mut expect: Option<GaugeBar> = None;
21935 let mut inputs: Vec<GaugeRef> = Vec::new();
21936 while !self.is_at_end() && !self.at_symbol('}') {
21937 if self.at_ident("judge") {
21938 let keyword = self.advance().clone();
21939 if !self.consume_ident("via") {
21940 self.expected("`via` after `judge`");
21941 self.synchronize_to_block_item();
21942 continue;
21943 }
21944 let form = if self.consume_ident("coerce") {
21945 self.expect_ident("coerce judge name").and_then(|name| {
21946 let mut args = Vec::new();
21947 if self.at_symbol('(') {
21948 self.expect_symbol('(');
21949 loop {
21950 let (path, _) = self.parse_dotted_name_spanned("judge argument")?;
21951 args.push(path);
21952 if !self.at_symbol(',') {
21953 break;
21954 }
21955 self.expect_symbol(',')?;
21956 }
21957 self.expect_symbol(')')?;
21958 }
21959 Some(GaugeJudge::Coerce(name, args))
21960 })
21961 } else if self.consume_ident("prompt") {
21962 self.expect_string("prompt judge template")
21963 .map(GaugeJudge::Prompt)
21964 } else if self.consume_ident("exec") {
21965 self.expect_string("exec judge command")
21966 .map(GaugeJudge::Exec)
21967 } else if self.consume_ident("labels") {
21968 self.expect_string("labels source").map(GaugeJudge::Labels)
21969 } else {
21970 self.diagnostics.push(Diagnostic {
21971 related: Vec::new(),
21972 span: keyword.span,
21973 message: "unknown judge form".to_owned(),
21974 suggestion: Some(
21975 "judge forms are `coerce <Name>`, `prompt \"<template>\"`, \
21976 `exec \"<command>\"`, and `labels \"<source>\"`"
21977 .to_owned(),
21978 ),
21979 });
21980 None
21981 };
21982 let Some(form) = form else {
21983 self.synchronize_to_block_item();
21984 continue;
21985 };
21986 if judge.is_some() {
21987 self.diagnostics.push(Diagnostic {
21988 related: Vec::new(),
21989 span: keyword.span,
21990 message: "gauge declares more than one judge".to_owned(),
21991 suggestion: Some("a gauge has exactly one judge".to_owned()),
21992 });
21993 } else {
21994 judge = Some(form);
21995 }
21996 } else if self.at_ident("expect") {
21997 let keyword = self.advance().clone();
21998 let subject_ident = match self.expect_ident("bar subject") {
21999 Some(ident) => ident,
22000 None => {
22001 self.synchronize_to_block_item();
22002 continue;
22003 }
22004 };
22005 let subject = if subject_ident.name == "P" && self.at_symbol('(') {
22006 self.expect_symbol('(');
22007 let Some(field) = self.expect_ident("chance bar field") else {
22008 self.synchronize_to_block_item();
22009 continue;
22010 };
22011 if self.expect_symbol(')').is_none() {
22012 self.synchronize_to_block_item();
22013 continue;
22014 }
22015 GaugeBarSubject::Chance { field }
22016 } else {
22017 let stat = &subject_ident.name;
22018 let is_quantile = stat.len() > 1
22019 && stat.starts_with('p')
22020 && stat[1..].chars().all(|ch| ch.is_ascii_digit());
22021 if stat != "mean" && !is_quantile {
22022 self.diagnostics.push(Diagnostic {
22023 related: Vec::new(),
22024 span: subject_ident.span,
22025 message: format!("unknown bar statistic `{stat}`"),
22026 suggestion: Some(
22027 "bars are chance-shaped (`P(<field>)`) or stat-shaped \
22028 (`mean`, `p10`, `p90`, ...)"
22029 .to_owned(),
22030 ),
22031 });
22032 }
22033 GaugeBarSubject::Stat {
22034 stat: subject_ident,
22035 }
22036 };
22037 let Some(at_least) = self.parse_bar_direction("the gauge bar") else {
22038 self.synchronize_to_block_item();
22039 continue;
22040 };
22041 let Some((threshold, threshold_span)) =
22042 self.parse_decl_number_text("bar threshold")
22043 else {
22044 self.synchronize_to_block_item();
22045 continue;
22046 };
22047 if expect.is_some() {
22048 self.diagnostics.push(Diagnostic {
22049 related: Vec::new(),
22050 span: keyword.span,
22051 message: "gauge declares more than one bar".to_owned(),
22052 suggestion: Some("a gauge has at most one `expect` bar".to_owned()),
22053 });
22054 } else {
22055 expect = Some(GaugeBar {
22056 subject,
22057 at_least,
22058 threshold,
22059 span: keyword.span.join(threshold_span),
22060 });
22061 }
22062 } else if self.at_ident("inputs") {
22063 self.advance();
22064 if self
22065 .parse_gauge_ref_list("input gauge name", &mut inputs)
22066 .is_none()
22067 {
22068 self.synchronize_to_block_item();
22069 }
22070 } else {
22071 let span = self.peek().map(|token| token.span).unwrap_or(open.span);
22072 self.diagnostics.push(Diagnostic {
22073 related: Vec::new(),
22074 span,
22075 message: "unknown gauge clause".to_owned(),
22076 suggestion: Some(
22077 "gauge clauses are `judge via`, `expect`, and `inputs`".to_owned(),
22078 ),
22079 });
22080 self.synchronize_to_block_item();
22081 }
22082 }
22083 let end = self
22084 .expect_symbol('}')
22085 .map(|token| token.span.end)
22086 .unwrap_or(open.span.end);
22087 let Some(judge) = judge else {
22088 self.diagnostics.push(Diagnostic {
22089 related: Vec::new(),
22090 span: name.span,
22091 message: format!("gauge `{}` declares no judge", name.name),
22092 suggestion: Some(
22093 "add `judge via coerce <Name>`, `judge via prompt \"<template>\"`, \
22094 `judge via exec \"<command>\"`, or `judge via labels \"<source>\"`"
22095 .to_owned(),
22096 ),
22097 });
22098 return None;
22099 };
22100 Some(GaugeDecl {
22101 name,
22102 site,
22103 site_span,
22104 judge,
22105 expect,
22106 inputs,
22107 span: SourceSpan { start, end },
22108 })
22109 }
22110
22111 fn parse_campaign(&mut self) -> Option<CampaignDecl> {
22113 let start = self.expect_keyword("campaign")?.span.start;
22114 let name = self.expect_ident("campaign name")?;
22115 let open = self.expect_symbol('{')?;
22116 let mut ascend: Vec<GaugeRef> = Vec::new();
22117 let mut reach: Vec<CampaignReach> = Vec::new();
22118 let mut guard: Vec<CampaignGuard> = Vec::new();
22119 let mut sacrifice: Vec<GaugeRef> = Vec::new();
22120 let mut proposer_redacted = false;
22121 while !self.is_at_end() && !self.at_symbol('}') {
22122 if self.at_ident("ascend") {
22123 self.advance();
22124 if self
22125 .parse_gauge_ref_list("ascend gauge name", &mut ascend)
22126 .is_none()
22127 {
22128 self.synchronize_to_block_item();
22129 }
22130 } else if self.at_ident("reach") {
22131 let keyword = self.advance().clone();
22132 let Some(gauge) = self.parse_gauge_ref("reach gauge name") else {
22133 self.synchronize_to_block_item();
22134 continue;
22135 };
22136 let Some(at_least) = self.parse_bar_direction("the reach target") else {
22137 self.synchronize_to_block_item();
22138 continue;
22139 };
22140 let Some((threshold, threshold_span)) =
22141 self.parse_decl_number_text("reach threshold")
22142 else {
22143 self.synchronize_to_block_item();
22144 continue;
22145 };
22146 let unit = if self
22149 .peek()
22150 .map(|token| {
22151 matches!(&token.kind, TokenKind::Ident(name)
22152 if matches!(name.as_str(), "ms" | "s" | "m" | "h" | "d"))
22153 })
22154 .unwrap_or(false)
22155 {
22156 self.expect_ident("unit").map(|ident| ident.name)
22157 } else {
22158 None
22159 };
22160 reach.push(CampaignReach {
22161 gauge,
22162 at_least,
22163 threshold,
22164 unit,
22165 span: keyword.span.join(threshold_span),
22166 });
22167 } else if self.at_ident("guard") {
22168 let keyword = self.advance().clone();
22169 let Some(gauge) = self.parse_gauge_ref("guard gauge name") else {
22170 self.synchronize_to_block_item();
22171 continue;
22172 };
22173 if !self.consume_ident("within") {
22174 self.expected("`within` after the guarded gauge");
22175 self.synchronize_to_block_item();
22176 continue;
22177 }
22178 let Some((band_percent, band_span)) = self.parse_decl_number_text("guard band")
22179 else {
22180 self.synchronize_to_block_item();
22181 continue;
22182 };
22183 if !self.consume_ident("percent") {
22184 self.expected("`percent` after the guard band");
22185 self.synchronize_to_block_item();
22186 continue;
22187 }
22188 guard.push(CampaignGuard {
22189 gauge,
22190 band_percent,
22191 span: keyword.span.join(band_span),
22192 });
22193 } else if self.at_ident("sacrifice") {
22194 self.advance();
22195 if self
22196 .parse_gauge_ref_list("sacrifice gauge name", &mut sacrifice)
22197 .is_none()
22198 {
22199 self.synchronize_to_block_item();
22200 }
22201 } else if self.at_ident("proposer") {
22202 self.advance();
22203 if self.consume_ident("redacted") {
22204 proposer_redacted = true;
22205 } else {
22206 self.expected("`redacted` after `proposer`");
22207 self.synchronize_to_block_item();
22208 }
22209 } else {
22210 let span = self.peek().map(|token| token.span).unwrap_or(open.span);
22211 self.diagnostics.push(Diagnostic {
22212 related: Vec::new(),
22213 span,
22214 message: "unknown campaign clause".to_owned(),
22215 suggestion: Some(
22216 "campaign clauses are `ascend`, `reach`, `guard`, `sacrifice`, \
22217 and `proposer redacted`"
22218 .to_owned(),
22219 ),
22220 });
22221 self.synchronize_to_block_item();
22222 }
22223 }
22224 let end = self
22225 .expect_symbol('}')
22226 .map(|token| token.span.end)
22227 .unwrap_or(open.span.end);
22228 if ascend.is_empty() && reach.is_empty() {
22229 self.diagnostics.push(Diagnostic {
22230 related: Vec::new(),
22231 span: name.span,
22232 message: format!("campaign `{}` names nothing to improve", name.name),
22233 suggestion: Some("add an `ascend` or `reach` clause".to_owned()),
22234 });
22235 }
22236 Some(CampaignDecl {
22237 name,
22238 ascend,
22239 reach,
22240 guard,
22241 sacrifice,
22242 proposer_redacted,
22243 span: SourceSpan { start, end },
22244 })
22245 }
22246
22247 fn last_span_end(&self) -> usize {
22248 self.pos
22249 .checked_sub(1)
22250 .and_then(|index| self.tokens.get(index))
22251 .map(|token| token.span.end)
22252 .unwrap_or(0)
22253 }
22254
22255 fn parse_dotted_name(&mut self, label: &str) -> Option<String> {
22256 let first = self.expect_ident(label)?;
22257 let mut name = first.name.clone();
22258 while self.at_symbol('.') {
22259 self.advance();
22260 let segment = self.expect_ident(label)?;
22261 name.push('.');
22262 name.push_str(&segment.name);
22263 }
22264 Some(name)
22265 }
22266
22267 fn capture_expr_to_line_end(&mut self) -> (String, SourceSpan) {
22270 let start = self
22271 .peek()
22272 .map(|token| token.span.start)
22273 .unwrap_or(self.source.len());
22274 let line_end = self.source[start..]
22275 .find('\n')
22276 .map(|offset| start + offset)
22277 .unwrap_or(self.source.len());
22278 let mut end = start;
22279 while !self.is_at_end() {
22280 let Some(token) = self.peek() else { break };
22281 if token.span.start >= line_end {
22282 break;
22283 }
22284 let token_end = token.span.end.min(line_end);
22285 self.advance();
22286 end = token_end;
22287 }
22288 let span = SourceSpan { start, end };
22289 trimmed_source_text(self.source_text(span), span)
22290 }
22291
22292 fn capture_expr_until_ident(&mut self, terminator: &str) -> (String, SourceSpan) {
22295 let start = self
22296 .peek()
22297 .map(|token| token.span.start)
22298 .unwrap_or(self.source.len());
22299 let mut end = start;
22300 while !self.is_at_end() && !self.at_ident(terminator) && !self.at_symbol('}') {
22301 let Some(token) = self.peek() else { break };
22302 let token_end = token.span.end;
22303 self.advance();
22304 end = token_end;
22305 }
22306 let span = SourceSpan { start, end };
22307 trimmed_source_text(self.source_text(span), span)
22308 }
22309
22310 fn parse_test(&mut self) -> Option<TestDecl> {
22311 let start = self.expect_keyword("test")?.span.start;
22312 let name = self.expect_string("test name")?;
22313 let open = self.expect_symbol('{')?;
22314 let mut workflow = None;
22315 let mut clauses = Vec::new();
22316 while !self.is_at_end() && !self.at_symbol('}') {
22317 if self.at_ident("workflow") {
22318 self.advance();
22319 match self.expect_ident("workflow name") {
22320 Some(name) => {
22321 if workflow.is_some() {
22322 self.diagnostics.push(Diagnostic {
22323 related: Vec::new(),
22324 span: name.span,
22325 message: "a test scenario binds at most one `workflow`".to_owned(),
22326 suggestion: Some(
22327 "remove the extra `workflow <Name>` header".to_owned(),
22328 ),
22329 });
22330 }
22331 workflow = Some(name);
22332 }
22333 None => self.synchronize_to_block_item(),
22334 }
22335 } else if self.at_ident("given") {
22336 match self.parse_given() {
22337 Some(clause) => clauses.push(TestClause::Given(clause)),
22338 None => self.synchronize_to_block_item(),
22339 }
22340 } else if self.at_ident("stub") {
22341 match self.parse_stub() {
22342 Some(clause) => clauses.push(TestClause::Stub(clause)),
22343 None => self.synchronize_to_block_item(),
22344 }
22345 } else if self.at_ident("run") {
22346 match self.parse_run() {
22347 Some(clause) => clauses.push(TestClause::Run(clause)),
22348 None => self.synchronize_to_block_item(),
22349 }
22350 } else if self.at_ident("expect") {
22351 match self.parse_expect() {
22352 Some(clause) => clauses.push(TestClause::Expect(clause)),
22353 None => self.synchronize_to_block_item(),
22354 }
22355 } else {
22356 self.unexpected("a test clause (`workflow`, `given`, `stub`, `run`, or `expect`)");
22357 self.synchronize_to_block_item();
22358 }
22359 }
22360 let end = self
22361 .expect_symbol('}')
22362 .map(|token| token.span.end)
22363 .unwrap_or(open.span.end);
22364 Some(TestDecl {
22365 name,
22366 workflow,
22367 clauses,
22368 span: SourceSpan { start, end },
22369 })
22370 }
22371
22372 fn parse_test_record(&mut self) -> Option<(Vec<TestField>, usize)> {
22373 let open = self.expect_symbol('{')?;
22374 let mut fields = Vec::new();
22375 while !self.is_at_end() && !self.at_symbol('}') {
22376 let Some(name) = self.expect_ident("test field name") else {
22377 self.synchronize_to_block_item();
22378 continue;
22379 };
22380 let (value, value_span) = self.capture_expr_to_line_end();
22381 fields.push(TestField {
22382 span: name.span.join(value_span),
22383 name,
22384 value,
22385 });
22386 }
22387 let end = self
22388 .expect_symbol('}')
22389 .map(|token| token.span.end)
22390 .unwrap_or(open.span.end);
22391 Some((fields, end))
22392 }
22393
22394 fn parse_given(&mut self) -> Option<GivenClause> {
22395 let start = self.expect_keyword("given")?.span.start;
22396 if self.consume_ident("input") {
22397 let (fields, end) = self.parse_test_record()?;
22398 Some(GivenClause::Input {
22399 fields,
22400 span: SourceSpan { start, end },
22401 })
22402 } else if self.consume_ident("fact") {
22403 let ty = self.expect_ident("fact type")?;
22404 let (fields, end) = self.parse_test_record()?;
22405 Some(GivenClause::Fact {
22406 ty,
22407 fields,
22408 span: SourceSpan { start, end },
22409 })
22410 } else if self.consume_ident("signal") {
22411 let name = self.parse_dotted_name("signal name")?;
22412 let (fields, end) = self.parse_test_record()?;
22413 Some(GivenClause::Signal {
22414 name,
22415 fields,
22416 span: SourceSpan { start, end },
22417 })
22418 } else if self.consume_ident("clock") {
22419 if !self.consume_ident("at") {
22420 self.expected("`at <timestamp>` after `given clock`");
22421 }
22422 let at = self.expect_string("clock timestamp")?;
22423 let end = at.span.end;
22424 Some(GivenClause::Clock {
22425 at,
22426 span: SourceSpan { start, end },
22427 })
22428 } else if self.consume_ident("tracker") {
22429 let tracker = self.parse_dotted_name("tracker name")?;
22430 if !self.consume_ident("issue") {
22431 self.expected("`issue { … }` after `given tracker <name>`");
22432 }
22433 let (fields, end) = self.parse_test_record()?;
22434 Some(GivenClause::Tracker {
22435 tracker,
22436 fields,
22437 span: SourceSpan { start, end },
22438 })
22439 } else if self.consume_ident("file") {
22440 let store = self.parse_dotted_name("file store name")?;
22441 if !self.consume_ident("at") {
22442 self.expected("`at <path> \"<content>\"` after `given file <store>`");
22443 }
22444 let path = self.expect_string("file path")?;
22445 let content = self.expect_string("file content")?;
22446 let end = content.span.end;
22447 Some(GivenClause::File {
22448 store,
22449 path,
22450 content,
22451 span: SourceSpan { start, end },
22452 })
22453 } else {
22454 self.unexpected(
22455 "`input`, `fact`, `signal`, `clock`, `tracker`, or `file` after `given`",
22456 );
22457 None
22458 }
22459 }
22460
22461 fn parse_stub(&mut self) -> Option<StubClause> {
22462 let start = self.expect_keyword("stub")?.span.start;
22463 let line_end = self.source[start..]
22468 .find('\n')
22469 .map(|offset| start + offset)
22470 .unwrap_or(self.source.len());
22471 let mut segments = Vec::new();
22472 while matches!(
22473 self.peek().map(|token| &token.kind),
22474 Some(TokenKind::Ident(_))
22475 ) && self.peek().is_some_and(|token| token.span.start < line_end)
22476 {
22477 match self.parse_dotted_name("stub surface") {
22478 Some(segment) => segments.push(segment),
22479 None => break,
22480 }
22481 }
22482 if segments.len() < 2 {
22483 self.diagnostics.push(Diagnostic {
22484 related: Vec::new(),
22485 span: SourceSpan {
22486 start,
22487 end: self.last_span_end(),
22488 },
22489 message: "stub needs a surface and an outcome (e.g. `stub agent triager succeeds`)"
22490 .to_owned(),
22491 suggestion: Some("write `stub <surface...> <outcome> [payload]`".to_owned()),
22492 });
22493 return None;
22494 }
22495 let outcome = segments.pop().expect("outcome present");
22496 let surface = segments;
22497 let payload = if self.at_symbol('{') {
22498 let (fields, _) = self.parse_test_record()?;
22499 Some(StubPayload::Record(fields))
22500 } else if matches!(
22501 self.peek().map(|token| &token.kind),
22502 Some(TokenKind::String(_))
22503 ) {
22504 Some(StubPayload::Message(self.expect_string("stub message")?))
22505 } else {
22506 None
22507 };
22508 let end = self.last_span_end();
22509 Some(StubClause {
22510 surface,
22511 outcome,
22512 payload,
22513 span: SourceSpan { start, end },
22514 })
22515 }
22516
22517 fn parse_run(&mut self) -> Option<RunClause> {
22518 let start = self.expect_keyword("run")?.span.start;
22519 let kind = if self.consume_ident("until") {
22520 if self.consume_ident("idle") {
22521 RunKind::UntilIdle
22522 } else if self.consume_ident("workflow") {
22523 if self.consume_ident("completed") {
22524 RunKind::UntilWorkflowCompleted
22525 } else if self.consume_ident("failed") {
22526 RunKind::UntilWorkflowFailed
22527 } else {
22528 self.expected("`completed` or `failed` after `workflow`");
22529 return None;
22530 }
22531 } else {
22532 self.expected("`idle` or `workflow completed|failed` after `until`");
22533 return None;
22534 }
22535 } else if self.consume_ident("for") {
22536 let (steps, _) = self.expect_u32("step count")?;
22537 if !self.consume_ident("steps") {
22538 self.expected("`steps` after the step count");
22539 }
22540 RunKind::ForSteps(steps)
22541 } else {
22542 self.expected("`until ...` or `for <N> steps` after `run`");
22543 return None;
22544 };
22545 let end = self.last_span_end();
22546 Some(RunClause {
22547 kind,
22548 span: SourceSpan { start, end },
22549 })
22550 }
22551
22552 fn parse_expect(&mut self) -> Option<ExpectClause> {
22553 let start = self.expect_keyword("expect")?.span.start;
22554 let target = if self.consume_ident("workflow") {
22555 if self.consume_ident("completed") {
22556 ExpectTarget::WorkflowCompleted
22557 } else if self.consume_ident("failed") {
22558 let failure = if self.consume_ident("with") {
22559 self.expect_ident("failure type")
22560 } else {
22561 None
22562 };
22563 ExpectTarget::WorkflowFailed { failure }
22564 } else {
22565 self.expected("`completed` or `failed` after `workflow`");
22566 return None;
22567 }
22568 } else if self.consume_ident("rule") {
22569 let name = self.expect_ident("rule name")?;
22570 let status = if self.consume_ident("fired") {
22571 if matches!(
22572 self.peek().map(|token| &token.kind),
22573 Some(TokenKind::Number(_))
22574 ) {
22575 let (count, _) = self.expect_u32("fired count")?;
22576 if !self.consume_ident("times") {
22577 self.expected("`times` after the fired count");
22578 }
22579 RuleStatus::FiredTimes(count)
22580 } else {
22581 RuleStatus::Fired
22582 }
22583 } else if self.consume_ident("did") {
22584 if !self.consume_ident("not") {
22585 self.expected("`not` in `did not fire`");
22586 }
22587 if !self.consume_ident("fire") {
22588 self.expected("`fire` in `did not fire`");
22589 }
22590 RuleStatus::DidNotFire
22591 } else {
22592 self.expected("`fired`, `fired <N> times`, or `did not fire`");
22593 return None;
22594 };
22595 ExpectTarget::Rule { name, status }
22596 } else if self.consume_ident("effect") {
22597 let name = self.parse_dotted_name("effect name")?;
22598 let status = if self.consume_ident("requested") {
22599 EffectStatus::Requested
22600 } else if self.consume_ident("completed") {
22601 EffectStatus::Completed
22602 } else if self.consume_ident("failed") {
22603 EffectStatus::Failed
22604 } else {
22605 self.expected("`requested`, `completed`, or `failed` after the effect name");
22606 return None;
22607 };
22608 ExpectTarget::Effect { name, status }
22609 } else if self.consume_ident("diagnostic") {
22610 let code = self.parse_dotted_name("diagnostic code")?;
22611 ExpectTarget::Diagnostic { code }
22612 } else if self.consume_ident("no") {
22613 let name = self.parse_dotted_name("forbidden effect name")?;
22614 ExpectTarget::NoEffect { name }
22615 } else {
22616 let noun = self.parse_dotted_name("projection noun")?;
22617 let kind = self.parse_proj_query_kind()?;
22618 let end = self.last_span_end();
22619 ExpectTarget::Projection(ProjQuery {
22620 noun,
22621 kind,
22622 span: SourceSpan { start, end },
22623 })
22624 };
22625 let end = self.last_span_end();
22626 Some(ExpectClause {
22627 target,
22628 span: SourceSpan { start, end },
22629 })
22630 }
22631
22632 fn parse_proj_query_kind(&mut self) -> Option<ProjQueryKind> {
22633 if self.consume_ident("exists") {
22634 return Some(ProjQueryKind::Exists);
22635 }
22636 if self.consume_ident("count") {
22637 if !self.consume_ident("where") {
22638 self.expected("`where <predicate> is <N>` after `count`");
22639 return None;
22640 }
22641 let (predicate, _) = self.capture_expr_until_ident("is");
22642 if !self.consume_ident("is") {
22643 self.expected("`is <N>` after the count predicate");
22644 return None;
22645 }
22646 let (count, _) = self.expect_u32("count value")?;
22647 return Some(ProjQueryKind::Count { predicate, count });
22648 }
22649 if self.consume_ident("where") {
22650 let (predicate, _) = self.capture_expr_to_line_end();
22651 return Some(ProjQueryKind::Where { predicate });
22652 }
22653 self.expected("`exists`, `count where ... is <N>`, or `where ...`");
22654 None
22655 }
22656
22657 fn parse_source(&mut self) -> Option<SourceDecl> {
22658 let start = self.expect_keyword("source")?.span.start;
22659 let provider = self.expect_ident("source provider")?;
22660 let is_clock = provider.name == "clock";
22661 if !self.consume_ident("as") {
22662 self.expected("`as <name>` after the source provider");
22663 return None;
22664 }
22665 let name = self.expect_ident("source name")?;
22666 let open = self.expect_symbol('{')?;
22667
22668 let mut recurrence: Option<Recurrence> = None;
22669 let mut timezone: Option<StringLiteral> = None;
22670 let mut missed: Option<MissedPolicy> = None;
22671 let mut path: Option<StringLiteral> = None;
22672 let mut watch: Option<StringLiteral> = None;
22673 let mut url: Option<StringLiteral> = None;
22674 let mut dedup: Option<SourceValue> = None;
22675 let mut observe_binding: Option<Ident> = None;
22676 let mut emit: Option<SourceEmit> = None;
22677
22678 while !self.is_at_end() && !self.at_symbol('}') {
22679 if self.at_ident("every") || self.at_ident("at") {
22680 if let Some(parsed) = self.parse_recurrence() {
22681 recurrence = Some(parsed);
22682 } else {
22683 self.synchronize_to_block_item();
22684 }
22685 } else if self.at_ident("timezone") {
22686 self.advance();
22687 timezone = self.expect_string("timezone string");
22688 } else if self.at_ident("path") {
22689 self.advance();
22690 path = self.expect_string("path string");
22691 } else if self.at_ident("watch") {
22692 self.advance();
22693 watch = self.expect_string("watch glob string");
22694 } else if self.at_ident("url") {
22695 self.advance();
22696 url = self.expect_string("url string");
22697 } else if self.at_ident("dedup") {
22698 self.advance();
22699 dedup = self.parse_source_value();
22700 } else if self.at_ident("missed") {
22701 missed = self.parse_missed_policy();
22702 } else if self.at_ident("observe") {
22703 self.advance();
22704 if !self.consume_ident("as") {
22705 self.expected("`as <binding>` after `observe`");
22706 }
22707 observe_binding = self.expect_ident("observe binding");
22708 } else if self.at_ident("emit") {
22709 emit = self.parse_source_emit();
22710 } else {
22711 self.unexpected(
22712 "a source clause (`every`/`at`, `timezone`, `path`, `watch`, `url`, `dedup`, `missed`, `observe`, `emit`)",
22713 );
22714 self.synchronize_to_block_item();
22715 }
22716 }
22717 let end = self
22718 .expect_symbol('}')
22719 .map(|token| token.span.end)
22720 .unwrap_or(open.span.end);
22721 let span = SourceSpan { start, end };
22722
22723 let observe_binding = match observe_binding {
22724 Some(binding) => binding,
22725 None => {
22726 self.diagnostics.push(Diagnostic {
22727 related: Vec::new(),
22728 span,
22729 message: format!("source `{}` must declare `observe as <binding>`", name.name),
22730 suggestion: Some("add `observe as tick`".to_owned()),
22731 });
22732 return None;
22733 }
22734 };
22735 let emit = match emit {
22736 Some(emit) => emit,
22737 None => {
22738 self.diagnostics.push(Diagnostic {
22739 related: Vec::new(),
22740 span,
22741 message: format!(
22742 "source `{}` must declare `emit <signal> {{ ... }}`",
22743 name.name
22744 ),
22745 suggestion: Some("add `emit triage.tick { ... }`".to_owned()),
22746 });
22747 return None;
22748 }
22749 };
22750
22751 let clock = if is_clock {
22752 let recurrence = match recurrence {
22753 Some(recurrence) => recurrence,
22754 None => {
22755 self.diagnostics.push(Diagnostic {
22756 related: Vec::new(),
22757 span,
22758 message: format!("clock source `{}` must declare a recurrence", name.name),
22759 suggestion: Some(
22760 "add `every weekday at 09:00`, `every 5m`, or `at 09:00`".to_owned(),
22761 ),
22762 });
22763 return None;
22764 }
22765 };
22766 Some(ClockPolicy {
22767 recurrence,
22768 timezone,
22769 missed,
22770 span,
22771 })
22772 } else {
22773 if recurrence.is_some() || timezone.is_some() || missed.is_some() {
22774 self.diagnostics.push(Diagnostic {
22775 related: Vec::new(),
22776 span,
22777 message: format!(
22778 "source `{}` uses clock-only clauses but its provider is `{}`, not `clock`",
22779 name.name, provider.name
22780 ),
22781 suggestion: Some(
22782 "use `source clock as ...` for recurrence, timezone, or missed clauses"
22783 .to_owned(),
22784 ),
22785 });
22786 }
22787 None
22788 };
22789
22790 Some(SourceDecl {
22791 name,
22792 provider,
22793 clock,
22794 path,
22795 watch,
22796 url,
22797 dedup,
22798 observe_binding,
22799 emit,
22800 span,
22801 })
22802 }
22803
22804 fn parse_recurrence(&mut self) -> Option<Recurrence> {
22805 if self.at_ident("at") {
22806 let at = self.expect_keyword("at")?;
22807 let time = self.parse_time_of_day()?;
22808 return Some(Recurrence::At {
22809 span: at.span.join(time.span),
22810 time,
22811 });
22812 }
22813 let every = self.expect_keyword("every")?;
22814 if matches!(
22815 self.peek().map(|token| &token.kind),
22816 Some(TokenKind::Number(_))
22817 ) {
22818 let (value, _) = self.expect_u32("recurrence interval")?;
22819 let unit = self.expect_ident("duration unit (`s`, `m`, `h`, or `d`)")?;
22820 let seconds = match unit.name.as_str() {
22821 "s" => value as u64,
22822 "m" => value as u64 * 60,
22823 "h" => value as u64 * 3_600,
22824 "d" => value as u64 * 86_400,
22825 other => {
22826 self.diagnostics.push(Diagnostic {
22827 related: Vec::new(),
22828 span: unit.span,
22829 message: format!("unknown duration unit `{other}`"),
22830 suggestion: Some("use `s`, `m`, `h`, or `d`".to_owned()),
22831 });
22832 return None;
22833 }
22834 };
22835 return Some(Recurrence::EveryDuration {
22836 seconds,
22837 source: format!("{value}{}", unit.name),
22838 span: every.span.join(unit.span),
22839 });
22840 }
22841 let pattern_ident =
22842 self.expect_ident("calendar pattern (`day`, `weekday`, or a weekday)")?;
22843 let pattern = match pattern_ident.name.as_str() {
22844 "day" => CalendarPattern::Day,
22845 "weekday" => CalendarPattern::Weekday,
22846 "monday" => CalendarPattern::Weekly(Weekday::Monday),
22847 "tuesday" => CalendarPattern::Weekly(Weekday::Tuesday),
22848 "wednesday" => CalendarPattern::Weekly(Weekday::Wednesday),
22849 "thursday" => CalendarPattern::Weekly(Weekday::Thursday),
22850 "friday" => CalendarPattern::Weekly(Weekday::Friday),
22851 "saturday" => CalendarPattern::Weekly(Weekday::Saturday),
22852 "sunday" => CalendarPattern::Weekly(Weekday::Sunday),
22853 other => {
22854 self.diagnostics.push(Diagnostic {
22855 related: Vec::new(),
22856 span: pattern_ident.span,
22857 message: format!("unknown calendar pattern `{other}`"),
22858 suggestion: Some(
22859 "use `day`, `weekday`, or a weekday such as `monday`".to_owned(),
22860 ),
22861 });
22862 return None;
22863 }
22864 };
22865 if !self.consume_ident("at") {
22866 self.expected("`at <hh:mm>` after the calendar pattern");
22867 return None;
22868 }
22869 let time = self.parse_time_of_day()?;
22870 Some(Recurrence::EveryCalendar {
22871 pattern,
22872 span: every.span.join(time.span),
22873 time,
22874 })
22875 }
22876
22877 fn parse_time_of_day(&mut self) -> Option<TimeOfDay> {
22878 let (hour, hour_span) = self.expect_u32("hour")?;
22879 self.expect_symbol(':')?;
22880 let (minute, minute_span) = self.expect_u32("minute")?;
22881 if hour > 23 || minute > 59 {
22882 self.diagnostics.push(Diagnostic {
22883 related: Vec::new(),
22884 span: hour_span.join(minute_span),
22885 message: format!("invalid time of day `{hour:02}:{minute:02}`"),
22886 suggestion: Some("use a 24-hour `hh:mm` such as `09:00`".to_owned()),
22887 });
22888 return None;
22889 }
22890 Some(TimeOfDay {
22891 hour: hour as u8,
22892 minute: minute as u8,
22893 span: hour_span.join(minute_span),
22894 })
22895 }
22896
22897 fn parse_missed_policy(&mut self) -> Option<MissedPolicy> {
22898 self.expect_keyword("missed")?;
22899 if self.consume_ident("skip") {
22900 return Some(MissedPolicy::Skip);
22901 }
22902 if self.consume_ident("coalesce") {
22903 return Some(MissedPolicy::Coalesce);
22904 }
22905 if self.consume_ident("catch_up") {
22906 if !self.consume_ident("limit") {
22907 self.expected("`limit <N>` after `catch_up`");
22908 return None;
22909 }
22910 let (limit, _) = self.expect_u32("catch_up limit")?;
22911 return Some(MissedPolicy::CatchUp { limit });
22912 }
22913 self.expected("`skip`, `coalesce`, or `catch_up limit <N>`");
22914 None
22915 }
22916
22917 fn parse_source_emit(&mut self) -> Option<SourceEmit> {
22918 let emit = self.expect_keyword("emit")?;
22919 let first = self.expect_ident("emit signal name")?;
22920 let mut signal = first.name.clone();
22921 let mut signal_span = first.span;
22922 while self.at_symbol('.') {
22923 self.advance();
22924 let segment = self.expect_ident("signal name segment")?;
22925 signal.push('.');
22926 signal.push_str(&segment.name);
22927 signal_span = signal_span.join(segment.span);
22928 }
22929 let from = if self.consume_ident("from") {
22930 Some(self.expect_ident("binding name after `from`")?)
22931 } else {
22932 None
22933 };
22934 if from.is_some() && !self.at_symbol('{') {
22935 let end = from.as_ref().map(|ident| ident.span.end).unwrap_or(0);
22936 return Some(SourceEmit {
22937 signal,
22938 signal_span,
22939 from,
22940 fields: Vec::new(),
22941 span: SourceSpan {
22942 start: emit.span.start,
22943 end,
22944 },
22945 });
22946 }
22947 let open = self.expect_symbol('{')?;
22948 let mut fields = Vec::new();
22949 while !self.is_at_end() && !self.at_symbol('}') {
22950 let Some(field_name) = self.expect_ident("emit field name") else {
22951 self.synchronize_to_block_item();
22952 continue;
22953 };
22954 let Some(value) = self.parse_source_value() else {
22955 self.synchronize_to_block_item();
22956 continue;
22957 };
22958 let value_span = match &value {
22959 SourceValue::Path { span, .. } => *span,
22960 SourceValue::String(literal) => literal.span,
22961 SourceValue::Number(_, span) => *span,
22962 };
22963 fields.push(SourceEmitField {
22964 span: field_name.span.join(value_span),
22965 name: field_name,
22966 value,
22967 });
22968 }
22969 let end = self
22970 .expect_symbol('}')
22971 .map(|token| token.span.end)
22972 .unwrap_or(open.span.end);
22973 Some(SourceEmit {
22974 signal,
22975 signal_span,
22976 from,
22977 fields,
22978 span: SourceSpan {
22979 start: emit.span.start,
22980 end,
22981 },
22982 })
22983 }
22984
22985 fn parse_source_value(&mut self) -> Option<SourceValue> {
22986 match self.peek().map(|token| &token.kind) {
22987 Some(TokenKind::String(_)) => self.expect_string("value").map(SourceValue::String),
22988 Some(TokenKind::Number(_)) => {
22989 let token = self.advance().clone();
22990 if let TokenKind::Number(value) = token.kind {
22991 Some(SourceValue::Number(value, token.span))
22992 } else {
22993 None
22994 }
22995 }
22996 Some(TokenKind::Ident(_)) => {
22997 let binding = self.expect_ident("value path")?;
22998 let mut segments = Vec::new();
22999 let mut span = binding.span;
23000 while self.at_symbol('.') {
23001 self.advance();
23002 let segment = self.expect_ident("path segment")?;
23003 span = span.join(segment.span);
23004 segments.push(segment);
23005 }
23006 Some(SourceValue::Path {
23007 binding,
23008 segments,
23009 span,
23010 })
23011 }
23012 _ => {
23013 self.expected("a value (observation path, string, or number)");
23014 None
23015 }
23016 }
23017 }
23018
23019 fn parse_class(&mut self) -> Option<ClassDecl> {
23020 let start = self.expect_keyword("class")?.span.start;
23021 let name = self.expect_ident("class name")?;
23022 let open = self.expect_symbol('{')?;
23023 let mut fields = Vec::new();
23024
23025 while !self.is_at_end() && !self.at_symbol('}') {
23026 let Some(field_name) = self.expect_ident("class field name") else {
23027 self.synchronize_to_block_item();
23028 continue;
23029 };
23030 let Some(ty) = self.parse_type() else {
23031 self.synchronize_to_block_item();
23032 continue;
23033 };
23034 let mut is_key = false;
23037 if self.at_symbol('@') {
23038 if let Some(tag) = self.parse_tag() {
23039 if tag.name == "key" {
23040 is_key = true;
23041 } else {
23042 self.diagnostics.push(Diagnostic {
23043 related: Vec::new(),
23044 span: tag.span,
23045 message: format!("unknown field tag `@{}`", tag.name),
23046 suggestion: Some(
23047 "the only field tag is `@key` (the class natural key)".to_owned(),
23048 ),
23049 });
23050 }
23051 }
23052 }
23053 let presence_condition = self.parse_field_presence_condition();
23054 let span = field_name.span.join(ty.span());
23055 fields.push(ClassField {
23056 span,
23057 name: field_name,
23058 ty,
23059 is_key,
23060 presence_condition,
23061 });
23062 }
23063
23064 let end = self
23065 .expect_symbol('}')
23066 .map(|token| token.span.end)
23067 .unwrap_or(open.span.end);
23068
23069 Some(ClassDecl {
23070 name,
23071 fields,
23072 span: SourceSpan { start, end },
23073 })
23074 }
23075
23076 fn parse_table(
23077 &mut self,
23078 tags: Vec<TagDecl>,
23079 description: Option<StringLiteral>,
23080 ) -> Option<TableDecl> {
23081 let start = self.expect_keyword("table")?.span.start;
23082 let name = self.expect_ident("table name")?;
23083 self.expect_keyword("as")?;
23084 let schema = self.expect_ident("table row class")?;
23085 let open = self.expect_symbol('[')?;
23086 let mut rows = Vec::new();
23087
23088 while !self.is_at_end() && !self.at_symbol(']') {
23089 if self.at_symbol(',') {
23090 self.advance();
23091 continue;
23092 }
23093 if !self.at_symbol('{') {
23094 self.unexpected("table row `{ ... }`");
23095 self.synchronize_to_table_row();
23096 continue;
23097 }
23098 if let Some(row) = self.parse_table_row() {
23099 rows.push(row);
23100 }
23101 if self.at_symbol(',') {
23102 self.advance();
23103 }
23104 }
23105
23106 let end = self
23107 .expect_symbol(']')
23108 .map(|token| token.span.end)
23109 .unwrap_or(open.span.end);
23110 Some(TableDecl {
23111 name,
23112 tags,
23113 description,
23114 schema,
23115 rows,
23116 span: SourceSpan { start, end },
23117 })
23118 }
23119
23120 fn parse_table_row(&mut self) -> Option<TableRow> {
23121 let open = self.expect_symbol('{')?;
23122 let body_start = open.span.end;
23123 let mut depth = 1usize;
23124 let mut body_end = body_start;
23125 let mut close_end = open.span.end;
23126
23127 while !self.is_at_end() {
23128 let token = self.advance().clone();
23129 match token.kind {
23130 TokenKind::Symbol('{') => {
23131 depth += 1;
23132 body_end = token.span.end;
23133 }
23134 TokenKind::Symbol('}') => {
23135 depth -= 1;
23136 if depth == 0 {
23137 body_end = token.span.start;
23138 close_end = token.span.end;
23139 break;
23140 }
23141 body_end = token.span.end;
23142 }
23143 _ => body_end = token.span.end,
23144 }
23145 }
23146
23147 if depth != 0 {
23148 self.diagnostics.push(Diagnostic {
23149 related: Vec::new(),
23150 span: SourceSpan {
23151 start: open.span.start,
23152 end: body_end,
23153 },
23154 message: "unterminated table row".to_owned(),
23155 suggestion: Some("close the table row with `}`".to_owned()),
23156 });
23157 return None;
23158 }
23159
23160 let body_span = SourceSpan {
23161 start: body_start,
23162 end: body_end,
23163 };
23164 let (text, span) = trimmed_source_text(self.source_text(body_span), body_span);
23165 Some(TableRow {
23166 body: BlockSource { text, span },
23167 span: SourceSpan {
23168 start: open.span.start,
23169 end: close_end,
23170 },
23171 })
23172 }
23173
23174 fn parse_coerce(&mut self) -> Option<CoerceDecl> {
23175 let start = self.expect_keyword("coerce")?.span.start;
23176 let name = self.expect_ident("coerce name")?;
23177 let params = self.parse_param_list()?;
23178 self.expect_thin_arrow()?;
23179 let output = self.parse_type()?;
23180 if !self.at_symbol('{') {
23185 if let Some(TokenKind::String(_)) = self.peek().map(|token| &token.kind) {
23186 let token = self.advance().clone();
23187 let raw = self
23188 .source_text(SourceSpan {
23189 start: token.span.start,
23190 end: token.span.end,
23191 })
23192 .to_owned();
23193 let body = BlockSource {
23194 text: format!("prompt {raw}"),
23195 span: token.span,
23196 };
23197 let span = SourceSpan {
23198 start,
23199 end: body.span.end,
23200 };
23201 return Some(CoerceDecl {
23202 name,
23203 params,
23204 output,
23205 body,
23206 span,
23207 });
23208 }
23209 }
23210 let body = self.parse_block_source()?;
23211 let span = SourceSpan {
23212 start,
23213 end: body.span.end,
23214 };
23215 Some(CoerceDecl {
23216 name,
23217 params,
23218 output,
23219 body,
23220 span,
23221 })
23222 }
23223
23224 fn parse_param_list(&mut self) -> Option<Vec<ParamDecl>> {
23225 self.expect_symbol('(')?;
23226 let mut params = Vec::new();
23227
23228 while !self.is_at_end() && !self.at_symbol(')') {
23229 let name = self.expect_ident("parameter name")?;
23230 let ty = self.parse_type()?;
23231 params.push(ParamDecl {
23232 span: name.span.join(ty.span()),
23233 name,
23234 ty,
23235 });
23236
23237 if self.at_symbol(',') {
23238 self.advance();
23239 } else if !self.at_symbol(')') {
23240 self.unexpected("`,` or `)`");
23241 while !self.is_at_end() && !self.at_symbol(')') && !self.at_symbol(',') {
23242 self.advance();
23243 }
23244 }
23245 }
23246
23247 self.expect_symbol(')')?;
23248 Some(params)
23249 }
23250
23251 fn parse_action(&mut self) -> Option<ActionDecl> {
23254 let start = self.expect_keyword("action")?.span.start;
23255 let name = self.expect_ident("action name")?;
23256 self.expect_symbol('(')?;
23257 let mut params = Vec::new();
23258 while !self.is_at_end() && !self.at_symbol(')') {
23259 let param_name = self.expect_ident("action parameter name")?;
23260 let ty = self.parse_type()?;
23261 let span = param_name.span.join(ty.span());
23262 params.push(ActionParam {
23263 name: param_name,
23264 ty,
23265 span,
23266 });
23267 if self.at_symbol(',') {
23268 self.advance();
23269 }
23270 }
23271 self.expect_symbol(')')?;
23272 let body = self.parse_block_source()?;
23273 let span = SourceSpan {
23274 start,
23275 end: body.span.end,
23276 };
23277 Some(ActionDecl {
23278 name,
23279 params,
23280 body,
23281 span,
23282 })
23283 }
23284
23285 fn parse_rule(
23286 &mut self,
23287 tags: Vec<TagDecl>,
23288 description: Option<StringLiteral>,
23289 ) -> Option<RuleDecl> {
23290 let start = self.expect_keyword("rule")?.span.start;
23291 let name = self.expect_ident("rule name")?;
23292 let mut whens = Vec::new();
23293
23294 while !self.is_at_end() && !self.at_arrow() {
23295 if self.at_ident("when") {
23296 whens.extend(self.parse_when_clauses()?);
23297 } else if self.at_ident("with") {
23298 let span = self
23299 .peek()
23300 .map(|token| token.span)
23301 .unwrap_or(SourceSpan { start, end: start });
23302 self.diagnostics.push(Diagnostic {
23303 related: Vec::new(),
23304 span,
23305 message: "`with` is not a rule readiness clause".to_owned(),
23306 suggestion: Some("use `when` for rule conditions".to_owned()),
23307 });
23308 self.advance();
23309 } else {
23310 self.unexpected("`when` clause or `=>`");
23311 self.advance();
23312 }
23313 }
23314
23315 self.expect_arrow()?;
23316 let body = self.parse_block_source()?;
23317 let span = SourceSpan {
23318 start,
23319 end: body.span.end,
23320 };
23321 Some(RuleDecl {
23322 name,
23323 tags,
23324 description,
23325 whens,
23326 body,
23327 span,
23328 })
23329 }
23330
23331 fn parse_when_clauses(&mut self) -> Option<Vec<WhenClause>> {
23332 let when = self.expect_keyword("when")?;
23333 if self.at_symbol('{') {
23334 return self.parse_grouped_when_clauses(when.span);
23335 }
23336
23337 Some(vec![self.parse_when_clause_after_keyword(when.span)?])
23338 }
23339
23340 fn parse_assert(
23341 &mut self,
23342 tags: Vec<TagDecl>,
23343 description: Option<StringLiteral>,
23344 ) -> Option<AssertDecl> {
23345 let assert = self.expect_keyword("assert")?;
23346 let expr_start = assert.span.end;
23347 let line_end = self.source[expr_start..]
23348 .find('\n')
23349 .map(|offset| expr_start + offset)
23350 .unwrap_or(self.source.len());
23351 let mut expr_end = line_end;
23352
23353 while !self.is_at_end() && self.peek()?.span.start < line_end {
23354 expr_end = self.peek()?.span.end.min(line_end);
23355 self.advance();
23356 }
23357 expr_end = Self::extend_span_over_skipped_operators(self.source, expr_end, line_end);
23358
23359 let span = SourceSpan {
23360 start: expr_start,
23361 end: expr_end,
23362 };
23363 let (expr, span) = trimmed_source_text(self.source_text(span), span);
23364 Some(AssertDecl {
23365 tags,
23366 description,
23367 expr,
23368 span,
23369 })
23370 }
23371
23372 fn parse_when_clause_after_keyword(&mut self, when: SourceSpan) -> Option<WhenClause> {
23373 self.parse_when_clause_with_stop(when, false)
23374 }
23375
23376 fn parse_when_clause_with_stop(
23378 &mut self,
23379 when: SourceSpan,
23380 stop_at_brace: bool,
23381 ) -> Option<WhenClause> {
23382 let text_start = when.end;
23383 let mut text_end = text_start;
23384
23385 while !(self.is_at_end()
23386 || self.at_arrow()
23387 || self.at_ident("when")
23388 || self.at_ident("rule")
23389 || stop_at_brace && self.at_symbol('{'))
23390 {
23391 text_end = self.peek()?.span.end;
23392 self.advance();
23393 }
23394 let limit = self
23395 .peek()
23396 .map(|token| token.span.start)
23397 .unwrap_or(self.source.len());
23398 text_end = Self::extend_span_over_skipped_operators(self.source, text_end, limit);
23399
23400 let span = SourceSpan {
23401 start: text_start,
23402 end: text_end,
23403 };
23404 let (text, span) = trimmed_source_text(self.source_text(span), span);
23405 Some(WhenClause { text, span })
23406 }
23407
23408 fn extend_span_over_skipped_operators(source: &str, mut end: usize, limit: usize) -> usize {
23418 let bytes = source.as_bytes();
23419 loop {
23420 let mut cursor = end;
23421 while cursor < limit && bytes[cursor].is_ascii_whitespace() && bytes[cursor] != b'\n' {
23422 cursor += 1;
23423 }
23424 let width = match (bytes.get(cursor), bytes.get(cursor + 1)) {
23425 _ if cursor >= limit => break,
23426 (Some(b'='), Some(b'='))
23427 | (Some(b'!'), Some(b'='))
23428 | (Some(b'<'), Some(b'='))
23429 | (Some(b'>'), Some(b'='))
23430 | (Some(b'&'), Some(b'&'))
23431 | (Some(b'|'), Some(b'|')) => 2,
23432 (Some(b'/'), Some(b'/')) => break,
23433 (Some(b'-'), Some(b'>')) => break,
23434 (Some(b'*' | b'/' | b'-'), _) => 1,
23435 _ => break,
23436 };
23437 if cursor + width > limit {
23438 break;
23439 }
23440 end = cursor + width;
23441 }
23442 end
23443 }
23444
23445 fn parse_grouped_when_clauses(&mut self, when: SourceSpan) -> Option<Vec<WhenClause>> {
23446 let open = self.expect_symbol('{')?;
23447 let body_start = open.span.end;
23448 let mut depth = 1usize;
23449 let mut body_end = body_start;
23450 let mut close_end = open.span.end;
23451
23452 while !self.is_at_end() {
23453 let token = self.advance().clone();
23454 match token.kind {
23455 TokenKind::Symbol('{') => {
23456 depth += 1;
23457 body_end = token.span.end;
23458 }
23459 TokenKind::Symbol('}') => {
23460 depth -= 1;
23461 if depth == 0 {
23462 body_end = token.span.start;
23463 close_end = token.span.end;
23464 break;
23465 }
23466 body_end = token.span.end;
23467 }
23468 _ => body_end = token.span.end,
23469 }
23470 }
23471
23472 if depth != 0 {
23473 self.diagnostics.push(Diagnostic {
23474 related: Vec::new(),
23475 span: SourceSpan {
23476 start: when.start,
23477 end: body_end,
23478 },
23479 message: "unterminated grouped `when` block".to_owned(),
23480 suggestion: Some("close the grouped readiness block with `}`".to_owned()),
23481 });
23482 return Some(Vec::new());
23483 }
23484
23485 let body_span = SourceSpan {
23486 start: body_start,
23487 end: body_end,
23488 };
23489 let mut clauses = Vec::new();
23490 let mut offset = 0usize;
23491 for line in self.source_text(body_span).split_inclusive('\n') {
23492 let line_without_newline = line.trim_end_matches('\n');
23493 let line_start = body_span.start + offset;
23494 offset += line.len();
23495 let leading = line_without_newline.len() - line_without_newline.trim_start().len();
23496 let trailing = line_without_newline.len() - line_without_newline.trim_end().len();
23497 let trimmed_start = line_start + leading;
23498 let trimmed_end = line_start + line_without_newline.len().saturating_sub(trailing);
23499 if trimmed_start >= trimmed_end {
23500 continue;
23501 }
23502 clauses.push(WhenClause {
23503 text: self.source[trimmed_start..trimmed_end].to_owned(),
23504 span: SourceSpan {
23505 start: trimmed_start,
23506 end: trimmed_end,
23507 },
23508 });
23509 }
23510
23511 if clauses.is_empty() {
23512 self.diagnostics.push(Diagnostic {
23513 related: Vec::new(),
23514 span: SourceSpan {
23515 start: when.start,
23516 end: close_end,
23517 },
23518 message: "grouped `when` block has no readiness clauses".to_owned(),
23519 suggestion: Some(
23520 "add one condition per line, such as `started` or `Class as binding`"
23521 .to_owned(),
23522 ),
23523 });
23524 }
23525
23526 Some(clauses)
23527 }
23528
23529 fn parse_block_source(&mut self) -> Option<BlockSource> {
23530 let open = self.expect_symbol('{')?;
23531 let body_start = open.span.end;
23532 let mut depth = 1usize;
23533 let mut body_end = body_start;
23534
23535 while !self.is_at_end() {
23536 let token = self.advance().clone();
23537 match token.kind {
23538 TokenKind::Symbol('{') => {
23539 depth += 1;
23540 body_end = token.span.end;
23541 }
23542 TokenKind::Symbol('}') => {
23543 depth -= 1;
23544 if depth == 0 {
23545 body_end = token.span.start;
23546 return Some(BlockSource {
23547 text: self
23548 .source_text(SourceSpan {
23549 start: body_start,
23550 end: body_end,
23551 })
23552 .trim()
23553 .to_owned(),
23554 span: SourceSpan {
23555 start: open.span.start,
23556 end: token.span.end,
23557 },
23558 });
23559 }
23560 body_end = token.span.end;
23561 }
23562 _ => {
23563 body_end = token.span.end;
23564 }
23565 }
23566 }
23567
23568 self.diagnostics.push(Diagnostic {
23569 related: Vec::new(),
23570 span: SourceSpan {
23571 start: open.span.start,
23572 end: body_end,
23573 },
23574 message: "unterminated block".to_owned(),
23575 suggestion: Some("add a closing `}`".to_owned()),
23576 });
23577 Some(BlockSource {
23578 text: self
23579 .source_text(SourceSpan {
23580 start: body_start,
23581 end: body_end,
23582 })
23583 .trim()
23584 .to_owned(),
23585 span: SourceSpan {
23586 start: open.span.start,
23587 end: body_end,
23588 },
23589 })
23590 }
23591
23592 fn parse_type(&mut self) -> Option<TypeSyntax> {
23593 let first = self.parse_type_atom()?;
23594 let first = self.parse_type_suffixes(first);
23595
23596 if !self.at_symbol('|') {
23597 return Some(first);
23598 }
23599
23600 let start = first.span().start;
23601 let mut end = first.span().end;
23602 let mut variants = vec![first];
23603
23604 while self.at_symbol('|') {
23605 self.advance();
23606 let variant = self.parse_type_atom()?;
23607 let variant = self.parse_type_suffixes(variant);
23608 end = variant.span().end;
23609 variants.push(variant);
23610 }
23611
23612 Some(TypeSyntax::Union {
23613 variants,
23614 span: SourceSpan { start, end },
23615 })
23616 }
23617
23618 fn parse_type_atom(&mut self) -> Option<TypeSyntax> {
23619 Some(if self.at_ident("AgentRef") {
23620 let agent_ref = self.advance().clone();
23621 self.expect_symbol('<')?;
23622 let mut agents = Vec::new();
23623 while !self.is_at_end() && !self.at_symbol('>') {
23624 if self.at_symbol('|') {
23625 self.advance();
23626 continue;
23627 }
23628 let Some(agent) = self.expect_ident("agent reference") else {
23629 break;
23630 };
23631 agents.push(agent);
23632 }
23633 let close = self.expect_symbol('>')?;
23634 TypeSyntax::AgentRef {
23635 agents,
23636 span: agent_ref.span.join(close.span),
23637 }
23638 } else if self.at_ident("map") {
23639 let map = self.advance().clone();
23640 self.expect_symbol('<')?;
23641 let inner = self.parse_type()?;
23642 let close = self.expect_symbol('>')?;
23643 TypeSyntax::Map {
23644 span: map.span.join(close.span),
23645 inner: Box::new(inner),
23646 }
23647 } else if matches!(
23648 self.peek().map(|token| &token.kind),
23649 Some(TokenKind::String(_))
23650 ) {
23651 let literal = self.expect_string("literal type")?;
23652 TypeSyntax::LiteralString {
23653 value: literal.value,
23654 span: literal.span,
23655 }
23656 } else {
23657 let ident = self.expect_ident("type name")?;
23658 if is_primitive_type(&ident.name) {
23659 TypeSyntax::Primitive {
23660 name: ident.name,
23661 span: ident.span,
23662 }
23663 } else {
23664 TypeSyntax::Ref { name: ident }
23665 }
23666 })
23667 }
23668
23669 fn parse_type_suffixes(&mut self, mut ty: TypeSyntax) -> TypeSyntax {
23670 loop {
23671 if self.at_symbol('?') {
23672 let question = self.advance().clone();
23673 ty = TypeSyntax::Optional {
23674 span: ty.span().join(question.span),
23675 inner: Box::new(ty),
23676 };
23677 } else if self.at_symbol('[') {
23678 self.advance();
23679 let Some(close) = self.expect_symbol(']') else {
23680 return ty;
23681 };
23682 ty = TypeSyntax::Array {
23683 span: ty.span().join(close.span),
23684 inner: Box::new(ty),
23685 };
23686 } else {
23687 return ty;
23688 }
23689 }
23690 }
23691
23692 fn parse_string_list(&mut self) -> Option<(Vec<StringLiteral>, SourceSpan)> {
23693 let open = self.expect_symbol('[')?;
23694 let mut values = Vec::new();
23695
23696 while !self.is_at_end() && !self.at_symbol(']') {
23697 values.push(self.expect_string("skill string")?);
23698 if self.at_symbol(',') {
23699 self.advance();
23700 } else if !self.at_symbol(']') {
23701 self.unexpected("`,` or `]`");
23702 self.synchronize_to_block_item();
23703 break;
23704 }
23705 }
23706
23707 let close = self.expect_symbol(']')?;
23708 Some((values, open.span.join(close.span)))
23709 }
23710
23711 fn parse_ident_list(&mut self) -> Option<(Vec<Ident>, SourceSpan)> {
23714 let open = self.expect_symbol('[')?;
23715 let mut values = Vec::new();
23716
23717 while !self.is_at_end() && !self.at_symbol(']') {
23718 values.push(self.expect_ident("tool workflow name")?);
23719 if self.at_symbol(',') {
23720 self.advance();
23721 } else if !self.at_symbol(']') {
23722 self.unexpected("`,` or `]`");
23723 self.synchronize_to_block_item();
23724 break;
23725 }
23726 }
23727
23728 let close = self.expect_symbol(']')?;
23729 Some((values, open.span.join(close.span)))
23730 }
23731
23732 fn parse_feature_class_list(&mut self) -> Option<(Vec<Ident>, SourceSpan)> {
23737 let open = self.expect_symbol('[')?;
23738 let mut values = Vec::new();
23739
23740 while !self.is_at_end() && !self.at_symbol(']') {
23741 let head = self.expect_ident("feature class")?;
23742 let mut name = head.name.clone();
23743 let mut span = head.span;
23744 while self.at_symbol('.') {
23745 self.advance();
23746 let part = self.expect_ident("feature class segment")?;
23747 name.push('.');
23748 name.push_str(&part.name);
23749 span = span.join(part.span);
23750 }
23751 values.push(Ident { name, span });
23752 if self.at_symbol(',') {
23753 self.advance();
23754 } else if !self.at_symbol(']') {
23755 self.unexpected("`,` or `]`");
23756 self.synchronize_to_block_item();
23757 break;
23758 }
23759 }
23760
23761 let close = self.expect_symbol(']')?;
23762 Some((values, open.span.join(close.span)))
23763 }
23764
23765 fn expect_keyword(&mut self, keyword: &str) -> Option<Token> {
23766 if self.at_ident(keyword) {
23767 Some(self.advance().clone())
23768 } else {
23769 self.expected(format!("`{keyword}`"));
23770 None
23771 }
23772 }
23773
23774 fn expect_ident(&mut self, label: &str) -> Option<Ident> {
23775 let token = self.peek()?;
23776 if let TokenKind::Ident(name) = &token.kind {
23777 let ident = Ident {
23778 name: name.clone(),
23779 span: token.span,
23780 };
23781 self.advance();
23782 Some(ident)
23783 } else {
23784 self.expected(label);
23785 None
23786 }
23787 }
23788
23789 fn parse_field_presence_condition(&mut self) -> Option<(String, String)> {
23795 if !self.at_ident("when") {
23796 return None;
23797 }
23798 self.advance(); let disc = self.expect_ident("discriminant field name after `when`")?;
23800 if self.at_ident("is") {
23801 self.advance();
23802 } else {
23803 self.expected("`is` after the discriminant field");
23804 return None;
23805 }
23806 let literal = self.expect_string("discriminant literal value")?;
23807 Some((disc.name, literal.value))
23808 }
23809
23810 fn expect_string(&mut self, label: &str) -> Option<StringLiteral> {
23811 let token = self.peek()?;
23812 if let TokenKind::String(value) = &token.kind {
23813 let literal = StringLiteral {
23814 value: value.clone(),
23815 span: token.span,
23816 };
23817 self.advance();
23818 Some(literal)
23819 } else {
23820 self.expected(label);
23821 None
23822 }
23823 }
23824
23825 fn expect_use_name(&mut self, label: &str) -> Option<StringLiteral> {
23826 let token = self.peek()?;
23827 match &token.kind {
23828 TokenKind::Ident(value) => {
23831 let mut name = value.clone();
23832 let mut span = token.span;
23833 self.advance();
23834 while self.at_symbol('.') {
23835 self.expect_symbol('.');
23836 let Some(segment) = self.expect_ident("package name segment") else {
23837 break;
23838 };
23839 name.push('.');
23840 name.push_str(&segment.name);
23841 span = span.join(segment.span);
23842 }
23843 Some(StringLiteral { value: name, span })
23844 }
23845 TokenKind::String(value) => {
23846 let literal = StringLiteral {
23847 value: value.clone(),
23848 span: token.span,
23849 };
23850 self.advance();
23851 Some(literal)
23852 }
23853 _ => {
23854 self.expected(label);
23855 None
23856 }
23857 }
23858 }
23859
23860 fn expect_u32(&mut self, label: &str) -> Option<(u32, SourceSpan)> {
23861 let token = self.peek()?;
23862 if let TokenKind::Number(value) = &token.kind {
23863 let span = token.span;
23864 let parsed = value.parse::<u32>();
23865 self.advance();
23866 match parsed {
23867 Ok(value) => Some((value, span)),
23868 Err(_) => {
23869 self.diagnostics.push(Diagnostic {
23870 related: Vec::new(),
23871 span,
23872 message: format!("{label} must fit in u32"),
23873 suggestion: Some("use a non-negative integer such as `1`".to_owned()),
23874 });
23875 None
23876 }
23877 }
23878 } else {
23879 self.expected(label);
23880 None
23881 }
23882 }
23883
23884 fn expect_symbol(&mut self, symbol: char) -> Option<Token> {
23885 if self.at_symbol(symbol) {
23886 Some(self.advance().clone())
23887 } else {
23888 self.expected(format!("`{symbol}`"));
23889 None
23890 }
23891 }
23892
23893 fn expect_arrow(&mut self) -> Option<Token> {
23894 if self.at_arrow() {
23895 Some(self.advance().clone())
23896 } else {
23897 self.expected("`=>`");
23898 None
23899 }
23900 }
23901
23902 fn expect_thin_arrow(&mut self) -> Option<Token> {
23903 if self.at_thin_arrow() {
23904 Some(self.advance().clone())
23905 } else {
23906 self.expected("`->`");
23907 None
23908 }
23909 }
23910
23911 fn at_ident(&self, expected: &str) -> bool {
23912 matches!(self.peek().map(|token| &token.kind), Some(TokenKind::Ident(value)) if value == expected)
23913 }
23914
23915 fn consume_ident(&mut self, expected: &str) -> bool {
23916 if self.at_ident(expected) {
23917 self.advance();
23918 true
23919 } else {
23920 false
23921 }
23922 }
23923
23924 fn at_symbol(&self, expected: char) -> bool {
23925 matches!(self.peek().map(|token| &token.kind), Some(TokenKind::Symbol(value)) if *value == expected)
23926 }
23927
23928 fn at_arrow(&self) -> bool {
23929 matches!(self.peek().map(|token| &token.kind), Some(TokenKind::Arrow))
23930 }
23931
23932 fn at_thin_arrow(&self) -> bool {
23933 matches!(
23934 self.peek().map(|token| &token.kind),
23935 Some(TokenKind::ThinArrow)
23936 )
23937 }
23938
23939 fn peek(&self) -> Option<&Token> {
23940 self.tokens.get(self.pos)
23941 }
23942
23943 fn advance(&mut self) -> &Token {
23944 let index = self.pos;
23945 self.pos += 1;
23946 &self.tokens[index]
23947 }
23948
23949 fn is_at_end(&self) -> bool {
23950 self.pos >= self.tokens.len()
23951 }
23952
23953 fn expected(&mut self, expected: impl fmt::Display) {
23954 let expected = expected.to_string();
23955 let (span, found) = match self.peek() {
23956 Some(token) => (token.span, token.kind.label()),
23957 None => (
23958 SourceSpan {
23959 start: self.source.len(),
23960 end: self.source.len(),
23961 },
23962 "end of file".to_owned(),
23963 ),
23964 };
23965 self.diagnostics.push(Diagnostic {
23966 related: Vec::new(),
23967 span,
23968 message: format!("expected {expected}, found {found}"),
23969 suggestion: suggestion_for_expected(&expected),
23970 });
23971 }
23972
23973 fn unexpected(&mut self, expected: impl fmt::Display) {
23974 let Some(token) = self.peek() else {
23975 self.expected(expected);
23976 return;
23977 };
23978 let expected = expected.to_string();
23979 self.diagnostics.push(Diagnostic {
23980 related: Vec::new(),
23981 span: token.span,
23982 message: format!("expected {expected}, found {}", token.kind.label()),
23983 suggestion: suggestion_for_expected(&expected),
23984 });
23985 }
23986
23987 fn synchronize_to_block_item(&mut self) {
23988 while !self.is_at_end() {
23989 if self.at_symbol('}')
23990 || self.at_ident("profile")
23991 || self.at_ident("provider")
23992 || self.at_ident("capacity")
23993 || self.at_ident("skills")
23994 || self.at_ident("capabilities")
23995 || self.at_ident("tools")
23996 || self.at_ident("compaction")
23997 || self.at_ident("settings")
23998 {
23999 return;
24000 }
24001 self.advance();
24002 }
24003 }
24004
24005 fn synchronize_to_table_row(&mut self) {
24006 while !self.is_at_end() {
24007 if self.at_symbol('{') || self.at_symbol(']') {
24008 return;
24009 }
24010 self.advance();
24011 }
24012 }
24013
24014 fn source_text(&self, span: SourceSpan) -> &str {
24015 &self.source[span.start..span.end]
24016 }
24017}
24018
24019fn trimmed_source_text(source: &str, span: SourceSpan) -> (String, SourceSpan) {
24020 let leading = source.len() - source.trim_start().len();
24021 let trailing = source.len() - source.trim_end().len();
24022 let end = source.len().saturating_sub(trailing);
24023 if leading > end {
24024 return (
24025 String::new(),
24026 SourceSpan {
24027 start: span.end,
24028 end: span.end,
24029 },
24030 );
24031 }
24032 (
24033 source[leading..end].to_owned(),
24034 SourceSpan {
24035 start: span.start + leading,
24036 end: span.start + end,
24037 },
24038 )
24039}
24040
24041fn is_primitive_type(name: &str) -> bool {
24042 matches!(
24043 name,
24044 "string"
24045 | "int"
24046 | "float"
24047 | "bool"
24048 | "null"
24049 | "duration"
24050 | "time"
24051 | "image"
24052 | "audio"
24053 | "pdf"
24054 | "video"
24055 )
24056}
24057
24058fn is_gherkin_keyword(keyword: &str) -> bool {
24059 matches!(
24060 keyword,
24061 "Feature"
24062 | "Rule"
24063 | "Background"
24064 | "Scenario"
24065 | "ScenarioOutline"
24066 | "Scenario-Outline"
24067 | "Examples"
24068 | "Given"
24069 | "When"
24070 | "Then"
24071 | "And"
24072 | "But"
24073 )
24074}
24075
24076fn suggestion_for_expected(expected: &str) -> Option<String> {
24077 match expected {
24078 "`{`" => Some("add a `{ ... }` block".to_owned()),
24079 "`=>`" => Some("add `=> { ... }` after the rule conditions".to_owned()),
24080 "`->`" => Some("add `-> OutputType` before the coerce prompt block".to_owned()),
24081 "profile string" => Some("write `profile \"profile-name\"`".to_owned()),
24082 "capacity value" => Some("write `capacity 1`".to_owned()),
24083 "package library name" => Some("write a package library name, such as `memory`".to_owned()),
24084 "type name" => Some("write a primitive type or schema name".to_owned()),
24085 _ => None,
24086 }
24087}
24088
24089#[cfg(test)]
24090mod tests {
24091 use super::*;
24092
24093 #[test]
24094 fn parser_scaffold_links_to_core() {
24095 assert_eq!(parser_stage(), "release");
24096 }
24097
24098 #[test]
24099 fn declaration_block_grammar_table_is_complete() {
24100 let keywords: Vec<&str> = DECLARATION_BLOCK_GRAMMAR
24104 .iter()
24105 .map(|spec| spec.keyword)
24106 .collect();
24107 assert_eq!(
24108 keywords.len(),
24109 7,
24110 "expected exactly 7 declaration_block specs"
24111 );
24112 for expected in [
24113 "tracker",
24114 "channel",
24115 "counter",
24116 "lease",
24117 "ledger",
24118 "file store",
24119 "memory pool",
24120 ] {
24121 assert!(
24122 keywords.contains(&expected),
24123 "missing declaration_block keyword `{expected}`; got {keywords:?}"
24124 );
24125 }
24126
24127 let find = |keyword: &str| -> &DeclarationBlockSpec {
24128 DECLARATION_BLOCK_GRAMMAR
24129 .iter()
24130 .find(|spec| spec.keyword == keyword)
24131 .unwrap_or_else(|| panic!("no spec for `{keyword}`"))
24132 };
24133 let clause = |keyword: &str, name: &str| -> &ClauseSpec {
24134 find(keyword)
24135 .clauses
24136 .iter()
24137 .find(|clause| clause.name == name)
24138 .unwrap_or_else(|| panic!("no clause `{name}` on `{keyword}`"))
24139 };
24140
24141 assert_eq!(find("memory pool").keyword_words, &["memory", "pool"]);
24143 assert_eq!(find("file store").keyword_words, &["file", "store"]);
24144 assert_eq!(find("tracker").keyword_words, &["tracker"]);
24145
24146 assert_eq!(clause("ledger", "partition").connective, Some("by"));
24148
24149 assert!(matches!(clause("lease", "shared").kind, ClauseKind::Flag));
24151 assert!(!clause("lease", "shared").list);
24152 assert_eq!(clause("lease", "shared").connective, None);
24153
24154 for (name, words) in [
24156 ("allow read", ["allow", "read"]),
24157 ("allow write", ["allow", "write"]),
24158 ] {
24159 let allow = clause("file store", name);
24160 assert!(allow.list, "`{name}` must be list:true");
24161 assert_eq!(allow.words, words);
24162 assert!(matches!(allow.kind, ClauseKind::Glob));
24163 }
24164
24165 let mut parser = Parser {
24167 source: "memory pool p { }",
24168 tokens: lex("memory pool p { }").tokens,
24169 pos: 0,
24170 diagnostics: Vec::new(),
24171 pending_contract_classes: Vec::new(),
24172 };
24173 let spec = parser
24174 .declaration_block_spec_at()
24175 .expect("head word `memory` must resolve to the memory pool spec");
24176 assert_eq!(spec.keyword, "memory pool");
24177 assert_eq!(parser.pos, 0);
24179 parser.diagnostics.clear();
24180 }
24181
24182 const SEND_PROGRAM: &str = r##"
24183@service
24184workflow Notify
24185
24186class Trigger { id string }
24187
24188agent worker { provider fixture profile "r" capacity 1 }
24189
24190channel alerts { provider fixture destination "#ops" }
24191
24192table seed as Trigger [ { id "t" } ]
24193
24194rule notify
24195 when Trigger as t
24196=> {
24197 send via alerts {
24198 text "hello"
24199 } as sent
24200}
24201"##;
24202
24203 #[test]
24204 fn send_lowers_to_messaging_capability_call_without_builtin_registration() {
24205 let compiled = compile_program(SEND_PROGRAM);
24212 assert_eq!(
24213 compiled.diagnostics,
24214 Vec::new(),
24215 "{:?}",
24216 compiled.diagnostics
24217 );
24218 let ir = compiled.ir.expect("lowered IR");
24219 let uses = ir.construct_uses();
24220 assert_eq!(uses.len(), 1);
24221 assert_eq!(uses[0].keyword, "send");
24222 assert_eq!(uses[0].target_capability, "messaging.send");
24223 let registry = ir.contract_registry();
24224 assert!(
24225 registry.constructs.is_empty(),
24226 "the parser registers no builtin constructs: {:?}",
24227 registry.constructs
24228 );
24229 assert!(
24230 !registry
24231 .effect_contracts
24232 .iter()
24233 .any(|c| c.id == "messaging.send"),
24234 "the messaging.send contract comes from the embedded manifest, not the parser"
24235 );
24236 assert!(
24237 registry
24238 .libraries
24239 .iter()
24240 .any(|lib| lib.id == "std.messaging" && lib.standard),
24241 "the channel declaration still registers the std.messaging standard library"
24242 );
24243 }
24244
24245 #[test]
24246 fn send_to_unknown_channel_is_rejected() {
24247 let source = SEND_PROGRAM.replace("send via alerts", "send via ghost");
24248 let compiled = compile_program(&source);
24249 let violations: Vec<&Diagnostic> = compiled
24250 .diagnostics
24251 .iter()
24252 .filter(|d| d.message.contains("unknown channel"))
24253 .collect();
24254 assert_eq!(violations.len(), 1, "{:?}", compiled.diagnostics);
24255 assert!(violations[0].message.contains("ghost"));
24256 }
24257
24258 #[test]
24259 fn derives_contract_registry_from_imports_and_effects() {
24260 let source = r#"
24261workflow RegistrySlice
24262
24263use memory
24264
24265class Task {
24266 title string
24267}
24268
24269class Review {
24270 accepted bool
24271}
24272
24273coerce reviewTask(title string) -> Review {
24274 prompt """
24275 Review {{ title }}
24276 """
24277}
24278
24279agent worker {
24280 provider fixture
24281 profile "repo-writer"
24282 capacity 1
24283}
24284
24285rule start
24286 when Task as task
24287=> {
24288 tell worker as turn """
24289 Work on {{ task.title }}
24290 """
24291
24292 after turn succeeds {
24293 coerce reviewTask(task.title) as review
24294 }
24295}
24296"#;
24297
24298 let compiled = compile_program(source);
24299 assert_eq!(compiled.diagnostics, Vec::new());
24300 let ir = compiled.ir.expect("program compiles");
24301 let registry = ir.contract_registry();
24302 assert_eq!(registry.validate(), Vec::new());
24303
24304 assert!(registry
24305 .libraries
24306 .iter()
24307 .any(|library| library.id == "memory" && !library.standard));
24308 assert!(registry
24309 .libraries
24310 .iter()
24311 .any(|library| library.id == "std.agent" && library.standard));
24312 assert!(registry
24313 .libraries
24314 .iter()
24315 .any(|library| library.id == "std.coercion" && library.standard));
24316
24317 let coerce = registry
24318 .effect_contracts
24319 .iter()
24320 .find(|contract| contract.id == "schema.coerce")
24321 .expect("coerce contract");
24322 assert_eq!(coerce.library_id, "std.coercion");
24323 assert_eq!(coerce.validation, TypedOutputValidation::RuntimeBoundary);
24324 assert!(coerce.source_forms.contains(&"coerce".to_owned()));
24325 assert!(coerce.source_forms.contains(&"prompt".to_owned()));
24326 assert!(coerce
24327 .required_capabilities
24328 .contains(&"schema.coerce".to_owned()));
24329 assert_eq!(coerce.provider_kinds, vec!["schema_coercer".to_owned()]);
24330
24331 let agent = registry
24332 .effect_contracts
24333 .iter()
24334 .find(|contract| contract.id == "agent.tell")
24335 .expect("agent contract");
24336 assert_eq!(agent.library_id, "std.agent");
24337 assert_eq!(agent.output_schema.as_deref(), Some("AgentTurn"));
24338 }
24339
24340 #[test]
24341 fn capability_calls_require_the_target_capability() {
24342 let source = r#"
24343workflow PackageCall
24344
24345use memory
24346
24347class Task {
24348 title string
24349}
24350
24351rule start
24352 when Task as task
24353=> {
24354 call memory.query for task as context
24355}
24356"#;
24357
24358 let compiled = compile_program(source);
24359 assert_eq!(compiled.diagnostics, Vec::new());
24360 let ir = compiled.ir.expect("program compiles");
24361 let effect = ir.rules[0]
24362 .metadata
24363 .effects
24364 .iter()
24365 .find(|effect| effect.kind == IrEffectKind::CapabilityCall)
24366 .expect("capability call effect");
24367 assert_eq!(
24368 effect.required_capabilities,
24369 vec!["memory.query".to_owned()]
24370 );
24371
24372 let registry = ir.contract_registry();
24373 let contract = registry
24374 .effect_contracts
24375 .iter()
24376 .find(|contract| contract.id == "capability.call")
24377 .expect("capability call contract");
24378 assert!(contract
24379 .required_capabilities
24380 .contains(&"memory.query".to_owned()));
24381 assert!(!contract
24382 .required_capabilities
24383 .contains(&"capability.call".to_owned()));
24384 }
24385
24386 #[test]
24387 fn package_recall_form_lowers_to_capability_call_marker() {
24388 let source = r#"
24389workflow PackageRecall
24390
24391use memory
24392
24393memory pool project_memory {
24394 context limit 8
24395}
24396
24397class Task {
24398 title string
24399}
24400
24401rule start
24402 when Task as task
24403=> {
24404 recall project_memory for task as context
24405}
24406"#;
24407
24408 let compiled = compile_program(source);
24409 assert_eq!(compiled.diagnostics, Vec::new());
24410 let ir = compiled.ir.expect("program compiles");
24411 let effect = ir.rules[0]
24412 .metadata
24413 .effects
24414 .iter()
24415 .find(|effect| effect.kind == IrEffectKind::CapabilityCall)
24416 .expect("capability call effect");
24417 assert_eq!(effect.binding.as_deref(), Some("context"));
24418 assert_eq!(
24419 effect.required_capabilities,
24420 vec!["memory.query".to_owned()]
24421 );
24422 assert_eq!(
24423 effect.construct_use,
24424 Some(IrConstructUse {
24425 keyword: "recall".to_owned(),
24426 scope: "rule_body".to_owned(),
24427 construct_family: "effect_operation".to_owned(),
24428 lowering_target: "capability_call".to_owned(),
24429 target_capability: "memory.query".to_owned(),
24430 })
24431 );
24432 assert_eq!(ir.construct_uses().len(), 1);
24433 assert!(ir.to_snapshot().contains("construct=recall->memory.query"));
24434 }
24435
24436 fn b1g_body_matrix_program(body: &str) -> String {
24437 r#"
24438workflow B1gMatrix {
24439 use memory
24440
24441 memory pool project_memory {
24442 context limit 8
24443 }
24444
24445 output result Done
24446 failure error Failed
24447
24448 class Ticket {
24449 id string
24450 title string
24451 due_at time
24452 amount int
24453 }
24454
24455 class TicketPublic {
24456 id string
24457 title string
24458 }
24459
24460 class Workspace {
24461 id string
24462 }
24463
24464 class Note {
24465 text string
24466 }
24467
24468 class Done {
24469 ok bool
24470 }
24471
24472 class Failed {
24473 reason string
24474 }
24475
24476 class Review {
24477 summary string
24478 fixed bool
24479 }
24480
24481 class LedgerEntry {
24482 area string
24483 text string
24484 }
24485
24486 class Row {
24487 title string
24488 }
24489
24490 signal deploy.finished {
24491 service string
24492 status string
24493 }
24494
24495 tracker backlog {
24496 provider builtin
24497 }
24498
24499 lease workspace_slot {
24500 key Workspace
24501 slots 1
24502 ttl 30m
24503 }
24504
24505 ledger review_log {
24506 entry LedgerEntry
24507 partition by area
24508 retain 30d
24509 }
24510
24511 counter request_budget {
24512 key Ticket
24513 cap 10
24514 reset daily
24515 }
24516
24517 file store docs {
24518 root "./data"
24519 allow write ["**"]
24520 }
24521
24522 channel ops_room {
24523 provider fixture
24524 }
24525
24526 agent worker {
24527 provider fixture
24528 profile "repo-writer"
24529 capacity 1
24530 }
24531
24532 coerce classify(title string) -> Review {
24533 prompt "classify"
24534 }
24535
24536 rule probe
24537 when Ticket as ticket
24538 when Workspace as workspace
24539 when backlog has ready issue as item
24540 when worker is available
24541 => {
24542__BODY__
24543 }
24544}
24545
24546workflow Child {
24547 input task ChildTask
24548 output result ChildResult
24549
24550 class ChildTask {
24551 title string
24552 }
24553
24554 class ChildResult {
24555 summary string
24556 }
24557
24558 rule finish
24559 when ChildTask as task
24560 => {
24561 complete result {
24562 summary task.title
24563 }
24564 }
24565}
24566"#
24567 .replace("__BODY__", body)
24568 }
24569
24570 #[test]
24571 fn coordination_shared_declarations_lower_to_ir() {
24572 let source = r#"
24573workflow SharedCoord
24574
24575class Key {
24576 id string
24577}
24578
24579class Entry {
24580 area string
24581}
24582
24583lease shared_slot {
24584 shared
24585 key Key
24586 slots 1
24587 ttl 30m
24588}
24589
24590ledger shared_log {
24591 shared
24592 entry Entry
24593 partition by area
24594 retain 30d
24595}
24596
24597counter shared_budget {
24598 shared
24599 key Key
24600 cap 10
24601 reset daily
24602}
24603"#;
24604 let compiled = compile_program(source);
24605 assert!(
24606 compiled.diagnostics.is_empty(),
24607 "unexpected diagnostics: {:?}",
24608 compiled.diagnostics
24609 );
24610 let ir = compiled.ir.expect("valid IR");
24611 assert!(ir
24612 .leases
24613 .iter()
24614 .any(|lease| lease.name == "shared_slot" && lease.shared && lease.ttl_seconds == 1800));
24615 assert!(ir
24616 .ledgers
24617 .iter()
24618 .any(|ledger| ledger.name == "shared_log" && ledger.shared));
24619 assert!(ir
24620 .counters
24621 .iter()
24622 .any(|counter| counter.name == "shared_budget" && counter.shared));
24623 }
24624
24625 #[test]
24626 fn file_store_clause_spans_are_first_word_tokens() {
24627 let source = r#"
24633workflow FileSpanProbe
24634file store notes_store {
24635 root "./data"
24636 allow read ["notes/**"]
24637 allow write ["notes/**"]
24638}
24639"#;
24640 let parsed = parse_program(source);
24641 assert_eq!(
24642 parsed.diagnostics,
24643 Vec::new(),
24644 "unexpected diagnostics: {:?}",
24645 parsed.diagnostics
24646 );
24647 let store = parsed
24648 .program
24649 .items
24650 .iter()
24651 .find_map(|item| match item {
24652 Item::FileStore(decl) => Some(decl),
24653 _ => None,
24654 })
24655 .expect("file store decl");
24656 let text_at = |span: SourceSpan| &source[span.start..span.end];
24657 assert_eq!(text_at(store.root_span.expect("root span")), "root");
24658 assert_eq!(text_at(store.read_span.expect("read span")), "allow");
24659 assert_eq!(text_at(store.write_span.expect("write span")), "allow");
24660 assert_eq!(store.provider, None);
24663 let ir = compile_program(source).ir.expect("valid IR");
24664 assert_eq!(ir.file_stores[0].provider, None);
24665 assert!(!ir.to_snapshot().contains("provider"));
24666 }
24667
24668 #[test]
24669 fn file_store_provider_clause_parses_and_unknown_is_rejected() {
24670 let source = r#"
24675workflow FileProviderProbe
24676file store notes_store {
24677 root "./data"
24678 allow read ["notes/**"]
24679 provider local
24680}
24681"#;
24682 let compiled = compile_program(source);
24683 assert_eq!(
24684 compiled.diagnostics,
24685 Vec::new(),
24686 "unexpected diagnostics: {:?}",
24687 compiled.diagnostics
24688 );
24689 let ir = compiled.ir.expect("valid IR");
24690 assert_eq!(ir.file_stores[0].provider.as_deref(), Some("local"));
24691 assert!(ir.to_snapshot().contains(" provider local"));
24692
24693 let unknown = source.replace("provider local", "provider s3");
24694 let compiled = compile_program(&unknown);
24695 assert!(
24696 compiled.diagnostics.iter().any(|diagnostic| {
24697 diagnostic
24698 .message
24699 .contains("file store `notes_store` names unknown provider `s3`")
24700 }),
24701 "unknown provider must be a check error: {:?}",
24702 compiled.diagnostics
24703 );
24704 }
24705
24706 #[test]
24707 fn formatter_preserves_file_store_provider_clause() {
24708 let source = r#"
24709workflow FileProviderFmt
24710file store notes_store { root "./data" provider local }
24711"#;
24712 let formatted = format_program(source);
24713 assert_eq!(formatted.diagnostics, Vec::new());
24714 let formatted = formatted.formatted.expect("formats");
24715 assert!(
24716 formatted.contains("file store notes_store {\n root \"./data\"\n provider local\n}"),
24717 "{formatted}"
24718 );
24719 }
24720
24721 #[test]
24722 fn formatter_preserves_shared_coordination_declarations() {
24723 let source = r#"
24724workflow SharedCoord
24725class Key { id string }
24726lease shared_slot { shared key Key slots 1 ttl 30m }
24727"#;
24728 let formatted = format_program(source);
24729 assert_eq!(formatted.diagnostics, Vec::new());
24730 let formatted = formatted.formatted.expect("formats");
24731 assert!(formatted.contains("lease shared_slot {\n shared\n key Key"));
24732 }
24733
24734 fn b1g_probe_rule(case_name: &str, body: &str) -> IrRule {
24735 let source = b1g_body_matrix_program(body);
24736 let compiled = compile_program_with_root(&source, Some("B1gMatrix"));
24737 assert!(
24738 compiled.diagnostics.is_empty(),
24739 "{case_name} emitted diagnostics: {:?}",
24740 compiled.diagnostics
24741 );
24742 let ir = compiled.ir.expect("valid matrix IR");
24743 ir.rules
24744 .into_iter()
24745 .find(|rule| rule.name == "probe")
24746 .expect("probe rule")
24747 }
24748
24749 fn b1g_effect<'a>(
24750 rule: &'a IrRule,
24751 kind: IrEffectKind,
24752 binding: Option<&str>,
24753 case_name: &str,
24754 ) -> &'a IrEffectNode {
24755 rule.metadata
24756 .effects
24757 .iter()
24758 .find(|effect| effect.kind == kind && effect.binding.as_deref() == binding)
24759 .unwrap_or_else(|| {
24760 panic!(
24761 "{case_name} did not lower {kind:?} / {binding:?}; effects: {:?}",
24762 rule.metadata.effects
24763 )
24764 })
24765 }
24766
24767 #[test]
24768 fn accepted_rule_body_matrix_has_no_silent_noops() {
24769 let effect_cases = [
24770 (
24771 "tell",
24772 r#" tell worker as turn "go""#,
24773 IrEffectKind::AgentTell,
24774 Some("turn"),
24775 ),
24776 (
24777 "coerce",
24778 r#" coerce classify(ticket.title) as review"#,
24779 IrEffectKind::SchemaCoerce,
24780 Some("review"),
24781 ),
24782 (
24783 "prompt",
24784 r#" prompt "Summarize {{ ticket.title }}" using fixture as summary"#,
24785 IrEffectKind::SchemaCoerce,
24786 Some("summary"),
24787 ),
24788 (
24789 "decide",
24790 r#" decide "fixed?" -> { fixed bool } as verdict"#,
24791 IrEffectKind::SchemaCoerce,
24792 Some("verdict"),
24793 ),
24794 (
24795 "call",
24796 r#" call memory.query for ticket as called"#,
24797 IrEffectKind::CapabilityCall,
24798 Some("called"),
24799 ),
24800 (
24801 "recall",
24802 r#" recall project_memory for ticket.title as memories"#,
24803 IrEffectKind::CapabilityCall,
24804 Some("memories"),
24805 ),
24806 (
24807 "send",
24808 r#" send via ops_room { text ticket.title } as sent"#,
24809 IrEffectKind::CapabilityCall,
24810 Some("sent"),
24811 ),
24812 (
24813 "invoke",
24814 r#" invoke Child { task { title ticket.title } } as child"#,
24815 IrEffectKind::WorkflowInvoke,
24816 Some("child"),
24817 ),
24818 (
24819 "timer_duration",
24820 r#" timer 5m as wait"#,
24821 IrEffectKind::TimerWait,
24822 Some("wait"),
24823 ),
24824 (
24825 "timer_until",
24826 r#" timer until ticket.due_at as deadline"#,
24827 IrEffectKind::TimerWait,
24828 Some("deadline"),
24829 ),
24830 (
24831 "exec_raw",
24832 r#" exec "echo hi" as run"#,
24833 IrEffectKind::ExecCommand,
24834 Some("run"),
24835 ),
24836 (
24837 "queue_file",
24838 r#" file issue into backlog { title ticket.title body "body" } as filed"#,
24839 IrEffectKind::TrackerFile,
24840 Some("filed"),
24841 ),
24842 (
24843 "queue_claim",
24844 r#" claim item as lease"#,
24845 IrEffectKind::TrackerClaim,
24846 Some("lease"),
24847 ),
24848 (
24849 "queue_release",
24850 r#" release item"#,
24851 IrEffectKind::TrackerRelease,
24852 None,
24853 ),
24854 (
24855 "queue_finish",
24856 r#" finish item { summary ticket.title }"#,
24857 IrEffectKind::TrackerFinish,
24858 None,
24859 ),
24860 (
24861 "lease_acquire",
24862 r#" acquire workspace_slot for workspace until ttl as slot"#,
24863 IrEffectKind::LeaseAcquire,
24864 Some("slot"),
24865 ),
24866 (
24867 "ledger_append",
24868 r#" append LedgerEntry { area ticket.id text ticket.title } to review_log as entry"#,
24869 IrEffectKind::LedgerAppend,
24870 Some("entry"),
24871 ),
24872 (
24873 "counter_consume",
24874 r#" consume request_budget for ticket amount ticket.amount as spend
24875
24876 after spend ok {
24877 record Note { text "ok" }
24878 }
24879
24880 after spend over {
24881 record Note { text "over" }
24882 }"#,
24883 IrEffectKind::CounterConsume,
24884 Some("spend"),
24885 ),
24886 (
24887 "notify",
24888 r#" emit signal deploy.finished to ticket.id { service ticket.title status "ok" } as signal_sent"#,
24889 IrEffectKind::SignalEmit,
24890 Some("signal_sent"),
24891 ),
24892 (
24893 "file_read",
24894 r#" read text from docs at "note.md" as file_read"#,
24895 IrEffectKind::FileRead,
24896 Some("file_read"),
24897 ),
24898 (
24899 "file_write",
24900 r#" write text to docs at "out.md" { body ticket.title mode create } as file_write"#,
24901 IrEffectKind::FileWrite,
24902 Some("file_write"),
24903 ),
24904 (
24905 "file_import",
24906 r#" import json Row from docs at "rows.json" as imported"#,
24907 IrEffectKind::FileImport,
24908 Some("imported"),
24909 ),
24910 (
24911 "file_export",
24912 r#" export json Row to docs at "rows.json" { mode create } as exported"#,
24913 IrEffectKind::FileExport,
24914 Some("exported"),
24915 ),
24916 ];
24917
24918 for (case_name, body, kind, binding) in effect_cases {
24919 let rule = b1g_probe_rule(case_name, body);
24920 let effect = b1g_effect(&rule, kind, binding, case_name);
24921 match case_name {
24922 "send" => {
24923 assert_eq!(effect.resource.as_deref(), Some("ops_room"));
24924 assert_eq!(
24925 effect
24926 .construct_use
24927 .as_ref()
24928 .map(|use_| use_.keyword.as_str()),
24929 Some("send")
24930 );
24931 }
24932 "notify" => {
24933 assert_eq!(effect.resource.as_deref(), Some("signal:deploy.finished"));
24934 }
24935 "file_read" | "file_write" | "file_import" | "file_export" => {
24936 assert_eq!(effect.resource.as_deref(), Some("docs"));
24937 }
24938 _ => {}
24939 }
24940 }
24941
24942 let record = b1g_probe_rule("record", r#" record Note { text ticket.title }"#);
24943 assert!(record
24944 .metadata
24945 .fact_writes
24946 .contains(&"schema:Note".to_owned()));
24947 assert!(record
24948 .metadata
24949 .egress_payload_reads
24950 .get("fact:Note")
24951 .is_some_and(|roots| roots.contains("ticket")));
24952
24953 let done = b1g_probe_rule("done", r#" done ticket"#);
24954 assert!(done
24955 .metadata
24956 .fact_consumes
24957 .contains(&"schema:Ticket".to_owned()));
24958
24959 let done_replacement = b1g_probe_rule(
24960 "done_replacement",
24961 r#" done ticket -> record Note { text ticket.title }"#,
24962 );
24963 assert!(done_replacement
24964 .metadata
24965 .fact_consumes
24966 .contains(&"schema:Ticket".to_owned()));
24967 assert!(done_replacement
24968 .metadata
24969 .fact_writes
24970 .contains(&"schema:Note".to_owned()));
24971
24972 let complete = b1g_probe_rule("complete", r#" complete result { ok true }"#);
24973 assert!(complete
24974 .metadata
24975 .terminal_completes
24976 .contains(&"result".to_owned()));
24977
24978 let fail = b1g_probe_rule("fail", r#" fail error { reason "bad" }"#);
24979 assert_eq!(fail.metadata.effects, Vec::new());
24980
24981 let exec_each = b1g_probe_rule("exec_each", r#" exec "printf '{}'" -> each Row"#);
24982 b1g_effect(&exec_each, IrEffectKind::ExecCommand, None, "exec_each");
24983 assert!(exec_each
24984 .metadata
24985 .fact_writes
24986 .contains(&"schema:Row".to_owned()));
24987
24988 let bounded = b1g_probe_rule(
24989 "bounded_record",
24990 r#" record TicketPublic from ticket {
24991 id
24992 title
24993 }"#,
24994 );
24995 assert!(
24996 bounded
24997 .metadata
24998 .bounded_egresses
24999 .iter()
25000 .any(|egress| egress.sink == "fact:TicketPublic"
25001 && egress.keep == vec!["id".to_owned(), "title".to_owned()]),
25002 "{:?}",
25003 bounded.metadata.bounded_egresses
25004 );
25005
25006 let redaction = b1g_probe_rule(
25007 "redaction",
25008 r#" redact ticket keep [id, title] as safe
25009 record TicketPublic from safe {
25010 id
25011 title
25012 }"#,
25013 );
25014 assert!(redaction
25015 .metadata
25016 .redactions
25017 .iter()
25018 .any(|projection| projection.source == "ticket" && projection.binding == "safe"));
25019 assert!(redaction
25020 .metadata
25021 .fact_writes
25022 .contains(&"schema:TicketPublic".to_owned()));
25023 }
25024
25025 #[test]
25026 fn prompt_lowers_to_coerce_with_string_payload() {
25027 let source = r#"
25028workflow PromptText
25029
25030output result string
25031
25032class Ticket {
25033 title string
25034}
25035
25036rule ask
25037 when Ticket as ticket
25038=> {
25039 prompt "Summarize {{ ticket.title }}" using fixture as answer
25040
25041 after answer succeeds as text {
25042 complete result text
25043 }
25044}
25045"#;
25046
25047 let compiled = compile_program(source);
25048 assert!(
25049 compiled.diagnostics.is_empty(),
25050 "prompt program diagnostics: {:?}",
25051 compiled.diagnostics
25052 );
25053 let ir = compiled.ir.expect("program compiles");
25054 let rule = ir
25055 .rules
25056 .iter()
25057 .find(|rule| rule.name == "ask")
25058 .expect("ask rule");
25059 let effect = rule
25060 .metadata
25061 .effects
25062 .iter()
25063 .find(|effect| effect.binding.as_deref() == Some("answer"))
25064 .expect("prompt effect");
25065 assert_eq!(effect.kind, IrEffectKind::SchemaCoerce);
25066
25067 let coerce = ir
25068 .contract_registry()
25069 .effect_contracts
25070 .into_iter()
25071 .find(|contract| contract.id == "schema.coerce")
25072 .expect("coerce contract");
25073 assert!(coerce.source_forms.contains(&"prompt".to_owned()));
25074 }
25075
25076 #[test]
25077 fn parses_schema_agent_and_rule_slice() {
25078 let source = r#"
25079workflow QueueWorkerSlice
25080
25081use memory
25082
25083tracker backlog {
25084 provider builtin
25085}
25086
25087enum ReviewStatus {
25088 Accept
25089 Revise
25090}
25091
25092class WorkReview {
25093 state "accepted" | "rejected"
25094 status ReviewStatus
25095 followups string[]
25096 maybeReason string?
25097 scores map<int>
25098}
25099
25100coerce reviewWork(issueTitle string, changedFiles string[]) -> WorkReview {
25101 prompt """
25102 Review {{ issueTitle }} with files {{ changedFiles }}
25103 """
25104}
25105
25106agent worker {
25107 provider fixture
25108 profile "repo-writer"
25109 capacity 1
25110 skills ["repo-user"]
25111}
25112
25113rule start_ready_item
25114 when backlog has ready issue as item
25115 when worker is available
25116=> {
25117 claim item as claim
25118
25119 after claim succeeds {
25120 tell worker """
25121 Implement {{ item.title }}
25122 """
25123 }
25124}
25125"#;
25126
25127 let parsed = parse_program(source);
25128 assert_eq!(parsed.diagnostics, Vec::new());
25129 let workflow = parsed
25130 .program
25131 .workflow
25132 .as_ref()
25133 .map(|ident| ident.name.as_str());
25134 assert_eq!(workflow, Some("QueueWorkerSlice"));
25135 assert_eq!(parsed.program.items.len(), 7);
25136
25137 let coerce = parsed.program.items.iter().find_map(|item| match item {
25138 Item::Coerce(coerce) => Some(coerce),
25139 _ => None,
25140 });
25141 let coerce = match coerce {
25142 Some(coerce) => coerce,
25143 None => panic!("expected coerce item"),
25144 };
25145 assert_eq!(coerce.params.len(), 2);
25146
25147 let rule = parsed.program.items.iter().find_map(|item| match item {
25148 Item::Rule(rule) => Some(rule),
25149 _ => None,
25150 });
25151 let rule = match rule {
25152 Some(rule) => rule,
25153 None => panic!("expected rule item"),
25154 };
25155 assert_eq!(rule.whens.len(), 2);
25156 assert_eq!(rule.whens[0].text, "backlog has ready issue as item");
25157 assert!(rule.body.text.contains("after claim succeeds"));
25158 }
25159
25160 #[test]
25161 fn parses_and_lowers_static_table_rows() {
25162 let source = r#"
25163workflow TableSeed
25164
25165agent codex {
25166 provider codex
25167 profile "repo-writer"
25168 capacity 1
25169}
25170
25171class Task {
25172 provider AgentRef<codex>
25173 title string
25174 priority int
25175 status "queued"
25176}
25177
25178table tasks as Task [
25179 {
25180 provider codex
25181 title "Review parser"
25182 priority 1
25183 status "queued"
25184 }
25185
25186 {
25187 provider codex
25188 title "Review runtime"
25189 priority 2
25190 status "queued"
25191 }
25192]
25193"#;
25194
25195 let parsed = parse_program(source);
25196 assert_eq!(parsed.diagnostics, Vec::new());
25197 let table = parsed
25198 .program
25199 .items
25200 .iter()
25201 .find_map(|item| match item {
25202 Item::Table(table) => Some(table),
25203 _ => None,
25204 })
25205 .expect("table item");
25206 assert_eq!(table.rows.len(), 2);
25207 let row_spans = table.rows.iter().map(|row| row.span).collect::<Vec<_>>();
25208
25209 let compiled = compile_program(source);
25210 let ir = compiled
25211 .ir
25212 .unwrap_or_else(|| panic!("source compiles: {:?}", compiled.diagnostics));
25213 let table_rule = ir
25214 .rules
25215 .iter()
25216 .find(|rule| rule.name == "table_tasks")
25217 .expect("table lowers to generated started rule");
25218 assert_eq!(table_rule.whens[0].pattern, "started");
25219 assert!(table_rule.body.contains("record Task"));
25220 assert_eq!(table_rule.metadata.fact_writes, vec!["schema:Task"]);
25221 assert_eq!(table_rule.metadata.record_sources.len(), 2);
25222 assert_eq!(
25223 table_rule
25224 .metadata
25225 .record_sources
25226 .iter()
25227 .map(|source| (
25228 source.schema.as_str(),
25229 source.construct.as_str(),
25230 source.span
25231 ))
25232 .collect::<Vec<_>>(),
25233 row_spans
25234 .iter()
25235 .map(|span| ("Task", "table_row", *span))
25236 .collect::<Vec<_>>()
25237 );
25238 }
25239
25240 #[test]
25241 fn rejects_old_matrix_declarations() {
25242 let source = r#"
25243workflow MatrixSeed
25244
25245class Task {
25246 title string
25247 status "queued"
25248}
25249
25250matrix tasks as Task [
25251 {
25252 title "Review parser"
25253 status "queued"
25254 }
25255]
25256"#;
25257
25258 let compiled = compile_program(source);
25259 assert!(compiled.ir.is_none());
25260 assert!(compiled.diagnostics.iter().any(|diagnostic| {
25261 diagnostic
25262 .message
25263 .contains("expected top-level declaration, found identifier `matrix`")
25264 }));
25265 }
25266
25267 #[test]
25268 fn rejects_table_rows_that_violate_row_schema() {
25269 let source = r#"
25270workflow BadTable
25271
25272agent codex {
25273 provider codex
25274 profile "repo-writer"
25275 capacity 1
25276}
25277
25278class Task {
25279 provider AgentRef<codex>
25280 status "queued"
25281}
25282
25283table tasks as Task [
25284 {
25285 provider "codex"
25286 status "done"
25287 }
25288]
25289"#;
25290
25291 let compiled = compile_program(source);
25292
25293 assert!(compiled.ir.is_none());
25294 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
25295 .message
25296 .contains("expects an AgentRef value, not string `codex`")));
25297 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
25298 .message
25299 .contains("expects literal string `queued`")));
25300 }
25301
25302 #[test]
25303 fn parses_formats_and_lowers_source_tags_as_metadata() {
25304 let source = r#"
25305@fixture
25306@release-gate
25307workflow Tagged
25308
25309class Task {
25310 status "queued"
25311}
25312
25313@seed
25314table tasks as Task [
25315 {
25316 status "queued"
25317 }
25318]
25319
25320@acceptance
25321assert count(Task where status == "queued") == 1
25322
25323@dispatch
25324rule consume_task
25325 when Task as task
25326=> {
25327 done task
25328}
25329"#;
25330
25331 let parsed = parse_program(source);
25332 assert_eq!(parsed.diagnostics, Vec::new());
25333 assert_eq!(
25334 parsed
25335 .program
25336 .workflow_tags
25337 .iter()
25338 .map(|tag| tag.name.as_str())
25339 .collect::<Vec<_>>(),
25340 vec!["fixture", "release-gate"]
25341 );
25342
25343 let formatted = format_program(source).formatted.expect("formats");
25344 assert!(formatted.contains("@fixture\n@release-gate\nworkflow Tagged"));
25345 assert!(formatted.contains("@seed\ntable tasks as Task"));
25346 assert!(formatted.contains("@acceptance\nassert count"));
25347 assert!(formatted.contains("@dispatch\nrule consume_task"));
25348
25349 let compiled = compile_program(source);
25350 let ir = compiled
25351 .ir
25352 .unwrap_or_else(|| panic!("source compiles: {:?}", compiled.diagnostics));
25353 let tags = ir
25354 .source_tags
25355 .iter()
25356 .map(|tag| {
25357 (
25358 tag.name.as_str(),
25359 tag.target_kind.as_str(),
25360 tag.target.as_str(),
25361 )
25362 })
25363 .collect::<Vec<_>>();
25364 assert!(tags.contains(&("fixture", "workflow", "Tagged")));
25365 assert!(tags.contains(&("release-gate", "workflow", "Tagged")));
25366 assert!(tags.contains(&("seed", "table", "tasks")));
25367 assert!(tags.contains(&("dispatch", "rule", "consume_task")));
25368 assert!(ir
25369 .source_tags
25370 .iter()
25371 .any(|tag| tag.name == "acceptance" && tag.target_kind == "assertion"));
25372 }
25373
25374 #[test]
25375 fn parses_formats_and_lowers_source_descriptions_as_metadata() {
25376 let source = r#"
25377@fixture
25378description "Fixture-backed acceptance workflow"
25379workflow Described
25380
25381class Task {
25382 status "queued"
25383}
25384
25385description "Static task seed rows"
25386table tasks as Task [
25387 {
25388 status "queued"
25389 }
25390]
25391
25392description "All seed tasks were consumed"
25393assert count(Task where status == "queued") == 0
25394
25395description "Consume one queued task"
25396rule consume_task
25397 when Task as task
25398=> {
25399 done task
25400}
25401"#;
25402
25403 let parsed = parse_program(source);
25404 assert_eq!(parsed.diagnostics, Vec::new());
25405 assert_eq!(
25406 parsed
25407 .program
25408 .workflow_description
25409 .as_ref()
25410 .map(|description| description.value.as_str()),
25411 Some("Fixture-backed acceptance workflow")
25412 );
25413
25414 let formatted = format_program(source).formatted.expect("formats");
25415 assert!(formatted.contains(
25416 "@fixture\ndescription \"Fixture-backed acceptance workflow\"\nworkflow Described"
25417 ));
25418 assert!(formatted.contains("description \"Static task seed rows\"\ntable tasks as Task"));
25419 assert!(formatted.contains("description \"All seed tasks were consumed\"\nassert count"));
25420 assert!(formatted.contains("description \"Consume one queued task\"\nrule consume_task"));
25421
25422 let compiled = compile_program(source);
25423 let ir = compiled
25424 .ir
25425 .unwrap_or_else(|| panic!("source compiles: {:?}", compiled.diagnostics));
25426 let descriptions = ir
25427 .source_descriptions
25428 .iter()
25429 .map(|description| {
25430 (
25431 description.value.as_str(),
25432 description.target_kind.as_str(),
25433 description.target.as_str(),
25434 )
25435 })
25436 .collect::<Vec<_>>();
25437 assert!(descriptions.contains(&(
25438 "Fixture-backed acceptance workflow",
25439 "workflow",
25440 "Described"
25441 )));
25442 assert!(descriptions.contains(&("Static task seed rows", "table", "tasks")));
25443 assert!(descriptions.contains(&("Consume one queued task", "rule", "consume_task")));
25444 assert!(ir
25445 .source_descriptions
25446 .iter()
25447 .any(
25448 |description| description.value == "All seed tasks were consumed"
25449 && description.target_kind == "assertion"
25450 ));
25451 }
25452
25453 #[test]
25454 fn rejects_descriptions_on_unsupported_declarations_for_now() {
25455 let source = r#"
25456workflow BadDescriptions
25457
25458description "Task schema"
25459class Task {
25460 status "queued"
25461}
25462"#;
25463
25464 let parsed = parse_program(source);
25465
25466 assert_eq!(parsed.diagnostics.len(), 1);
25467 assert_eq!(
25468 parsed.diagnostics[0].message,
25469 "description cannot be attached to class"
25470 );
25471 }
25472
25473 #[test]
25474 fn rejects_tags_on_unsupported_declarations_for_now() {
25475 let source = r#"
25476workflow BadTags
25477
25478@schema
25479class Task {
25480 status "queued"
25481}
25482"#;
25483
25484 let parsed = parse_program(source);
25485
25486 assert_eq!(parsed.diagnostics.len(), 1);
25487 assert_eq!(
25488 parsed.diagnostics[0].message,
25489 "tag `@schema` cannot be attached to class"
25490 );
25491 }
25492
25493 #[test]
25494 fn use_short_form_imports_package_libraries_and_rejects_removed_kinds() {
25495 let parsed = parse_program("workflow Imports\n\nuse memory\n");
25496 assert_eq!(parsed.diagnostics, Vec::new());
25497 let use_decl = parsed.program.items.iter().find_map(|item| match item {
25498 Item::Use(use_decl) => Some(use_decl),
25499 _ => None,
25500 });
25501 assert_eq!(
25502 use_decl.map(|decl| decl.name.value.as_str()),
25503 Some("memory")
25504 );
25505
25506 let removed_plugin = parse_program("workflow Imports\n\nuse plugin \"memory\"\n");
25507 assert_eq!(removed_plugin.diagnostics.len(), 1);
25508 assert_eq!(
25509 removed_plugin.diagnostics[0].message,
25510 "`use plugin` is no longer supported"
25511 );
25512
25513 let removed_skill = parse_program("workflow Imports\n\nuse skill \"repo-user\"\n");
25514 assert_eq!(removed_skill.diagnostics.len(), 1);
25515 assert_eq!(
25516 removed_skill.diagnostics[0].message,
25517 "`use skill` is no longer supported"
25518 );
25519 }
25520
25521 #[test]
25522 fn parses_include_declarations_and_records_ir_metadata() {
25523 let source = r#"include "library.whip"
25524
25525workflow Imports
25526
25527class Task {
25528 id string
25529}
25530"#;
25531 let parsed = parse_program(source);
25532 assert_eq!(parsed.diagnostics, Vec::new());
25533 let include = parsed.program.items.iter().find_map(|item| match item {
25534 Item::Include(include) => Some(include),
25535 _ => None,
25536 });
25537 assert_eq!(
25538 include.map(|decl| decl.path.value.as_str()),
25539 Some("library.whip")
25540 );
25541
25542 let compiled = compile_program(source);
25543 let ir = compiled.ir.expect("source compiles");
25544 assert_eq!(ir.includes[0].path, "library.whip");
25545 assert!(ir.to_snapshot().contains("includes\n library.whip\n"));
25546 }
25547
25548 #[test]
25549 fn parses_explicit_workflow_block_and_contracts() {
25550 let source = r#"
25551workflow ReviewPhase {
25552 input phase PhaseReviewRequest
25553 output result PhaseReviewResult
25554 failure error ReviewFailure
25555
25556 class PhaseReviewRequest {
25557 title string
25558 }
25559
25560 class PhaseReviewResult {
25561 accepted bool
25562 }
25563
25564 class ReviewFailure {
25565 reason string
25566 }
25567
25568 rule noop
25569 when started
25570 => {
25571 }
25572}
25573"#;
25574 let compiled = compile_program(source);
25575 assert_eq!(compiled.diagnostics, Vec::new());
25576 let ir = compiled.ir.expect("source compiles");
25577 assert_eq!(ir.workflow, "ReviewPhase");
25578 assert_eq!(ir.workflow_contracts.len(), 3);
25579 let snapshot = ir.to_snapshot();
25580 assert!(snapshot.contains("workflow_contracts\n input phase ref<PhaseReviewRequest>"));
25581 assert!(snapshot.contains(" output result ref<PhaseReviewResult>"));
25582 assert!(snapshot.contains(" failure error ref<ReviewFailure>"));
25583 }
25584
25585 #[test]
25586 fn revision_fixture_bundles_compile_with_expected_contract_shapes() {
25587 let compatible_v1 =
25588 compile_program(include_str!("../fixtures/revision-compatible-v1.whip"));
25589 let compatible_v2 =
25590 compile_program(include_str!("../fixtures/revision-compatible-v2.whip"));
25591 let incompatible_v2 =
25592 compile_program(include_str!("../fixtures/revision-incompatible-v2.whip"));
25593 for compiled in [&compatible_v1, &compatible_v2, &incompatible_v2] {
25594 assert_eq!(compiled.diagnostics, Vec::new());
25595 }
25596 let compatible_v1 = compatible_v1.ir.expect("compatible v1 compiles");
25597 let compatible_v2 = compatible_v2.ir.expect("compatible v2 compiles");
25598 let incompatible_v2 = incompatible_v2.ir.expect("incompatible v2 compiles");
25599
25600 assert_eq!(compatible_v1.workflow, "RevisionFixture");
25601 assert_eq!(compatible_v2.workflow, "RevisionFixture");
25602 assert_eq!(incompatible_v2.workflow, "RevisionFixture");
25603 assert_eq!(
25604 compatible_v1
25605 .workflow_contracts
25606 .iter()
25607 .map(|contract| (&contract.kind, contract.name.as_str(), &contract.ty))
25608 .collect::<Vec<_>>(),
25609 compatible_v2
25610 .workflow_contracts
25611 .iter()
25612 .map(|contract| (&contract.kind, contract.name.as_str(), &contract.ty))
25613 .collect::<Vec<_>>()
25614 );
25615 assert_ne!(
25616 compatible_v1
25617 .workflow_contracts
25618 .iter()
25619 .map(|contract| (&contract.kind, contract.name.as_str(), &contract.ty))
25620 .collect::<Vec<_>>(),
25621 incompatible_v2
25622 .workflow_contracts
25623 .iter()
25624 .map(|contract| (&contract.kind, contract.name.as_str(), &contract.ty))
25625 .collect::<Vec<_>>()
25626 );
25627 assert!(compatible_v2
25628 .schemas
25629 .iter()
25630 .any(|schema| matches!(schema, IrSchema::Class(class) if class.name == "AuditTrail")));
25631 }
25632
25633 #[test]
25634 fn expands_pattern_applications_with_hygienic_names() {
25635 let source = r#"
25636pattern Review<Input> {
25637 class Result {
25638 item Input
25639 }
25640
25641 rule dispatch
25642 when Input as item
25643 => {
25644 }
25645}
25646
25647workflow Root {
25648 class Task {
25649 title string
25650 }
25651
25652 apply Review<Task> as taskReview {
25653 }
25654}
25655"#;
25656 let compiled = compile_program(source);
25657 assert_eq!(compiled.diagnostics, Vec::new());
25658 let ir = compiled.ir.expect("source compiles");
25659 let snapshot = ir.to_snapshot();
25660 assert!(snapshot.contains("pattern_applications\n Review as taskReview<ref<Task>>"));
25661 assert!(snapshot.contains(" generated class:taskReview_Result"));
25662 assert!(snapshot.contains(" generated rule:taskReview_dispatch"));
25663 assert!(snapshot.contains("class taskReview_Result"));
25664 assert!(snapshot.contains(" item ref<Task>"));
25665 assert!(snapshot.contains("rule taskReview_dispatch"));
25666 assert!(snapshot.contains(" when Task as item"));
25667 }
25668
25669 #[test]
25670 fn pattern_application_records_definition_and_application_spans() {
25671 let source = r#"
25672pattern Review<Input> {
25673 rule dispatch
25674 when Input as item
25675 => {
25676 }
25677}
25678
25679workflow Root {
25680 class Task {
25681 title string
25682 }
25683
25684 apply Review<Task> as taskReview {
25685 }
25686}
25687"#;
25688 let compiled = compile_program(source);
25689 assert_eq!(compiled.diagnostics, Vec::new());
25690 let ir = compiled.ir.expect("source compiles");
25691 let application = ir
25692 .pattern_applications
25693 .first()
25694 .expect("one pattern application");
25695
25696 let definition =
25699 &source[application.definition_span.start..application.definition_span.end];
25700 assert!(definition.starts_with("pattern Review"));
25701 assert!(definition.ends_with('}'));
25702 let application_site =
25703 &source[application.application_span.start..application.application_span.end];
25704 assert!(application_site.starts_with("apply Review<Task> as taskReview"));
25705 assert!(application_site.ends_with('}'));
25706
25707 let snapshot = ir.to_snapshot();
25708 assert!(snapshot.contains(&format!(
25709 " defined-at {}..{}",
25710 application.definition_span.start, application.definition_span.end
25711 )));
25712 assert!(snapshot.contains(&format!(
25713 " applied-at {}..{}",
25714 application.application_span.start, application.application_span.end
25715 )));
25716 }
25717
25718 #[test]
25719 fn rejects_terminal_statement_in_pattern_body() {
25720 let source = r#"
25721pattern Finisher<Input> {
25722 rule wrap_up
25723 when Input as item
25724 => {
25725 complete result {
25726 done 1
25727 }
25728 }
25729}
25730
25731workflow Root {
25732 output result Summary
25733
25734 class Summary {
25735 done int
25736 }
25737
25738 class Task {
25739 title string
25740 }
25741
25742 apply Finisher<Task> as finish {
25743 }
25744}
25745"#;
25746 let compiled = compile_program(source);
25747 assert!(compiled.ir.is_none());
25748 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
25749 .message
25750 .contains("cannot reach a workflow terminal")));
25751 }
25752
25753 #[test]
25754 fn rejects_workflow_contract_in_pattern_body() {
25755 let source = r#"
25756pattern Contracted<Input> {
25757 output result Input
25758
25759 rule dispatch
25760 when Input as item
25761 => {
25762 }
25763}
25764
25765workflow Root {
25766 class Task {
25767 title string
25768 }
25769
25770 apply Contracted<Task> as contracted {
25771 }
25772}
25773"#;
25774 let compiled = compile_program(source);
25775 assert!(compiled.ir.is_none());
25776 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
25777 .message
25778 .contains("workflow contracts are not allowed in pattern bodies")));
25779 }
25780
25781 #[test]
25782 fn parses_workflow_invoke_effect_metadata() {
25783 let source = r#"
25784workflow Parent {
25785 class Task {
25786 title string
25787 }
25788
25789 rule dispatch
25790 when Task as task
25791 => {
25792 invoke Child { task task } as child
25793 }
25794}
25795
25796workflow Child {
25797 input task Task
25798
25799 class Task {
25800 title string
25801 }
25802}
25803"#;
25804 let compiled = compile_program_with_root(source, Some("Parent"));
25805 assert_eq!(compiled.diagnostics, Vec::new());
25806 let ir = compiled.ir.expect("source compiles");
25807 let rule = ir
25808 .rules
25809 .iter()
25810 .find(|rule| rule.name == "dispatch")
25811 .expect("dispatch rule lowers");
25812 assert_eq!(rule.metadata.effects.len(), 1);
25813 assert_eq!(rule.metadata.effects[0].kind, IrEffectKind::WorkflowInvoke);
25814 assert_eq!(rule.metadata.effects[0].binding.as_deref(), Some("child"));
25815 assert_eq!(
25816 rule.metadata.effects[0].workflow_target.as_deref(),
25817 Some("Child")
25818 );
25819 assert!(ir
25820 .to_snapshot()
25821 .contains("child kind=workflow.invoke binding=child"));
25822 }
25823
25824 #[test]
25825 fn rejects_unknown_workflow_invocation_target() {
25826 let source = r#"
25827workflow Parent {
25828 class Task {
25829 title string
25830 }
25831
25832 rule dispatch
25833 when Task as task
25834 => {
25835 invoke Missing { task task } as child
25836 }
25837}
25838"#;
25839 let compiled = compile_program(source);
25840 assert!(compiled.ir.is_none());
25841 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
25842 .message
25843 .contains("invokes unknown workflow `Missing`")));
25844 }
25845
25846 #[test]
25847 fn validates_workflow_invocation_inputs_against_target_contract() {
25848 let source = r#"
25849workflow Parent {
25850 class Task {
25851 title string
25852 }
25853
25854 rule dispatch
25855 when Task as task
25856 => {
25857 invoke Child { wrong task } as child
25858 }
25859}
25860
25861workflow Child {
25862 input task Task
25863
25864 class Task {
25865 title string
25866 }
25867}
25868"#;
25869 let compiled = compile_program_with_root(source, Some("Parent"));
25870 assert!(compiled.ir.is_none());
25871 let messages = compiled
25872 .diagnostics
25873 .iter()
25874 .map(|diagnostic| diagnostic.message.as_str())
25875 .collect::<Vec<_>>();
25876 assert!(
25877 messages
25878 .iter()
25879 .any(|message| message.contains("workflow `Child` has no input `wrong`")),
25880 "{messages:#?}"
25881 );
25882 assert!(
25883 messages
25884 .iter()
25885 .any(|message| message
25886 .contains("workflow invocation `Child` is missing input `task`")),
25887 "{messages:#?}"
25888 );
25889 }
25890
25891 #[test]
25892 fn validates_nested_workflow_invocation_input_payloads() {
25893 let source = r#"
25894workflow Parent {
25895 class Task {
25896 title string
25897 }
25898
25899 rule dispatch
25900 when Task as task
25901 => {
25902 invoke Child { task { count "bad" } } as child
25903 }
25904}
25905
25906workflow Child {
25907 input task ChildTask
25908
25909 class ChildTask {
25910 count int
25911 }
25912}
25913"#;
25914 let compiled = compile_program_with_root(source, Some("Parent"));
25915 assert!(compiled.ir.is_none());
25916 assert!(compiled.diagnostics.iter().any(|diagnostic| {
25917 diagnostic
25918 .message
25919 .contains("field `ChildTask.count` expects `int`")
25920 }));
25921 }
25922
25923 #[test]
25924 fn rejects_direct_recursive_workflow_invocation() {
25925 let source = r#"
25926workflow Parent {
25927 input task Task
25928
25929 class Task {
25930 title string
25931 }
25932
25933 rule dispatch
25934 when Task as task
25935 => {
25936 invoke Parent { task task } as next
25937 }
25938}
25939"#;
25940 let compiled = compile_program(source);
25941 assert!(compiled.ir.is_none());
25942 assert!(compiled.diagnostics.iter().any(|diagnostic| {
25943 diagnostic
25944 .message
25945 .contains("recursively invokes workflow `Parent`")
25946 }));
25947 }
25948
25949 #[test]
25950 fn expands_pattern_application_value_arguments() {
25951 let source = r#"
25952pattern Review<Input> {
25953 rule dispatch
25954 when Input as item
25955 => {
25956 }
25957}
25958
25959workflow Root {
25960 class Task {
25961 title string
25962 }
25963
25964 apply Review<Task> as taskReview {
25965 item task
25966 }
25967}
25968"#;
25969 let compiled = compile_program(source);
25970 assert_eq!(compiled.diagnostics, Vec::new());
25971 let snapshot = compiled.ir.expect("source compiles").to_snapshot();
25972 assert!(snapshot.contains(" arg item task"));
25973 }
25974
25975 #[test]
25976 fn rejects_malformed_pattern_application_arguments() {
25977 let source = r#"
25978pattern Review<Input> {
25979 rule dispatch
25980 when Input as item
25981 => {
25982 }
25983}
25984
25985workflow Root {
25986 class Task {
25987 title string
25988 }
25989
25990 apply Review<Task> as taskReview {
25991 item
25992 item task
25993 }
25994}
25995"#;
25996 let compiled = compile_program(source);
25997 assert!(compiled.ir.is_none());
25998 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
25999 .message
26000 .contains("argument `item` is missing a value")));
26001 }
26002
26003 #[test]
26004 fn rejects_unknown_workflow_terminal_actions() {
26005 let source = r#"
26006workflow BadTerminal {
26007 output result Result
26008
26009 class Result {
26010 status "ok"
26011 }
26012
26013 rule bad
26014 when started
26015 => {
26016 complete missing {
26017 status "ok"
26018 }
26019 }
26020}
26021"#;
26022 let compiled = compile_program(source);
26023 assert!(compiled.ir.is_none());
26024 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
26025 .message
26026 .contains("completes unknown workflow terminal `missing`")));
26027 }
26028
26029 #[test]
26030 fn rejects_duplicate_workflow_inputs() {
26031 let source = r#"
26032workflow DuplicateInput {
26033 input phase PhaseRequest
26034 input phase PhaseRequest
26035
26036 class PhaseRequest {
26037 title string
26038 }
26039
26040 rule noop
26041 when started
26042 => {
26043 }
26044}
26045"#;
26046 let compiled = compile_program(source);
26047 assert!(compiled.ir.is_none());
26048 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
26049 .message
26050 .contains("workflow declares input `phase` more than once")));
26051 }
26052
26053 #[test]
26054 fn rejects_with_as_rule_readiness_alias() {
26055 let source = r#"
26056workflow WithIsNotWhen
26057
26058rule bad
26059 with started
26060=> {
26061}
26062"#;
26063 let compiled = compile_program(source);
26064
26065 assert!(compiled.ir.is_none());
26066 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
26067 .message
26068 .contains("`with` is not a rule readiness clause")));
26069 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
26070 .suggestion
26071 .as_deref()
26072 .is_some_and(|suggestion| suggestion.contains("use `when` for rule conditions"))));
26073 }
26074
26075 #[test]
26076 fn parses_grouped_when_clauses_as_ordinary_readiness_clauses() {
26077 let source = r#"
26078workflow GroupedWhen
26079
26080class Task {
26081 status "queued"
26082}
26083
26084agent worker {
26085 provider fixture
26086 profile "repo-writer"
26087 capacity 1
26088}
26089
26090rule start
26091 when {
26092 Task as task where task.status == "queued"
26093 worker is available
26094 }
26095=> {
26096 tell worker "do it"
26097}
26098"#;
26099 let compiled = compile_program(source);
26100 let ir = compiled.ir.expect("program compiles");
26101 let rule = &ir.rules[0];
26102
26103 assert_eq!(rule.whens.len(), 2);
26104 assert_eq!(rule.whens[0].pattern, "Task as task");
26105 assert_eq!(
26106 rule.whens[0]
26107 .guard
26108 .as_ref()
26109 .map(|guard| guard.expr.to_snapshot()),
26110 Some("task.status == \"queued\"".to_owned())
26111 );
26112 assert_eq!(rule.whens[1].pattern, "worker is available");
26113 assert!(ir
26114 .to_snapshot()
26115 .contains(" when Task as task where task.status == \"queued\""));
26116 assert!(ir.to_snapshot().contains(" when worker is available"));
26117 }
26118
26119 #[test]
26120 fn accepts_harness_declarations_and_agent_bindings() {
26121 let source = r#"
26122workflow HarnessTopology
26123
26124harness coder: codex
26125harness reviewer: claude
26126
26127agent implementer using coder {
26128 profile "repo-writer"
26129 capacity 1
26130}
26131
26132agent critic using reviewer {
26133 profile "repo-reader"
26134 capacity 1
26135}
26136
26137rule start
26138 when started
26139=> {
26140 tell implementer as turn "implement"
26141}
26142"#;
26143
26144 let compiled = compile_program(source);
26145 assert_eq!(compiled.diagnostics, Vec::new());
26146 let ir = compiled.ir.expect("program compiles");
26147 assert_eq!(ir.harnesses.len(), 2);
26148 assert_eq!(ir.harnesses[0].name, "coder");
26149 assert_eq!(ir.harnesses[0].kind, "codex");
26150 assert_eq!(
26151 ir.agents
26152 .iter()
26153 .find(|agent| agent.name == "implementer")
26154 .and_then(|agent| agent.harness.as_deref()),
26155 Some("coder")
26156 );
26157 let snapshot = ir.to_snapshot();
26158 assert!(snapshot.contains("harness coder kind=codex"));
26159 assert!(snapshot.contains("agent implementer harness=coder"));
26160 }
26161
26162 #[test]
26163 fn rejects_agent_binding_to_unknown_harness() {
26164 let source = r#"
26165workflow UnknownHarness
26166
26167agent worker using missing {
26168 profile "repo-writer"
26169 capacity 1
26170}
26171"#;
26172
26173 let compiled = compile_program(source);
26174 assert!(compiled.ir.is_none());
26175 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
26176 .message
26177 .contains("agent `worker` uses unknown harness `missing`")));
26178 }
26179
26180 #[test]
26181 fn rejects_duplicate_harness_declarations_and_accepts_kinds_structurally() {
26182 let source = r#"
26183workflow BadHarnesses
26184
26185harness coder: spaceship
26186harness coder: codex
26187
26188agent worker using coder {
26189 profile "repo-writer"
26190 capacity 1
26191}
26192"#;
26193
26194 let compiled = compile_program(source);
26195 assert!(compiled.ir.is_none());
26196 let messages = compiled
26197 .diagnostics
26198 .iter()
26199 .map(|diagnostic| diagnostic.message.as_str())
26200 .collect::<Vec<_>>();
26201 assert!(
26202 messages
26203 .iter()
26204 .any(|message| message.contains("harness `coder` is declared more than once")),
26205 "{messages:#?}"
26206 );
26207 assert!(
26212 !messages
26213 .iter()
26214 .any(|message| message.contains("unsupported kind")),
26215 "{messages:#?}"
26216 );
26217 }
26218
26219 #[test]
26220 fn validates_workflow_terminal_payload_fields() {
26221 let source = r#"
26222workflow BadTerminalPayload {
26223 output result Result
26224
26225 class Result {
26226 status "ok"
26227 summary string
26228 }
26229
26230 rule bad
26231 when started
26232 => {
26233 complete result {
26234 status "bad"
26235 extra "ignored"
26236 }
26237 }
26238}
26239"#;
26240 let compiled = compile_program(source);
26241 assert!(compiled.ir.is_none());
26242 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
26243 .message
26244 .contains("field `Result.status` expects literal string `ok`")));
26245 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
26246 .message
26247 .contains("class `Result` has no field `extra`")));
26248 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
26249 .message
26250 .contains("workflow terminal `result` is missing required field `Result.summary`")));
26251 }
26252
26253 #[test]
26254 fn accepts_workflow_terminal_actions_in_header_style_workflows() {
26255 let source = r#"
26256workflow ImplicitTerminal
26257
26258output result Result
26259
26260class Result {
26261 status "ok"
26262}
26263
26264rule finish
26265 when started
26266=> {
26267 complete result {
26268 status "ok"
26269 }
26270}
26271"#;
26272 let compiled = compile_program(source);
26273 assert_eq!(compiled.diagnostics, Vec::new());
26274 let ir = compiled.ir.expect("header-style terminals compile");
26275 assert_eq!(ir.workflow_contracts.len(), 1);
26276 }
26277
26278 #[test]
26279 fn rejects_header_style_terminal_for_undeclared_contract() {
26280 let source = r#"
26281workflow ImplicitTerminal
26282
26283class Result {
26284 status "ok"
26285}
26286
26287rule bad
26288 when started
26289=> {
26290 complete result {
26291 status "ok"
26292 }
26293}
26294"#;
26295 let compiled = compile_program(source);
26296 assert!(compiled.ir.is_none());
26297 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
26298 .message
26299 .contains("completes unknown workflow terminal `result`")));
26300 }
26301
26302 #[test]
26303 fn scalar_terminal_contract_accepts_bare_value() {
26304 let source = r#"
26306workflow ScalarTerminal {
26307 output result float
26308 failure error string
26309
26310 rule good
26311 when started
26312 => {
26313 complete result 0.9
26314 }
26315}
26316"#;
26317 let compiled = compile_program(source);
26318 assert!(
26319 compiled.diagnostics.is_empty(),
26320 "scalar terminal payload rejected: {:?}",
26321 compiled.diagnostics
26322 );
26323 assert!(compiled.ir.is_some());
26324 }
26325
26326 #[test]
26327 fn scalar_terminal_contract_rejects_a_field_block() {
26328 let source = r#"
26330workflow ScalarTerminal {
26331 output result float
26332
26333 rule bad
26334 when started
26335 => {
26336 complete result {
26337 value 0.9
26338 }
26339 }
26340}
26341"#;
26342 let compiled = compile_program(source);
26343 assert!(compiled.ir.is_none());
26344 assert!(
26345 compiled.diagnostics.iter().any(|d| d
26346 .message
26347 .contains("has a scalar payload contract but is given a field block")),
26348 "{:?}",
26349 compiled.diagnostics
26350 );
26351 }
26352
26353 #[test]
26354 fn class_terminal_contract_rejects_a_bare_scalar() {
26355 let source = r#"
26357workflow ClassTerminal {
26358 output result Score
26359 class Score { value number }
26360
26361 rule bad
26362 when started
26363 => {
26364 complete result 0.9
26365 }
26366}
26367"#;
26368 let compiled = compile_program(source);
26369 assert!(compiled.ir.is_none());
26370 assert!(
26371 compiled.diagnostics.iter().any(|d| d
26372 .message
26373 .contains("has a class payload contract `Score` but is given a bare scalar value")),
26374 "{:?}",
26375 compiled.diagnostics
26376 );
26377 }
26378
26379 #[test]
26380 fn scalar_terminal_value_is_typechecked_against_the_contract() {
26381 let source = r#"
26383workflow ScalarTerminal {
26384 output result float
26385
26386 rule bad
26387 when started
26388 => {
26389 complete result "not a number"
26390 }
26391}
26392"#;
26393 let compiled = compile_program(source);
26394 assert!(compiled.ir.is_none());
26395 assert!(
26396 compiled
26397 .diagnostics
26398 .iter()
26399 .any(|d| d.message.contains("result.value") || d.message.contains("number")),
26400 "{:?}",
26401 compiled.diagnostics
26402 );
26403 }
26404
26405 #[test]
26406 fn selects_root_from_multiple_explicit_workflows() {
26407 let source = r#"
26408class Shared {
26409 id string
26410}
26411
26412workflow First {
26413 rule one
26414 when started
26415 => {
26416 record Shared {
26417 id "first"
26418 }
26419 }
26420}
26421
26422workflow Second {
26423 rule two
26424 when started
26425 => {
26426 record Shared {
26427 id "second"
26428 }
26429 }
26430}
26431"#;
26432 let ambiguous = compile_program(source);
26433 assert!(ambiguous.ir.is_none());
26434 assert!(ambiguous.diagnostics.iter().any(|diagnostic| diagnostic
26435 .message
26436 .contains("multiple workflow declarations require an explicit root")));
26437
26438 let compiled = compile_program_with_root(source, Some("Second"));
26439 assert_eq!(compiled.diagnostics, Vec::new());
26440 let ir = compiled.ir.expect("selected root compiles");
26441 assert_eq!(ir.workflow, "Second");
26442 assert_eq!(ir.rules.len(), 1);
26443 assert_eq!(ir.rules[0].name, "two");
26444 assert!(ir.to_snapshot().contains("class Shared"));
26445 }
26446
26447 #[test]
26448 fn reports_recoverable_diagnostics() {
26449 let source = r#"
26450workflow Broken
26451
26452agent worker {
26453 provider fixture
26454 profile 42
26455 capacity nope
26456}
26457
26458rule missing_body
26459 when started
26460=>
26461"#;
26462
26463 let parsed = parse_program(source);
26464 assert!(parsed.diagnostics.len() >= 3);
26465 assert!(parsed
26466 .diagnostics
26467 .iter()
26468 .any(|diagnostic| diagnostic.message.contains("profile string")));
26469 assert!(parsed
26470 .diagnostics
26471 .iter()
26472 .any(|diagnostic| diagnostic.suggestion.as_deref()
26473 == Some("write `profile \"profile-name\"`")));
26474 assert!(parsed
26475 .diagnostics
26476 .iter()
26477 .any(|diagnostic| diagnostic.message.contains("capacity value")));
26478 assert!(parsed
26479 .diagnostics
26480 .iter()
26481 .any(|diagnostic| diagnostic.message.contains("`{`")));
26482 }
26483
26484 #[test]
26485 fn lowers_and_formats_agent_tools_grant() {
26486 let source = r#"
26490workflow GrantHost
26491
26492agent worker {
26493 provider owned
26494 profile "repo-writer"
26495 capacity 1
26496 tools [WordCount, OpenPr]
26497}
26498"#;
26499 let compiled = compile_program(source);
26500 assert_eq!(compiled.diagnostics, Vec::new());
26501 let ir = compiled.ir.expect("valid ir");
26502 let agent = ir
26503 .agents
26504 .iter()
26505 .find(|agent| agent.name == "worker")
26506 .expect("worker agent");
26507 assert_eq!(
26508 agent.tools,
26509 vec!["WordCount".to_owned(), "OpenPr".to_owned()]
26510 );
26511
26512 let formatted = format_program(source).formatted.expect("formats");
26513 assert!(
26514 formatted.contains("tools [WordCount, OpenPr]"),
26515 "formatted: {formatted}"
26516 );
26517
26518 let dup = compile_program(
26520 "workflow Dup\nagent a {\n provider owned\n profile \"p\"\n tools [X, X]\n}\n",
26521 );
26522 assert!(
26523 dup.diagnostics
26524 .iter()
26525 .any(|d| d.message.contains("grants tool `X` more than once")),
26526 "diagnostics: {:?}",
26527 dup.diagnostics
26528 );
26529 }
26530
26531 #[test]
26532 fn harness_class_classifies_managed_vs_delegated_and_emits_only_delegated() {
26533 assert_eq!(harness_class("owned"), HarnessClass::Managed);
26535 assert_eq!(harness_class("fixture"), HarnessClass::Managed);
26536 assert_eq!(harness_class("claude"), HarnessClass::Delegated);
26537 assert_eq!(harness_class("codex"), HarnessClass::Delegated);
26538 assert_eq!(harness_class("native-fixture"), HarnessClass::Delegated);
26539 assert_eq!(harness_class("command"), HarnessClass::Delegated);
26540
26541 let managed = compile_program(
26543 "workflow W\nagent m {\n provider owned\n profile \"p\"\n capacity 1\n}\n",
26544 );
26545 let managed_ir = managed.ir.expect("ir");
26546 assert_eq!(managed_ir.agents[0].harness_class, HarnessClass::Managed);
26547 assert!(!managed_ir.to_snapshot().contains("class="));
26548
26549 let delegated = compile_program(
26551 "workflow W\nagent d {\n provider claude\n profile \"repo-writer\"\n capacity 1\n}\n",
26552 );
26553 let delegated_ir = delegated.ir.expect("ir");
26554 assert_eq!(
26555 delegated_ir.agents[0].harness_class,
26556 HarnessClass::Delegated
26557 );
26558 assert!(delegated_ir.to_snapshot().contains("class=delegated"));
26559
26560 let via_harness = compile_program(
26562 "workflow W\nharness box: claude\nagent d using box {\n profile \"repo-writer\"\n capacity 1\n}\n",
26563 );
26564 let via_ir = via_harness.ir.expect("ir");
26565 assert_eq!(via_ir.agents[0].harness_class, HarnessClass::Delegated);
26566 }
26567
26568 #[test]
26569 fn tell_with_skills_lowers_to_effect_turn_skills_and_ir_snapshot() {
26570 let source = concat!(
26571 "workflow W\n",
26572 "agent coder {\n provider owned\n profile \"p\"\n capacity 1\n}\n",
26573 "class Task {\n note string\n}\n",
26574 "rule go\n when Task as t\n=> {\n tell coder with skills [\"review\", \"lint\"] \"do it\" as turn\n}\n",
26575 );
26576 let compiled = compile_program(source);
26577 assert!(
26578 compiled.diagnostics.is_empty(),
26579 "{:?}",
26580 compiled.diagnostics
26581 );
26582 let ir = compiled.ir.expect("ir");
26583 let effect = ir
26584 .rules
26585 .iter()
26586 .flat_map(|rule| &rule.metadata.effects)
26587 .find(|effect| effect.kind == IrEffectKind::AgentTell)
26588 .expect("tell effect");
26589 assert_eq!(
26590 effect.turn_skills,
26591 vec!["review".to_owned(), "lint".to_owned()]
26592 );
26593 assert!(
26595 ir.to_snapshot().contains("skills=review,lint"),
26596 "{}",
26597 ir.to_snapshot()
26598 );
26599 }
26600
26601 #[test]
26602 fn agent_compaction_strategy_parses_lowers_formats_and_validates() {
26603 let source = compile_program(
26604 "workflow C\nagent w {\n provider owned\n profile \"p\"\n capacity 1\n compaction hard_reset\n}\n",
26605 );
26606 assert!(source.diagnostics.is_empty(), "{:?}", source.diagnostics);
26607 let ir = source.ir.expect("ir");
26608 let agent = ir.agents.iter().find(|a| a.name == "w").expect("agent");
26609 assert_eq!(agent.compaction.as_deref(), Some("hard_reset"));
26610
26611 let formatted = format_program(
26613 "workflow C\nagent w {\n provider owned\n profile \"p\"\n capacity 1\n compaction hard_reset\n}\n",
26614 )
26615 .formatted
26616 .expect("formats");
26617 assert!(formatted.contains("compaction hard_reset"), "{formatted}");
26618 assert!(ir.to_snapshot().contains("compaction=hard_reset"));
26619
26620 let bad = compile_program(
26622 "workflow C\nagent w {\n provider owned\n profile \"p\"\n capacity 1\n compaction squish\n}\n",
26623 );
26624 assert!(
26625 bad.diagnostics
26626 .iter()
26627 .any(|d| d.message.contains("unknown compaction strategy `squish`")),
26628 "diagnostics: {:?}",
26629 bad.diagnostics
26630 );
26631
26632 let plain = compile_program(
26634 "workflow C\nagent w {\n provider owned\n profile \"p\"\n capacity 1\n}\n",
26635 );
26636 let plain_ir = plain.ir.expect("ir");
26637 assert_eq!(plain_ir.agents[0].compaction, None);
26638 assert!(!plain_ir.to_snapshot().contains("compaction="));
26639 }
26640
26641 #[test]
26642 fn agent_settings_source_parses_lowers_formats_and_validates() {
26643 let source = compile_program(
26645 "workflow C\nagent w {\n provider claude\n profile \"p\"\n capacity 1\n settings project\n}\n",
26646 );
26647 assert!(source.diagnostics.is_empty(), "{:?}", source.diagnostics);
26648 let ir = source.ir.expect("ir");
26649 let agent = ir.agents.iter().find(|a| a.name == "w").expect("agent");
26650 assert_eq!(agent.settings.as_deref(), Some("project"));
26651
26652 let formatted = format_program(
26654 "workflow C\nagent w {\n provider claude\n profile \"p\"\n capacity 1\n settings project\n}\n",
26655 )
26656 .formatted
26657 .expect("formats");
26658 assert!(formatted.contains("settings project"), "{formatted}");
26659 assert!(ir.to_snapshot().contains("settings=project"));
26660
26661 let bad = compile_program(
26663 "workflow C\nagent w {\n provider claude\n profile \"p\"\n capacity 1\n settings everything\n}\n",
26664 );
26665 assert!(
26666 bad.diagnostics
26667 .iter()
26668 .any(|d| d.message.contains("unknown settings source `everything`")),
26669 "diagnostics: {:?}",
26670 bad.diagnostics
26671 );
26672
26673 let dup = compile_program(
26675 "workflow C\nagent w {\n provider claude\n profile \"p\"\n capacity 1\n settings project\n settings user\n}\n",
26676 );
26677 assert!(
26678 dup.diagnostics
26679 .iter()
26680 .any(|d| d.message.contains("declares settings more than once")),
26681 "diagnostics: {:?}",
26682 dup.diagnostics
26683 );
26684
26685 let plain = compile_program(
26688 "workflow C\nagent w {\n provider claude\n profile \"p\"\n capacity 1\n}\n",
26689 );
26690 let plain_ir = plain.ir.expect("ir");
26691 assert_eq!(plain_ir.agents[0].settings, None);
26692 assert!(!plain_ir.to_snapshot().contains("settings="));
26693 }
26694
26695 #[test]
26696 fn agent_thread_mode_parses_lowers_and_partitions() {
26697 let source = "workflow ChatDemo\n\noutput result Done\n\nclass Done {\n ok int\n}\n\n\
26699 agent helper {\n provider owned\n profile \"repo-reader\"\n capacity 1\n thread continue\n}\n\n\
26700 rule go\n when started\n=> {\n tell helper as reply \"\"\"\n Hi.\n \"\"\"\n\n\
26701 \x20 after reply succeeds {\n complete result { ok 1 }\n }\n}\n";
26702 let compiled = compile_program(source);
26703 let ir = compiled.ir.expect("thread continue compiles");
26704 let agent = ir
26705 .agents
26706 .iter()
26707 .find(|agent| agent.name == "helper")
26708 .expect("agent lowered");
26709 assert_eq!(agent.thread.as_deref(), Some("continue"));
26710
26711 let bad = source.replace("thread continue", "thread sometimes");
26713 let compiled = compile_program(&bad);
26714 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
26715 .message
26716 .contains("unknown thread mode `sometimes`")));
26717
26718 let delegated = source.replace("provider owned", "provider codex");
26720 let compiled = compile_program(&delegated);
26721 assert!(
26722 compiled.diagnostics.iter().any(|diagnostic| diagnostic
26723 .message
26724 .contains("is delegated; `thread` is a managed-harness knob")),
26725 "{:?}",
26726 compiled
26727 .diagnostics
26728 .iter()
26729 .map(|d| &d.message)
26730 .collect::<Vec<_>>()
26731 );
26732 }
26733
26734 #[test]
26735 fn agent_knobs_partition_by_harness_class() {
26736 let bad = compile_program(
26742 "workflow C\nagent w {\n provider claude\n profile \"p\"\n capacity 1\n compaction summarize\n}\n",
26743 );
26744 assert!(
26745 bad.diagnostics.iter().any(|d| d
26746 .message
26747 .contains("is delegated; `compaction` is a managed-harness knob")),
26748 "diagnostics: {:?}",
26749 bad.diagnostics
26750 );
26751
26752 let bad = compile_program(
26755 "workflow C\nagent w {\n provider owned\n profile \"p\"\n capacity 1\n settings project\n}\n",
26756 );
26757 assert!(
26758 bad.diagnostics.iter().any(|d| d
26759 .message
26760 .contains("is managed; `settings` is a delegated-harness knob")),
26761 "diagnostics: {:?}",
26762 bad.diagnostics
26763 );
26764
26765 let bad = compile_program(
26767 "workflow C\nharness box: claude\nagent w using box {\n profile \"p\"\n capacity 1\n compaction summarize\n}\n",
26768 );
26769 assert!(
26770 bad.diagnostics.iter().any(|d| d
26771 .message
26772 .contains("is delegated; `compaction` is a managed-harness knob")),
26773 "diagnostics: {:?}",
26774 bad.diagnostics
26775 );
26776
26777 let good = compile_program(
26779 "workflow C\nagent m {\n provider owned\n profile \"p\"\n capacity 1\n compaction summarize\n}\nagent d {\n provider claude\n profile \"p\"\n capacity 1\n settings project\n}\n",
26780 );
26781 assert!(good.diagnostics.is_empty(), "{:?}", good.diagnostics);
26782
26783 let unbound = compile_program(
26786 "workflow C\nagent w {\n profile \"p\"\n capacity 1\n compaction summarize\n}\n",
26787 );
26788 assert!(
26789 !unbound
26790 .diagnostics
26791 .iter()
26792 .any(|d| d.message.contains("managed-harness knob")),
26793 "diagnostics: {:?}",
26794 unbound.diagnostics
26795 );
26796 }
26797
26798 #[test]
26803 fn agent_requires_parses_taxonomy_classes() {
26804 let program = "workflow R\n\nagent a {\n provider owned\n profile \"repo-reader\"\n capacity 1\n requires [session.resume, turn.cancel]\n}\n";
26805 let compiled = compile_program(program);
26806 assert!(
26807 compiled.diagnostics.is_empty(),
26808 "{:?}",
26809 compiled.diagnostics
26810 );
26811 let ir = compiled.ir.expect("ir");
26812 let agent = ir.agents.first().expect("agent");
26813 assert_eq!(
26814 agent.requires,
26815 vec!["session.resume".to_owned(), "turn.cancel".to_owned()]
26816 );
26817 assert!(ir
26818 .to_snapshot()
26819 .contains("requires=[session.resume, turn.cancel]"));
26820
26821 let formatted = format_program(program).formatted.expect("formats");
26823 assert!(
26824 formatted.contains(" requires [session.resume, turn.cancel]"),
26825 "{formatted}"
26826 );
26827
26828 let plain = compile_program(
26830 "workflow R\n\nagent a {\n provider owned\n profile \"p\"\n capacity 1\n}\n",
26831 );
26832 assert!(!plain.ir.expect("ir").to_snapshot().contains("requires="));
26833
26834 let unknown = compile_program(
26836 "workflow R\n\nagent a {\n provider owned\n profile \"p\"\n capacity 1\n requires [warp.drive]\n}\n",
26837 );
26838 assert!(
26839 unknown.diagnostics.iter().any(|d| d
26840 .message
26841 .contains("requires unknown feature class `warp.drive`")),
26842 "{:?}",
26843 unknown.diagnostics
26844 );
26845
26846 let duplicate = compile_program(
26848 "workflow R\n\nagent a {\n provider owned\n profile \"p\"\n capacity 1\n requires [turn.cancel, turn.cancel]\n}\n",
26849 );
26850 assert!(
26851 duplicate.diagnostics.iter().any(|d| d
26852 .message
26853 .contains("requires feature class `turn.cancel` more than once")),
26854 "{:?}",
26855 duplicate.diagnostics
26856 );
26857 }
26858
26859 #[test]
26860 fn agent_delegated_to_sugar_and_managed_default() {
26861 let program = "workflow C\nagent d delegated to claude {\n profile \"p\"\n capacity 1\n settings project\n}\n";
26864 let source = compile_program(program);
26865 assert!(source.diagnostics.is_empty(), "{:?}", source.diagnostics);
26866 let ir = source.ir.expect("ir");
26867 let agent = ir.agents.iter().find(|a| a.name == "d").expect("agent");
26868 assert_eq!(agent.provider.as_deref(), Some("claude"));
26869 assert_eq!(agent.harness_class, HarnessClass::Delegated);
26870 assert!(ir.to_snapshot().contains("class=delegated"));
26871
26872 let formatted = format_program(program).formatted.expect("formats");
26874 assert!(
26875 formatted.contains("agent d delegated to claude {"),
26876 "{formatted}"
26877 );
26878
26879 let bad = compile_program(
26881 "workflow C\nagent d delegated to owned {\n profile \"p\"\n capacity 1\n}\n",
26882 );
26883 assert!(
26884 bad.diagnostics.iter().any(|d| d
26885 .message
26886 .contains("delegates to `owned`, which is a managed kind")),
26887 "diagnostics: {:?}",
26888 bad.diagnostics
26889 );
26890
26891 let unknown = compile_program(
26896 "workflow C\nagent d delegated to mystery {\n profile \"p\"\n capacity 1\n}\n",
26897 );
26898 assert!(
26899 !unknown
26900 .diagnostics
26901 .iter()
26902 .any(|d| d.message.contains("unsupported provider")),
26903 "diagnostics: {:?}",
26904 unknown.diagnostics
26905 );
26906
26907 let both = compile_program(
26909 "workflow C\nagent d delegated to claude {\n provider codex\n profile \"p\"\n capacity 1\n}\n",
26910 );
26911 assert!(
26912 both.diagnostics.iter().any(|d| d
26913 .message
26914 .contains("declares both `delegated to` and direct provider")),
26915 "diagnostics: {:?}",
26916 both.diagnostics
26917 );
26918
26919 let plain = compile_program("workflow C\nagent m {\n profile \"p\"\n capacity 1\n}\n");
26922 assert!(plain.diagnostics.is_empty(), "{:?}", plain.diagnostics);
26923 let plain_ir = plain.ir.expect("ir");
26924 assert_eq!(plain_ir.agents[0].provider.as_deref(), Some("owned"));
26925 assert_eq!(plain_ir.agents[0].harness_class, HarnessClass::Managed);
26926 }
26927
26928 #[test]
26929 fn accepts_agent_ref_dynamic_tell_targets() {
26930 let source = r#"
26931workflow AgentRefRouting
26932
26933agent codex {
26934 provider codex
26935 profile "repo-writer"
26936 capacity 1
26937 capabilities ["agent.tell"]
26938}
26939
26940agent claude {
26941 provider claude
26942 profile "repo-writer"
26943 capacity 1
26944 capabilities ["agent.tell"]
26945}
26946
26947class LanguageTask {
26948 provider AgentRef<codex | claude>
26949 prompt string
26950}
26951
26952rule run_task
26953 when LanguageTask as task
26954 when task.provider is available
26955=> {
26956 tell task.provider requires ["agent.tell"] as turn "{{ task.prompt }}"
26957}
26958"#;
26959
26960 let compiled = compile_program(source);
26961 assert_eq!(compiled.diagnostics, Vec::new());
26962 let ir = compiled.ir.expect("valid ir");
26963 let rule = ir
26964 .rules
26965 .iter()
26966 .find(|rule| rule.name == "run_task")
26967 .expect("run_task");
26968 assert_eq!(rule.metadata.effects.len(), 1);
26969 assert_eq!(rule.metadata.effects[0].kind, IrEffectKind::AgentTell);
26970 }
26971
26972 #[test]
26973 fn rejects_agent_ref_targets_missing_required_capabilities() {
26974 let source = r#"
26975workflow BadAgentRefCapabilities
26976
26977agent codex {
26978 provider codex
26979 profile "repo-writer"
26980 capacity 1
26981 capabilities ["agent.tell", "repo.write"]
26982}
26983
26984agent claude {
26985 provider claude
26986 profile "repo-reader"
26987 capacity 1
26988 capabilities ["agent.tell"]
26989}
26990
26991class LanguageTask {
26992 provider AgentRef<codex | claude>
26993 prompt string
26994}
26995
26996rule run_task
26997 when LanguageTask as task
26998=> {
26999 tell task.provider requires ["repo.write"] as turn """
27000 {{ task.prompt }}
27001 """
27002}
27003"#;
27004
27005 let compiled = compile_program(source);
27006 assert!(compiled.ir.is_none());
27007 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27008 .message
27009 .contains("agent `claude` requiring undeclared capability `repo.write`")));
27010 }
27011
27012 #[test]
27013 fn rejects_plain_string_dynamic_tell_targets() {
27014 let source = r#"
27015workflow BadAgentRefRouting
27016
27017agent codex {
27018 provider codex
27019 profile "repo-writer"
27020 capacity 1
27021}
27022
27023class LanguageTask {
27024 provider string
27025}
27026
27027rule run_task
27028 when LanguageTask as task
27029=> {
27030 tell task.provider "bad"
27031}
27032"#;
27033
27034 let compiled = compile_program(source);
27035 assert!(compiled.ir.is_none());
27036 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27037 .message
27038 .contains("non-AgentRef dynamic tell target `task.provider`")));
27039 }
27040
27041 #[test]
27042 fn rejects_unknown_agent_ref_domain_values() {
27043 let source = r#"
27044workflow BadAgentRefDomain
27045
27046agent codex {
27047 provider codex
27048 profile "repo-writer"
27049 capacity 1
27050}
27051
27052class LanguageTask {
27053 provider AgentRef<codex | ghost>
27054}
27055
27056rule seed
27057 when started
27058=> {
27059 record LanguageTask {
27060 provider claude
27061 }
27062}
27063"#;
27064
27065 let compiled = compile_program(source);
27066 assert!(compiled.ir.is_none());
27067 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27068 .message
27069 .contains("AgentRef references unknown agent `ghost`")));
27070 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27071 .message
27072 .contains("field `LanguageTask.provider` cannot reference agent `claude`")));
27073 }
27074
27075 #[test]
27076 fn rejects_quoted_agent_ref_record_values() {
27077 let source = r#"
27078workflow BadQuotedAgentRef
27079
27080agent codex {
27081 provider codex
27082 profile "repo-writer"
27083 capacity 1
27084}
27085
27086class LanguageTask {
27087 provider AgentRef<codex>
27088}
27089
27090rule seed
27091 when started
27092=> {
27093 record LanguageTask {
27094 provider "codex"
27095 }
27096}
27097"#;
27098
27099 let compiled = compile_program(source);
27100 assert!(compiled.ir.is_none());
27101 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27102 .message
27103 .contains("expects an AgentRef value, not string `codex`")));
27104 }
27105
27106 #[test]
27107 fn requires_presence_proof_for_optional_field_access() {
27108 let source = r#"
27109workflow OptionalProof
27110
27111class Person {
27112 name string
27113}
27114
27115class Issue {
27116 assignee Person?
27117}
27118
27119rule unsafe_optional
27120 when Issue as issue where issue.assignee.name == "Ada"
27121=> {
27122}
27123"#;
27124
27125 let compiled = compile_program(source);
27126 assert!(compiled.ir.is_none());
27127 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27128 .message
27129 .contains("unsafe optional path `issue.assignee.name`")));
27130 }
27131
27132 #[test]
27133 fn accepts_presence_proof_before_optional_field_access() {
27134 let source = r#"
27135workflow OptionalProof
27136
27137class Person {
27138 name string
27139}
27140
27141class Issue {
27142 assignee Person?
27143}
27144
27145rule safe_optional
27146 when Issue as issue where issue.assignee != null && issue.assignee.name == "Ada"
27147=> {
27148}
27149
27150rule safe_exists
27151 when Issue as issue where exists issue.assignee && issue.assignee.name == "Ada"
27152=> {
27153}
27154
27155rule safe_not_null
27156 when Issue as issue where !(issue.assignee == null) && issue.assignee.name == "Ada"
27157=> {
27158}
27159"#;
27160
27161 let compiled = compile_program(source);
27162 assert_eq!(compiled.diagnostics, Vec::new());
27163 assert!(compiled.ir.is_some());
27164 }
27165
27166 #[test]
27167 fn parses_expression_kernel_surface() {
27168 let cases = [
27169 ("true || false && !ready", "true || (false && !ready)"),
27170 (
27171 "count(task.labels) == 0 || exists(Result where status == \"done\")",
27172 "(count(task.labels) == 0) || exists(Result where status == \"done\")",
27173 ),
27174 (
27175 "task.labels[\"priority\"] == [\"high\", \"urgent\"][0]",
27176 "task.labels[\"priority\"] == [\"high\", \"urgent\"][0]",
27177 ),
27178 (
27179 "exists issue.assignee && issue.assignee.name == \"Ada\"",
27180 "exists(issue.assignee) && (issue.assignee.name == \"Ada\")",
27181 ),
27182 (
27183 "{title task.title, metadata {phase \"kernel\"}}",
27184 "{title task.title, metadata {phase \"kernel\"}}",
27185 ),
27186 (
27187 "count(effect agent.tell where target == \"worker\") >= 1",
27188 "count(effect agent.tell where target == \"worker\") >= 1",
27189 ),
27190 ];
27191
27192 for (source, expected) in cases {
27193 let expr = parse_expression(source).expect(source);
27194 assert_eq!(expr.to_snapshot(), expected);
27195 }
27196
27197 for source in ["task.labels[", "count(Result where)", "[1,,2]"] {
27198 assert!(
27199 parse_expression(source).is_err(),
27200 "{source} unexpectedly parsed"
27201 );
27202 }
27203 }
27204
27205 #[test]
27209 fn deeply_nested_expression_errors_instead_of_overflowing_the_stack() {
27210 std::thread::Builder::new()
27215 .stack_size(8 * 1024 * 1024)
27216 .spawn(|| {
27217 let deep = format!("{}task.done{}", "(".repeat(8000), ")".repeat(8000));
27218 let result = parse_expression(&deep);
27219 assert!(
27220 result
27221 .as_ref()
27222 .err()
27223 .is_some_and(|message| message.contains("nested too deeply")),
27224 "expected a depth-limit diagnostic, got {result:?}"
27225 );
27226 let ok = format!("{}task.done{}", "(".repeat(64), ")".repeat(64));
27228 assert!(parse_expression(&ok).is_ok(), "64-deep nesting must parse");
27229 })
27230 .expect("spawn")
27231 .join()
27232 .expect("nested-expression parse must not crash");
27233 }
27234
27235 #[test]
27236 fn parses_every_expression_form_with_pinned_precedence() {
27237 let cases = [
27238 ("\"text\"", "\"text\""),
27240 ("42", "42"),
27241 ("2.5", "2.5"),
27242 ("true && false", "true && false"),
27243 ("task.note == null", "task.note == null"),
27244 (
27246 "task.meta[\"a\"][\"b\"] == \"c\"",
27247 "task.meta[\"a\"][\"b\"] == \"c\"",
27248 ),
27249 ("not task.done", "!task.done"),
27251 ("!!task.done", "!!task.done"),
27252 (
27253 "task.a and task.b or task.c",
27254 "(task.a && task.b) || task.c",
27255 ),
27256 ("not task.state == \"open\"", "!(task.state == \"open\")"),
27258 (
27260 "task.a || task.b && !task.c",
27261 "task.a || (task.b && !task.c)",
27262 ),
27263 ("1 + 2 * 3 == 7", "(1 + (2 * 3)) == 7"),
27265 ("10 - 4 / 2 >= 8", "(10 - (4 / 2)) >= 8"),
27266 ("task.a == task.b < task.c", "(task.a == task.b) < task.c"),
27272 ("task.n <= 5 && task.n > 0", "(task.n <= 5) && (task.n > 0)"),
27274 ("\"x\" in task.labels", "\"x\" in task.labels"),
27275 ("\"x\" not in task.labels", "\"x\" not in task.labels"),
27276 ("exists task.owner", "exists(task.owner)"),
27278 (
27279 "exists(Task where done == false)",
27280 "exists(Task where done == false)",
27281 ),
27282 ("count([1, 2]) == 2", "count([1, 2]) == 2"),
27284 (
27285 "empty(Task where done == false)",
27286 "empty(Task where done == false)",
27287 ),
27288 ("empty(task.labels)", "empty(task.labels)"),
27289 ("empty([])", "empty([])"),
27290 (
27291 "count(effect kind agent.tell where target == \"w\") == 0",
27292 "count(effect kind agent.tell where target == \"w\") == 0",
27293 ),
27294 (
27295 "exists(effect kind schema.coerce)",
27296 "exists(effect kind schema.coerce)",
27297 ),
27298 ("[\"a\", \"b\"]", "[\"a\", \"b\"]"),
27300 (
27301 "{title task.title, meta {phase \"kernel\"}}",
27302 "{title task.title, meta {phase \"kernel\"}}",
27303 ),
27304 ];
27305
27306 for (source, expected) in cases {
27307 let expr = parse_expression(source).expect(source);
27308 assert_eq!(expr.to_snapshot(), expected, "for `{source}`");
27309 }
27310 }
27311
27312 #[test]
27316 fn invalid_expression_syntax_produces_deterministic_errors() {
27317 let cases = [
27318 ("task.a ==", "expected expression"),
27320 ("1 +", "expected expression"),
27321 ("task.a && || task.b", "expected expression"),
27322 ("task.a == == 1", "expected expression"),
27323 ("!", "expected expression"),
27324 ("(task.a == 1", "expected `)`"),
27326 ("task.labels[\"k\"", "expected `]`"),
27327 ("[1, 2", "expected `,`"),
27328 ("{a 1", "expected object field name"),
27329 ("task.a == 1)", "unexpected token"),
27331 ("in task.a", "unexpected token"),
27332 ("count(Task where", "expected expression"),
27334 ("count(Task where )", "expected expression"),
27335 ("task..a", "expected field name after `.`"),
27336 ("task.a not b", "expected `in` after `not`"),
27337 ];
27338
27339 for (source, expected) in cases {
27340 let message = parse_expression(source).expect_err(source);
27341 assert!(
27342 message.contains(expected),
27343 "`{source}` -> `{message}` (expected `{expected}`)"
27344 );
27345 }
27346 }
27347
27348 #[test]
27351 fn guard_and_assertion_syntax_errors_surface_with_context() {
27352 let source = r#"
27353workflow BadExpressionSyntax
27354
27355class Task {
27356 title string
27357}
27358
27359assert count(Task) ==
27360
27361rule dangling_guard
27362 when Task as task where task.title ==
27363=> {
27364}
27365"#;
27366
27367 let compiled = compile_program(source);
27368 assert!(compiled.ir.is_none());
27369 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27370 .message
27371 .contains("invalid assertion expression: expected expression")));
27372 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27373 .message
27374 .contains("rule `dangling_guard` has invalid guard expression: expected expression")));
27375 }
27376
27377 #[test]
27381 fn validates_empty_call_arity_and_optional_arguments() {
27382 let source = r#"
27383workflow EmptyCallChecks
27384
27385class Task {
27386 title string
27387 note string?
27388 age int?
27389 done bool
27390}
27391
27392assert empty() == true
27393assert empty(["a"], ["b"]) == true
27394assert count(Task where empty(note) && empty(title)) == 0
27395assert count(Task where empty(age)) == 0
27396assert count(Task where empty(done)) == 0
27397"#;
27398
27399 let compiled = compile_program(source);
27400 assert!(compiled.ir.is_none());
27401 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27402 .message
27403 .contains("calls `empty` with 0 arguments, expected 1")));
27404 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27405 .message
27406 .contains("calls `empty` with 2 arguments, expected 1")));
27407 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27408 .message
27409 .contains("calls `empty` with unsupported optional argument type `int?`")));
27410 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27411 .message
27412 .contains("calls `empty` with unsupported argument type `bool`")));
27413 assert!(!compiled
27415 .diagnostics
27416 .iter()
27417 .any(|diagnostic| diagnostic.message.contains("`string?`")));
27418 assert!(!compiled.diagnostics.iter().any(|diagnostic| diagnostic
27419 .message
27420 .contains("unsupported argument type `string`")));
27421 }
27422
27423 #[test]
27429 fn format_preserves_expression_source_text_verbatim() {
27430 let source = r#"workflow FormatExpressions
27431
27432class Task {
27433 title string
27434 done bool
27435}
27436
27437assert count(Task where (done == false) and not done) == 0
27438
27439rule keep_spelling
27440 when Task as task where not task.done and (task.title == "a" or task.title == "b")
27441=> {
27442}
27443"#;
27444
27445 let formatted = format_program(source);
27446 assert_eq!(formatted.diagnostics, Vec::new());
27447 let once = formatted.formatted.expect("formats");
27448 assert!(once.contains(
27449 "when Task as task where not task.done and (task.title == \"a\" or task.title == \"b\")"
27450 ));
27451 assert!(once.contains("assert count(Task where (done == false) and not done) == 0"));
27452
27453 let twice = format_program(&once).formatted.expect("formats twice");
27454 assert_eq!(once, twice, "formatting is idempotent over expressions");
27455 }
27456
27457 #[test]
27458 fn validates_expected_schema_object_and_map_record_fields() {
27459 let source = r#"
27460workflow ObjectRecordFields
27461
27462class Owner {
27463 name string
27464}
27465
27466class Task {
27467 title string
27468 metadata map<string>
27469 owner Owner?
27470}
27471
27472rule seed
27473 when started
27474=> {
27475 record Task {
27476 title "Implement object literals"
27477 metadata { phase "kernel" }
27478 owner { name "Ada" }
27479 }
27480
27481 record Task {
27482 title "Implement multiline object literals"
27483 metadata {
27484 phase "kernel"
27485 owner "Ada"
27486 }
27487 owner {
27488 name "Ada"
27489 }
27490 }
27491}
27492"#;
27493
27494 let compiled = compile_program(source);
27495 assert_eq!(compiled.diagnostics, Vec::new());
27496 assert!(compiled.ir.is_some());
27497 }
27498
27499 #[test]
27500 fn rejects_invalid_expected_schema_object_and_map_record_fields() {
27501 let source = r#"
27502workflow BadObjectRecordFields
27503
27504class Owner {
27505 name string
27506}
27507
27508class Task {
27509 metadata map<string>
27510 owner Owner
27511}
27512
27513rule seed
27514 when started
27515=> {
27516 record Task {
27517 metadata { phase 1 }
27518 owner { alias "Ada" }
27519 }
27520}
27521
27522rule bad_guard
27523 when Task as task where { phase "kernel" } == task.metadata
27524=> {
27525}
27526"#;
27527
27528 let compiled = compile_program(source);
27529 assert!(compiled.ir.is_none());
27530 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27531 .message
27532 .contains("field `Task.metadata` expects `string`")));
27533 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27534 .message
27535 .contains("class `Owner` has no field `alias`")));
27536 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27537 .message
27538 .contains("missing required object field `Owner.name`")));
27539 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27540 .message
27541 .contains("compares incompatible expression types")));
27542 }
27543
27544 #[test]
27545 fn rejects_invalid_expression_types() {
27546 let source = r#"
27547workflow BadExpressionTypes
27548
27549class Task {
27550 title string
27551 labels map<string>
27552 priority int
27553 ready bool
27554}
27555
27556rule non_bool_guard
27557 when Task as task where task.priority
27558=> {
27559}
27560
27561rule bad_ordering
27562 when Task as task where task.title > "abc"
27563=> {
27564}
27565
27566rule bad_membership
27567 when Task as task where task.title in task.priority
27568=> {
27569}
27570
27571rule bad_equality
27572 when Task as task where task.ready == "yes"
27573=> {
27574}
27575
27576rule bad_array
27577 when Task as task where task.title in ["abc", 1]
27578=> {
27579}
27580
27581rule bad_map_key
27582 when Task as task where task.labels[1] == "urgent"
27583=> {
27584}
27585
27586rule bad_map_membership
27587 when Task as task where 1 in task.labels
27588=> {
27589}
27590"#;
27591
27592 let compiled = compile_program(source);
27593 assert!(compiled.ir.is_none());
27594 assert!(compiled
27595 .diagnostics
27596 .iter()
27597 .any(|diagnostic| diagnostic.message.contains("non-boolean guard expression")));
27598 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27599 .message
27600 .contains("orders non-orderable expression values")));
27601 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27602 .message
27603 .contains("uses membership against a non-array/non-map expression")));
27604 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27605 .message
27606 .contains("compares incompatible expression types")));
27607 assert!(compiled
27608 .diagnostics
27609 .iter()
27610 .any(|diagnostic| diagnostic.message.contains("mixed-type array literal")));
27611 assert!(compiled
27612 .diagnostics
27613 .iter()
27614 .any(|diagnostic| diagnostic.message.contains("non-string key")));
27615 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27616 .message
27617 .contains("map membership with a non-string key")));
27618 }
27619
27620 #[test]
27621 fn validates_duration_and_time_ordering_and_literals() {
27622 let source = r#"
27623workflow DurationTimeExpressions
27624
27625class Window {
27626 elapsed duration
27627 limit duration
27628 opened_at time
27629 due_at time
27630}
27631
27632assert exists(Window where elapsed < limit)
27633assert exists(Window where opened_at <= due_at)
27634
27635rule seed
27636 when started
27637=> {
27638 record Window {
27639 elapsed "PT30.5M"
27640 limit "PT1.25H"
27641 opened_at "2026-05-29T10:00:00.250-04:00"
27642 due_at "2026-05-29T14:00:00.500Z"
27643 }
27644}
27645"#;
27646
27647 let compiled = compile_program(source);
27648 assert_eq!(compiled.diagnostics, Vec::new());
27649 assert!(compiled.ir.is_some());
27650 }
27651
27652 #[test]
27653 fn rejects_invalid_duration_and_time_literals() {
27654 let source = r#"
27655workflow BadDurationTimeExpressions
27656
27657class Window {
27658 elapsed duration
27659 limit duration
27660 opened_at time
27661}
27662
27663rule seed
27664 when started
27665=> {
27666 record Window {
27667 elapsed "thirty minutes"
27668 limit "P1M"
27669 opened_at "morning"
27670 }
27671}
27672"#;
27673
27674 let compiled = compile_program(source);
27675 assert!(compiled.ir.is_none());
27676 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27677 .message
27678 .contains("field `Window.elapsed` has invalid duration literal")));
27679 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27680 .message
27681 .contains("field `Window.limit` has invalid duration literal")));
27682 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27683 .message
27684 .contains("field `Window.opened_at` has invalid time literal")));
27685 }
27686
27687 #[test]
27688 fn validates_assertion_expression_types_and_paths() {
27689 let source = r#"
27690workflow BadAssertions
27691
27692class Task {
27693 provider "codex" | "claude"
27694 priority int
27695}
27696
27697assert count(Task where provider == "bad") == 0
27698assert count(Task)
27699assert missing.root == "value"
27700"#;
27701
27702 let compiled = compile_program(source);
27703 assert!(compiled.ir.is_none());
27704 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27705 .message
27706 .contains("assertion compares finite-domain value to unknown `bad`")));
27707 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27708 .message
27709 .contains("assertion has non-boolean assertion expression")));
27710 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27711 .message
27712 .contains("assertion has unknown expression root `missing`")));
27713 }
27714
27715 #[test]
27716 fn validates_symmetric_finite_domain_literals_and_unknown_guard_roots() {
27717 let source = r#"
27718workflow SymmetricFiniteDomain
27719
27720enum ReviewStatus {
27721 Accept
27722 Revise
27723}
27724
27725class Task {
27726 status ReviewStatus
27727 provider "codex" | "claude"
27728}
27729
27730rule symmetric_literal
27731 when Task as task where "bad" == task.provider
27732=> {
27733}
27734
27735rule enum_variant_literal
27736 when Task as task where Missing == task.status
27737=> {
27738}
27739
27740rule array_membership_literal
27741 when Task as task where task.provider in ["codex", "bad"]
27742=> {
27743}
27744
27745rule implicit_query_head
27746 when Task as task where exists(Task where status == Missing)
27747=> {
27748}
27749
27750rule unknown_root
27751 when Task as task where other.provider == "codex"
27752=> {
27753}
27754"#;
27755
27756 let compiled = compile_program(source);
27757 assert!(compiled.ir.is_none());
27758 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27759 .message
27760 .contains("finite-domain value to unknown `bad`")));
27761 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27762 .message
27763 .contains("finite-domain value to unknown `Missing`")));
27764 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27765 .message
27766 .contains("unknown expression root `other`")));
27767 }
27768
27769 #[test]
27770 fn rejects_unsatisfiable_finite_domain_expression_relations() {
27771 let source = r#"
27772workflow UnsatisfiableFiniteDomains
27773
27774class Task {
27775 provider "codex" | "claude"
27776 route "cache" | "coerce"
27777}
27778
27779rule disjoint_equality
27780 when Task as task where task.provider == task.route
27781=> {
27782}
27783
27784rule empty_membership
27785 when Task as task where task.provider in []
27786=> {
27787}
27788
27789rule excluded_membership
27790 when Task as task where task.provider not in ["codex", "claude"]
27791=> {
27792}
27793"#;
27794
27795 let compiled = compile_program(source);
27796 assert!(compiled.ir.is_none());
27797 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27798 .message
27799 .contains("statically unsatisfiable finite-domain equality")));
27800 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27801 .message
27802 .contains("statically unsatisfiable finite-domain membership")));
27803 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27804 .message
27805 .contains("statically unsatisfiable finite-domain exclusion")));
27806 }
27807
27808 #[test]
27809 fn accepts_map_index_expressions() {
27810 let source = r#"
27811workflow MapIndex
27812
27813class Task {
27814 labels map<string>
27815}
27816
27817rule route
27818 when Task as task where task.labels["priority"] == "high"
27819=> {
27820}
27821"#;
27822
27823 let compiled = compile_program(source);
27824 assert_eq!(compiled.diagnostics, Vec::new());
27825 let ir = compiled.ir.expect("valid ir");
27826 let guard = ir.rules[0].whens[0].guard.as_ref().expect("guard");
27827 assert_eq!(
27828 guard.expr.to_snapshot(),
27829 "task.labels[\"priority\"] == \"high\""
27830 );
27831 }
27832
27833 #[test]
27834 fn lowers_deterministic_ir_snapshot() {
27835 let source = r#"
27836workflow Snapshot
27837
27838
27839class Work {
27840 title string
27841 files string[]
27842 state "open" | "done"
27843}
27844
27845class Result {
27846 title string
27847 files string[]
27848}
27849
27850agent worker {
27851 provider fixture
27852 profile "repo-writer"
27853 capacity 2
27854 skills ["repo-user"]
27855}
27856
27857rule start
27858 when Work as work
27859=>
27860{
27861 tell worker "{{ work.title }}"
27862}
27863
27864rule finish
27865 when Result as result
27866=>
27867{
27868 record Work {
27869 title result.title
27870 files result.files
27871 state "done"
27872 }
27873}
27874"#;
27875
27876 let compiled = compile_program(source);
27877 assert_eq!(compiled.diagnostics, Vec::new());
27878 let ir = match compiled.ir {
27879 Some(ir) => ir,
27880 None => panic!("expected lowered IR"),
27881 };
27882
27883 let expected = "\
27884workflow Snapshot
27885schemas
27886 class Work
27887 title string
27888 files array<string>
27889 state union<literal<\"open\"> | literal<\"done\">>
27890 class Result
27891 title string
27892 files array<string>
27893agents
27894 agent worker harness=<fallback> provider=fixture profile=repo-writer capacity=2 skills=[repo-user] capabilities=[] tools=[]
27895rules
27896 rule start
27897 when Work as work
27898 reads
27899 schema:Work
27900 effects
27901 effect1 kind=agent.tell binding=- key=ef8ed2edd19578a222b6ad56ea1bffa8
27902 body_hash 96e148d2d421ee97960ef8f3edf61db9
27903 rule finish
27904 when Result as result
27905 reads
27906 schema:Result
27907 writes
27908 schema:Work
27909 body_hash 4a5ce925842b5b0bceb64bf33361d523
27910rule_dependencies
27911 finish --schema:Work--> start
27912";
27913
27914 assert_eq!(ir.to_snapshot(), expected);
27915 }
27916
27917 #[test]
27918 fn example_ir_snapshots_are_stable() {
27919 let examples = [
27920 (
27921 include_str!("../../../examples/minimal-noop.whip"),
27922 include_str!("../../../examples/minimal-noop.ir"),
27923 ),
27924 (
27925 include_str!("../../../examples/queue-worker-with-review.whip"),
27926 include_str!("../../../examples/queue-worker-with-review.ir"),
27927 ),
27928 (
27929 include_str!("../../../examples/circuit-breaker.whip"),
27930 include_str!("../../../examples/circuit-breaker.ir"),
27931 ),
27932 (
27933 include_str!("../../../examples/coerce-branch.whip"),
27934 include_str!("../../../examples/coerce-branch.ir"),
27935 ),
27936 (
27937 include_str!("../../../examples/terminal-output-union.whip"),
27938 include_str!("../../../examples/terminal-output-union.ir"),
27939 ),
27940 (
27941 include_str!("../../../examples/triage-chain.whip"),
27942 include_str!("../../../examples/triage-chain.ir"),
27943 ),
27944 (
27945 include_str!("../../../examples/incident-router.whip"),
27946 include_str!("../../../examples/incident-router.ir"),
27947 ),
27948 (
27949 include_str!("../../../examples/expression-kernel.whip"),
27950 include_str!("../../../examples/expression-kernel.ir"),
27951 ),
27952 (
27953 include_str!("../../../examples/multi-agent-bounded-concurrency.whip"),
27954 include_str!("../../../examples/multi-agent-bounded-concurrency.ir"),
27955 ),
27956 (
27961 include_str!("../../../examples/scheduled-escalation.whip"),
27962 include_str!("../../../examples/scheduled-escalation.ir"),
27963 ),
27964 (
27965 include_str!("../../../examples/event-bridge.whip"),
27966 include_str!("../../../examples/event-bridge.ir"),
27967 ),
27968 (
27969 include_str!("../../../examples/reusable-review-pattern.whip"),
27970 include_str!("../../../examples/reusable-review-pattern.ir"),
27971 ),
27972 (
27973 include_str!("../../../examples/reusable-action-chain.whip"),
27974 include_str!("../../../examples/reusable-action-chain.ir"),
27975 ),
27976 (
27977 include_str!("../../../examples/exec-json-ingest.whip"),
27978 include_str!("../../../examples/exec-json-ingest.ir"),
27979 ),
27980 (
27981 include_str!("../../../examples/deterministic-validation.whip"),
27982 include_str!("../../../examples/deterministic-validation.ir"),
27983 ),
27984 (
27985 include_str!("../../../examples/autoresearch-lite.whip"),
27986 include_str!("../../../examples/autoresearch-lite.ir"),
27987 ),
27988 (
27989 include_str!("../../../examples/gastown-lite.whip"),
27990 include_str!("../../../examples/gastown-lite.ir"),
27991 ),
27992 (
27993 include_str!("../../../examples/ralph.whip"),
27994 include_str!("../../../examples/ralph.ir"),
27995 ),
27996 ];
27997
27998 for (source, expected) in examples {
27999 let compiled = compile_program(source);
28000 assert_eq!(compiled.diagnostics, Vec::new());
28001 let ir = match compiled.ir {
28002 Some(ir) => ir,
28003 None => panic!("expected lowered IR"),
28004 };
28005 assert_eq!(ir.to_snapshot(), expected);
28006 }
28007 }
28008
28009 #[test]
28010 fn revision_examples_compile() {
28011 let examples = [
28012 (
28013 include_str!("../../../examples/revision-ticket-v1.whip"),
28014 Some("RevisionTicket"),
28015 ),
28016 (
28017 include_str!("../../../examples/revision-ticket-v2.whip"),
28018 Some("RevisionTicket"),
28019 ),
28020 (
28021 include_str!("../../../examples/revision-repair-planner.whip"),
28022 Some("RevisionRepairPlanner"),
28023 ),
28024 (
28025 include_str!("../../../examples/revision-running-cancel.whip"),
28026 Some("RevisionRunningCancel"),
28027 ),
28028 (
28029 include_str!("../../../examples/revision-parent-child.whip"),
28030 Some("ParentRevisionExample"),
28031 ),
28032 (
28033 include_str!("../../../examples/revision-validation-approval.whip"),
28034 Some("RevisionValidation"),
28035 ),
28036 ];
28037
28038 for (source, root) in examples {
28039 let compiled = compile_program_with_root(source, root);
28040 assert_eq!(compiled.diagnostics, Vec::new());
28041 assert!(compiled.ir.is_some());
28042 }
28043 }
28044
28045 #[test]
28046 fn rejects_unknown_schema_references() {
28047 let source = include_str!("../../../examples/invalid/unknown-schema.whip");
28048 let compiled = compile_program(source);
28049
28050 assert!(compiled.ir.is_none());
28051 assert_eq!(compiled.diagnostics.len(), 2);
28052 assert!(compiled
28053 .diagnostics
28054 .iter()
28055 .any(|diagnostic| diagnostic.message == "unknown schema reference `MissingStatus`"));
28056 assert!(compiled
28057 .diagnostics
28058 .iter()
28059 .any(|diagnostic| diagnostic.message == "unknown schema reference `MissingOutput`"));
28060 }
28061
28062 #[test]
28063 fn emit_of_undeclared_signal_is_flagged_statically() {
28064 let source = "\
28067workflow Emitter
28068signal trigger.x { peer string }
28069signal known.sig { note string }
28070rule relay
28071 when trigger.x as t
28072=> {
28073 emit signal known.sig to t.peer { note \"ok\" } as a
28074 emit signal unknown.sig to t.peer { note \"bad\" } as b
28075}
28076";
28077 let compiled = compile_program(source);
28078 let messages: Vec<&str> = compiled
28079 .diagnostics
28080 .iter()
28081 .map(|d| d.message.as_str())
28082 .collect();
28083 assert!(
28084 messages.contains(&"rule `relay` emits undeclared signal `unknown.sig`"),
28085 "expected the undeclared-emit diagnostic, got {messages:?}"
28086 );
28087 assert!(
28089 !messages
28090 .iter()
28091 .any(|m| m.contains("emits undeclared signal `known.sig`")),
28092 "a declared signal must not be flagged: {messages:?}"
28093 );
28094 }
28095
28096 #[test]
28097 fn source_emit_of_undeclared_signal_is_flagged_statically() {
28098 let source = "\
28102workflow SourceEmit
28103signal ingress.known { text string }
28104source file as feed {
28105 path \"/tmp/x.txt\"
28106 observe as obs
28107 emit ingress.unknown { text obs.line }
28108}
28109output result Done
28110class Done { ok string }
28111rule react
28112 when ingress.known as k
28113=> {
28114 complete result { ok \"ok\" }
28115}
28116";
28117 let compiled = compile_program(source);
28118 let messages: Vec<&str> = compiled
28119 .diagnostics
28120 .iter()
28121 .map(|d| d.message.as_str())
28122 .collect();
28123 assert!(
28124 messages.contains(&"source `feed` emits undeclared signal `ingress.unknown`"),
28125 "expected the undeclared source-emit diagnostic, got {messages:?}"
28126 );
28127 let ok_source = source.replace("ingress.unknown", "ingress.known");
28129 let ok_compiled = compile_program(&ok_source);
28130 assert!(
28131 !ok_compiled
28132 .diagnostics
28133 .iter()
28134 .any(|d| d.message.contains("emits undeclared signal")),
28135 "a declared signal must not be flagged: {:?}",
28136 ok_compiled
28137 .diagnostics
28138 .iter()
28139 .map(|d| d.message.as_str())
28140 .collect::<Vec<_>>()
28141 );
28142 }
28143
28144 #[test]
28145 fn source_emit_of_unknown_observation_field_is_flagged_statically() {
28146 let source = "\
28151workflow BadObs
28152signal ingress.fed { text string }
28153source file as feed {
28154 path \"/tmp/x.txt\"
28155 observe as obs
28156 emit ingress.fed { text obs.nosuchfield }
28157}
28158output result Done
28159class Done { ok string }
28160rule react
28161 when ingress.fed as f
28162=> { complete result { ok \"ok\" } }
28163";
28164 let messages: Vec<String> = compile_program(source)
28165 .diagnostics
28166 .iter()
28167 .map(|d| d.message.clone())
28168 .collect();
28169 assert!(
28170 messages.iter().any(|m| m.contains(
28171 "emit reads `obs.nosuchfield`, but a `file` source's observation has no field"
28172 )),
28173 "expected the unknown-observation-field diagnostic, got {messages:?}"
28174 );
28175 let ok = source.replace("obs.nosuchfield", "obs.line");
28177 assert!(
28178 !compile_program(&ok)
28179 .diagnostics
28180 .iter()
28181 .any(|d| d.message.contains("observation has no field")),
28182 "a valid observation field must not be flagged"
28183 );
28184 }
28185
28186 #[test]
28187 fn renew_of_unacquired_lease_is_flagged_statically() {
28188 let source = "\
28192workflow RenewTypo
28193class Ticket { id string }
28194class Done { ok string }
28195lease slot { shared key Ticket slots 1 ttl 60s }
28196output result Done
28197table seed as Ticket [ { id \"t\" } ]
28198rule grab
28199 when Ticket as t
28200=> {
28201 acquire slot for t.id until ttl as held
28202 after held held {
28203 renew nonexistent until 300s as r
28204 complete result { ok \"ok\" }
28205 }
28206}
28207";
28208 let messages: Vec<String> = compile_program(source)
28209 .diagnostics
28210 .iter()
28211 .map(|d| d.message.clone())
28212 .collect();
28213 assert!(
28214 messages
28215 .iter()
28216 .any(|m| m.contains("renews unbound coordination binding `nonexistent`")),
28217 "expected the unbound-renew diagnostic, got {messages:?}"
28218 );
28219 let ok = source.replace("renew nonexistent", "renew held");
28221 assert!(
28222 !compile_program(&ok)
28223 .diagnostics
28224 .iter()
28225 .any(|d| d.message.contains("renews unbound coordination binding")),
28226 "renewing an acquired lease must not be flagged"
28227 );
28228 }
28229
28230 #[test]
28234 fn renew_of_a_claim_binding_is_accepted_and_lowers_to_tracker_renew() {
28235 let source = "\
28236workflow RenewClaim
28237class Done { ok string }
28238tracker backlog { provider builtin }
28239output result Done
28240rule work
28241 when backlog has ready issue as issue
28242=> {
28243 claim issue ttl 1h as active
28244
28245 after active succeeds {
28246 renew active as renewed
28247 }
28248
28249 after renewed succeeds {
28250 complete result { ok \"ok\" }
28251 }
28252}
28253";
28254 let compiled = compile_program(source);
28255 assert!(
28256 !compiled
28257 .diagnostics
28258 .iter()
28259 .any(|d| d.message.contains("renews unbound coordination binding")),
28260 "renewing a claim binding must not be flagged: {:?}",
28261 compiled.diagnostics
28262 );
28263 let ir = compiled.ir.expect("compiles");
28264 let work = ir
28265 .rules
28266 .iter()
28267 .find(|rule| rule.name == "work")
28268 .expect("work rule");
28269 assert!(
28270 work.metadata
28271 .effects
28272 .iter()
28273 .any(|effect| effect.kind == IrEffectKind::TrackerRenew),
28274 "a renew of a claim binding lowers to TrackerRenew: {:?}",
28275 work.metadata.effects
28276 );
28277 assert!(
28279 !work
28280 .metadata
28281 .effects
28282 .iter()
28283 .any(|effect| effect.kind == IrEffectKind::LeaseRenew),
28284 "no lease.renew for a claim-binding renew: {:?}",
28285 work.metadata.effects
28286 );
28287 }
28288
28289 #[test]
28290 fn release_of_each_bound_coordination_form_is_accepted() {
28291 let source = "\
28297workflow ReleaseForms
28298class Ticket { id string }
28299class Done { ok string }
28300lease slot { key Ticket slots 1 ttl 60s }
28301tracker backlog { provider builtin }
28302agent worker { provider fixture profile \"repo-writer\" capacity 1 }
28303output result Done
28304rule work
28305 when backlog has ready issue as issue
28306 when worker is available
28307=> {
28308 acquire slot for issue.id until ttl as held
28309 claim issue as active_claim
28310 after active_claim succeeds {
28311 release held
28312 release issue
28313 complete result { ok \"done\" }
28314 }
28315 after active_claim fails {
28316 release held
28317 complete result { ok \"gave-up\" }
28318 }
28319}
28320";
28321 let messages: Vec<String> = compile_program(source)
28322 .diagnostics
28323 .iter()
28324 .map(|d| d.message.clone())
28325 .collect();
28326 assert!(
28327 !messages
28328 .iter()
28329 .any(|m| m.contains("releases unbound coordination item")),
28330 "no bound release form must be flagged, got {messages:?}"
28331 );
28332 }
28333
28334 #[test]
28335 fn release_of_unbound_coordination_item_is_flagged_statically() {
28336 let source = "\
28341workflow ReleaseTypo
28342class Done { ok string }
28343tracker backlog { provider builtin }
28344agent worker { provider fixture profile \"repo-writer\" capacity 1 }
28345output result Done
28346rule work
28347 when backlog has ready issue as issue
28348 when worker is available
28349=> {
28350 claim issue as active_claim
28351 after active_claim succeeds {
28352 release nonexistent
28353 complete result { ok \"done\" }
28354 }
28355 after active_claim fails {
28356 complete result { ok \"gave-up\" }
28357 }
28358}
28359";
28360 let messages: Vec<String> = compile_program(source)
28361 .diagnostics
28362 .iter()
28363 .map(|d| d.message.clone())
28364 .collect();
28365 assert!(
28366 messages
28367 .iter()
28368 .any(|m| m.contains("releases unbound coordination item `nonexistent`")),
28369 "expected the unbound-release diagnostic, got {messages:?}"
28370 );
28371 let ok = source.replace("release nonexistent", "release issue");
28373 assert!(
28374 !compile_program(&ok)
28375 .diagnostics
28376 .iter()
28377 .any(|d| d.message.contains("releases unbound coordination item")),
28378 "releasing a bound work item must not be flagged"
28379 );
28380 }
28381
28382 #[test]
28383 fn http_source_url_must_have_an_http_scheme() {
28384 let source = "\
28386workflow BadUrl
28387signal ingress.fed { text string }
28388source http as feed {
28389 url \"not-a-real-url\"
28390 observe as obs
28391 emit ingress.fed { text obs.item }
28392}
28393output result Done
28394class Done { ok string }
28395rule react
28396 when ingress.fed as f
28397=> { complete result { ok \"ok\" } }
28398";
28399 let messages: Vec<String> = compile_program(source)
28400 .diagnostics
28401 .iter()
28402 .map(|d| d.message.clone())
28403 .collect();
28404 assert!(
28405 messages
28406 .iter()
28407 .any(|m| m.contains("is not an absolute http(s) URL")),
28408 "expected the http url-scheme diagnostic, got {messages:?}"
28409 );
28410 let ok = source.replace("not-a-real-url", "https://example.com/feed.json");
28412 assert!(
28413 !compile_program(&ok)
28414 .diagnostics
28415 .iter()
28416 .any(|d| d.message.contains("absolute http(s) URL")),
28417 "a well-formed url must not be flagged"
28418 );
28419 }
28420
28421 #[test]
28426 fn file_watch_source_parses_lowers_and_formats() {
28427 let source = "\
28428workflow WatchSource
28429signal drop.arrived { path string digest string }
28430source file as drops {
28431 watch \"./drops/*.json\"
28432 observe as obs
28433 emit drop.arrived {
28434 path obs.path
28435 digest obs.content_hash
28436 }
28437}
28438output result Done
28439class Done { ok string }
28440rule react
28441 when drop.arrived as f
28442=> { complete result { ok \"ok\" } }
28443";
28444 let compiled = compile_program(source);
28445 let ir = compiled.ir.expect("watch source compiles");
28446 let decl = ir.sources.first().expect("source lowered");
28447 assert!(decl.is_file);
28448 assert_eq!(decl.watch.as_deref(), Some("./drops/*.json"));
28449 assert_eq!(decl.path, None);
28450 let formatted = format_program(source).formatted.expect("formats");
28451 assert!(
28452 formatted.contains("watch \"./drops/*.json\""),
28453 "{formatted}"
28454 );
28455 assert_eq!(
28456 format_program(&formatted).formatted.expect("reformats"),
28457 formatted,
28458 "fmt must be idempotent over the watch clause"
28459 );
28460 let bad = source.replace("digest obs.content_hash", "digest obs.line");
28463 let messages: Vec<String> = compile_program(&bad)
28464 .diagnostics
28465 .iter()
28466 .map(|d| d.message.clone())
28467 .collect();
28468 assert!(
28469 messages
28470 .iter()
28471 .any(|m| m.contains("observation has no field `line`")),
28472 "watch-mode emit must validate against the occurrence schema, got {messages:?}"
28473 );
28474 }
28475
28476 #[test]
28480 fn file_source_clause_set_is_closed() {
28481 let watch_on_clock = "\
28482workflow BadWatch
28483signal tick.fired { at time }
28484source clock as ticker {
28485 every 5m
28486 missed skip
28487 watch \"./drops/*.json\"
28488 observe as tick
28489 emit tick.fired { at tick.scheduled_at }
28490}
28491output result Done
28492class Done { ok string }
28493rule react
28494 when tick.fired as f
28495=> { complete result { ok \"ok\" } }
28496";
28497 let messages: Vec<String> = compile_program(watch_on_clock)
28498 .diagnostics
28499 .iter()
28500 .map(|d| d.message.clone())
28501 .collect();
28502 assert!(
28503 messages
28504 .iter()
28505 .any(|m| m.contains("`watch` clause but its provider is `clock`")),
28506 "watch outside `file` must be rejected, got {messages:?}"
28507 );
28508
28509 let both_modes = "\
28510workflow BothModes
28511signal ingress.fed { text string }
28512source file as feed {
28513 path \"./inbox.txt\"
28514 watch \"./drops/*.txt\"
28515 observe as obs
28516 emit ingress.fed { text obs.path }
28517}
28518output result Done
28519class Done { ok string }
28520rule react
28521 when ingress.fed as f
28522=> { complete result { ok \"ok\" } }
28523";
28524 let messages: Vec<String> = compile_program(both_modes)
28525 .diagnostics
28526 .iter()
28527 .map(|d| d.message.clone())
28528 .collect();
28529 assert!(
28530 messages
28531 .iter()
28532 .any(|m| m.contains("declares both `path` and `watch`")),
28533 "path+watch must be rejected as exclusive modes, got {messages:?}"
28534 );
28535
28536 let neither = "\
28537workflow Neither
28538signal ingress.fed { text string }
28539source file as feed {
28540 observe as obs
28541 emit ingress.fed { text obs.line }
28542}
28543output result Done
28544class Done { ok string }
28545rule react
28546 when ingress.fed as f
28547=> { complete result { ok \"ok\" } }
28548";
28549 let messages: Vec<String> = compile_program(neither)
28550 .diagnostics
28551 .iter()
28552 .map(|d| d.message.clone())
28553 .collect();
28554 assert!(
28555 messages
28556 .iter()
28557 .any(|m| m.contains("requires a `path` or `watch` clause")),
28558 "a file source with neither mode must be rejected, got {messages:?}"
28559 );
28560 }
28561
28562 #[test]
28567 fn dedup_clause_parses_lowers_and_validates() {
28568 let source = "\
28569workflow DedupSource
28570signal ingress.ingested { text string }
28571source http as feed {
28572 url \"https://example.com/feed.json\"
28573 dedup obs.item
28574 observe as obs
28575 emit ingress.ingested { text obs.item }
28576}
28577output result Done
28578class Done { ok string }
28579rule react
28580 when ingress.ingested as f
28581=> { complete result { ok \"ok\" } }
28582";
28583 let compiled = compile_program(source);
28584 let ir = compiled.ir.expect("dedup source compiles");
28585 let decl = ir.sources.first().expect("source lowered");
28586 assert!(decl.is_http);
28587 assert_eq!(decl.dedup_field.as_deref(), Some("item"));
28588 let formatted = format_program(source).formatted.expect("formats");
28589 assert!(formatted.contains("dedup obs.item"), "{formatted}");
28590 assert_eq!(
28591 format_program(&formatted).formatted.expect("reformats"),
28592 formatted,
28593 "fmt must be idempotent over the dedup clause"
28594 );
28595
28596 let bad_field = source.replace("dedup obs.item", "dedup obs.delivery_id");
28598 let messages: Vec<String> = compile_program(&bad_field)
28599 .diagnostics
28600 .iter()
28601 .map(|d| d.message.clone())
28602 .collect();
28603 assert!(
28604 messages
28605 .iter()
28606 .any(|m| m.contains("observation has no field `delivery_id`")),
28607 "an unknown dedup field must be rejected, got {messages:?}"
28608 );
28609
28610 let bad_root = source.replace("dedup obs.item", "dedup other.item");
28612 let messages: Vec<String> = compile_program(&bad_root)
28613 .diagnostics
28614 .iter()
28615 .map(|d| d.message.clone())
28616 .collect();
28617 assert!(
28618 messages
28619 .iter()
28620 .any(|m| m.contains("`dedup` must name one observation field")),
28621 "a dedup path off a foreign binding must be rejected, got {messages:?}"
28622 );
28623
28624 let on_clock = "\
28626workflow DedupClock
28627signal tick.fired { at time }
28628source clock as ticker {
28629 every 5m
28630 missed skip
28631 dedup tick.occurrence_id
28632 observe as tick
28633 emit tick.fired { at tick.scheduled_at }
28634}
28635output result Done
28636class Done { ok string }
28637rule react
28638 when tick.fired as f
28639=> { complete result { ok \"ok\" } }
28640";
28641 let messages: Vec<String> = compile_program(on_clock)
28642 .diagnostics
28643 .iter()
28644 .map(|d| d.message.clone())
28645 .collect();
28646 assert!(
28647 messages
28648 .iter()
28649 .any(|m| m.contains("declares a `dedup` clause but its provider is `clock`")),
28650 "dedup on a clock source must be rejected, got {messages:?}"
28651 );
28652 }
28653
28654 #[test]
28655 fn enum_variants_are_one_per_line() {
28656 let garbage = compile_program(
28659 "workflow T\noutput result D\nclass D { a string }\nenum E {\n A\n utterly unknown line\n B\n}\nrule r\n when started\n=> {\n complete result { a \"x\" }\n}\n",
28660 );
28661 assert!(garbage.diagnostics.iter().any(|d| d
28662 .message
28663 .contains("on the same line as the previous variant")));
28664 let payload = compile_program(
28666 "workflow T\noutput result D\nclass D { a string }\nenum E {\n Approved {\n score float\n }\n Rejected {\n reason string\n }\n}\nrule r\n when started\n=> {\n complete result { a \"x\" }\n}\n",
28667 );
28668 assert!(
28669 !payload
28670 .diagnostics
28671 .iter()
28672 .any(|d| d.message.contains("same line")),
28673 "{:?}",
28674 payload.diagnostics
28675 );
28676 }
28677
28678 #[test]
28679 fn unknown_std_package_import_is_a_check_error() {
28680 let typo = compile_program(
28683 "use std.coercon\nworkflow T\noutput result D\nclass D { a string }\nrule r\n when started\n=> {\n complete result { a \"x\" }\n}\n",
28684 );
28685 assert!(typo
28686 .diagnostics
28687 .iter()
28688 .any(|d| d.message.contains("unknown standard package `std.coercon`")));
28689 for id in STD_PACKAGE_IDS {
28691 let ok = compile_program(&format!(
28692 "use {id}\nworkflow T\noutput result D\nclass D {{ a string }}\nrule r\n when started\n=> {{\n complete result {{ a \"x\" }}\n}}\n"
28693 ));
28694 assert!(
28695 !ok.diagnostics
28696 .iter()
28697 .any(|d| d.message.contains("unknown standard package")),
28698 "{id}: {:?}",
28699 ok.diagnostics
28700 );
28701 }
28702 let nonstd = compile_program(
28704 "use notes\nworkflow T\noutput result D\nclass D { a string }\nrule r\n when started\n=> {\n complete result { a \"x\" }\n}\n",
28705 );
28706 assert!(!nonstd
28707 .diagnostics
28708 .iter()
28709 .any(|d| d.message.contains("unknown standard package")));
28710 }
28711
28712 #[test]
28713 fn blockless_coerce_desugars_to_the_prompt_clause() {
28714 let block = compile_program(
28715 "use std.coercion\nworkflow T\noutput result D\nclass D { a string }\nclass V { a string }\ncoerce f(x string) -> V {\n prompt \"\"\"markdown\n Judge {{ x }}. {{ ctx.output_format }}\n \"\"\"\n}\nrule r\n when started\n=> {\n coerce f(\"t\") as v\n after v succeeds as o {\n complete result { a o.a }\n }\n}\n",
28716 );
28717 let blockless = compile_program(
28718 "use std.coercion\nworkflow T\noutput result D\nclass D { a string }\nclass V { a string }\ncoerce f(x string) -> V \"\"\"markdown\nJudge {{ x }}. {{ ctx.output_format }}\n\"\"\"\nrule r\n when started\n=> {\n coerce f(\"t\") as v\n after v succeeds as o {\n complete result { a o.a }\n }\n}\n",
28719 );
28720 let block_ir = block.ir.expect("block form compiles");
28721 let blockless_ir = blockless.ir.expect("blockless form compiles");
28722 assert!(blockless_ir.coerces[0].body.starts_with("prompt \"\"\""));
28725 assert_eq!(block_ir.coerces[0].name, blockless_ir.coerces[0].name);
28726 }
28727
28728 #[test]
28729 fn coerce_body_is_a_validated_clause_list() {
28730 let source = |body: &str| {
28731 format!(
28732 "use std.coercion\nworkflow T\noutput result D\nclass D {{ a string }}\nclass V {{ a string }}\ncoerce f(x string) -> V {{\n{body}\n}}\nrule r\n when started\n=> {{\n coerce f(\"t\") as v\n after v succeeds as o {{\n complete result {{ a o.a }}\n }}\n}}\n"
28733 )
28734 };
28735 let typo = compile_program(&source(" promt \"Judge {{ x }}\""));
28737 assert!(typo
28738 .diagnostics
28739 .iter()
28740 .any(|d| d.message.contains("unknown coerce field `promt`")));
28741 let junk = compile_program(&source(" prompt \"Judge {{ x }}\"\n mystery field"));
28743 assert!(junk
28744 .diagnostics
28745 .iter()
28746 .any(|d| d.message.contains("unknown coerce field `mystery`")));
28747 let legal = compile_program(&source(
28750 " # choose the fixture\n provider fixture\n\n prompt \"\"\"markdown\n Judge {{ x }}.\n {{ ctx.output_format }}\n \"\"\"",
28751 ));
28752 assert!(
28753 !legal
28754 .diagnostics
28755 .iter()
28756 .any(|d| d.message.contains("unknown coerce field")),
28757 "{:?}",
28758 legal.diagnostics
28759 );
28760 let malformed = compile_program(&source(" provider one two\n prompt \"Judge {{ x }}\""));
28762 assert!(malformed
28763 .diagnostics
28764 .iter()
28765 .any(|d| d.message.contains("malformed `provider` clause")));
28766 }
28767
28768 #[test]
28769 fn rejects_invalid_agent_declarations() {
28770 let source = include_str!("../../../examples/invalid/bad-agent.whip");
28771 let compiled = compile_program(source);
28772
28773 assert!(compiled.ir.is_none());
28774 assert_eq!(compiled.diagnostics.len(), 3);
28775 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
28776 .message
28777 .contains("capacity must be greater than zero")));
28778 assert!(compiled
28779 .diagnostics
28780 .iter()
28781 .any(|diagnostic| diagnostic.message.contains("more than once")));
28782 assert!(compiled
28783 .diagnostics
28784 .iter()
28785 .any(|diagnostic| diagnostic.message.contains("unknown agent field")));
28786 assert!(!compiled
28789 .diagnostics
28790 .iter()
28791 .any(|diagnostic| diagnostic.message.contains("missing a profile")));
28792 }
28793
28794 #[test]
28795 fn rejects_invalid_effect_dependencies() {
28796 let source = include_str!("../../../examples/invalid/bad-effect-graph.whip");
28797 let compiled = compile_program(source);
28798
28799 assert!(compiled.ir.is_none());
28800 assert!(compiled
28801 .diagnostics
28802 .iter()
28803 .any(|diagnostic| diagnostic.message.contains("unknown effect binding")));
28804 assert!(compiled
28805 .diagnostics
28806 .iter()
28807 .any(|diagnostic| diagnostic.message.contains("unsupported `after`")));
28808 }
28809
28810 #[test]
28811 fn accepts_equality_guards_in_when_clauses() {
28812 let source = r#"
28813workflow GuardGuess
28814
28815class WorkItem {
28816 state "ready" | "blocked"
28817}
28818
28819rule branch
28820 when WorkItem as item where item.state == "ready"
28821=> {
28822}
28823"#;
28824 let compiled = compile_program(source);
28825 assert_eq!(compiled.diagnostics, Vec::new());
28826 let ir = compiled.ir.expect("valid ir");
28827 let when = ir
28828 .rules
28829 .iter()
28830 .flat_map(|rule| &rule.whens)
28831 .find(|when| when.source == "WorkItem as item where item.state == \"ready\"")
28832 .expect("guarded when");
28833 assert_eq!(when.pattern, "WorkItem as item");
28834 assert_eq!(
28835 when.guard.as_ref().map(|guard| guard.expr.to_snapshot()),
28836 Some("item.state == \"ready\"".to_owned())
28837 );
28838 }
28839
28840 #[test]
28841 fn lowers_assertions_to_parsed_expression_ir() {
28842 let source = r#"
28843workflow AssertionGuess
28844
28845class Result {
28846 status "done"
28847}
28848
28849assert count(Result where status == "done") == 1
28850"#;
28851 let compiled = compile_program(source);
28852 assert_eq!(compiled.diagnostics, Vec::new());
28853 let ir = compiled.ir.expect("valid ir");
28854 let assertion = ir.assertions.first().expect("assertion");
28855 assert_eq!(
28856 assertion.expr.source,
28857 "count(Result where status == \"done\") == 1"
28858 );
28859 assert_eq!(
28860 assertion.expr.expr.to_snapshot(),
28861 "count(Result where status == \"done\") == 1"
28862 );
28863 assert_eq!(
28864 assertion
28865 .projection_reads
28866 .iter()
28867 .map(IrProjectionRead::to_snapshot)
28868 .collect::<Vec<_>>(),
28869 vec!["fact:Result where status == \"done\""]
28870 );
28871 }
28872
28873 #[test]
28874 fn lowers_guard_projection_reads_to_rule_metadata() {
28875 let source = r#"
28876workflow GuardProjection
28877
28878class Task {
28879 status "ready"
28880}
28881
28882class Result {
28883 status "done"
28884}
28885
28886rule gated
28887 when Task as task where exists(Result where status == "done")
28888=> {
28889}
28890"#;
28891 let compiled = compile_program(source);
28892 assert_eq!(compiled.diagnostics, Vec::new());
28893 let ir = compiled.ir.expect("valid ir");
28894 let rule = ir.rules.first().expect("rule");
28895 assert_eq!(
28896 rule.metadata
28897 .projection_reads
28898 .iter()
28899 .map(IrProjectionRead::to_snapshot)
28900 .collect::<Vec<_>>(),
28901 vec!["fact:Result where status == \"done\""]
28902 );
28903 }
28904
28905 fn read_codec_program(format: &str) -> String {
28906 format!(
28907 r#"
28908workflow ReadBody
28909
28910output result Result
28911
28912class Result {{
28913 status string
28914}}
28915
28916file store project_files {{
28917 root "./data"
28918}}
28919
28920rule pick
28921 when started
28922=> {{
28923 read {format} from project_files at "note.md" as fileResult
28924 after fileResult succeeds as result {{
28925 complete result {{
28926 status "ok"
28927 }}
28928 }}
28929}}
28930"#
28931 )
28932 }
28933
28934 #[test]
28935 fn read_accepts_text_and_markdown_body_codecs() {
28936 for format in ["text", "markdown"] {
28937 let compiled = compile_program(&read_codec_program(format));
28938 assert_eq!(
28939 compiled.diagnostics,
28940 Vec::new(),
28941 "`read {format}` compiles clean"
28942 );
28943 assert!(compiled.ir.is_some(), "`read {format}` produces IR");
28944 }
28945 }
28946
28947 #[test]
28948 fn read_rejects_structured_and_binary_codecs() {
28949 for format in ["json", "jsonl", "csv", "bytes"] {
28952 let compiled = compile_program(&read_codec_program(format));
28953 assert!(
28954 compiled
28955 .diagnostics
28956 .iter()
28957 .any(|diagnostic| diagnostic.message.contains("not supported")),
28958 "`read {format}` is rejected with a diagnostic; got {:?}",
28959 compiled.diagnostics
28960 );
28961 }
28962 }
28963
28964 fn write_program(format: &str, mode_clause: &str) -> String {
28965 format!(
28966 r#"
28967workflow WriteBody
28968
28969output result Result
28970
28971class Result {{
28972 status string
28973}}
28974
28975file store out_files {{
28976 root "./data"
28977 allow write ["**"]
28978}}
28979
28980rule pick
28981 when started
28982=> {{
28983 write {format} to out_files at "report.md" {{
28984 body "hello"
28985 {mode_clause}
28986 }} as written
28987 after written succeeds as result {{
28988 complete result {{
28989 status "ok"
28990 }}
28991 }}
28992}}
28993"#
28994 )
28995 }
28996
28997 #[test]
28998 fn write_accepts_text_and_markdown_with_explicit_mode() {
28999 for format in ["text", "markdown"] {
29000 let compiled = compile_program(&write_program(format, "mode create"));
29001 assert_eq!(
29002 compiled.diagnostics,
29003 Vec::new(),
29004 "`write {format}` with an explicit mode compiles clean"
29005 );
29006 assert!(compiled.ir.is_some(), "`write {format}` produces IR");
29007 }
29008 }
29009
29010 #[test]
29011 fn write_rejects_structured_codecs() {
29012 for format in ["json", "csv", "bytes"] {
29013 let compiled = compile_program(&write_program(format, "mode create"));
29014 assert!(
29015 compiled
29016 .diagnostics
29017 .iter()
29018 .any(|diagnostic| diagnostic.message.contains("not supported")),
29019 "`write {format}` is rejected; got {:?}",
29020 compiled.diagnostics
29021 );
29022 }
29023 }
29024
29025 #[test]
29026 fn write_requires_an_explicit_mode() {
29027 let compiled = compile_program(&write_program("text", ""));
29029 assert!(
29030 compiled
29031 .diagnostics
29032 .iter()
29033 .any(|diagnostic| diagnostic.message.contains("explicit `mode`")),
29034 "`write` without a mode is rejected; got {:?}",
29035 compiled.diagnostics
29036 );
29037 }
29038
29039 #[test]
29040 fn write_rejects_unknown_mode() {
29041 let compiled = compile_program(&write_program("text", "mode clobber"));
29042 assert!(
29043 compiled
29044 .diagnostics
29045 .iter()
29046 .any(|diagnostic| diagnostic.message.contains("unknown write mode")),
29047 "an unknown write mode is rejected; got {:?}",
29048 compiled.diagnostics
29049 );
29050 }
29051
29052 fn import_program(format: &str) -> String {
29053 format!(
29054 r#"
29055workflow ImportRows
29056
29057output result Result
29058
29059class Result {{
29060 status string
29061}}
29062
29063class IssueRow {{
29064 title string
29065 priority string
29066}}
29067
29068file store data_files {{
29069 root "./data"
29070}}
29071
29072rule pick
29073 when started
29074=> {{
29075 import {format} IssueRow from data_files at "issues.in" as imported
29076 after imported succeeds as r {{
29077 complete result {{
29078 status "ok"
29079 }}
29080 }}
29081}}
29082"#
29083 )
29084 }
29085
29086 #[test]
29087 fn import_accepts_structured_codecs_and_lowers_to_file_import() {
29088 for format in ["jsonl", "json", "csv"] {
29089 let compiled = compile_program(&import_program(format));
29090 assert_eq!(
29091 compiled.diagnostics,
29092 Vec::new(),
29093 "`import {format}` compiles clean"
29094 );
29095 let ir = compiled.ir.expect("import produces IR");
29096 let rule = ir.rules.first().expect("rule");
29097 assert!(
29098 rule.metadata
29099 .effects
29100 .iter()
29101 .any(|effect| effect.kind == IrEffectKind::FileImport),
29102 "`import {format}` lowers to a file.import effect"
29103 );
29104 }
29105 }
29106
29107 #[test]
29108 fn import_rejects_unsupported_codecs() {
29109 for format in ["xml", "text", "markdown", "bytes"] {
29112 let compiled = compile_program(&import_program(format));
29113 assert!(
29114 compiled
29115 .diagnostics
29116 .iter()
29117 .any(|diagnostic| diagnostic.message.contains("not supported")),
29118 "`import {format}` is rejected; got {:?}",
29119 compiled.diagnostics
29120 );
29121 }
29122 }
29123
29124 #[test]
29125 fn class_field_key_annotation_lowers_and_rejects_duplicates() {
29126 let single = compile_program(
29127 r#"
29128workflow Keyed
29129
29130class Row {
29131 id string @key
29132 title string
29133}
29134"#,
29135 );
29136 assert_eq!(
29137 single.diagnostics,
29138 Vec::new(),
29139 "single `@key` compiles clean"
29140 );
29141 let ir = single.ir.expect("ir");
29142 let class = ir
29143 .schemas
29144 .iter()
29145 .find_map(|schema| match schema {
29146 IrSchema::Class(class) if class.name == "Row" => Some(class),
29147 _ => None,
29148 })
29149 .expect("Row class");
29150 let key_fields = class
29151 .fields
29152 .iter()
29153 .filter(|field| field.is_key)
29154 .map(|field| field.name.as_str())
29155 .collect::<Vec<_>>();
29156 assert_eq!(key_fields, vec!["id"], "the `@key` field is recorded");
29157
29158 let dual = compile_program(
29159 r#"
29160workflow Keyed
29161
29162class Row {
29163 a string @key
29164 b string @key
29165}
29166"#,
29167 );
29168 assert!(
29169 dual.diagnostics
29170 .iter()
29171 .any(|diagnostic| diagnostic.message.contains("more than one `@key`")),
29172 "two `@key` fields are rejected; got {:?}",
29173 dual.diagnostics
29174 );
29175 }
29176
29177 #[test]
29178 fn single_line_terminal_block_validates_its_fields() {
29179 for body in [
29184 " complete result { status \"ok\" }",
29185 " complete result {\n status \"ok\"\n }",
29186 ] {
29187 let source = format!(
29188 r#"
29189workflow S
29190
29191output result Result
29192
29193class Result {{
29194 status string
29195}}
29196
29197rule go
29198 when started
29199=> {{
29200{body}
29201}}
29202"#
29203 );
29204 let compiled = compile_program(&source);
29205 assert!(
29206 !compiled
29207 .diagnostics
29208 .iter()
29209 .any(|diagnostic| diagnostic.message.contains("missing required field")),
29210 "terminal block validates its field; got {:?}",
29211 compiled.diagnostics
29212 );
29213 }
29214 }
29215
29216 #[test]
29217 fn action_declaration_parses_and_is_inert_until_expansion() {
29218 let compiled = compile_program(
29223 r#"
29224workflow A
29225
29226output result Result
29227
29228class Result {
29229 status string
29230}
29231
29232class Task {
29233 name string
29234}
29235
29236action do_it(task Task, label string) {
29237 record Result {
29238 status label
29239 }
29240}
29241
29242rule go
29243 when started
29244=> {
29245 complete result {
29246 status "ok"
29247 }
29248}
29249"#,
29250 );
29251 assert_eq!(
29252 compiled.diagnostics,
29253 Vec::new(),
29254 "an unused action declaration compiles clean"
29255 );
29256 let ir = compiled.ir.expect("program with an action lowers");
29257 assert!(
29260 ir.rules.iter().any(|rule| rule.name == "go"),
29261 "the ordinary rule still lowers alongside the action template"
29262 );
29263 }
29264
29265 #[test]
29266 fn accepts_typed_case_branches_in_rule_bodies() {
29267 let source = r#"
29268workflow CaseGuess
29269
29270enum ReviewStatus {
29271 Accept
29272 Revise
29273 Blocked
29274}
29275
29276class Review {
29277 status ReviewStatus
29278 assignee string?
29279}
29280
29281class Routed {
29282 status ReviewStatus
29283}
29284
29285rule route
29286 when Review as review
29287=> {
29288 case review.status {
29289 Accept => {
29290 record Routed {
29291 status Accept
29292 }
29293 }
29294 Revise => {
29295 record Routed {
29296 status Revise
29297 }
29298 }
29299 Blocked => {
29300 record Routed {
29301 status Blocked
29302 }
29303 }
29304 }
29305
29306 case review.assignee {
29307 Some owner => {
29308 record Routed {
29309 status Accept
29310 }
29311 }
29312 None => {
29313 record Routed {
29314 status Blocked
29315 }
29316 }
29317 }
29318}
29319"#;
29320 let compiled = compile_program(source);
29321 assert_eq!(compiled.diagnostics, Vec::new());
29322 assert!(compiled.ir.is_some());
29323 }
29324
29325 #[test]
29326 fn accepts_terminal_output_case_branches_inside_completes_after() {
29327 let source = r#"
29328workflow TerminalCaseGuess
29329
29330class WorkItem {
29331 title string
29332}
29333
29334class MessageClassification {
29335 summary string
29336}
29337
29338class Routed {
29339 branch string
29340 detail string
29341}
29342
29343coerce classifyMessage(title string) -> MessageClassification {
29344 prompt "Classify"
29345}
29346
29347rule classify
29348 when WorkItem as item
29349=> {
29350 coerce classifyMessage(item.title) as classification
29351
29352 after classification completes {
29353 case classification {
29354 Completed as result => {
29355 record Routed {
29356 branch "completed"
29357 detail result.summary
29358 }
29359 }
29360 Failed as failure => {
29361 record Routed {
29362 branch "failed"
29363 detail failure.reason
29364 }
29365 }
29366 TimedOut as timeout => {
29367 record Routed {
29368 branch "timed_out"
29369 detail timeout.summary
29370 }
29371 }
29372 Cancelled as cancel => {
29373 record Routed {
29374 branch "cancelled"
29375 detail cancel.summary
29376 }
29377 }
29378 }
29379 }
29380}
29381"#;
29382 let compiled = compile_program(source);
29383 assert_eq!(compiled.diagnostics, Vec::new());
29384 assert!(compiled.ir.is_some());
29385 }
29386
29387 #[test]
29388 fn accepts_terminal_output_case_as_binding_form() {
29389 let source = r#"
29392workflow T
29393
29394class WorkItem { title string }
29395class MessageClassification { summary string }
29396class Routed {
29397 branch string
29398 detail string
29399}
29400
29401coerce classifyMessage(title string) -> MessageClassification {
29402 prompt "Classify"
29403}
29404
29405rule classify
29406 when WorkItem as item
29407=> {
29408 coerce classifyMessage(item.title) as classification
29409
29410 after classification completes {
29411 case classification {
29412 Completed as result => {
29413 record Routed { branch "completed" detail result.summary }
29414 }
29415 Failed as failure => {
29416 record Routed { branch "failed" detail failure.reason }
29417 }
29418 TimedOut as timeout => {
29419 record Routed { branch "timed_out" detail timeout.summary }
29420 }
29421 Cancelled as cancel => {
29422 record Routed { branch "cancelled" detail cancel.summary }
29423 }
29424 }
29425 }
29426}
29427"#;
29428 let compiled = compile_program(source);
29429 assert_eq!(compiled.diagnostics, Vec::new());
29430 assert!(compiled.ir.is_some());
29431 }
29432
29433 #[test]
29434 fn accepts_after_times_out_branch_and_types_payload_alias() {
29435 let source = r#"
29436workflow TimedOutBranch
29437
29438class WorkItem {
29439 title string
29440}
29441
29442class MessageClassification {
29443 summary string
29444}
29445
29446class Routed {
29447 branch string
29448 detail string
29449}
29450
29451coerce classifyMessage(title string) -> MessageClassification {
29452 prompt "Classify"
29453}
29454
29455rule classify
29456 when WorkItem as item
29457=> {
29458 coerce classifyMessage(item.title) as classification
29459
29460 after classification times out as t {
29461 record Routed {
29462 branch "timed_out"
29463 detail t.summary
29464 }
29465 }
29466}
29467"#;
29468 let compiled = compile_program(source);
29469 assert_eq!(compiled.diagnostics, Vec::new());
29470 assert!(compiled.ir.is_some());
29471 }
29472
29473 #[test]
29474 fn accepts_after_cancelled_branch_and_types_payload_alias() {
29475 let source = r#"
29476workflow CancelledBranch
29477
29478class WorkItem {
29479 title string
29480}
29481
29482class MessageClassification {
29483 summary string
29484}
29485
29486class Routed {
29487 branch string
29488 detail string
29489}
29490
29491coerce classifyMessage(title string) -> MessageClassification {
29492 prompt "Classify"
29493}
29494
29495rule classify
29496 when WorkItem as item
29497=> {
29498 coerce classifyMessage(item.title) as classification
29499
29500 after classification cancelled as c {
29501 record Routed {
29502 branch "cancelled"
29503 detail c.summary
29504 }
29505 }
29506}
29507"#;
29508 let compiled = compile_program(source);
29509 assert_eq!(compiled.diagnostics, Vec::new());
29510 assert!(compiled.ir.is_some());
29511 }
29512
29513 #[test]
29514 fn rejects_invalid_after_predicate_during_compilation() {
29515 let source = r#"
29516workflow BadPredicate
29517
29518class WorkItem {
29519 title string
29520}
29521
29522class MessageClassification {
29523 summary string
29524}
29525
29526class Routed {
29527 branch string
29528}
29529
29530coerce classifyMessage(title string) -> MessageClassification {
29531 prompt "Classify"
29532}
29533
29534rule classify
29535 when WorkItem as item
29536=> {
29537 coerce classifyMessage(item.title) as classification
29538
29539 after classification explodes {
29540 record Routed {
29541 branch "boom"
29542 }
29543 }
29544}
29545"#;
29546 let compiled = compile_program(source);
29547 assert!(compiled.diagnostics.iter().any(|d| d
29548 .message
29549 .contains("unsupported `after` predicate `explodes`")));
29550 }
29551
29552 #[test]
29553 fn lowers_terminal_output_case_branches_to_typed_ir() {
29554 let source = include_str!("../../../examples/terminal-output-union.whip");
29555 let compiled = compile_program(source);
29556 assert_eq!(compiled.diagnostics, Vec::new());
29557 let ir = compiled.ir.expect("expected lowered IR");
29558 let rule = ir
29559 .rules
29560 .iter()
29561 .find(|rule| rule.name == "classify_work")
29562 .expect("rule");
29563
29564 let terminal_output = rule
29565 .metadata
29566 .terminal_outputs
29567 .iter()
29568 .find(|output| output.binding == "classification")
29569 .expect("terminal output");
29570 assert_eq!(terminal_output.alternatives.len(), 4);
29571 assert_eq!(
29572 terminal_output.alternatives[0].payload_type,
29573 IrType::Ref("Classification".to_owned())
29574 );
29575 assert_eq!(
29576 rule.metadata
29577 .terminal_branches
29578 .iter()
29579 .map(|branch| {
29580 (
29581 branch.tag.as_deref().unwrap_or("_"),
29582 branch.binding.as_deref().unwrap_or("-"),
29583 )
29584 })
29585 .collect::<Vec<_>>(),
29586 vec![
29587 ("Completed", "result"),
29588 ("Failed", "failure"),
29589 ("TimedOut", "timeout"),
29590 ("Cancelled", "cancel"),
29591 ]
29592 );
29593 }
29594
29595 #[test]
29596 fn rejects_terminal_payload_fields_outside_refined_tag_schema() {
29597 let source = r#"
29598workflow BadTerminalPayload
29599
29600class WorkItem {
29601 title string
29602}
29603
29604class Classification {
29605 summary string
29606}
29607
29608class TerminalRoute {
29609 detail string
29610}
29611
29612coerce classify(title string) -> Classification {
29613 prompt "Classify"
29614}
29615
29616rule classify_work
29617 when WorkItem as item
29618=> {
29619 coerce classify(item.title) as classification
29620
29621 after classification completes {
29622 case classification {
29623 Completed as result => {
29624 record TerminalRoute {
29625 detail result.reason
29626 }
29627 }
29628 Failed as failure => {
29629 record TerminalRoute {
29630 detail failure.reason
29631 }
29632 }
29633 TimedOut as timeout => {
29634 record TerminalRoute {
29635 detail timeout.summary
29636 }
29637 }
29638 Cancelled as cancel => {
29639 record TerminalRoute {
29640 detail cancel.summary
29641 }
29642 }
29643 }
29644 }
29645}
29646"#;
29647 let compiled = compile_program(source);
29648 assert!(compiled.ir.is_none());
29649 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
29650 .message
29651 .contains("invalid field path `result.reason`")));
29652 }
29653
29654 #[test]
29655 fn rejects_invalid_terminal_output_case_branches() {
29656 let source = r#"
29657workflow BadTerminalCaseGuess
29658
29659class WorkItem {
29660 title string
29661}
29662
29663class MessageClassification {
29664 summary string
29665}
29666
29667coerce classifyMessage(title string) -> MessageClassification {
29668 prompt "Classify"
29669}
29670
29671rule classify
29672 when WorkItem as item
29673=> {
29674 coerce classifyMessage(item.title) as classification
29675
29676 after classification completes {
29677 case classification {
29678 Success as result => {
29679 }
29680 Completed as result => {
29681 }
29682 }
29683 }
29684}
29685"#;
29686 let compiled = compile_program(source);
29687 assert!(compiled.ir.is_none());
29688 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
29689 .message
29690 .contains("terminal-output case pattern cannot be `Success`")));
29691 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
29692 .message
29693 .contains("non-exhaustive terminal-output case; missing Failed, TimedOut, Cancelled")));
29694 }
29695
29696 fn terminal_case_program(cases: &str) -> String {
29700 format!(
29701 r#"
29702workflow TerminalCaseMatrix
29703
29704class WorkItem {{
29705 title string
29706}}
29707
29708class MessageClassification {{
29709 summary string
29710}}
29711
29712class Routed {{
29713 branch string
29714}}
29715
29716coerce classifyMessage(title string) -> MessageClassification {{
29717 prompt "Classify"
29718}}
29719
29720rule classify
29721 when WorkItem as item
29722=> {{
29723 coerce classifyMessage(item.title) as classification
29724
29725 after classification completes {{
29726 case classification {{
29727{cases}
29728 }}
29729 }}
29730}}
29731"#
29732 )
29733 }
29734
29735 #[test]
29736 fn accepts_guarded_terminal_case_branch_referencing_refined_payload() {
29737 let source = terminal_case_program(
29742 " Completed as result where result.summary == \"ok\" => { record Routed { branch \"ok\" } }\n _ => { record Routed { branch \"other\" } }",
29743 );
29744 let compiled = compile_program(&source);
29745 assert_eq!(
29746 compiled.diagnostics,
29747 Vec::new(),
29748 "{:?}",
29749 compiled.diagnostics
29750 );
29751 assert!(compiled.ir.is_some());
29752 }
29753
29754 #[test]
29755 fn rejects_terminal_case_guard_referencing_unknown_payload_field() {
29756 let source = terminal_case_program(
29757 " Completed as result where result.nonexistent == \"ok\" => { record Routed { branch \"ok\" } }\n _ => { record Routed { branch \"other\" } }",
29758 );
29759 let compiled = compile_program(&source);
29760 assert!(compiled.ir.is_none());
29761 assert!(
29762 compiled.diagnostics.iter().any(|d| d
29763 .message
29764 .contains("schema `MessageClassification` has no field `nonexistent`")),
29765 "{:?}",
29766 compiled.diagnostics
29767 );
29768 }
29769
29770 #[test]
29771 fn rejects_non_boolean_terminal_case_guard() {
29772 let source = terminal_case_program(
29773 " Completed as result where result.summary => { record Routed { branch \"ok\" } }\n _ => { record Routed { branch \"other\" } }",
29774 );
29775 let compiled = compile_program(&source);
29776 assert!(compiled.ir.is_none());
29777 assert!(
29778 compiled
29779 .diagnostics
29780 .iter()
29781 .any(|d| d.message.contains("non-boolean case guard expression")),
29782 "{:?}",
29783 compiled.diagnostics
29784 );
29785 }
29786
29787 #[test]
29788 fn rejects_duplicate_terminal_output_case_tag() {
29789 let source = terminal_case_program(
29790 " Completed as result => { record Routed { branch \"a\" } }\n Completed as other => { record Routed { branch \"b\" } }\n Failed as failure => { record Routed { branch \"f\" } }\n TimedOut as timeout => { record Routed { branch \"t\" } }\n Cancelled as cancel => { record Routed { branch \"c\" } }",
29791 );
29792 let compiled = compile_program(&source);
29793 assert!(compiled.ir.is_none());
29794 assert!(
29795 compiled.diagnostics.iter().any(|d| d
29796 .message
29797 .contains("duplicate unguarded terminal-output case pattern `Completed`")),
29798 "{:?}",
29799 compiled.diagnostics
29800 );
29801 }
29802
29803 #[test]
29804 fn rejects_terminal_output_case_branch_without_payload_binding() {
29805 let source = terminal_case_program(
29806 " Completed => { record Routed { branch \"a\" } }\n Failed as failure => { record Routed { branch \"f\" } }\n TimedOut as timeout => { record Routed { branch \"t\" } }\n Cancelled as cancel => { record Routed { branch \"c\" } }",
29807 );
29808 let compiled = compile_program(&source);
29809 assert!(compiled.ir.is_none());
29810 assert!(
29811 compiled.diagnostics.iter().any(|d| d
29812 .message
29813 .contains("malformed terminal-output case pattern `Completed`")),
29814 "{:?}",
29815 compiled.diagnostics
29816 );
29817 }
29818
29819 #[test]
29820 fn rejects_invalid_case_branch_patterns() {
29821 let source = r#"
29822workflow BadCaseGuess
29823
29824enum ReviewStatus {
29825 Accept
29826 Revise
29827}
29828
29829class Review {
29830 status ReviewStatus
29831 assignee string
29832}
29833
29834rule route
29835 when Review as review
29836=> {
29837 case review.status {
29838 Missing => {
29839 }
29840 }
29841
29842 case review.assignee {
29843 Some owner => {
29844 }
29845 }
29846}
29847"#;
29848 let compiled = compile_program(source);
29849 assert!(compiled.ir.is_none());
29850 let missing = compiled
29851 .diagnostics
29852 .iter()
29853 .find(|diagnostic| {
29854 diagnostic
29855 .message
29856 .contains("enum `ReviewStatus` has no variant `Missing`")
29857 })
29858 .expect("missing variant diagnostic");
29859 assert!(source[missing.span.start..missing.span.end].contains("Mis"));
29860 let some = compiled
29861 .diagnostics
29862 .iter()
29863 .find(|diagnostic| {
29864 diagnostic
29865 .message
29866 .contains("uses `Some` for a non-optional case")
29867 })
29868 .expect("some diagnostic");
29869 assert!(source[some.span.start..some.span.end].contains("Some"));
29870 }
29871
29872 #[test]
29873 fn diagnoses_non_exhaustive_and_duplicate_case_branches() {
29874 let source = r#"
29875workflow CaseCoverageGuess
29876
29877enum ReviewStatus {
29878 Accept
29879 Revise
29880 Blocked
29881}
29882
29883class Review {
29884 status ReviewStatus
29885 provider "codex" | "claude"
29886 owner string?
29887}
29888
29889rule route
29890 when Review as review
29891=> {
29892 case review.status {
29893 Accept => {
29894 }
29895 Accept => {
29896 }
29897 Revise => {
29898 }
29899 }
29900
29901 case review.provider {
29902 "codex" => {
29903 }
29904 }
29905
29906 case review.owner {
29907 Some owner => {
29908 }
29909 }
29910}
29911"#;
29912 let compiled = compile_program(source);
29913 assert!(compiled.ir.is_none());
29914 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
29915 .message
29916 .contains("duplicate unguarded case pattern `Accept`")));
29917 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
29918 .message
29919 .contains("non-exhaustive case; missing Blocked")));
29920 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
29921 .message
29922 .contains("non-exhaustive case; missing claude")));
29923 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
29924 .message
29925 .contains("non-exhaustive case; missing None")));
29926 }
29927
29928 #[test]
29929 fn accepts_fallback_and_guarded_duplicate_case_branches() {
29930 let source = r#"
29931workflow CaseFallbackGuess
29932
29933enum ReviewStatus {
29934 Accept
29935 Revise
29936 Blocked
29937}
29938
29939class Review {
29940 status ReviewStatus
29941 owner string?
29942}
29943
29944rule route
29945 when Review as review
29946=> {
29947 case review.status {
29948 Accept where review.owner != null => {
29949 }
29950 Accept where review.owner == null => {
29951 }
29952 _ => {
29953 }
29954 }
29955}
29956"#;
29957 let compiled = compile_program(source);
29958 assert_eq!(compiled.diagnostics, Vec::new());
29959 assert!(compiled.ir.is_some());
29960 }
29961
29962 #[test]
29963 fn rejects_unreachable_case_branch_after_wildcard() {
29964 let source = r#"
29967workflow CaseUnreachableGuess
29968
29969enum ReviewStatus {
29970 Accept
29971 Revise
29972 Blocked
29973}
29974
29975class Review {
29976 status ReviewStatus
29977}
29978
29979rule route
29980 when Review as review
29981=> {
29982 case review.status {
29983 Accept => {
29984 }
29985 _ => {
29986 }
29987 Revise => {
29988 }
29989 }
29990}
29991"#;
29992 let compiled = compile_program(source);
29993 assert!(compiled.ir.is_none());
29994 assert!(
29995 compiled.diagnostics.iter().any(|diagnostic| diagnostic
29996 .message
29997 .contains("unreachable case branch after the `_` wildcard")),
29998 "expected unreachable-after-wildcard diagnostic: {:?}",
29999 compiled.diagnostics
30000 );
30001 }
30002
30003 #[test]
30004 fn family_b_presence_condition_validates_discriminant() {
30005 let program = |fields: &str| {
30006 format!(
30007 r#"
30008workflow B
30009input e Event
30010output result Done
30011class Done {{ ok bool }}
30012class Event {{
30013{fields}
30014}}
30015rule r
30016 when Event as e
30017=> {{
30018 complete result {{ ok true }}
30019}}
30020"#
30021 )
30022 };
30023 let ok = compile_program(&program(
30025 " kind \"deploy\" | \"rollback\"\n region string when kind is \"deploy\"",
30026 ));
30027 assert_eq!(ok.diagnostics, Vec::new());
30028 assert!(ok.ir.is_some());
30029 let bad1 = compile_program(&program(
30031 " kind \"deploy\" | \"rollback\"\n region string when missing is \"deploy\"",
30032 ));
30033 assert!(bad1
30034 .diagnostics
30035 .iter()
30036 .any(|d| d.message.contains("unknown discriminant `missing`")));
30037 let bad2 = compile_program(&program(
30039 " kind \"deploy\" | \"rollback\"\n region string when kind is \"ship\"",
30040 ));
30041 assert!(bad2
30042 .diagnostics
30043 .iter()
30044 .any(|d| d.message.contains("not a value of `kind`")));
30045 let bad3 = compile_program(&program(
30047 " kind string\n region string when kind is \"deploy\"",
30048 ));
30049 assert!(bad3
30050 .diagnostics
30051 .iter()
30052 .any(|d| d.message.contains("not a string-literal discriminant")));
30053 }
30054
30055 #[test]
30056 fn case_arm_effect_records_its_selector() {
30057 let source = r#"
30061workflow S
30062
30063input item WorkItem
30064output result R
30065class WorkItem { kind "a" | "b" }
30066class R { ok bool }
30067class V { ok bool }
30068
30069coerce f(t string) -> V { prompt "x" }
30070
30071rule r
30072 when WorkItem as item
30073=> {
30074 case item.kind {
30075 "a" => {
30076 coerce f("hi") as v
30077 after v succeeds {
30078 complete result { ok v.ok }
30079 }
30080 }
30081 "b" => {
30082 complete result { ok false }
30083 }
30084 }
30085}
30086"#;
30087 let ir = compile_program(source).ir.expect("compiles");
30088 let rule = ir.rules.iter().find(|r| r.name == "r").expect("rule r");
30089 let coerce = rule
30090 .metadata
30091 .effects
30092 .iter()
30093 .find(|e| e.binding.as_deref() == Some("v"))
30094 .expect("coerce effect v");
30095 let (scrutinee, pattern) = coerce
30096 .selected_by
30097 .as_ref()
30098 .expect("coerce in a case arm records its selector");
30099 assert_eq!(scrutinee, "item.kind");
30100 assert_eq!(pattern, "\"a\"");
30101 }
30105
30106 #[test]
30107 fn family_b_read_narrowing_restricts_conditioned_reads() {
30108 let program = |body: &str| {
30109 format!(
30110 r#"
30111workflow B
30112input e Event
30113output result Done
30114class Done {{ region string }}
30115class Event {{
30116 kind "deploy" | "rollback"
30117 region string when kind is "deploy"
30118}}
30119rule r
30120 when Event as e
30121=> {{
30122{body}
30123}}
30124"#
30125 )
30126 };
30127 let outside = compile_program(&program(" complete result { region e.region }"));
30129 assert!(
30130 outside
30131 .diagnostics
30132 .iter()
30133 .any(|d| d.message.contains("conditional field `e.region`")),
30134 "{:?}",
30135 outside.diagnostics
30136 );
30137 let matching = compile_program(&program(
30139 " case e.kind {\n \"deploy\" => { complete result { region e.region } }\n \"rollback\" => { complete result { region \"none\" } }\n }",
30140 ));
30141 assert_eq!(matching.diagnostics, Vec::new());
30142 assert!(matching.ir.is_some());
30143 let wrong = compile_program(&program(
30145 " case e.kind {\n \"deploy\" => { complete result { region \"x\" } }\n \"rollback\" => { complete result { region e.region } }\n }",
30146 ));
30147 assert!(
30148 wrong
30149 .diagnostics
30150 .iter()
30151 .any(|d| d.message.contains("conditional field `e.region`")),
30152 "{:?}",
30153 wrong.diagnostics
30154 );
30155 }
30156
30157 #[test]
30158 fn rejects_conflicting_reused_effect_binding() {
30159 let source = r#"
30162workflow D
30163
30164output result R
30165class R { x string }
30166class WorkItem { title string }
30167class A { a string }
30168class B { b string }
30169
30170coerce fa(t string) -> A { prompt "x" }
30171coerce fb(t string) -> B { prompt "x" }
30172
30173rule r
30174 when WorkItem as item
30175=> {
30176 coerce fa(item.title) as v
30177 coerce fb(item.title) as v
30178 complete result { x "done" }
30179}
30180"#;
30181 let compiled = compile_program(source);
30182 assert!(
30183 compiled
30184 .diagnostics
30185 .iter()
30186 .any(|d| d.message.contains("reuses effect binding `v`")),
30187 "{:?}",
30188 compiled.diagnostics
30189 );
30190 }
30191
30192 #[test]
30193 fn rejects_transitive_workflow_invocation_cycle() {
30194 let source = r#"
30197workflow A {
30198 input task TA
30199 output result RA
30200 class TA { id string }
30201 class RA { id string }
30202 rule go
30203 when TA as t
30204 => {
30205 invoke B { task { id t.id } } as b
30206 after b succeeds as r { complete result { id r.id } }
30207 }
30208}
30209
30210workflow B {
30211 input task TB
30212 output result RB
30213 class TB { id string }
30214 class RB { id string }
30215 rule go
30216 when TB as t
30217 => {
30218 invoke A { task { id t.id } } as a
30219 after a succeeds as r { complete result { id r.id } }
30220 }
30221}
30222"#;
30223 let compiled = compile_program_with_root(source, Some("A"));
30224 assert!(compiled.ir.is_none());
30225 assert!(
30226 compiled.diagnostics.iter().any(|d| d
30227 .message
30228 .contains("graph.unbounded_workflow_invocation_recursion")
30229 && d.message.contains("A -> B -> A")),
30230 "{:?}",
30231 compiled.diagnostics
30232 );
30233 }
30234
30235 #[test]
30236 fn accepts_acyclic_workflow_invocation_chain() {
30237 let source = r#"
30240workflow A {
30241 input task TA
30242 output result RA
30243 class TA { id string }
30244 class RA { id string }
30245 rule go
30246 when TA as t
30247 => {
30248 invoke B { task { id t.id } } as b
30249 after b succeeds as r { complete result { id r.id } }
30250 }
30251}
30252
30253workflow B {
30254 input task TB
30255 output result RB
30256 class TB { id string }
30257 class RB { id string }
30258 rule go
30259 when TB as t
30260 => {
30261 invoke C { task { id t.id } } as c
30262 after c succeeds as r { complete result { id r.id } }
30263 }
30264}
30265
30266workflow C {
30267 input task TC
30268 output result RC
30269 class TC { id string }
30270 class RC { id string }
30271 rule go
30272 when TC as t
30273 => {
30274 complete result { id t.id }
30275 }
30276}
30277"#;
30278 let compiled = compile_program_with_root(source, Some("A"));
30279 assert!(
30280 !compiled.diagnostics.iter().any(|d| d
30281 .message
30282 .contains("graph.unbounded_workflow_invocation_recursion")),
30283 "acyclic chain wrongly flagged: {:?}",
30284 compiled.diagnostics
30285 );
30286 }
30287
30288 #[test]
30289 fn rejects_invoking_private_sibling_workflow() {
30290 let source = r#"
30291class Job { id string }
30292class Report { id string }
30293
30294@private
30295workflow Child {
30296 input task Job
30297 output result Report
30298 rule work
30299 when Job as t
30300 => {
30301 complete result { id t.id }
30302 }
30303}
30304
30305workflow Parent {
30306 input task Job
30307 output result Report
30308 rule go
30309 when Job as t
30310 => {
30311 invoke Child { task t } as child
30312 after child succeeds as r { complete result { id r.id } }
30313 }
30314}
30315"#;
30316 let compiled = compile_program_with_root(source, Some("Parent"));
30317 assert!(compiled.ir.is_none());
30318 assert!(
30319 compiled
30320 .diagnostics
30321 .iter()
30322 .any(|d| d.message.contains("private workflow `Child`")),
30323 "{:?}",
30324 compiled.diagnostics
30325 );
30326 }
30327
30328 #[test]
30329 fn accepts_private_workflow_as_selected_root() {
30330 let source = r#"
30331class Job { id string }
30332class Report { id string }
30333
30334@private
30335workflow Child {
30336 input task Job
30337 output result Report
30338 rule work
30339 when Job as t
30340 => {
30341 complete result { id t.id }
30342 }
30343}
30344"#;
30345 let compiled = compile_program_with_root(source, Some("Child"));
30346 let ir = compiled
30347 .ir
30348 .unwrap_or_else(|| panic!("private root compiles: {:?}", compiled.diagnostics));
30349 assert!(ir.source_tags.iter().any(|tag| {
30350 tag.name == "private" && tag.target_kind == "workflow" && tag.target == "Child"
30351 }));
30352 }
30353
30354 #[test]
30355 fn typed_invoke_result_checks_field_access_against_child_output() {
30356 let source = r#"
30360class Report { id string }
30361class Job { id string }
30362
30363workflow Parent {
30364 input task Job
30365 output result Report
30366 rule go
30367 when Job as t
30368 => {
30369 invoke Child { task { id t.id } } as child
30370 after child succeeds as r {
30371 complete result { id r.missing }
30372 }
30373 }
30374}
30375
30376workflow Child {
30377 input task Job
30378 output result Report
30379 rule work
30380 when Job as t
30381 => {
30382 complete result { id t.id }
30383 }
30384}
30385"#;
30386 let compiled = compile_program_with_root(source, Some("Parent"));
30387 assert!(
30388 compiled.ir.is_none(),
30389 "unknown field on invoke result must not compile"
30390 );
30391 assert!(
30392 compiled
30393 .diagnostics
30394 .iter()
30395 .any(|d| d.message.contains("r.missing") || d.message.contains("missing")),
30396 "typed invoke result did not reject r.missing: {:?}",
30397 compiled.diagnostics
30398 );
30399 }
30400
30401 #[test]
30402 fn typed_invoke_result_accepts_a_valid_child_output_field() {
30403 let source = r#"
30406class Report { id string }
30407class Job { id string }
30408
30409workflow Parent {
30410 input task Job
30411 output result Report
30412 rule go
30413 when Job as t
30414 => {
30415 invoke Child { task { id t.id } } as child
30416 after child succeeds as r {
30417 complete result { id r.id }
30418 }
30419 }
30420}
30421
30422workflow Child {
30423 input task Job
30424 output result Report
30425 rule work
30426 when Job as t
30427 => {
30428 complete result { id t.id }
30429 }
30430}
30431"#;
30432 let compiled = compile_program_with_root(source, Some("Parent"));
30433 assert!(
30434 compiled.diagnostics.is_empty(),
30435 "valid invoke-result field access wrongly rejected: {:?}",
30436 compiled.diagnostics
30437 );
30438 assert!(compiled.ir.is_some());
30439 }
30440
30441 #[test]
30442 fn typed_invoke_failure_checks_field_access_against_child_failure() {
30443 let source = r#"
30448class Report { id string }
30449class Job { id string }
30450class ChildError { reason string }
30451class ParentError { detail string }
30452
30453workflow Parent {
30454 input task Job
30455 output result Report
30456 failure err ParentError
30457 rule go
30458 when Job as t
30459 => {
30460 invoke Child { task { id t.id } } as child
30461 after child succeeds as r {
30462 complete result { id r.id }
30463 }
30464 after child fails as f {
30465 fail err { detail f.nonexistent }
30466 }
30467 }
30468}
30469
30470workflow Child {
30471 input task Job
30472 output result Report
30473 failure err ChildError
30474 rule work
30475 when Job as t
30476 => {
30477 fail err { reason t.id }
30478 }
30479}
30480"#;
30481 let compiled = compile_program_with_root(source, Some("Parent"));
30482 assert!(
30483 compiled.ir.is_none(),
30484 "unknown field on invoke failure must not compile"
30485 );
30486 assert!(
30487 compiled
30488 .diagnostics
30489 .iter()
30490 .any(|d| d.message.contains("f.nonexistent") || d.message.contains("nonexistent")),
30491 "typed invoke failure did not reject f.nonexistent: {:?}",
30492 compiled.diagnostics
30493 );
30494 }
30495
30496 #[test]
30497 fn typed_invoke_failure_accepts_a_valid_child_failure_field() {
30498 let source = r#"
30502class Report { id string }
30503class Job { id string }
30504class ChildError { reason string }
30505class ParentError { detail string }
30506
30507workflow Parent {
30508 input task Job
30509 output result Report
30510 failure err ParentError
30511 rule go
30512 when Job as t
30513 => {
30514 invoke Child { task { id t.id } } as child
30515 after child succeeds as r {
30516 complete result { id r.id }
30517 }
30518 after child fails as f {
30519 fail err { detail f.reason }
30520 }
30521 }
30522}
30523
30524workflow Child {
30525 input task Job
30526 output result Report
30527 failure err ChildError
30528 rule work
30529 when Job as t
30530 => {
30531 fail err { reason t.id }
30532 }
30533}
30534"#;
30535 let compiled = compile_program_with_root(source, Some("Parent"));
30536 assert!(
30537 compiled.diagnostics.is_empty(),
30538 "valid invoke-failure field access wrongly rejected: {:?}",
30539 compiled.diagnostics
30540 );
30541 assert!(compiled.ir.is_some());
30542 }
30543
30544 #[test]
30545 fn whole_program_validation_catches_a_broken_sibling_under_any_root() {
30546 let source = r#"
30552workflow Good {
30553 input task TG
30554 output result RG
30555 class TG { id string }
30556 class RG { id string }
30557 rule go
30558 when TG as t
30559 => {
30560 complete result { id t.id }
30561 }
30562}
30563
30564workflow Broken {
30565 input task TB
30566 output result RB
30567 class TB { id string }
30568 class RB { id string }
30569 rule go
30570 when Nonexistent as t
30571 => {
30572 complete result { id t.id }
30573 }
30574}
30575"#;
30576 let compiled = compile_program_with_root(source, Some("Good"));
30577 assert!(
30578 compiled.ir.is_none(),
30579 "a program with a broken sibling must not compile"
30580 );
30581 assert!(
30582 compiled
30583 .diagnostics
30584 .iter()
30585 .any(|d| d.message.contains("Nonexistent")),
30586 "the broken sibling's error was not surfaced: {:?}",
30587 compiled.diagnostics
30588 );
30589 }
30590
30591 #[test]
30592 fn cross_workflow_reference_to_sibling_local_is_annotated() {
30593 let source = r#"
30598workflow Owner {
30599 input task TO
30600 output result RO
30601 class TO { id string }
30602 class RO { id string }
30603 class Secret { id string }
30604 rule go
30605 when TO as t
30606 => {
30607 complete result { id t.id }
30608 }
30609}
30610
30611workflow Consumer {
30612 input task TC
30613 output result RC
30614 class TC { id string }
30615 class RC { id string }
30616 rule go
30617 when Secret as s
30618 => {
30619 complete result { id s.id }
30620 }
30621}
30622"#;
30623 let compiled = compile_program_with_root(source, Some("Consumer"));
30624 assert!(
30625 compiled.ir.is_none(),
30626 "sibling-local reference must not compile"
30627 );
30628 let leak = compiled
30629 .diagnostics
30630 .iter()
30631 .find(|d| d.message.contains("`Secret`"))
30632 .expect("an unknown-name diagnostic for Secret");
30633 assert!(
30634 leak.related
30635 .iter()
30636 .any(|r| r.message.contains("workflow `Owner`")
30637 && r.message.contains("private to that workflow")),
30638 "missing sibling-local leak note: {:?}",
30639 leak.related
30640 );
30641 }
30642
30643 #[test]
30644 fn shared_top_level_name_is_not_annotated_as_a_leak() {
30645 let source = r#"
30649class Shared { id string }
30650
30651workflow Alpha {
30652 input task Shared
30653 output result RA
30654 class RA { id string }
30655 rule go
30656 when Shared as s
30657 => {
30658 complete result { id s.id }
30659 }
30660}
30661
30662workflow Beta {
30663 input task Shared
30664 output result RB
30665 class RB { id string }
30666 rule go
30667 when Shared as s
30668 => {
30669 complete result { id s.id }
30670 }
30671}
30672"#;
30673 let compiled = compile_program_with_root(source, Some("Alpha"));
30674 assert!(
30675 compiled.diagnostics.is_empty(),
30676 "shared top-level global wrongly rejected: {:?}",
30677 compiled.diagnostics
30678 );
30679 assert!(compiled.ir.is_some());
30680 }
30681
30682 #[test]
30683 fn whole_program_validation_accepts_all_well_formed_workflows() {
30684 let source = r#"
30687workflow Alpha {
30688 input task TA
30689 output result RA
30690 class TA { id string }
30691 class RA { id string }
30692 rule go
30693 when TA as t
30694 => {
30695 complete result { id t.id }
30696 }
30697}
30698
30699workflow Beta {
30700 input task TB
30701 output result RB
30702 class TB { id string }
30703 class RB { id string }
30704 rule go
30705 when TB as t
30706 => {
30707 complete result { id t.id }
30708 }
30709}
30710"#;
30711 let compiled = compile_program_with_root(source, Some("Alpha"));
30712 assert!(
30713 compiled.diagnostics.is_empty(),
30714 "well-formed multi-workflow program emitted diagnostics: {:?}",
30715 compiled.diagnostics
30716 );
30717 assert!(compiled.ir.is_some(), "selected root failed to compile");
30718 }
30719
30720 #[test]
30721 fn compact_workflow_signature_desugars_to_keyword_contracts() {
30722 let compact = r#"
30725workflow Triage(ticket: Ticket) -> Resolution ! TriageFailed
30726
30727class Ticket { id string }
30728class Resolution { id string }
30729class TriageFailed { reason string }
30730
30731rule go
30732 when Ticket as t
30733=> {
30734 complete result { id t.id }
30735}
30736"#;
30737 let keyword = r#"
30738workflow Triage
30739
30740input ticket Ticket
30741output result Resolution
30742failure error TriageFailed
30743
30744class Ticket { id string }
30745class Resolution { id string }
30746class TriageFailed { reason string }
30747
30748rule go
30749 when Ticket as t
30750=> {
30751 complete result { id t.id }
30752}
30753"#;
30754 let compact_ir = compile_program_with_root(compact, None);
30755 let keyword_ir = compile_program_with_root(keyword, None);
30756 assert!(
30757 compact_ir.diagnostics.is_empty(),
30758 "compact form did not compile: {:?}",
30759 compact_ir.diagnostics
30760 );
30761 assert!(
30762 keyword_ir.diagnostics.is_empty(),
30763 "keyword form did not compile: {:?}",
30764 keyword_ir.diagnostics
30765 );
30766 let project = |ir: &IrProgram| {
30769 ir.workflow_contracts
30770 .iter()
30771 .map(|c| {
30772 (
30773 format!("{:?}", c.kind),
30774 c.name.clone(),
30775 format!("{:?}", c.ty),
30776 )
30777 })
30778 .collect::<Vec<_>>()
30779 };
30780 assert_eq!(
30781 project(&compact_ir.ir.expect("compact ir")),
30782 project(&keyword_ir.ir.expect("keyword ir")),
30783 "compact signature did not desugar to the same contracts"
30784 );
30785 }
30786
30787 #[test]
30788 fn compact_signature_supports_multiple_inputs_and_optional_failure() {
30789 let source = r#"
30791workflow Merge(left: LeftIn, right: RightIn) -> Merged
30792
30793class LeftIn { id string }
30794class RightIn { id string }
30795class Merged { id string }
30796
30797rule go
30798 when {
30799 LeftIn as l
30800 RightIn as r
30801 }
30802=> {
30803 complete result { id l.id }
30804}
30805"#;
30806 let compiled = compile_program_with_root(source, None);
30807 assert!(
30808 compiled.diagnostics.is_empty(),
30809 "multi-input compact form did not compile: {:?}",
30810 compiled.diagnostics
30811 );
30812 let ir = compiled.ir.expect("ir");
30813 let inputs = ir
30814 .workflow_contracts
30815 .iter()
30816 .filter(|c| matches!(c.kind, IrWorkflowContractKind::Input))
30817 .count();
30818 let failures = ir
30819 .workflow_contracts
30820 .iter()
30821 .filter(|c| matches!(c.kind, IrWorkflowContractKind::Failure))
30822 .count();
30823 assert_eq!(inputs, 2, "expected two inputs");
30824 assert_eq!(
30825 failures, 0,
30826 "omitted failure clause must add no failure contract"
30827 );
30828 }
30829
30830 #[test]
30831 fn rejects_headerless_program_with_no_workflow() {
30832 let source = r#"
30836class SharedTicket {
30837 id string
30838}
30839
30840pattern TagReviewed<Input> {
30841 rule tag
30842 when Input as item
30843 => {
30844 record SharedTicket { id item.id }
30845 }
30846}
30847"#;
30848 let compiled = compile_program_with_root(source, None);
30849 assert!(compiled.ir.is_none());
30850 assert!(
30851 compiled
30852 .diagnostics
30853 .iter()
30854 .any(|d| d.message.contains("program declares no `workflow`")),
30855 "{:?}",
30856 compiled.diagnostics
30857 );
30858 }
30859
30860 #[test]
30861 fn accepts_single_workflow_header_program() {
30862 let source = r#"
30865workflow OnlyOne
30866
30867input item Job
30868output result Done
30869
30870class Job { id string }
30871class Done { id string }
30872
30873rule go
30874 when Job as j
30875=> {
30876 complete result { id j.id }
30877}
30878"#;
30879 let compiled = compile_program_with_root(source, None);
30880 assert!(
30881 !compiled
30882 .diagnostics
30883 .iter()
30884 .any(|d| d.message.contains("program declares no `workflow`")),
30885 "header-form program wrongly rejected as headerless: {:?}",
30886 compiled.diagnostics
30887 );
30888 }
30889
30890 #[test]
30891 fn rejects_recording_observer_only_terminal_schema() {
30892 for schema in ["TerminalFailed", "TerminalTimedOut", "TerminalCancelled"] {
30896 let source = format!(
30897 r#"
30898workflow Forge
30899
30900input item Job
30901output result Done
30902
30903class Job {{ id string }}
30904class Done {{ id string }}
30905
30906rule sneak
30907 when Job as q
30908=> {{
30909 record {schema} {{ reason "x" summary "y" }}
30910 complete result {{ id q.id }}
30911}}
30912"#
30913 );
30914 let compiled = compile_program(&source);
30915 assert!(
30916 compiled
30917 .diagnostics
30918 .iter()
30919 .any(|d| d.message.contains(&format!(
30920 "cannot record kernel-owned terminal schema `{schema}`"
30921 ))),
30922 "expected rejection for {schema}, got {:?}",
30923 compiled.diagnostics
30924 );
30925 }
30926 }
30927
30928 #[test]
30929 fn allows_recording_user_writable_builtin_schema() {
30930 let source = r#"
30934workflow WriteWork
30935
30936input item Job
30937output result Done
30938
30939class Job { id string }
30940class Done { id string }
30941
30942rule track
30943 when Job as q
30944=> {
30945 record WorkItem { title "t" status "reviewed" }
30946 complete result { id q.id }
30947}
30948"#;
30949 let compiled = compile_program(source);
30950 assert!(
30951 !compiled.diagnostics.iter().any(|d| d
30952 .message
30953 .contains("cannot record kernel-owned terminal schema")),
30954 "WorkItem must remain user-writable, got {:?}",
30955 compiled.diagnostics
30956 );
30957 }
30958
30959 #[test]
30960 fn exhaustive_bool_case_compiles() {
30961 let source = r#"
30964workflow BoolCaseOk
30965
30966output result Done
30967
30968class Done {
30969 note string
30970}
30971
30972class Flag {
30973 ready bool
30974}
30975
30976rule route
30977 when Flag as f
30978=> {
30979 case f.ready {
30980 true => {
30981 complete result {
30982 note "t"
30983 }
30984 }
30985 false => {
30986 complete result {
30987 note "f"
30988 }
30989 }
30990 }
30991}
30992"#;
30993 let compiled = compile_program(source);
30994 assert_eq!(
30995 compiled.diagnostics,
30996 Vec::new(),
30997 "{:?}",
30998 compiled.diagnostics
30999 );
31000 assert!(compiled.ir.is_some());
31001 }
31002
31003 #[test]
31004 fn bool_case_rejects_non_exhaustive_and_non_bool_patterns() {
31005 let source = r#"
31006workflow BoolCaseBad
31007
31008class Flag {
31009 ready bool
31010}
31011
31012rule route
31013 when Flag as f
31014=> {
31015 case f.ready {
31016 true => {
31017 }
31018 }
31019
31020 case f.ready {
31021 maybe => {
31022 }
31023 false => {
31024 }
31025 }
31026}
31027"#;
31028 let compiled = compile_program(source);
31029 assert!(compiled.ir.is_none());
31030 assert!(
31031 compiled
31032 .diagnostics
31033 .iter()
31034 .any(|d| d.message.contains("non-exhaustive case; missing false")),
31035 "expected non-exhaustive diagnostic: {:?}",
31036 compiled.diagnostics
31037 );
31038 assert!(
31039 compiled.diagnostics.iter().any(|d| d
31040 .message
31041 .contains("case pattern `maybe` that is not a `bool` value")),
31042 "expected non-bool pattern diagnostic: {:?}",
31043 compiled.diagnostics
31044 );
31045 }
31046
31047 #[test]
31048 fn exec_schema_result_resolves_typed_fields_for_case() {
31049 let source = r#"
31055@service
31056workflow ExecTyped
31057
31058class Pick { kind "a" | "b" }
31059class R { choice string }
31060
31061output result R
31062
31063signal go.now {
31064 x string
31065}
31066
31067rule j
31068 when go.now as g
31069=> {
31070 exec "echo hi" -> Pick as v
31071
31072 after v succeeds as r {
31073 case r.kind {
31074 "a" => {
31075 complete result {
31076 choice "a"
31077 }
31078 }
31079 "b" => {
31080 complete result {
31081 choice "b"
31082 }
31083 }
31084 }
31085 }
31086}
31087"#;
31088 let compiled = compile_program(source);
31089 assert!(
31090 !compiled
31091 .diagnostics
31092 .iter()
31093 .any(|d| d.message.contains("not a typed path")),
31094 "exec -> Schema result fields should resolve: {:?}",
31095 compiled.diagnostics
31096 );
31097 assert!(compiled.ir.is_some(), "{:?}", compiled.diagnostics);
31098 }
31099
31100 #[test]
31101 fn exec_with_requires_typed_record_binding() {
31102 let source = |with_line: &str, when_line: &str| {
31107 format!(
31108 r#"
31109@service
31110workflow ExecWith
31111
31112class Request {{ text string }}
31113class Report {{ message string }}
31114
31115output result Report
31116
31117rule go
31118 when {when_line}
31119=> {{
31120 exec echo_report with {with_line} -> Report as report
31121
31122 after report succeeds as out {{
31123 complete result {{
31124 message out.message
31125 }}
31126 }}
31127}}
31128"#
31129 )
31130 };
31131
31132 let compiled = compile_program(&source("request", "Request as request"));
31134 assert!(
31135 !compiled
31136 .diagnostics
31137 .iter()
31138 .any(|d| d.message.contains("typed record binding")),
31139 "typed record binding must pass: {:?}",
31140 compiled.diagnostics
31141 );
31142 assert!(compiled.ir.is_some(), "{:?}", compiled.diagnostics);
31143
31144 let compiled = compile_program(&source("missing", "Request as request"));
31146 assert!(
31147 compiled.diagnostics.iter().any(|d| d
31148 .message
31149 .contains("uses unknown binding `missing` in `exec echo_report with missing`")),
31150 "unknown binding must be rejected: {:?}",
31151 compiled.diagnostics
31152 );
31153
31154 let compiled = compile_program(&source("g", "fact foo.bar as g"));
31156 assert!(
31157 compiled.diagnostics.iter().any(|d| d
31158 .message
31159 .contains("passes untyped fact binding `g` to `exec echo_report with`")),
31160 "untyped fact binding must be rejected: {:?}",
31161 compiled.diagnostics
31162 );
31163
31164 let chained = r#"
31167@service
31168workflow ExecWithChained
31169
31170class Request { text string }
31171class Report { message string }
31172
31173output result Report
31174
31175rule go
31176 when Request as request
31177=> {
31178 exec fetch_request with request -> Request as fetched
31179
31180 after fetched succeeds as staged {
31181 exec echo_report with staged -> Report as report
31182
31183 after report succeeds as out {
31184 complete result {
31185 message out.message
31186 }
31187 }
31188 }
31189}
31190"#;
31191 let compiled = compile_program(chained);
31192 assert!(
31193 !compiled
31194 .diagnostics
31195 .iter()
31196 .any(|d| d.message.contains("typed record binding")),
31197 "typed exec-result binding must pass: {:?}",
31198 compiled.diagnostics
31199 );
31200 assert!(compiled.ir.is_some(), "{:?}", compiled.diagnostics);
31201 }
31202
31203 #[test]
31204 fn redact_projection_keeps_only_kept_fields() {
31205 let kept = r#"
31210@service
31211workflow RedactKept
31212
31213class Customer { id string ssn string status string }
31214class Result { tag string }
31215output result Result
31216
31217signal go.now { x string }
31218
31219coerce read_customer(x string) -> Customer { prompt "x" }
31220
31221rule r
31222 when go.now as g
31223=> {
31224 coerce read_customer(g.x) as c
31225 after c succeeds as cust {
31226 redact cust keep [id, status] as safe
31227 complete result {
31228 tag safe.id
31229 }
31230 }
31231}
31232"#;
31233 let compiled = compile_program(kept);
31234 assert!(
31235 !compiled
31236 .diagnostics
31237 .iter()
31238 .any(|d| d.message.contains("unknown field") || d.message.contains("not a typed")),
31239 "kept field `safe.id` should resolve: {:?}",
31240 compiled.diagnostics
31241 );
31242
31243 assert!(
31244 compiled.ir.is_some(),
31245 "kept program should compile: {compiled:?}"
31246 );
31247
31248 let dropped = kept.replace("tag safe.id", "tag safe.ssn");
31249 let compiled = compile_program(&dropped);
31250 assert!(
31251 compiled
31252 .diagnostics
31253 .iter()
31254 .any(|d| d.message.contains("safe.ssn") || d.message.contains("`ssn`")),
31255 "dropped field `safe.ssn` should be rejected: {:?}",
31256 compiled.diagnostics
31257 );
31258 }
31259
31260 #[test]
31261 fn redact_unknown_kept_field_is_rejected() {
31262 let source = r#"
31263@service
31264workflow RedactBadKeep
31265
31266class Customer { id string status string }
31267class Result { tag string }
31268output result Result
31269
31270signal go.now { x string }
31271
31272coerce read_customer(x string) -> Customer { prompt "x" }
31273
31274rule r
31275 when go.now as g
31276=> {
31277 coerce read_customer(g.x) as c
31278 after c succeeds as cust {
31279 redact cust keep [id, nonexistent] as safe
31280 complete result {
31281 tag safe.id
31282 }
31283 }
31284}
31285"#;
31286 let compiled = compile_program(source);
31287 assert!(
31288 compiled
31289 .diagnostics
31290 .iter()
31291 .any(|d| d.message.contains("keeping unknown field `nonexistent`")),
31292 "expected unknown-kept-field rejection: {:?}",
31293 compiled.diagnostics
31294 );
31295 }
31296
31297 #[test]
31298 fn inline_decide_result_resolves_typed_fields_for_case() {
31299 let source = r#"
31305@service
31306workflow InlineDecideTyped
31307
31308class R { choice string }
31309output result R
31310
31311signal go.now {
31312 x string
31313}
31314
31315rule j
31316 when go.now as g
31317=> {
31318 decide "is it fixed?" -> { fixed bool } as v
31319
31320 after v succeeds as r {
31321 case r.fixed {
31322 true => {
31323 complete result {
31324 choice "a"
31325 }
31326 }
31327 false => {
31328 complete result {
31329 choice "b"
31330 }
31331 }
31332 }
31333 }
31334}
31335"#;
31336 let compiled = compile_program(source);
31337 assert!(
31338 !compiled
31339 .diagnostics
31340 .iter()
31341 .any(|d| d.message.contains("not a typed path")),
31342 "inline decide result fields should resolve: {:?}",
31343 compiled.diagnostics
31344 );
31345 let ir = compiled.ir.expect("compiles");
31346 assert!(
31349 ir.schemas.iter().any(|schema| matches!(
31350 schema,
31351 IrSchema::Class(class) if class.name == "decide.j.v"
31352 )),
31353 "expected synthesized inline-decide class `decide.j.v` in IR schemas"
31354 );
31355 }
31356
31357 #[test]
31358 fn rejects_malformed_multiline_prompt_content_type_on_rule_prompt() {
31359 let source = r#"
31360workflow PromptAnnotationGuess
31361
31362agent worker {
31363 provider fixture
31364 profile "repo-writer"
31365 capacity 1
31366}
31367
31368rule ask
31369 when started
31370=> {
31371 tell worker as turn """markdown extra
31372 do work
31373 """
31374}
31375"#;
31376 let compiled = compile_program(source);
31377
31378 assert!(compiled.ir.is_none());
31379 assert!(compiled.diagnostics.iter().any(|diagnostic| {
31380 diagnostic
31381 .message
31382 .contains("malformed multiline prompt content type `markdown extra`")
31383 && diagnostic.suggestion.as_deref().is_some_and(|suggestion| {
31384 suggestion.contains("put prompt text on the next line")
31385 })
31386 }));
31387 }
31388
31389 #[test]
31390 fn rejects_malformed_multiline_prompt_content_type_on_coerce_prompt() {
31391 let source = r#"
31392workflow CoerceAnnotationGuess
31393
31394class Review {
31395 status "ok"
31396}
31397
31398coerce review() -> Review {
31399 prompt """text/markdown extra
31400 classify the review
31401 """
31402}
31403
31404rule run
31405 when started
31406=> {
31407 coerce review() as result
31408}
31409"#;
31410 let compiled = compile_program(source);
31411
31412 assert!(compiled.ir.is_none());
31413 assert!(compiled
31414 .diagnostics
31415 .iter()
31416 .any(|diagnostic| diagnostic.message.contains(
31417 "coerce `review` has malformed multiline prompt content type `text/markdown extra`"
31418 )));
31419 }
31420
31421 #[test]
31422 fn rejects_pasted_top_level_gherkin_with_targeted_diagnostic() {
31423 let source = r#"
31424Feature: provider language routing
31425
31426Scenario: fixture provider reviews every language task
31427 Given a queued language task
31428 When the provider turn completes
31429 Then the language result is reviewed
31430"#;
31431 let compiled = compile_program(source);
31432
31433 assert!(compiled.ir.is_none());
31434 assert!(compiled.diagnostics.iter().any(|diagnostic| {
31435 diagnostic
31436 .message
31437 .contains("Gherkin keyword `Feature` is not WhippleScript workflow syntax")
31438 && diagnostic.suggestion.as_deref().is_some_and(|suggestion| {
31439 suggestion.contains("use `workflow`, `table`, `rule")
31440 && suggestion.contains("instead of free-text Given/When/Then steps")
31441 })
31442 }));
31443 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
31444 .message
31445 .contains("Gherkin keyword `Given` is not WhippleScript workflow syntax")));
31446 }
31447
31448 #[test]
31449 fn rejects_pasted_gherkin_inside_workflow_body_with_targeted_diagnostic() {
31450 let source = r#"
31451workflow PastedGherkin {
31452 Scenario: fixture provider reviews every language task
31453 Given a queued language task
31454 When the provider turn completes
31455 Then the language result is reviewed
31456}
31457"#;
31458 let compiled = compile_program(source);
31459
31460 assert!(compiled.ir.is_none());
31461 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
31462 .message
31463 .contains("Gherkin keyword `Scenario` is not WhippleScript workflow syntax")));
31464 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
31465 .message
31466 .contains("Gherkin keyword `Then` is not WhippleScript workflow syntax")));
31467 }
31468
31469 #[test]
31470 fn rejects_pasted_gherkin_background_outline_examples_and_continuations() {
31471 let source = r#"
31472Feature: provider language routing
31473
31474Rule: provider execution remains explicit
31475
31476Background:
31477 Given a seeded provider table
31478 And all provider profiles are available
31479
31480Scenario Outline: provider reviews language task
31481 When <provider> completes <language>
31482 But the review is missing
31483 Then the fixture fails
31484
31485Examples:
31486 | provider | language |
31487 | codex | French |
31488"#;
31489 let compiled = compile_program(source);
31490
31491 assert!(compiled.ir.is_none());
31492 for keyword in ["Rule", "Background", "And", "Scenario", "But", "Examples"] {
31493 assert!(
31494 compiled
31495 .diagnostics
31496 .iter()
31497 .any(|diagnostic| diagnostic.message.contains(&format!(
31498 "Gherkin keyword `{keyword}` is not WhippleScript workflow syntax"
31499 ))),
31500 "missing diagnostic for {keyword}: {:?}",
31501 compiled
31502 .diagnostics
31503 .iter()
31504 .map(|diagnostic| diagnostic.message.as_str())
31505 .collect::<Vec<_>>()
31506 );
31507 }
31508 }
31509
31510 #[test]
31511 fn explains_multiline_string_binding_position() {
31512 let source = r#"
31513workflow BindingGuess
31514
31515agent worker {
31516 provider fixture
31517 profile "repo-writer"
31518 capacity 1
31519}
31520
31521rule branch
31522 when started
31523=> {
31524 tell worker """
31525 do work
31526 """ as turn
31527
31528 after turn succeeds {
31529 tell worker "review" as review
31530 }
31531}
31532"#;
31533 let compiled = compile_program(source);
31534
31535 assert!(compiled.ir.is_none());
31536 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
31537 .message
31538 .contains("places effect binding `turn` after a multiline string delimiter")
31539 && diagnostic.suggestion.as_deref().is_some_and(
31540 |suggestion| suggestion.contains("move `as turn` onto the effect line")
31541 )));
31542 }
31543
31544 #[test]
31545 fn invalid_fixtures_have_actionable_diagnostics() {
31546 let fixtures = [
31547 (
31548 "bad-agent",
31549 include_str!("../../../examples/invalid/bad-agent.whip"),
31550 ),
31551 (
31552 "bad-record",
31553 include_str!("../../../examples/invalid/bad-record.whip"),
31554 ),
31555 (
31556 "bad-terminal-payload",
31557 include_str!("../../../examples/invalid/bad-terminal-payload.whip"),
31558 ),
31559 (
31560 "recursive-workflow-invocation",
31561 include_str!("../../../examples/invalid/recursive-workflow-invocation.whip"),
31562 ),
31563 (
31564 "bad-effect-graph",
31565 include_str!("../../../examples/invalid/bad-effect-graph.whip"),
31566 ),
31567 (
31568 "bad-effect-payload",
31569 include_str!("../../../examples/invalid/bad-effect-payload.whip"),
31570 ),
31571 (
31572 "bad-expression-functions",
31573 include_str!("../../../examples/invalid/bad-expression-functions.whip"),
31574 ),
31575 (
31576 "bad-finite-domain",
31577 include_str!("../../../examples/invalid/bad-finite-domain.whip"),
31578 ),
31579 (
31580 "broken",
31581 include_str!("../../../examples/invalid/broken.whip"),
31582 ),
31583 (
31584 "effect-output-scope",
31585 include_str!("../../../examples/invalid/effect-output-scope.whip"),
31586 ),
31587 (
31588 "effectful-self-loop",
31589 include_str!("../../../examples/invalid/effectful-self-loop.whip"),
31590 ),
31591 (
31592 "recursive-pattern",
31593 include_str!("../../../examples/invalid/recursive-pattern.whip"),
31594 ),
31595 (
31596 "evidence-fact-match",
31597 include_str!("../../../examples/invalid/evidence-fact-match.whip"),
31598 ),
31599 (
31600 "unknown-schema",
31601 include_str!("../../../examples/invalid/unknown-schema.whip"),
31602 ),
31603 (
31604 "headerless-library",
31605 include_str!("../../../examples/invalid/headerless-library.whip"),
31606 ),
31607 ];
31608
31609 for (name, source) in fixtures {
31610 let compiled = compile_program(source);
31611 assert!(compiled.ir.is_none(), "{name} unexpectedly compiled");
31612 assert!(
31613 !compiled.diagnostics.is_empty(),
31614 "{name} did not emit diagnostics"
31615 );
31616 assert!(
31617 compiled
31618 .diagnostics
31619 .iter()
31620 .all(|diagnostic| diagnostic.suggestion.is_some()),
31621 "{name} emitted a diagnostic without a suggestion: {:?}",
31622 compiled.diagnostics
31623 );
31624 }
31625 }
31626
31627 #[test]
31628 fn rejects_dangling_root_in_record_value() {
31629 let source = r#"
31632@service
31633workflow DanglingRoot
31634
31635class Ticket { id string }
31636class Note { text string }
31637
31638table seed as Ticket [ { id "1" } ]
31639
31640rule r
31641 when Ticket as ticket
31642=> {
31643 record Note {
31644 text tikcet.id
31645 }
31646}
31647"#;
31648 let compiled = compile_program(source);
31649 assert!(compiled.ir.is_none());
31650 assert!(
31651 compiled
31652 .diagnostics
31653 .iter()
31654 .any(|d| d.message.contains("unknown binding `tikcet`")),
31655 "{:?}",
31656 compiled.diagnostics
31657 );
31658 }
31659
31660 #[test]
31661 fn rejects_dangling_root_in_single_line_record() {
31662 let source = r#"
31666@service
31667workflow DanglingSingleLine
31668
31669class Ticket { id string }
31670class Note { text string }
31671
31672table seed as Ticket [ { id "1" } ]
31673
31674rule r
31675 when Ticket as ticket
31676=> {
31677 record Note { text tikcet.id }
31678}
31679"#;
31680 let compiled = compile_program(source);
31681 assert!(compiled.ir.is_none());
31682 assert!(
31683 compiled
31684 .diagnostics
31685 .iter()
31686 .any(|d| d.message.contains("unknown binding `tikcet`")),
31687 "{:?}",
31688 compiled.diagnostics
31689 );
31690 }
31691
31692 #[test]
31693 fn rejects_dangling_root_in_coerce_argument() {
31694 let source = r#"
31697@service
31698workflow DanglingCoerceArg
31699
31700class Ticket { id string title string }
31701class Review { summary string }
31702
31703coerce classify(title string) -> Review { prompt "c" }
31704
31705agent reviewer { provider fixture profile "r" capacity 1 }
31706
31707table seed as Ticket [ { id "1" title "t" } ]
31708
31709rule r
31710 when Ticket as ticket
31711 when reviewer is available
31712=> {
31713 coerce classify(tikcet.title) as rev
31714}
31715"#;
31716 let compiled = compile_program(source);
31717 assert!(compiled.ir.is_none());
31718 assert!(
31719 compiled
31720 .diagnostics
31721 .iter()
31722 .any(|d| d.message.contains("unknown binding `tikcet`")
31723 && d.message.contains("coerce `classify`")),
31724 "{:?}",
31725 compiled.diagnostics
31726 );
31727 }
31728
31729 #[test]
31730 fn rejects_dangling_root_in_counter_consume_operand() {
31731 let source = r#"
31734@service
31735workflow CounterOperandDangling
31736
31737class CallFailed { service string }
31738class Service { id string }
31739
31740counter failure_budget { key Service cap 3 reset daily }
31741
31742table seed as CallFailed [ { service "x" } ]
31743
31744rule strike
31745 when CallFailed as f
31746=> {
31747 consume failure_budget for fff.service amount 1 as strike
31748}
31749"#;
31750 let compiled = compile_program(source);
31751 assert!(compiled.ir.is_none());
31752 assert!(
31753 compiled
31754 .diagnostics
31755 .iter()
31756 .any(|d| d.message.contains("unknown binding `fff`")
31757 && d.message.contains("consume")),
31758 "{:?}",
31759 compiled.diagnostics
31760 );
31761 }
31762
31763 #[test]
31764 fn rejects_dangling_root_in_queue_file_payload() {
31765 let source = r#"
31769@service
31770workflow QueueFieldDangling
31771
31772class Ticket { id string }
31773
31774tracker backlog { provider builtin }
31775
31776table seed as Ticket [ { id "1" } ]
31777
31778rule r
31779 when Ticket as ticket
31780=> {
31781 file issue into backlog {
31782 title tikcet.id
31783 body "x"
31784 }
31785}
31786"#;
31787 let compiled = compile_program(source);
31788 assert!(compiled.ir.is_none());
31789 assert!(
31790 compiled
31791 .diagnostics
31792 .iter()
31793 .any(|d| d.message.contains("unknown binding `tikcet`")
31794 && d.message.contains("file into")),
31795 "{:?}",
31796 compiled.diagnostics
31797 );
31798 }
31799
31800 #[test]
31801 fn rejects_dangling_root_in_invoke_input() {
31802 let source = r#"
31805workflow Parent {
31806 input task Task
31807 output result Out
31808
31809 class Task { id string }
31810 class Out { x string }
31811
31812 rule r
31813 when Task as task
31814 => {
31815 invoke Child { item tikcet.id } as c
31816 after c succeeds as cr {
31817 done task
31818 complete result { x cr.summary }
31819 }
31820 }
31821}
31822
31823workflow Child {
31824 input item string
31825 output result ChildOut
31826 class ChildOut { y string }
31827 rule c
31828 when item as i
31829 => {
31830 complete result { y "done" }
31831 }
31832}
31833"#;
31834 let compiled = compile_program_with_root(source, Some("Parent"));
31835 assert!(compiled.ir.is_none());
31836 assert!(
31837 compiled
31838 .diagnostics
31839 .iter()
31840 .any(|d| d.message.contains("unknown binding `tikcet`")
31841 && d.message.contains("invoke Child")),
31842 "{:?}",
31843 compiled.diagnostics
31844 );
31845 }
31846
31847 #[test]
31848 fn rejects_dangling_root_in_tell_target() {
31849 let source = r#"
31852@service
31853workflow DanglingTellTarget
31854
31855class Ticket { id string provider AgentRef<reviewer> }
31856
31857agent reviewer { provider fixture profile "r" capacity 1 }
31858
31859table seed as Ticket [ { id "1" provider reviewer } ]
31860
31861rule r
31862 when Ticket as ticket
31863=> {
31864 tell tikcet.provider as turn "go"
31865}
31866"#;
31867 let compiled = compile_program(source);
31868 assert!(compiled.ir.is_none());
31869 assert!(
31870 compiled
31871 .diagnostics
31872 .iter()
31873 .any(|d| d.message.contains("unknown binding `tikcet`")
31874 && d.message.contains("tell target")),
31875 "{:?}",
31876 compiled.diagnostics
31877 );
31878 }
31879
31880 #[test]
31881 fn accepts_effect_binding_root_in_record_value() {
31882 let source = r#"
31887@service
31888workflow EffectRoot
31889
31890class Ticket { id string }
31891class Note { text string }
31892
31893agent reviewer { provider fixture profile "r" capacity 1 }
31894
31895table seed as Ticket [ { id "1" } ]
31896
31897rule r
31898 when Ticket as ticket
31899 when reviewer is available
31900=> {
31901 tell reviewer as turn "review"
31902 after turn succeeds {
31903 record Note {
31904 text turn.summary
31905 }
31906 }
31907}
31908"#;
31909 let compiled = compile_program(source);
31910 assert_eq!(
31911 compiled.diagnostics,
31912 Vec::new(),
31913 "{:?}",
31914 compiled.diagnostics
31915 );
31916 assert!(compiled.ir.is_some());
31917 }
31918
31919 #[test]
31920 fn rejects_invalid_record_fields_paths_and_literals() {
31921 let source = include_str!("../../../examples/invalid/bad-record.whip");
31922 let compiled = compile_program(source);
31923
31924 assert!(compiled.ir.is_none());
31925 assert_eq!(compiled.diagnostics.len(), 5);
31926 assert!(compiled
31927 .diagnostics
31928 .iter()
31929 .any(|diagnostic| diagnostic.message.contains("request.missing")));
31930 assert!(compiled
31931 .diagnostics
31932 .iter()
31933 .any(|diagnostic| diagnostic.message.contains("no variant `Maybe`")));
31934 assert!(compiled
31935 .diagnostics
31936 .iter()
31937 .any(|diagnostic| diagnostic.message.contains("expects `float`")));
31938 assert!(compiled
31939 .diagnostics
31940 .iter()
31941 .any(|diagnostic| diagnostic.message.contains("cannot be `scripted`")));
31942 assert!(compiled
31943 .diagnostics
31944 .iter()
31945 .any(|diagnostic| diagnostic.message.contains("no field `extra`")));
31946 }
31947
31948 #[test]
31949 fn rejects_effect_output_outside_after_scope() {
31950 let source = include_str!("../../../examples/invalid/effect-output-scope.whip");
31951 let compiled = compile_program(source);
31952
31953 assert!(compiled.ir.is_none());
31954 assert_eq!(compiled.diagnostics.len(), 1);
31955 assert!(compiled.diagnostics[0]
31956 .message
31957 .contains("outside a matching `after claim ...` block"));
31958 }
31959
31960 #[test]
31965 fn region_compiles_and_ir_carries_variants() {
31966 let source = r#"
31967workflow Deploy
31968
31969output result Done
31970failure error Halted
31971
31972class Incident {
31973 sev string
31974}
31975
31976class Done {
31977 note string
31978}
31979
31980class Halted {
31981 reason string
31982}
31983
31984rule ship
31985 when started
31986=> {
31987 until exists(Incident where sev == "sev1") {
31988 then plan <- timer 1s
31989 then approved <- timer 1s
31990 complete result {
31991 note "shipped"
31992 }
31993 } on lapse as got {
31994 fail error {
31995 reason "halted"
31996 }
31997 }
31998}
31999"#;
32000 let compiled = compile_program(source);
32001 assert!(
32002 compiled.diagnostics.is_empty(),
32003 "region must compile: {:?}",
32004 compiled.diagnostics
32005 );
32006 let ir = compiled.ir.expect("ir");
32007 let rule = &ir.rules[0];
32008 assert!(
32009 !rule.body.contains("until exists") && !rule.body.contains("on lapse"),
32010 "canonical body is the HOLDS splice: {}",
32011 rule.body
32012 );
32013 let region = rule.metadata.region.as_ref().expect("region metadata");
32014 assert!(region.until);
32015 assert_eq!(region.condition, "exists(Incident where sev == \"sev1\")");
32016 assert_eq!(region.lapse_binding.as_deref(), Some("got"));
32017 assert!(
32018 region.body_lapsed.contains("fail error"),
32019 "lapsed variant carries the arm: {}",
32020 region.body_lapsed
32021 );
32022 assert!(
32023 !region.body_removed.contains("timer") && !region.body_removed.contains("fail error"),
32024 "removed variant drops region AND arm: {}",
32025 region.body_removed
32026 );
32027 let bindings: Vec<&str> = region
32028 .effects
32029 .iter()
32030 .map(|effect| effect.binding.as_str())
32031 .collect();
32032 assert!(
32033 bindings.contains(&"__then_plan") && bindings.contains(&"__then_approved"),
32034 "region effects recorded: {bindings:?}"
32035 );
32036 }
32037
32038 #[test]
32040 fn two_regions_in_one_rule_rejected() {
32041 let source = r#"
32042workflow Two
32043
32044output result Done
32045
32046class Done {
32047 note string
32048}
32049
32050class Flag {
32051 on string
32052}
32053
32054rule go
32055 when started
32056=> {
32057 during empty(Flag) {
32058 timer 1s as a
32059
32060 after a completes {
32061 record Flag {
32062 on "x"
32063 }
32064 }
32065 } on lapse {
32066 complete result {
32067 note "one"
32068 }
32069 }
32070
32071 during empty(Flag) {
32072 timer 1s as b
32073
32074 after b completes {
32075 complete result {
32076 note "two"
32077 }
32078 }
32079 } on lapse {
32080 complete result {
32081 note "three"
32082 }
32083 }
32084}
32085"#;
32086 let compiled = compile_program(source);
32087 assert!(
32088 compiled
32089 .diagnostics
32090 .iter()
32091 .any(|d| d.message.contains("more than one `during`/`until` region")),
32092 "second region rejected: {:?}",
32093 compiled.diagnostics
32094 );
32095 }
32096
32097 #[test]
32100 fn lapse_arm_referencing_region_binding_rejected() {
32101 let source = r#"
32102workflow Scope
32103
32104output result Done
32105failure error Halted
32106
32107class Incident {
32108 sev string
32109}
32110
32111class Done {
32112 note string
32113}
32114
32115class Halted {
32116 reason string
32117}
32118
32119rule go
32120 when started
32121=> {
32122 until exists(Incident where sev == "sev1") {
32123 then plan <- timer 1s
32124 complete result {
32125 note plan.status
32126 }
32127 } on lapse {
32128 fail error {
32129 reason plan.status
32130 }
32131 }
32132}
32133"#;
32134 let compiled = compile_program(source);
32135 assert!(
32136 compiled
32137 .diagnostics
32138 .iter()
32139 .any(|d| d.message.contains("references `plan`, a binding the")),
32140 "arm scope violation rejected: {:?}",
32141 compiled.diagnostics
32142 );
32143 }
32144
32145 #[test]
32151 fn full_line_comments_in_rule_bodies_compile_and_prompts_keep_hashes() {
32152 let source = r#"
32153use std.script
32154
32155workflow Commented
32156
32157output result Done
32158
32159class Done {
32160 note string
32161}
32162
32163agent helper
32164
32165rule go
32166 when started
32167=> {
32168 # request the probe command
32169 exec "true" as probe
32170
32171 after probe succeeds {
32172 # a comment with braces { and quotes " should be inert
32173 then turn <- tell helper """markdown
32174 # This heading is prompt CONTENT, not a comment.
32175 Summarize.
32176 """
32177 # comment between then chain and terminal
32178 complete result {
32179 note turn.summary
32180 }
32181 }
32182
32183 after probe fails {
32184 # losing is fine
32185 }
32186}
32187"#;
32188 let compiled = compile_program(source);
32189 assert!(
32190 compiled.diagnostics.is_empty(),
32191 "comments must not produce diagnostics: {:?}",
32192 compiled.diagnostics
32193 );
32194 let ir = compiled.ir.expect("compiles");
32195 let rule = &ir.rules[0];
32196 assert!(
32197 !rule.body.contains("# request"),
32198 "compile-path body text is comment-blanked: {}",
32199 rule.body
32200 );
32201 assert!(
32202 rule.body.contains("# This heading is prompt CONTENT"),
32203 "prompt interiors are untouched by blanking: {}",
32204 rule.body
32205 );
32206 }
32207
32208 #[test]
32211 fn trailing_comment_in_rule_body_still_rejected() {
32212 let source = r#"
32213workflow Trailing
32214
32215output result Done
32216
32217class Done {
32218 note string
32219}
32220
32221rule go
32222 when started
32223=> {
32224 complete result {
32225 note "x"
32226 } # not allowed here
32227}
32228"#;
32229 let compiled = compile_program(source);
32230 assert!(
32231 compiled
32232 .diagnostics
32233 .iter()
32234 .any(|d| d.message.contains("unexpected character `#`")),
32235 "trailing comment must still be rejected: {:?}",
32236 compiled.diagnostics
32237 );
32238 }
32239
32240 #[test]
32241 fn rejects_effectful_self_trigger_loop() {
32242 let source = include_str!("../../../examples/invalid/effectful-self-loop.whip");
32243 let compiled = compile_program(source);
32244
32245 assert!(compiled.ir.is_none());
32246 assert_eq!(compiled.diagnostics.len(), 1);
32247 assert!(compiled.diagnostics[0]
32248 .message
32249 .contains("preserves trigger fact `schema:WorkItem`"));
32250 }
32251
32252 #[test]
32253 fn rejects_non_file_operation_on_a_file_store_grant() {
32254 let program = |op: &str, resource: &str, store: &str| {
32257 format!(
32258 r#"
32259@service
32260workflow FileGrant
32261
32262output result R
32263class R {{ ok bool }}
32264class Ticket {{ id string status "open" }}
32265
32266agent coder {{ provider fixture profile "repo-writer" capacity 1 }}
32267
32268file store {store} {{ root "./data" allow read ["docs/**"] }}
32269
32270table seed as Ticket [ {{ id "T1" status "open" }} ]
32271
32272rule work
32273 when Ticket as ticket where ticket.status == "open"
32274 when coder is available
32275=> {{
32276 tell coder as turn
32277 with access to {resource} {{
32278 {op}
32279 }}
32280 "go"
32281
32282 after turn succeeds as outcome {{
32283 complete result {{ ok true }}
32284 }}
32285}}
32286"#
32287 )
32288 };
32289
32290 let bad = compile_program(&program(
32292 "recall for ticket",
32293 "project_files",
32294 "project_files",
32295 ));
32296 assert!(
32297 bad.diagnostics
32298 .iter()
32299 .any(|d| d.message.contains("not a file operation")),
32300 "{:?}",
32301 bad.diagnostics
32302 );
32303 let ok = compile_program(&program(
32306 "recall for ticket",
32307 "project_memory",
32308 "project_files",
32309 ));
32310 assert!(
32311 !ok.diagnostics
32312 .iter()
32313 .any(|d| d.message.contains("not a file operation")),
32314 "{:?}",
32315 ok.diagnostics
32316 );
32317 }
32318
32319 #[test]
32320 fn parses_memory_pool_declaration_and_snapshots_it() {
32321 let source = r#"
32324workflow PoolDecl
32325
32326memory pool project_memory {
32327 context limit 8
32328}
32329"#;
32330 let compiled = compile_program(source);
32331 let ir = compiled.ir.expect("compiles");
32332 assert_eq!(ir.memory_pools.len(), 1);
32333 assert_eq!(ir.memory_pools[0].name, "project_memory");
32334 assert_eq!(ir.memory_pools[0].context_limit, Some(8));
32335 let snapshot = ir.to_snapshot();
32336 assert!(snapshot.contains("memory_pools"), "{snapshot}");
32337 assert!(
32338 snapshot.contains("memory pool project_memory"),
32339 "{snapshot}"
32340 );
32341 assert!(snapshot.contains("context limit 8"), "{snapshot}");
32342
32343 let bare = compile_program("workflow Bare\n\nmemory pool p {\n}\n")
32346 .ir
32347 .expect("bare pool compiles");
32348 assert_eq!(bare.memory_pools[0].context_limit, None);
32349 assert!(!bare.to_snapshot().contains("context limit"));
32350 }
32351
32352 #[test]
32353 fn rejects_unknown_and_provider_memory_pool_clauses() {
32354 let unknown = compile_program("workflow U\n\nmemory pool p {\n retention 5\n}\n");
32359 assert!(
32360 unknown
32361 .diagnostics
32362 .iter()
32363 .any(|d| d.message.contains("unknown memory pool field `retention`")),
32364 "{:?}",
32365 unknown.diagnostics
32366 );
32367 let provider = compile_program("workflow P\n\nmemory pool p {\n provider local\n}\n");
32368 assert!(
32369 provider
32370 .diagnostics
32371 .iter()
32372 .any(|d| d.message.contains("unknown memory pool field `provider`")),
32373 "{:?}",
32374 provider.diagnostics
32375 );
32376 }
32377
32378 #[test]
32379 fn rejects_non_memory_operation_on_a_memory_pool_grant() {
32380 let program = |op: &str, resource: &str, pool: &str| {
32386 format!(
32387 r#"
32388@service
32389workflow MemoryGrant
32390
32391output result R
32392class R {{ ok bool }}
32393class Ticket {{ id string status "open" }}
32394
32395agent coder {{ provider fixture profile "repo-writer" capacity 1 }}
32396
32397memory pool {pool} {{ context limit 8 }}
32398
32399table seed as Ticket [ {{ id "T1" status "open" }} ]
32400
32401rule work
32402 when Ticket as ticket where ticket.status == "open"
32403 when coder is available
32404=> {{
32405 tell coder as turn
32406 with access to {resource} {{
32407 {op}
32408 }}
32409 "go"
32410
32411 after turn succeeds as outcome {{
32412 complete result {{ ok true }}
32413 }}
32414}}
32415"#
32416 )
32417 };
32418
32419 let bad = compile_program(&program(
32421 r#"read ["docs/**"]"#,
32422 "project_memory",
32423 "project_memory",
32424 ));
32425 assert!(
32426 bad.diagnostics
32427 .iter()
32428 .any(|d| d.message.contains("not a memory operation")),
32429 "{:?}",
32430 bad.diagnostics
32431 );
32432
32433 let ok_recall = compile_program(&program(
32435 "recall for ticket\n learn for ticket",
32436 "project_memory",
32437 "project_memory",
32438 ));
32439 assert!(
32440 !ok_recall
32441 .diagnostics
32442 .iter()
32443 .any(|d| d.message.contains("not a memory operation")),
32444 "{:?}",
32445 ok_recall.diagnostics
32446 );
32447
32448 let ok_other = compile_program(&program(
32451 r#"read ["docs/**"]"#,
32452 "project_files",
32453 "project_memory",
32454 ));
32455 assert!(
32456 !ok_other
32457 .diagnostics
32458 .iter()
32459 .any(|d| d.message.contains("not a memory operation")),
32460 "{:?}",
32461 ok_other.diagnostics
32462 );
32463 }
32464
32465 #[test]
32466 fn rejects_malformed_turn_access_grants() {
32467 let program = |grant_block: &str| {
32470 format!(
32471 r#"
32472@service
32473workflow GrantCheck
32474
32475output result R
32476class R {{ ok bool }}
32477class Ticket {{ id string status "open" }}
32478
32479agent coder {{ provider fixture profile "repo-writer" capacity 1 }}
32480
32481table seed as Ticket [ {{ id "T1" status "open" }} ]
32482
32483rule work
32484 when Ticket as ticket where ticket.status == "open"
32485 when coder is available
32486=> {{
32487 tell coder as turn
32488{grant_block}
32489 "Work it."
32490
32491 after turn succeeds as outcome {{
32492 complete result {{ ok true }}
32493 }}
32494}}
32495"#
32496 )
32497 };
32498
32499 let empty = compile_program(&program(" with access to project_memory {\n }\n"));
32500 assert!(
32501 empty
32502 .diagnostics
32503 .iter()
32504 .any(|d| d.message.contains("grants no operations")),
32505 "{:?}",
32506 empty.diagnostics
32507 );
32508
32509 let duplicate = compile_program(&program(
32510 " with access to project_memory {\n recall for ticket\n }\n with access to project_memory {\n learn for ticket\n }\n",
32511 ));
32512 assert!(
32513 duplicate
32514 .diagnostics
32515 .iter()
32516 .any(|d| d.message.contains("more than once")),
32517 "{:?}",
32518 duplicate.diagnostics
32519 );
32520 }
32521
32522 #[test]
32526 fn warns_inert_memory_grant_on_a_native_adapter_tell() {
32527 let program = |harness_kind: &str| {
32528 format!(
32529 r#"
32530@service
32531workflow InertGrant
32532
32533output result R
32534class R {{ ok bool }}
32535class Ticket {{ id string status "open" }}
32536
32537memory pool project_memory {{
32538 context limit 4
32539}}
32540
32541harness h: {harness_kind}
32542agent coder using h {{ profile "repo-writer" capacity 1 }}
32543
32544table seed as Ticket [ {{ id "T1" status "open" }} ]
32545
32546rule work
32547 when Ticket as ticket where ticket.status == "open"
32548 when coder is available
32549=> {{
32550 tell coder as turn
32551 with access to project_memory {{
32552 recall for ticket
32553 }}
32554 "Work it."
32555
32556 after turn succeeds as outcome {{
32557 complete result {{ ok true }}
32558 }}
32559}}
32560"#
32561 )
32562 };
32563 let native = compile_program(&program("codex"));
32564 assert!(
32565 native.diagnostics.is_empty(),
32566 "the grant itself is legal: {:?}",
32567 native.diagnostics
32568 );
32569 assert!(
32570 native
32571 .warnings
32572 .iter()
32573 .any(|warning| warning.message.contains("inert")),
32574 "a codex-harness tell warns: {:?}",
32575 native.warnings
32576 );
32577 let owned = compile_program(&program("owned"));
32578 assert!(
32579 owned
32580 .warnings
32581 .iter()
32582 .all(|warning| !warning.message.contains("inert")),
32583 "an owned-harness tell does not warn: {:?}",
32584 owned.warnings
32585 );
32586 }
32587
32588 #[test]
32589 fn counter_timezone_clause_parses_and_default_utc_warns() {
32590 let program = |timezone_clause: &str| {
32594 format!(
32595 r#"
32596@service
32597workflow CounterTz
32598
32599class CallFailed {{ service string }}
32600class Service {{ id string }}
32601output result CallFailed
32602failure trouble CallFailed
32603
32604counter failure_budget {{ key Service cap 3 reset daily {timezone_clause} }}
32605
32606rule strike
32607 when CallFailed as f
32608=> {{
32609 consume failure_budget for f.service amount 1 as strike
32610 after strike ok {{
32611 complete result {{ service f.service }}
32612 }}
32613 after strike over {{
32614 fail trouble {{ service f.service }}
32615 }}
32616}}
32617"#
32618 )
32619 };
32620 let anchored = compile_program(&program(r#"timezone "America/New_York""#));
32621 assert!(
32622 anchored.diagnostics.is_empty(),
32623 "timezone clause parses: {:?}",
32624 anchored.diagnostics
32625 );
32626 let ir = anchored.ir.expect("anchored program compiles");
32627 assert_eq!(ir.counters[0].timezone.as_deref(), Some("America/New_York"));
32628 assert!(
32629 anchored
32630 .warnings
32631 .iter()
32632 .all(|warning| !warning.message.contains("timezone")),
32633 "an anchored counter does not warn: {:?}",
32634 anchored.warnings
32635 );
32636
32637 let unanchored = compile_program(&program(""));
32638 assert!(
32639 unanchored.diagnostics.is_empty(),
32640 "omitting timezone stays legal: {:?}",
32641 unanchored.diagnostics
32642 );
32643 let ir = unanchored.ir.expect("unanchored program compiles");
32644 assert_eq!(ir.counters[0].timezone, None);
32645 assert!(
32646 unanchored
32647 .warnings
32648 .iter()
32649 .any(|warning| warning.message.contains("anchors to UTC")),
32650 "an unanchored counter draws the default-UTC warning: {:?}",
32651 unanchored.warnings
32652 );
32653 }
32654
32655 #[test]
32656 fn then_sugar_desugars_to_nested_after_and_composes_in_after_blocks() {
32657 let source = r#"
32662use std.script
32663
32664workflow ThenSugar
32665
32666output result Done
32667
32668class Done {
32669 note string
32670}
32671
32672class Trigger {
32673 id string
32674}
32675
32676table seed as Trigger [
32677 { id "t" }
32678]
32679
32680rule pipeline
32681 when Trigger as t
32682=> {
32683 exec "true" as pre
32684
32685 after pre succeeds {
32686 then a <- exec "one"
32687 then b <- exec "two"
32688 complete result {
32689 note b.stdout
32690 }
32691 }
32692}
32693"#;
32694 let compiled = compile_program(source);
32695 assert_eq!(compiled.diagnostics, Vec::new());
32696 let ir = compiled.ir.expect("compiles");
32697 let body = &ir
32698 .rules
32699 .iter()
32700 .find(|rule| rule.name == "pipeline")
32701 .expect("rule")
32702 .body;
32703 assert!(
32704 body.contains("exec \"one\" as __then_a"),
32705 "the chained effect binds the synthetic handle:\n{body}"
32706 );
32707 assert!(
32708 body.contains("after __then_a succeeds as a {"),
32709 "the continuation nests under the success predicate:\n{body}"
32710 );
32711 assert!(
32712 body.contains("after __then_b succeeds as b {"),
32713 "chained thens nest:\n{body}"
32714 );
32715 assert!(!body.contains("then a <-"), "no sugar survives:\n{body}");
32716
32717 let reserved = compile_program(
32718 r#"
32719use std.script
32720
32721workflow Reserved
32722
32723output result Done
32724
32725class Done {
32726 note string
32727}
32728
32729rule r
32730 when started
32731=> {
32732 exec "true" as __then_x
32733
32734 after __then_x succeeds {
32735 complete result { note "no" }
32736 }
32737}
32738"#,
32739 );
32740 assert!(
32741 reserved
32742 .diagnostics
32743 .iter()
32744 .any(|d| d.message.contains("reserved `__then_` binding namespace")),
32745 "{:?}",
32746 reserved.diagnostics
32747 );
32748 }
32749
32750 #[test]
32751 fn warns_on_unhandled_effect_failure_and_stays_quiet_when_observed() {
32752 let program = |handler: &str| {
32756 format!(
32757 r#"
32758use std.script
32759
32760workflow AutoFailWarn
32761
32762output result Done
32763failure error Broken
32764
32765class Done {{ note string }}
32766class Broken {{ reason string }}
32767class Trigger {{ id string }}
32768
32769table seed as Trigger [
32770 {{ id "t" }}
32771]
32772
32773rule r
32774 when Trigger as t
32775=> {{
32776 exec "true" as x
32777
32778 after x succeeds {{
32779 complete result {{ note "ok" }}
32780 }}
32781{handler}}}
32782"#
32783 )
32784 };
32785 let unhandled = compile_program(&program(""));
32786 assert!(
32787 unhandled.diagnostics.is_empty(),
32788 "{:?}",
32789 unhandled.diagnostics
32790 );
32791 assert!(
32792 unhandled
32793 .warnings
32794 .iter()
32795 .any(|warning| warning.message.contains("`x`'s failure is unhandled")),
32796 "succeeds-only handling draws the R1a warning: {:?}",
32797 unhandled.warnings
32798 );
32799
32800 for observer in [
32801 "\n after x fails {\n fail error { reason \"broken\" }\n }\n",
32802 "\n after x completes {\n complete result { note \"any\" }\n }\n",
32803 "\n after x times out {\n fail error { reason \"slow\" }\n }\n",
32804 ] {
32805 let observed = compile_program(&program(observer));
32806 assert!(
32807 observed.diagnostics.is_empty(),
32808 "{:?}",
32809 observed.diagnostics
32810 );
32811 assert!(
32812 observed
32813 .warnings
32814 .iter()
32815 .all(|warning| !warning.message.contains("failure is unhandled")),
32816 "an observer silences the warning ({observer:?}): {:?}",
32817 observed.warnings
32818 );
32819 }
32820 }
32821
32822 #[test]
32823 fn unhandled_failure_warning_exempts_services_timers_and_coordination() {
32824 let service = compile_program(
32829 r#"
32830use std.script
32831
32832@service
32833workflow ServiceQuiet
32834
32835class Trigger { id string }
32836class Seen { note string }
32837
32838table seed as Trigger [
32839 { id "t" }
32840]
32841
32842rule r
32843 when Trigger as t
32844=> {
32845 exec "true" as x
32846
32847 after x succeeds {
32848 record Seen { note "ok" }
32849 }
32850}
32851"#,
32852 );
32853 assert!(service.diagnostics.is_empty(), "{:?}", service.diagnostics);
32854 assert!(
32855 service
32856 .warnings
32857 .iter()
32858 .all(|warning| !warning.message.contains("failure is unhandled")),
32859 "@service is exempt: {:?}",
32860 service.warnings
32861 );
32862
32863 let timer = compile_program(
32864 r#"
32865workflow TimerQuiet
32866
32867output result Done
32868
32869class Done { note string }
32870class Trigger { id string }
32871
32872table seed as Trigger [
32873 { id "t" }
32874]
32875
32876rule r
32877 when Trigger as t
32878=> {
32879 timer 5m as pause
32880
32881 after pause completes {
32882 complete result { note "ok" }
32883 }
32884}
32885"#,
32886 );
32887 assert!(timer.diagnostics.is_empty(), "{:?}", timer.diagnostics);
32888 assert!(
32889 timer
32890 .warnings
32891 .iter()
32892 .all(|warning| !warning.message.contains("failure is unhandled")),
32893 "timers are exempt: {:?}",
32894 timer.warnings
32895 );
32896
32897 let coordination = compile_program(
32898 r#"
32899workflow CoordQuiet
32900
32901output result Done
32902failure error Broken
32903
32904class Done { note string }
32905class Broken { reason string }
32906class Trigger { id string }
32907
32908lease build_slot { key Trigger ttl 10m }
32909
32910table seed as Trigger [
32911 { id "t" }
32912]
32913
32914rule r
32915 when Trigger as t
32916=> {
32917 acquire build_slot for t.id as slot
32918
32919 after slot held {
32920 complete result { note "ok" }
32921 }
32922
32923 after slot contended {
32924 fail error { reason "busy" }
32925 }
32926}
32927"#,
32928 );
32929 assert!(
32930 coordination.diagnostics.is_empty(),
32931 "{:?}",
32932 coordination.diagnostics
32933 );
32934 assert!(
32935 coordination
32936 .warnings
32937 .iter()
32938 .all(|warning| !warning.message.contains("failure is unhandled")),
32939 "coordination outcome observers count at check time: {:?}",
32940 coordination.warnings
32941 );
32942 }
32943
32944 #[test]
32945 fn lowers_turn_access_grants_onto_the_agent_tell_effect() {
32946 let source = r#"
32949@service
32950workflow GrantDemo
32951
32952output result R
32953class R { ok bool }
32954class Ticket { id string status "open" }
32955
32956agent coder { provider fixture profile "repo-writer" capacity 1 }
32957
32958table seed as Ticket [ { id "T1" status "open" } ]
32959
32960rule work
32961 when Ticket as ticket where ticket.status == "open"
32962 when coder is available
32963=> {
32964 tell coder as turn
32965 with access to project_memory {
32966 recall for ticket
32967 learn for ticket
32968 }
32969 with access to project_files {
32970 read ["docs/**"]
32971 }
32972 "Work it."
32973
32974 after turn succeeds as outcome {
32975 complete result { ok true }
32976 }
32977}
32978"#;
32979 let compiled = compile_program(source);
32980 let ir = compiled.ir.expect("compiles");
32981 let tell = ir
32982 .rules
32983 .iter()
32984 .flat_map(|rule| rule.metadata.effects.iter())
32985 .find(|effect| effect.kind == IrEffectKind::AgentTell)
32986 .expect("agent.tell effect");
32987 assert_eq!(tell.access_grants.len(), 2);
32988 let memory = &tell.access_grants[0];
32989 assert_eq!(memory.resource, "project_memory");
32990 assert_eq!(memory.operations.len(), 2);
32991 assert_eq!(memory.operations[0].operation, "recall");
32992 assert_eq!(memory.operations[0].target.as_deref(), Some("ticket"));
32993 let files = &tell.access_grants[1];
32994 assert_eq!(files.resource, "project_files");
32995 assert_eq!(files.operations[0].operation, "read");
32996 assert_eq!(files.operations[0].globs, vec!["docs/**".to_owned()]);
32997 }
32998
32999 #[test]
33000 fn lowers_start_access_grants_onto_the_workflow_invoke_effect() {
33001 let source = r#"
33004workflow Parent {
33005 class Task { id string }
33006
33007 rule dispatch
33008 when Task as task
33009 => {
33010 invoke Child { task task }
33011 with access to project_files {
33012 read ["docs/**"]
33013 }
33014 as child
33015 }
33016}
33017
33018workflow Child {
33019 input task Task
33020 class Task { id string }
33021}
33022"#;
33023 let compiled = compile_program_with_root(source, Some("Parent"));
33024 let ir = compiled.ir.unwrap_or_else(|| {
33025 panic!(
33026 "source should compile, diagnostics: {:?}",
33027 compiled
33028 .diagnostics
33029 .iter()
33030 .map(|d| &d.message)
33031 .collect::<Vec<_>>()
33032 )
33033 });
33034 let invoke = ir
33035 .rules
33036 .iter()
33037 .flat_map(|rule| rule.metadata.effects.iter())
33038 .find(|effect| effect.kind == IrEffectKind::WorkflowInvoke)
33039 .expect("workflow.invoke effect");
33040 assert_eq!(invoke.binding.as_deref(), Some("child"));
33041 assert_eq!(invoke.access_grants.len(), 1);
33042 let files = &invoke.access_grants[0];
33043 assert_eq!(files.resource, "project_files");
33044 assert_eq!(files.operations[0].operation, "read");
33045 assert_eq!(files.operations[0].globs, vec!["docs/**".to_owned()]);
33046 }
33047
33048 #[test]
33049 fn lowers_resource_less_start_access_grant_shorthand_onto_the_workflow_invoke_effect() {
33050 let source = r#"
33053workflow Parent {
33054 class Task { id string }
33055
33056 rule dispatch
33057 when Task as task
33058 => {
33059 invoke Child { task task }
33060 with access to {
33061 project_memory {
33062 recall for task
33063 }
33064 project_files {
33065 read ["docs/**"]
33066 }
33067 }
33068 as child
33069 }
33070}
33071
33072workflow Child {
33073 input task Task
33074 class Task { id string }
33075}
33076"#;
33077 let compiled = compile_program_with_root(source, Some("Parent"));
33078 let ir = compiled.ir.unwrap_or_else(|| {
33079 panic!(
33080 "source should compile, diagnostics: {:?}",
33081 compiled
33082 .diagnostics
33083 .iter()
33084 .map(|d| &d.message)
33085 .collect::<Vec<_>>()
33086 )
33087 });
33088 let invoke = ir
33089 .rules
33090 .iter()
33091 .flat_map(|rule| rule.metadata.effects.iter())
33092 .find(|effect| effect.kind == IrEffectKind::WorkflowInvoke)
33093 .expect("workflow.invoke effect");
33094 assert_eq!(invoke.binding.as_deref(), Some("child"));
33095 assert_eq!(invoke.access_grants.len(), 2);
33096 let memory = &invoke.access_grants[0];
33097 assert_eq!(memory.resource, "project_memory");
33098 assert_eq!(memory.operations[0].operation, "recall");
33099 assert_eq!(memory.operations[0].target.as_deref(), Some("task"));
33100 let files = &invoke.access_grants[1];
33101 assert_eq!(files.resource, "project_files");
33102 assert_eq!(files.operations[0].operation, "read");
33103 assert_eq!(files.operations[0].globs, vec!["docs/**".to_owned()]);
33104 }
33105
33106 #[test]
33107 fn rejects_rule_matching_evidence_only_turn_fact() {
33108 for evidence in [
33112 "agent.turn.streamed",
33113 "agent.turn.tool_requested",
33114 "agent.turn.artifact_captured",
33115 ] {
33116 let source = format!(
33117 "workflow EvidenceMatch\n\noutput result R\nclass R {{ ok bool }}\n\nrule react\n when fact {evidence} as ev\n=> {{\n complete result {{ ok true }}\n}}\n"
33118 );
33119 let compiled = compile_program(&source);
33120 assert!(compiled.ir.is_none(), "{evidence} should be rejected");
33121 assert!(
33122 compiled
33123 .diagnostics
33124 .iter()
33125 .any(|d| d.message.contains("evidence-only fact")
33126 && d.message.contains(evidence)),
33127 "{evidence}: {:?}",
33128 compiled.diagnostics
33129 );
33130 }
33131 let matchable = "workflow M\n\noutput result R\nclass R {{ ok bool }}\n\nrule react\n when fact agent.turn.completed as ev\n=> {{\n complete result {{ ok true }}\n}}\n".replace("{{", "{").replace("}}", "}");
33134 let compiled = compile_program(&matchable);
33135 assert!(
33136 !compiled
33137 .diagnostics
33138 .iter()
33139 .any(|d| d.message.contains("evidence-only fact")),
33140 "completed must not be flagged as evidence-only: {:?}",
33141 compiled.diagnostics
33142 );
33143 }
33144
33145 #[test]
33146 fn rejects_self_recursive_pattern_application() {
33147 let source = include_str!("../../../examples/invalid/recursive-pattern.whip");
33150 let compiled = compile_program(source);
33151
33152 assert!(compiled.ir.is_none());
33153 assert_eq!(compiled.diagnostics.len(), 1, "{:?}", compiled.diagnostics);
33156 let diagnostic = &compiled.diagnostics[0];
33157 assert!(
33158 diagnostic
33159 .message
33160 .contains("graph.unbounded_pattern_recursion"),
33161 "{}",
33162 diagnostic.message
33163 );
33164 assert!(
33165 diagnostic.message.contains("expansion cycle Loop -> Loop"),
33166 "the diagnostic names the cycle: {}",
33167 diagnostic.message
33168 );
33169 }
33170
33171 #[test]
33172 fn rejects_mutually_recursive_pattern_application() {
33173 let source = r#"
33175workflow MutualRecursion
33176
33177class Item {
33178 id string
33179}
33180
33181pattern Ping<T> {
33182 apply Pong<T> as a {
33183 }
33184}
33185
33186pattern Pong<T> {
33187 apply Ping<T> as b {
33188 }
33189}
33190
33191apply Ping<Item> as top {
33192}
33193"#;
33194 let compiled = compile_program(source);
33195
33196 assert!(compiled.ir.is_none());
33197 let recursion: Vec<&Diagnostic> = compiled
33198 .diagnostics
33199 .iter()
33200 .filter(|d| d.message.contains("graph.unbounded_pattern_recursion"))
33201 .collect();
33202 assert_eq!(recursion.len(), 1, "{:?}", compiled.diagnostics);
33204 assert!(
33205 recursion[0].message.contains("Ping -> Pong -> Ping"),
33206 "names the full cycle: {}",
33207 recursion[0].message
33208 );
33209 }
33210
33211 #[test]
33212 fn allows_non_recursive_nested_apply_without_recursion_error() {
33213 let source = r#"
33216workflow NonRecursive
33217
33218class Item {
33219 id string
33220}
33221
33222pattern Inner<T> {
33223}
33224
33225pattern Outer<T> {
33226 apply Inner<T> as x {
33227 }
33228}
33229
33230apply Outer<Item> as top {
33231}
33232"#;
33233 let compiled = compile_program(source);
33234
33235 assert!(
33236 !compiled
33237 .diagnostics
33238 .iter()
33239 .any(|d| d.message.contains("graph.unbounded_pattern_recursion")),
33240 "non-recursive nesting must not be flagged as recursion: {:?}",
33241 compiled.diagnostics
33242 );
33243 }
33244
33245 #[test]
33246 fn rejects_unknown_or_wrong_arity_coerce_calls() {
33247 let source = r#"
33248workflow BadCoerce
33249
33250class Review {
33251 reason string
33252}
33253
33254coerce review(summary string) -> Review {
33255 prompt "review"
33256}
33257
33258rule bad
33259 when started
33260=> {
33261 coerce missing("x") as one
33262 coerce review("x", "y") as two
33263}
33264"#;
33265 let compiled = compile_program(source);
33266
33267 assert!(compiled.ir.is_none());
33268 assert_eq!(compiled.diagnostics.len(), 2);
33269 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
33270 .message
33271 .contains("unknown coerce function `missing`")));
33272 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
33273 .message
33274 .contains("with 2 argument(s), expected 1")));
33275 }
33276
33277 #[test]
33278 fn rejects_bad_effect_payload_argument_types() {
33279 let source = r#"
33280workflow BadEffectPayloads
33281
33282class Owner {
33283 name string
33284}
33285
33286class Payload {
33287 title string
33288 owner Owner
33289 metadata map<string>
33290 tags string[]
33291}
33292
33293class Task {
33294 title string
33295 owner string
33296}
33297
33298class Review {
33299 accepted bool
33300}
33301
33302coerce reviewPayload(payload Payload, metadata map<string>, score int) -> Review {
33303 prompt "review"
33304}
33305
33306rule bad_coerce
33307 when Task as task where { owner "Ada" } == task.owner
33308=> {
33309 coerce reviewPayload(
33310 {
33311 title task.title
33312 owner { handle task.owner }
33313 metadata { phase 3 }
33314 tags ["object", 7]
33315 extra "bad"
33316 },
33317 { phase task.owner, count 3 },
33318 "high"
33319 ) as review
33320}
33321"#;
33322 let compiled = compile_program(source);
33323
33324 assert!(compiled.ir.is_none());
33325 let messages = compiled
33326 .diagnostics
33327 .iter()
33328 .map(|diagnostic| diagnostic.message.as_str())
33329 .collect::<Vec<_>>();
33330 assert!(messages
33331 .iter()
33332 .any(|message| message.contains("object literal without an expected object")));
33333 assert!(messages
33334 .iter()
33335 .any(|message| message.contains("class `Owner` has no field `handle`")));
33336 assert!(messages
33337 .iter()
33338 .any(|message| message.contains("missing required object field `Owner.name`")));
33339 assert!(messages
33340 .iter()
33341 .any(|message| message.contains("class `Payload` has no field `extra`")));
33342 assert!(messages
33343 .iter()
33344 .any(|message| message
33345 .contains("field `coerce `reviewPayload`.metadata` expects `string`")));
33346 assert!(messages.iter().any(|message| {
33347 message.contains("field `coerce `reviewPayload`.score` expects `int`")
33348 }));
33349 }
33350
33351 #[test]
33352 fn lowers_fact_consumption_metadata() {
33353 let source = r#"
33354workflow ConsumeTask
33355
33356class Task {
33357 status "queued"
33358}
33359
33360rule finish
33361 when Task as task
33362=> {
33363 done task
33364}
33365"#;
33366 let compiled = compile_program(source);
33367 let ir = compiled.ir.expect("program compiles");
33368
33369 assert_eq!(ir.rules[0].metadata.fact_consumes, vec!["schema:Task"]);
33370 assert!(ir.to_snapshot().contains("consumes\n schema:Task"));
33371 }
33372
33373 #[test]
33374 fn rejects_unknown_fact_consumption_binding() {
33375 let source = r#"
33376workflow BadConsume
33377
33378class Task {
33379 status "queued"
33380}
33381
33382rule finish
33383 when Task as task
33384=> {
33385 done missing
33386}
33387"#;
33388 let compiled = compile_program(source);
33389
33390 assert!(compiled.ir.is_none());
33391 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
33392 .message
33393 .contains("consumes unknown fact binding `missing`")));
33394 }
33395
33396 #[test]
33397 fn rejects_then_sequencing() {
33398 let source = r#"
33399workflow NoThen
33400
33401class Task {
33402 topic string
33403 status "queued"
33404}
33405
33406class Result {
33407 topic string
33408 turn AgentTurn
33409 status "done"
33410}
33411
33412agent codex {
33413 provider codex
33414 profile "repo-writer"
33415 capacity 1
33416}
33417
33418assert count(Task where status == "queued") == 0
33419assert count(Result where status == "done") == 1
33420
33421rule finish
33422 when Task as task where task.status == "queued"
33423 when codex is available
33424=> {
33425 tell codex as turn "write"
33426 then done task -> record Result from task {
33427 topic
33428 turn turn
33429 status "done"
33430 }
33431}
33432"#;
33433 let compiled = compile_program(source);
33434 assert!(compiled.ir.is_none());
33435 assert!(compiled
33436 .diagnostics
33437 .iter()
33438 .any(|diagnostic| diagnostic.message.contains("unsupported `then` sequencing")));
33439 }
33440
33441 #[test]
33442 fn rejects_after_arrow_sequencing() {
33443 let source = r#"
33444workflow NoAfterArrow
33445
33446agent codex {
33447 provider codex
33448 profile "repo-writer"
33449 capacity 1
33450}
33451
33452rule finish
33453 when started
33454 when codex is available
33455=> {
33456 tell codex as turn "write"
33457
33458 after turn succeeds => {
33459 record Done {
33460 status "done"
33461 }
33462 }
33463}
33464"#;
33465 let compiled = compile_program(source);
33466 assert!(compiled.ir.is_none());
33467 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
33468 .message
33469 .contains("unsupported `after ... =>` sequencing")));
33470 }
33471
33472 #[test]
33473 fn formats_top_level_syntax_scaffold() {
33474 let source = r#"workflow Messy
33475class Status {
33476kind "open"|"done"
33477}
33478rule start
33479when started
33480=> {tell worker "hi"}
33481"#;
33482
33483 let formatted = format_program(source);
33484 assert_eq!(formatted.diagnostics, Vec::new());
33485 let expected = concat!(
33486 "workflow Messy\n",
33487 "\n",
33488 "class Status {\n",
33489 " kind \"open\" | \"done\"\n",
33490 "}\n",
33491 "\n",
33492 "rule start\n",
33493 " when started\n",
33494 "=> {\n",
33495 " tell worker \"hi\"\n",
33496 "}\n",
33497 );
33498
33499 assert_eq!(formatted.formatted.as_deref(), Some(expected));
33500 }
33501
33502 #[test]
33503 fn formats_content_typed_multiline_prompts() {
33504 let source = r#"workflow PromptFormat
33505class Review {
33506status "ok"
33507}
33508coerce review() -> Review {
33509prompt """markdown
33510classify
33511"""
33512}
33513agent worker {
33514 provider fixture
33515profile "repo-writer"
33516capacity 1
33517}
33518rule start
33519when started
33520=> {tell worker as turn """markdown
33521write
33522"""
33523tell worker """application/json
33524{"question":"approve?"}
33525"""}
33526"#;
33527
33528 let formatted = format_program(source);
33529 assert_eq!(formatted.diagnostics, Vec::new());
33530 let expected = concat!(
33531 "workflow PromptFormat\n",
33532 "\n",
33533 "class Review {\n",
33534 " status \"ok\"\n",
33535 "}\n",
33536 "\n",
33537 "coerce review() -> Review {\n",
33538 " prompt \"\"\"markdown\n",
33539 " classify\n",
33540 " \"\"\"\n",
33541 "}\n",
33542 "\n",
33543 "agent worker {\n",
33544 " provider fixture\n",
33545 " profile \"repo-writer\"\n",
33546 " capacity 1\n",
33547 "}\n",
33548 "\n",
33549 "rule start\n",
33550 " when started\n",
33551 "=> {\n",
33552 " tell worker as turn \"\"\"markdown\n",
33553 " write\n",
33554 " \"\"\"\n",
33555 " tell worker \"\"\"application/json\n",
33556 " {\"question\":\"approve?\"}\n",
33557 " \"\"\"\n",
33558 "}\n",
33559 );
33560
33561 assert_eq!(formatted.formatted.as_deref(), Some(expected));
33562 }
33563
33564 #[test]
33565 fn formats_harness_declarations_and_agent_bindings() {
33566 let source = r#"workflow HarnessFormat
33567harness coder: codex
33568agent implementer using coder {
33569profile "repo-writer"
33570capacity 1
33571}
33572"#;
33573
33574 let formatted = format_program(source);
33575 assert_eq!(formatted.diagnostics, Vec::new());
33576 let expected = concat!(
33577 "workflow HarnessFormat\n",
33578 "\n",
33579 "harness coder: codex\n",
33580 "\n",
33581 "agent implementer using coder {\n",
33582 " profile \"repo-writer\"\n",
33583 " capacity 1\n",
33584 "}\n",
33585 );
33586
33587 assert_eq!(formatted.formatted.as_deref(), Some(expected));
33588 }
33589
33590 #[test]
33591 fn formats_explicit_workflow_blocks() {
33592 let source = r#"class Shared {
33593id string
33594}
33595workflow One {
33596input item Shared
33597rule start
33598when Shared as item
33599=> {complete result {id item.id}}
33600}
33601"#;
33602
33603 let formatted = format_program(source);
33604 assert_eq!(formatted.diagnostics, Vec::new());
33605 let expected = concat!(
33606 "class Shared {\n",
33607 " id string\n",
33608 "}\n",
33609 "\n",
33610 "workflow One {\n",
33611 " input item Shared\n",
33612 "\n",
33613 " rule start\n",
33614 " when Shared as item\n",
33615 " => {\n",
33616 " complete result {id item.id}\n",
33617 " }\n",
33618 "}\n",
33619 );
33620
33621 assert_eq!(formatted.formatted.as_deref(), Some(expected));
33622 }
33623
33624 #[test]
33625 fn formats_invoke_start_access_grants() {
33626 let source = r#"workflow Parent {
33627file store project_files { root "./data" allow read ["docs/**"] allow write ["reports/**"] }
33628class Task { id string }
33629rule dispatch
33630when Task as task
33631=> {
33632invoke Child {
33633task task
33634}
33635with access to project_files {
33636read ["docs/**"]
33637write ["reports/**"]
33638}
33639as child
33640}
33641}
33642
33643workflow Child {
33644input task Task
33645class Task { id string }
33646}
33647"#;
33648
33649 let formatted = format_program(source);
33650 assert_eq!(formatted.diagnostics, Vec::new());
33651 let expected = concat!(
33652 "workflow Parent {\n",
33653 " file store project_files {\n",
33654 " root \"./data\"\n",
33655 " allow read [\"docs/**\"]\n",
33656 " allow write [\"reports/**\"]\n",
33657 " }\n",
33658 "\n",
33659 " class Task {\n",
33660 " id string\n",
33661 " }\n",
33662 "\n",
33663 " rule dispatch\n",
33664 " when Task as task\n",
33665 " => {\n",
33666 " invoke Child {\n",
33667 " task task\n",
33668 " }\n",
33669 " with access to project_files {\n",
33670 " read [\"docs/**\"]\n",
33671 " write [\"reports/**\"]\n",
33672 " }\n",
33673 " as child\n",
33674 " }\n",
33675 "}\n",
33676 "\n",
33677 "workflow Child {\n",
33678 " input task Task\n",
33679 "\n",
33680 " class Task {\n",
33681 " id string\n",
33682 " }\n",
33683 "}\n",
33684 );
33685
33686 assert_eq!(formatted.formatted.as_deref(), Some(expected));
33687 }
33688
33689 #[test]
33690 fn formats_patterns_and_apply_syntax() {
33691 let source = r#"pattern Review<Input>{
33692rule dispatch
33693when Input as item
33694=> {}
33695}
33696workflow Root {
33697apply Review<Task> as taskReview {}
33698}
33699"#;
33700
33701 let formatted = format_program(source);
33702 assert_eq!(formatted.diagnostics, Vec::new());
33703 let expected = concat!(
33704 "pattern Review<Input> {\n",
33705 " rule dispatch\n",
33706 " when Input as item\n",
33707 " => {\n",
33708 " }\n",
33709 "}\n",
33710 "\n",
33711 "workflow Root {\n",
33712 " apply Review<Task> as taskReview {\n",
33713 " }\n",
33714 "}\n",
33715 );
33716
33717 assert_eq!(formatted.formatted.as_deref(), Some(expected));
33718 }
33719
33720 #[test]
33721 fn lexer_captures_comments_without_affecting_tokens() {
33722 let source =
33723 "# top comment\nworkflow Demo\n\nclass Task {\n title string // trailing\n}\n";
33724 let comments = lex_comments(source);
33725 assert_eq!(comments.len(), 2);
33726 assert_eq!(comments[0].marker, CommentMarker::Hash);
33727 assert_eq!(comments[0].text, "top comment");
33728 assert_eq!(comments[1].marker, CommentMarker::Slash);
33729 assert_eq!(comments[1].text, "trailing");
33730 let first = &comments[0];
33732 assert_eq!(&source[first.span.start..first.span.end], "# top comment");
33733 let compiled = compile_program(source);
33735 assert_eq!(compiled.diagnostics, Vec::new());
33736 }
33737
33738 #[test]
33739 fn test_block_parses_given_run_and_expect_clauses() {
33740 let source = r#"
33741@service
33742workflow Demo
33743
33744test "ci triage" {
33745 given signal github.workflow_failed {
33746 run_id "run_123"
33747 }
33748 stub agent triager succeeds
33749 run until idle
33750 expect issue count where external_id == "run_123" is 1
33751 expect rule triage_failed_run fired
33752}
33753"#;
33754 let compiled = compile_program(source);
33755 assert_eq!(compiled.diagnostics, Vec::new());
33756 let ir = compiled.ir.expect("program compiles");
33757 assert_eq!(ir.tests.len(), 1);
33758 let test = &ir.tests[0];
33759 assert_eq!(test.name, "ci triage");
33760 assert_eq!(test.clauses.len(), 5);
33761
33762 match &test.clauses[0] {
33763 TestClause::Given(GivenClause::Signal { name, fields, .. }) => {
33764 assert_eq!(name, "github.workflow_failed");
33765 assert_eq!(fields.len(), 1);
33766 assert_eq!(fields[0].name.name, "run_id");
33767 assert_eq!(fields[0].value, "\"run_123\"");
33768 }
33769 other => panic!("expected given signal, got {other:?}"),
33770 }
33771 match &test.clauses[1] {
33772 TestClause::Stub(stub) => {
33773 assert_eq!(stub.surface, vec!["agent".to_owned(), "triager".to_owned()]);
33774 assert_eq!(stub.outcome, "succeeds");
33775 }
33776 other => panic!("expected stub, got {other:?}"),
33777 }
33778 assert!(matches!(
33779 &test.clauses[2],
33780 TestClause::Run(RunClause {
33781 kind: RunKind::UntilIdle,
33782 ..
33783 })
33784 ));
33785 match &test.clauses[3] {
33786 TestClause::Expect(ExpectClause {
33787 target: ExpectTarget::Projection(query),
33788 ..
33789 }) => {
33790 assert_eq!(query.noun, "issue");
33791 match &query.kind {
33792 ProjQueryKind::Count { predicate, count } => {
33793 assert_eq!(predicate, "external_id == \"run_123\"");
33794 assert_eq!(*count, 1);
33795 }
33796 other => panic!("expected count query, got {other:?}"),
33797 }
33798 }
33799 other => panic!("expected expect projection, got {other:?}"),
33800 }
33801 match &test.clauses[4] {
33802 TestClause::Expect(ExpectClause {
33803 target: ExpectTarget::Rule { name, status },
33804 ..
33805 }) => {
33806 assert_eq!(name.name, "triage_failed_run");
33807 assert_eq!(*status, RuleStatus::Fired);
33808 }
33809 other => panic!("expected expect rule, got {other:?}"),
33810 }
33811 }
33812
33813 #[test]
33814 fn test_block_rejects_a_malformed_predicate() {
33815 let source = r#"
33816@service
33817workflow Demo
33818
33819test "bad predicate" {
33820 run until idle
33821 expect issue count where == == is 1
33822}
33823"#;
33824 let compiled = compile_program(source);
33825 assert!(
33826 compiled
33827 .diagnostics
33828 .iter()
33829 .any(|diagnostic| diagnostic.message.contains("predicate on `issue`")),
33830 "{:?}",
33831 compiled.diagnostics
33832 );
33833 }
33834
33835 #[test]
33836 fn source_clock_block_lowers_to_clock_source() {
33837 let source = r#"
33838workflow ClockSource
33839
33840signal triage.tick {
33841 scheduled_at time
33842 observed_at time
33843 occurrence_id string
33844 missed_count int
33845}
33846
33847source clock as daily_triage {
33848 every weekday at 09:00
33849 timezone "America/New_York"
33850 missed coalesce
33851
33852 observe as tick
33853 emit triage.tick {
33854 scheduled_at tick.scheduled_at
33855 observed_at tick.observed_at
33856 occurrence_id tick.occurrence_id
33857 missed_count tick.missed_count
33858 }
33859}
33860"#;
33861 let compiled = compile_program(source);
33862 assert_eq!(compiled.diagnostics, Vec::new());
33863 let ir = compiled.ir.expect("program compiles");
33864 assert_eq!(ir.sources.len(), 1);
33865 let decl = &ir.sources[0];
33866 assert_eq!(decl.name, "daily_triage");
33867 assert_eq!(decl.provider, "clock");
33868 assert!(decl.is_clock);
33869 assert_eq!(decl.observe_binding, "tick");
33870 assert_eq!(decl.emit_signal, "triage.tick");
33871 assert_eq!(decl.emit_fields.len(), 4);
33872 assert_eq!(decl.timezone.as_deref(), Some("America/New_York"));
33873 assert_eq!(decl.missed, Some(MissedPolicy::Coalesce));
33874 match &decl.recurrence {
33875 Some(Recurrence::EveryCalendar { pattern, time, .. }) => {
33876 assert_eq!(*pattern, CalendarPattern::Weekday);
33877 assert_eq!(time.hour, 9);
33878 assert_eq!(time.minute, 0);
33879 }
33880 other => panic!("expected calendar recurrence, got {other:?}"),
33881 }
33882 let registry = ir.contract_registry();
33886 assert!(
33887 registry
33888 .libraries
33889 .iter()
33890 .any(|library| library.id == "std.time" && library.standard),
33891 "clock source registers std.time: {:?}",
33892 registry.libraries
33893 );
33894 }
33895
33896 #[test]
33897 fn gauge_and_campaign_declarations_parse_and_lower() {
33898 let source = r##"
33899@service
33900workflow Improve
33901
33902output result R
33903class R { v string }
33904signal go.now { x string }
33905
33906coerce DueDateJudge(v string) -> R {
33907 prompt """markdown
33908 Judge {{ v }}.
33909
33910 {{ ctx.output_format }}
33911 """
33912}
33913
33914gauge extract_quality on j.result {
33915 judge via coerce DueDateJudge
33916 expect P(due_date_correct) at least 0.9
33917}
33918
33919gauge tail_latency {
33920 judge via exec "./latency_check.py"
33921 expect p90 at most 800
33922}
33923
33924gauge fulfillment_cost {
33925 judge via exec "./cost_model.py"
33926 inputs extract_quality, std.spend
33927}
33928
33929campaign release_tuning {
33930 ascend extract_quality
33931 reach std.latency at most 800ms
33932 guard tail_latency within 2 percent
33933 sacrifice fulfillment_cost
33934 proposer redacted
33935}
33936
33937rule j
33938 when go.now as g
33939=> {
33940 complete result {
33941 v "ok"
33942 }
33943}
33944"##;
33945 let compiled = compile_program(source);
33946 assert_eq!(compiled.diagnostics, Vec::new());
33947 let ir = compiled.ir.expect("program compiles");
33948 assert_eq!(ir.gauges.len(), 3);
33949 let extract = &ir.gauges[0];
33950 assert_eq!(extract.name, "extract_quality");
33951 assert_eq!(extract.site.as_deref(), Some("j.result"));
33952 assert_eq!(extract.judge_kind, "coerce");
33953 assert_eq!(extract.judge_target, "DueDateJudge");
33954 let bar = extract.expect.as_ref().expect("bar declared");
33955 assert_eq!(
33956 (
33957 bar.form.as_str(),
33958 bar.subject.as_str(),
33959 bar.op.as_str(),
33960 bar.threshold.as_str()
33961 ),
33962 ("chance", "due_date_correct", ">=", "0.9")
33963 );
33964 let tail = &ir.gauges[1];
33965 let tail_bar = tail.expect.as_ref().expect("stat bar declared");
33966 assert_eq!(
33967 (
33968 tail_bar.form.as_str(),
33969 tail_bar.subject.as_str(),
33970 tail_bar.op.as_str()
33971 ),
33972 ("stat", "p90", "<=")
33973 );
33974 let derived = &ir.gauges[2];
33975 assert_eq!(derived.judge_kind, "exec");
33976 assert_eq!(derived.inputs, vec!["extract_quality", "std.spend"]);
33977 assert_eq!(ir.campaigns.len(), 1);
33978 let campaign = &ir.campaigns[0];
33979 assert_eq!(campaign.ascend, vec!["extract_quality"]);
33980 assert_eq!(campaign.reach.len(), 1);
33981 assert_eq!(campaign.reach[0].gauge, "std.latency");
33982 assert_eq!(campaign.reach[0].op, "<=");
33983 assert_eq!(campaign.reach[0].threshold, "800");
33984 assert_eq!(campaign.reach[0].unit.as_deref(), Some("ms"));
33985 assert_eq!(campaign.guard[0].gauge, "tail_latency");
33986 assert_eq!(campaign.guard[0].band_percent, "2");
33987 assert_eq!(campaign.sacrifice, vec!["fulfillment_cost"]);
33988 assert!(campaign.proposer_redacted);
33989 let snapshot = ir.to_snapshot();
33990 assert!(snapshot.contains("gauge extract_quality judge=coerce:DueDateJudge site=j.result expect=chance:due_date_correct>=0.9"));
33991 assert!(snapshot.contains(
33992 "campaign release_tuning ascend=extract_quality reach=std.latency<=800ms guard=tail_latency:within:2% sacrifice=fulfillment_cost proposer=redacted"
33993 ));
33994 }
33995
33996 #[test]
33997 fn mark_declaration_parses_lowers_and_validates() {
33998 let source = r##"
33999@service
34000workflow Improve
34001
34002output result R
34003class R { v string }
34004signal go.now { x string }
34005
34006mark "triaged" after j
34007
34008rule j
34009 when go.now as g
34010=> {
34011 complete result {
34012 v "ok"
34013 }
34014}
34015"##;
34016 let compiled = compile_program(source);
34017 assert_eq!(compiled.diagnostics, Vec::new());
34018 let ir = compiled.ir.expect("program compiles");
34019 assert_eq!(ir.marks.len(), 1);
34020 assert_eq!(ir.marks[0].name, "triaged");
34021 assert_eq!(ir.marks[0].site, "j");
34022 assert!(ir.to_snapshot().contains("mark \"triaged\" after j"));
34023 let unknown = compile_program(&source.replace(
34025 "mark \"triaged\" after j",
34026 "mark \"nowhere\" after missing_rule",
34027 ));
34028 assert!(unknown.diagnostics.iter().any(|d| d
34029 .message
34030 .contains("mark `nowhere` rides unknown site `missing_rule`")));
34031 let dup = compile_program(&source.replace(
34033 "mark \"triaged\" after j",
34034 "mark \"triaged\" after j\nmark \"triaged\" after j",
34035 ));
34036 assert!(dup.diagnostics.iter().any(|d| d
34037 .message
34038 .contains("mark `triaged` is declared more than once")));
34039 let formatted = format_program(source).formatted.expect("formats");
34041 assert!(formatted.contains("mark \"triaged\" after j"));
34042 assert_eq!(
34043 format_program(&formatted).formatted.expect("reformats"),
34044 formatted
34045 );
34046 }
34047
34048 #[test]
34049 fn coerce_judge_explicit_arguments_parse_lower_and_validate() {
34050 let program = |judge_line: &str| {
34051 format!(
34052 r##"
34053@service
34054workflow Improve
34055
34056output result R
34057class R {{ v string }}
34058class Ticket {{ title string }}
34059signal go.now {{ x string }}
34060
34061coerce Assess(title string, priority string) -> R {{
34062 prompt """markdown
34063 Judge {{{{ title }}}} at {{{{ priority }}}}.
34064
34065 {{{{ ctx.output_format }}}}
34066 """
34067}}
34068
34069gauge quality {{
34070 {judge_line}
34071}}
34072
34073rule j
34074 when go.now as g
34075=> {{
34076 complete result {{
34077 v "ok"
34078 }}
34079}}
34080"##
34081 )
34082 };
34083 let source =
34085 program("judge via coerce Assess(input.ticket.title, facts.Assessment.priority)");
34086 let compiled = compile_program(&source);
34087 assert_eq!(compiled.diagnostics, Vec::new());
34088 let ir = compiled.ir.expect("compiles");
34089 assert_eq!(
34090 ir.gauges[0].judge_args,
34091 vec!["input.ticket.title", "facts.Assessment.priority"]
34092 );
34093 let formatted = format_program(&source).formatted.expect("formats");
34094 assert!(
34095 formatted
34096 .contains("judge via coerce Assess(input.ticket.title, facts.Assessment.priority)"),
34097 "fmt keeps the binding: {formatted}"
34098 );
34099 let compiled = compile_program(&program("judge via coerce Assess(input.ticket.title)"));
34102 assert!(
34103 compiled
34104 .diagnostics
34105 .iter()
34106 .any(|diagnostic| diagnostic.message.contains("passes 1 argument")),
34107 "{:?}",
34108 compiled.diagnostics
34109 );
34110 let compiled = compile_program(&program(
34112 "judge via coerce Assess(whatever.title, facts.Assessment.priority)",
34113 ));
34114 assert!(
34115 compiled
34116 .diagnostics
34117 .iter()
34118 .any(|diagnostic| diagnostic.message.contains("not a record path")),
34119 "{:?}",
34120 compiled.diagnostics
34121 );
34122 let compiled = compile_program(&program("judge via coerce Assess(record)"));
34124 assert!(
34125 compiled
34126 .diagnostics
34127 .iter()
34128 .any(|diagnostic| diagnostic.message.contains("single-parameter")),
34129 "{:?}",
34130 compiled.diagnostics
34131 );
34132 let compiled = compile_program(&program("judge via coerce Assess"));
34134 assert_eq!(compiled.diagnostics, Vec::new());
34135 assert!(compiled.ir.expect("compiles").gauges[0]
34136 .judge_args
34137 .is_empty());
34138 }
34139
34140 #[test]
34141 fn gauge_and_campaign_cross_reference_validation() {
34142 let source = r##"
34143@service
34144workflow Improve
34145
34146output result R
34147class R { v string }
34148signal go.now { x string }
34149
34150gauge broken_judge {
34151 judge via coerce MissingJudge
34152}
34153
34154gauge broken_inputs {
34155 judge via prompt "score this"
34156 inputs nowhere
34157}
34158
34159campaign confused {
34160 ascend broken_judge
34161 sacrifice broken_judge
34162}
34163
34164campaign unknown_ref {
34165 ascend nowhere_else
34166}
34167
34168rule j
34169 when go.now as g
34170=> {
34171 complete result {
34172 v "ok"
34173 }
34174}
34175"##;
34176 let compiled = compile_program(source);
34177 let messages: Vec<String> = compiled
34178 .diagnostics
34179 .iter()
34180 .map(|diagnostic| diagnostic.message.clone())
34181 .collect();
34182 assert!(messages
34183 .iter()
34184 .any(|m| m.contains("judges via undeclared coerce `MissingJudge`")));
34185 assert!(messages
34186 .iter()
34187 .any(|m| m.contains("derived gauge `broken_inputs` must judge via exec")));
34188 assert!(messages
34189 .iter()
34190 .any(|m| m.contains("unknown gauge `nowhere`")));
34191 assert!(messages
34192 .iter()
34193 .any(|m| m.contains("unknown gauge `nowhere_else`")));
34194 assert!(messages
34195 .iter()
34196 .any(|m| m.contains("names gauge `broken_judge` as both ascend and sacrifice")));
34197 }
34198
34199 #[test]
34200 fn campaign_naming_nothing_is_rejected_at_parse() {
34201 let source = r##"
34202@service
34203workflow Improve
34204
34205output result R
34206class R { v string }
34207signal go.now { x string }
34208
34209campaign nothing_named {
34210 guard std.spend within 5 percent
34211}
34212
34213rule j
34214 when go.now as g
34215=> {
34216 complete result {
34217 v "ok"
34218 }
34219}
34220"##;
34221 let compiled = compile_program(source);
34222 assert!(compiled.diagnostics.iter().any(|d| d
34223 .message
34224 .contains("campaign `nothing_named` names nothing to improve")));
34225 }
34226
34227 #[test]
34228 fn gauge_bar_operator_gets_word_form_diagnostic() {
34229 let source = r##"
34230@service
34231workflow Improve
34232
34233output result R
34234class R { v string }
34235signal go.now { x string }
34236
34237gauge extract_quality {
34238 judge via exec "./judge.py"
34239 expect P(ok) >= 0.9
34240}
34241
34242rule j
34243 when go.now as g
34244=> {
34245 complete result {
34246 v "ok"
34247 }
34248}
34249"##;
34250 let compiled = compile_program(source);
34251 assert!(compiled.diagnostics.iter().any(|diagnostic| {
34252 diagnostic
34253 .suggestion
34254 .as_deref()
34255 .is_some_and(|s| s.contains("write `at least`"))
34256 }));
34257 }
34258
34259 #[test]
34260 fn formats_gauge_and_campaign_declarations() {
34261 let source = "workflow Improve\n\n\ngauge extract_quality on j.result {\n judge via exec \"./judge.py\"\n expect P(ok) at least 0.9\n}\n\ncampaign release_tuning {\n ascend extract_quality\n reach std.latency at most 800ms\n guard std.tokens within 2 percent\n sacrifice std.spend\n proposer redacted\n}\n";
34262 let formatted = format_program(source);
34263 assert_eq!(formatted.diagnostics, Vec::new());
34264 let once = formatted.formatted.expect("formats");
34265 assert!(once.contains("gauge extract_quality on j.result {"));
34266 assert!(once.contains(" judge via exec \"./judge.py\""));
34267 assert!(once.contains(" expect P(ok) at least 0.9"));
34268 assert!(once.contains("campaign release_tuning {"));
34269 assert!(once.contains(" reach std.latency at most 800ms"));
34270 assert!(once.contains(" guard std.tokens within 2 percent"));
34271 assert!(once.contains(" proposer redacted"));
34272 let twice = format_program(&once).formatted.expect("reformats");
34273 assert_eq!(once, twice, "gauge/campaign formatting is idempotent");
34274 }
34275
34276 #[test]
34277 fn channel_declaration_parses_and_lowers() {
34278 let source = r##"
34279@service
34280workflow ChannelDecl
34281
34282use std.messaging
34283
34284channel release_room {
34285 provider fixture
34286 workspace ops
34287 destination "#release"
34288}
34289
34290output result R
34291class R { v string }
34292signal go.now { x string }
34293
34294rule j
34295 when go.now as g
34296=> {
34297 complete result {
34298 v "ok"
34299 }
34300}
34301"##;
34302 let compiled = compile_program(source);
34303 assert_eq!(compiled.diagnostics, Vec::new());
34304 let ir = compiled.ir.expect("program compiles");
34305 assert_eq!(ir.channels.len(), 1);
34306 let channel = &ir.channels[0];
34307 assert_eq!(channel.name, "release_room");
34308 assert_eq!(channel.provider, "fixture");
34309 assert_eq!(channel.workspace.as_deref(), Some("ops"));
34310 assert_eq!(channel.destination.as_deref(), Some("#release"));
34311 let registry = ir.contract_registry();
34315 assert!(registry
34316 .libraries
34317 .iter()
34318 .any(|library| library.id == "std.messaging"));
34319 assert!(SchemaIndex::with_builtins().class_exists("Message"));
34321 assert!(SchemaIndex::with_builtins().class_exists("MessageSendReceipt"));
34324 }
34325
34326 #[test]
34327 fn single_line_multi_field_terminal_payload_collects_every_field() {
34328 let source = r#"
34332workflow OneLine
34333
34334output result Done
34335
34336class Done {
34337 first string
34338 second string
34339}
34340
34341rule r
34342 when started
34343=> {
34344 complete result { first "a" second "b" }
34345}
34346"#;
34347 let compiled = compile_program(source);
34348 assert_eq!(compiled.diagnostics, Vec::new());
34349 assert!(compiled.ir.is_some());
34350 }
34351
34352 #[test]
34353 fn file_store_is_read_only_by_default() {
34354 let program = |allow: &str| {
34357 format!(
34358 r#"
34359use std.files
34360
34361workflow Posture
34362
34363output result Done
34364
34365class Done {{
34366 note string
34367}}
34368
34369file store docs {{
34370 root "./docs"
34371{allow}}}
34372
34373rule r
34374 when started
34375=> {{
34376 write text to docs at "out.txt" {{
34377 body "x"
34378 mode create
34379 }} as out
34380
34381 after out completes {{
34382 complete result {{ note "done" }}
34383 }}
34384}}
34385"#
34386 )
34387 };
34388 let denied = compile_program(&program(""));
34389 assert!(
34390 denied
34391 .diagnostics
34392 .iter()
34393 .any(|d| d.message.contains("permits no writes")),
34394 "{:?}",
34395 denied.diagnostics
34396 );
34397 let allowed = compile_program(&program(" allow write [\"**\"]\n"));
34398 assert_eq!(allowed.diagnostics, Vec::new());
34399
34400 let read_only = compile_program(
34402 r#"
34403use std.files
34404
34405workflow ReadOnly
34406
34407output result Done
34408
34409class Done {
34410 note string
34411}
34412
34413file store docs {
34414 root "./docs"
34415}
34416
34417rule r
34418 when started
34419=> {
34420 read text from docs at "in.txt" as doc
34421
34422 after doc completes {
34423 complete result { note "done" }
34424 }
34425}
34426"#,
34427 );
34428 assert_eq!(read_only.diagnostics, Vec::new());
34429 }
34430
34431 #[test]
34432 fn tracker_bare_defaults_provider_to_builtin() {
34433 let source = r#"
34436@service
34437workflow TrackerBare
34438
34439tracker backlog
34440
34441class Item { id string }
34442signal go.now { x string }
34443rule j
34444 when go.now as g
34445=> {
34446 file issue into backlog {
34447 title g.x
34448 }
34449}
34450"#;
34451 let compiled = compile_program(source);
34452 let ir = compiled.ir.expect("compiles");
34453 assert_eq!(ir.trackers.len(), 1);
34454 assert_eq!(ir.trackers[0].provider, "builtin");
34455 }
34456
34457 #[test]
34458 fn emit_signal_from_projects_bounded_fields() {
34459 let source = r#"
34463use std.ingress
34464
34465@service
34466workflow EmitFrom
34467
34468signal deploy.finished {
34469 service string
34470 peer string
34471}
34472
34473signal deploy.acknowledged {
34474 service string
34475}
34476
34477rule relay
34478 when deploy.finished as deployed
34479=> {
34480 emit signal deploy.acknowledged to deployed.peer from deployed as sent
34481}
34482"#;
34483 let compiled = compile_program(source);
34484 assert_eq!(compiled.diagnostics, Vec::new());
34485 assert!(compiled.ir.is_some());
34486 }
34487
34488 #[test]
34489 fn inline_contract_payload_synthesizes_anonymous_class() {
34490 let source = r#"
34494workflow Inline
34495
34496output result {
34497 message string
34498}
34499
34500failure error {
34501 reason string
34502}
34503
34504rule r
34505 when started
34506=> {
34507 complete result {
34508 message "hello"
34509 }
34510}
34511"#;
34512 let compiled = compile_program(source);
34513 assert_eq!(compiled.diagnostics, Vec::new());
34514 let ir = compiled.ir.expect("compiles");
34515 assert!(ir.schemas.iter().any(|schema| matches!(
34516 schema,
34517 IrSchema::Class(class) if class.name == "output.result"
34518 )));
34519 assert!(ir.schemas.iter().any(|schema| matches!(
34520 schema,
34521 IrSchema::Class(class) if class.name == "failure.error"
34522 )));
34523
34524 let bad = compile_program(
34526 r#"
34527workflow InlineBad
34528
34529output result {
34530 message string
34531}
34532
34533rule r
34534 when started
34535=> {
34536 complete result {
34537 wrong "hello"
34538 }
34539}
34540"#,
34541 );
34542 assert!(
34543 !bad.diagnostics.is_empty(),
34544 "unknown field on the synthesized class must be rejected"
34545 );
34546 }
34547
34548 #[test]
34549 fn channel_defaults_provider_to_local() {
34550 let source = r#"
34553use std.messaging
34554use std.ingress
34555
34556@service
34557workflow ChannelDefault
34558
34559channel orphan {
34560 workspace ops
34561}
34562
34563channel bare
34564
34565output result R
34566class R { v string }
34567signal go.now { x string }
34568rule j
34569 when go.now as g
34570=> { complete result { v "ok" } }
34571"#;
34572 let compiled = compile_program(source);
34573 assert_eq!(compiled.diagnostics, Vec::new());
34574 let ir = compiled.ir.expect("compiles");
34575 assert!(
34576 ir.channels
34577 .iter()
34578 .all(|channel| channel.provider == "local"),
34579 "{:?}",
34580 ir.channels
34581 );
34582 assert_eq!(ir.channels.len(), 2);
34583 }
34584
34585 #[test]
34586 fn duplicate_channel_is_rejected() {
34587 let source = r#"
34588@service
34589workflow DupChannel
34590
34591channel room {
34592 provider fixture
34593}
34594channel room {
34595 provider discord
34596}
34597
34598output result R
34599class R { v string }
34600signal go.now { x string }
34601rule j
34602 when go.now as g
34603=> { complete result { v "ok" } }
34604"#;
34605 let compiled = compile_program(source);
34606 let dup = compiled
34607 .diagnostics
34608 .iter()
34609 .find(|d| d.message.contains("declared more than once"))
34610 .expect("expected duplicate-channel diagnostic");
34611 assert_eq!(dup.related.len(), 1, "expected one related-info entry");
34614 assert_eq!(dup.related[0].message, "first declared here");
34615 assert!(dup.related[0].span.start < dup.span.start);
34616 }
34617
34618 #[test]
34619 fn when_message_from_binds_message_and_validates_channel() {
34620 let ok = compile_program(
34623 r#"
34624@service
34625workflow Inbound
34626
34627channel release_room {
34628 provider fixture
34629}
34630
34631output result Decision
34632class Decision { note string }
34633
34634rule react
34635 when message from release_room as msg
34636=> {
34637 complete result { note msg.text }
34638}
34639"#,
34640 );
34641 assert!(
34642 ok.diagnostics.is_empty(),
34643 "expected clean compile, got {:?}",
34644 ok.diagnostics
34645 );
34646 let bad = compile_program(
34649 r#"
34650@service
34651workflow Inbound
34652
34653channel release_room {
34654 provider fixture
34655}
34656
34657output result Decision
34658class Decision { note string }
34659
34660rule react
34661 when message from typo_room as msg
34662=> {
34663 complete result { note msg.text }
34664}
34665"#,
34666 );
34667 assert!(
34668 bad.diagnostics.iter().any(|d| d
34669 .message
34670 .contains("`when message from typo_room` names an unknown channel")),
34671 "expected unknown-channel diagnostic, got {:?}",
34672 bad.diagnostics
34673 );
34674 }
34675
34676 #[test]
34677 fn unknown_channel_provider_is_a_check_error() {
34678 let compiled = compile_program(
34682 r##"
34683@service
34684workflow UnknownProvider
34685
34686channel ops_room {
34687 provider slack
34688 destination "#ops"
34689}
34690
34691output result R
34692class R { v string }
34693signal go.now { x string }
34694rule j
34695 when go.now as g
34696=> { complete result { v "ok" } }
34697"##,
34698 );
34699 let unknown = compiled
34700 .diagnostics
34701 .iter()
34702 .find(|d| {
34703 d.message
34704 .contains("channel `ops_room` names unknown messaging provider `slack`")
34705 })
34706 .expect("expected unknown-provider diagnostic");
34707 assert!(
34708 unknown
34709 .suggestion
34710 .as_deref()
34711 .is_some_and(|s| s.contains("fixture") && s.contains("desktop")),
34712 "suggestion lists the v1 providers: {:?}",
34713 unknown.suggestion
34714 );
34715 }
34716
34717 #[test]
34718 fn desktop_channel_is_outbound_only_at_check_time() {
34719 let send_ok = compile_program(
34724 r#"
34725@service
34726workflow DesktopSend
34727
34728use std.messaging
34729
34730channel alerts {
34731 provider desktop
34732}
34733
34734output result R
34735class R { v string }
34736signal go.now { x string }
34737
34738rule j
34739 when go.now as g
34740=> {
34741 send via alerts {
34742 text "ping"
34743 } as sent
34744
34745 after sent succeeds {
34746 complete result { v "ok" }
34747 }
34748}
34749"#,
34750 );
34751 assert!(
34752 send_ok.diagnostics.is_empty(),
34753 "outbound send over desktop passes: {:?}",
34754 send_ok.diagnostics
34755 );
34756
34757 let inbound_bad = compile_program(
34758 r#"
34759@service
34760workflow DesktopInbound
34761
34762channel alerts {
34763 provider desktop
34764}
34765
34766output result R
34767class R { v string }
34768
34769rule react
34770 when message from alerts as msg
34771=> { complete result { v msg.text } }
34772"#,
34773 );
34774 assert!(
34775 inbound_bad.diagnostics.iter().any(|d| d.message.contains(
34776 "`when message from alerts` observes a channel whose provider `desktop` is outbound-only"
34777 )),
34778 "expected outbound-only diagnostic, got {:?}",
34779 inbound_bad.diagnostics
34780 );
34781
34782 let bidirectional = compile_program(
34784 r#"
34785@service
34786workflow LocalInbound
34787
34788channel alerts {
34789 provider local
34790}
34791
34792output result R
34793class R { v string }
34794
34795rule react
34796 when message from alerts as msg
34797=> { complete result { v msg.text } }
34798"#,
34799 );
34800 assert!(
34801 bidirectional.diagnostics.is_empty(),
34802 "bidirectional provider admits inbound observation: {:?}",
34803 bidirectional.diagnostics
34804 );
34805 }
34806
34807 #[test]
34808 fn channel_provider_reports_cover_the_v1_matrix() {
34809 let shorts: Vec<&str> = CHANNEL_PROVIDER_REPORTS
34812 .iter()
34813 .map(|r| r.short_name)
34814 .collect();
34815 assert_eq!(shorts, ["fixture", "local", "desktop", "stdio"]);
34816 for report in CHANNEL_PROVIDER_REPORTS {
34817 assert!(
34818 matches!(
34819 report.direction,
34820 "outbound_only" | "inbound_only" | "bidirectional"
34821 ),
34822 "direction vocabulary: {}",
34823 report.direction
34824 );
34825 assert!(
34826 matches!(report.identity, "anonymous" | "claimed_actor"),
34827 "identity ladder is v1-narrowed (no verified_actor): {}",
34828 report.identity
34829 );
34830 assert_eq!(report.delivery_receipts, &["accepted", "failed"]);
34831 assert_eq!(
34832 channel_provider_report(report.short_name),
34833 Some(report),
34834 "short name resolves"
34835 );
34836 assert_eq!(
34837 channel_provider_report(report.provider_id),
34838 Some(report),
34839 "provider id resolves"
34840 );
34841 }
34842 assert_eq!(channel_provider_report("slack"), None);
34843 assert_eq!(
34844 channel_provider_report("desktop").map(|r| r.direction),
34845 Some("outbound_only")
34846 );
34847 }
34848
34849 #[test]
34850 fn duplicate_schema_diagnostic_points_at_first_declaration() {
34851 let source = r#"
34852@service
34853workflow DupSchema
34854
34855class Thing { v string }
34856class Thing { w string }
34857
34858output result R
34859class R { v string }
34860signal go.now { x string }
34861rule j
34862 when go.now as g
34863=> { complete result { v "ok" } }
34864"#;
34865 let compiled = compile_program(source);
34866 let dup = compiled
34867 .diagnostics
34868 .iter()
34869 .find(|d| {
34870 d.message
34871 .contains("schema `Thing` is declared more than once")
34872 })
34873 .expect("expected duplicate-schema diagnostic");
34874 assert_eq!(dup.related.len(), 1);
34875 assert_eq!(dup.related[0].message, "first declared here");
34876 assert!(dup.related[0].span.start < dup.span.start);
34877 }
34878
34879 #[test]
34880 fn interval_clock_source_parses_duration() {
34881 let source = r#"
34882workflow Interval
34883
34884signal tick.beat {
34885 at_time time
34886}
34887
34888source clock as heartbeat {
34889 every 5m
34890 missed skip
34891
34892 observe as tick
34893 emit tick.beat {
34894 at_time tick.scheduled_at
34895 }
34896}
34897"#;
34898 let compiled = compile_program(source);
34899 assert_eq!(compiled.diagnostics, Vec::new());
34900 let ir = compiled.ir.expect("program compiles");
34901 match &ir.sources[0].recurrence {
34902 Some(Recurrence::EveryDuration { seconds, .. }) => assert_eq!(*seconds, 300),
34903 other => panic!("expected duration recurrence, got {other:?}"),
34904 }
34905 assert_eq!(ir.sources[0].missed, Some(MissedPolicy::Skip));
34906 }
34907
34908 #[test]
34909 fn fails_binding_types_to_effecterror_base() {
34910 let source = r#"
34914workflow W {
34915 input task T
34916 output result R
34917 failure error E
34918 class T { x string }
34919 class R { y string }
34920 class E { reason string detail string }
34921
34922 rule go when T as task => {
34923 exec "true" as e
34924 after e fails as f {
34925 fail error { reason f.reason detail f.kind }
34926 }
34927 after e succeeds {
34928 complete result { y task.x }
34929 }
34930 }
34931}
34932"#;
34933 let compiled = compile_program(source);
34934 assert!(
34935 !compiled
34936 .diagnostics
34937 .iter()
34938 .any(|d| d.message.contains("invalid field path")),
34939 "base fields should type-check: {:?}",
34940 compiled.diagnostics
34941 );
34942 }
34943
34944 #[test]
34945 fn fails_binding_rejects_non_base_field() {
34946 let exec_source = r#"
34952workflow W {
34953 input task T
34954 output result R
34955 failure error E
34956 class T { x string }
34957 class R { y string }
34958 class E { reason string }
34959
34960 rule go when T as task => {
34961 exec "true" as e
34962 after e fails as f {
34963 fail error { reason f.stderr }
34964 }
34965 after e succeeds {
34966 complete result { y task.x }
34967 }
34968 }
34969}
34970"#;
34971 let compiled = compile_program(exec_source);
34972 assert!(
34973 compiled
34974 .diagnostics
34975 .iter()
34976 .any(|d| d.message.contains("invalid field path `f.stderr`")),
34977 "{:?}",
34978 compiled.diagnostics
34979 );
34980
34981 let cross_kind = r#"
34982workflow W {
34983 input task T
34984 output result R
34985 failure error E
34986 class T { x string }
34987 class R { y string }
34988 class E { reason string }
34989 class V { note string }
34990
34991 coerce judge(x string) -> V {
34992 prompt "Classify {{ x }}"
34993 }
34994
34995 rule go when T as task => {
34996 coerce judge(task.x) as c
34997 after c fails as f {
34998 fail error { reason f.exit_code }
34999 }
35000 after c succeeds {
35001 complete result { y task.x }
35002 }
35003 }
35004}
35005"#;
35006 let compiled = compile_program(cross_kind);
35007 assert!(
35008 compiled
35009 .diagnostics
35010 .iter()
35011 .any(|d| d.message.contains("invalid field path `f.exit_code`")
35012 && d.message.contains("TerminalFailedCoerce")),
35013 "a coerce binding must not read exec extras: {:?}",
35014 compiled.diagnostics
35015 );
35016 }
35017
35018 #[test]
35019 fn fails_binding_narrows_to_per_kind_failure_extras() {
35020 let source = r#"
35024workflow W {
35025 input task T
35026 output result R
35027 failure error E
35028 class T { x string }
35029 class R { y string }
35030 class E { reason string code int klass string }
35031 class V { note string }
35032
35033 agent worker {
35034 provider fixture
35035 profile "repo-reader"
35036 capacity 1
35037 }
35038
35039 coerce judge(x string) -> V {
35040 prompt "Classify {{ x }}"
35041 }
35042
35043 rule go when T as task => {
35044 exec "true" as e
35045 coerce judge(task.x) as c
35046 tell worker as turn "go"
35047
35048 after e fails as fe {
35049 fail error { reason fe.reason code fe.exit_code klass "x" }
35050 }
35051 after c fails as fc {
35052 fail error { reason fc.reason code 0 klass fc.error_class }
35053 }
35054 after turn fails as ft {
35055 fail error { reason ft.reason code 0 klass ft.error_class }
35056 }
35057 after e succeeds {
35058 complete result { y task.x }
35059 }
35060 }
35061}
35062"#;
35063 let compiled = compile_program(source);
35064 assert!(
35065 !compiled
35066 .diagnostics
35067 .iter()
35068 .any(|d| d.message.contains("invalid field path")),
35069 "per-kind extras must type-check under the matching kind: {:?}",
35070 compiled.diagnostics
35071 );
35072 }
35073
35074 #[test]
35075 fn milestone_reaches_rejects_undeclared_milestone() {
35076 let source = r#"
35079workflow Parent {
35080 input task Task
35081 class Task { title string }
35082 class Saw { note string }
35083
35084 rule dispatch when Task as task => {
35085 invoke Child { task { title task.title } } as child
35086 after child reaches "never_declared" as m {
35087 record Saw { note m.note }
35088 }
35089 }
35090}
35091
35092workflow Child {
35093 input task Task
35094 output result R
35095 class Task { title string }
35096 class R { title string }
35097 class P { note string }
35098
35099 rule go when Task as task => {
35100 emit milestone "actually_declared" of P { note task.title }
35101 complete result { title task.title }
35102 }
35103}
35104"#;
35105 let compiled = compile_program_with_root(source, Some("Parent"));
35106 assert!(
35107 compiled.diagnostics.iter().any(|d| d.message.contains(
35108 "reaches milestone `never_declared` that workflow `Child` does not declare"
35109 )),
35110 "{:?}",
35111 compiled.diagnostics
35112 );
35113 }
35114
35115 #[test]
35116 fn emit_milestone_rejects_unknown_payload_class() {
35117 let source = r#"
35118workflow Child {
35119 input task Task
35120 output result R
35121 class Task { title string }
35122 class R { title string }
35123
35124 rule go when Task as task => {
35125 emit milestone "m1" of Nonexistent { note task.title }
35126 complete result { title task.title }
35127 }
35128}
35129"#;
35130 let compiled = compile_program(source);
35131 assert!(
35132 compiled.diagnostics.iter().any(|d| d
35133 .message
35134 .contains("emits milestone `m1` with unknown payload class `Nonexistent`")),
35135 "{:?}",
35136 compiled.diagnostics
35137 );
35138 }
35139
35140 #[test]
35141 fn milestone_reaches_accepts_declared_milestone() {
35142 let source = r#"
35145workflow Parent {
35146 input task Task
35147 class Task { title string }
35148 class Saw { note string }
35149
35150 rule dispatch when Task as task => {
35151 invoke Child { task { title task.title } } as child
35152 after child reaches "halfway" as m {
35153 record Saw { note m.note }
35154 }
35155 }
35156}
35157
35158workflow Child {
35159 input task Task
35160 output result R
35161 class Task { title string }
35162 class R { title string }
35163 class P { note string }
35164
35165 rule go when Task as task => {
35166 emit milestone "halfway" of P { note task.title }
35167 complete result { title task.title }
35168 }
35169}
35170"#;
35171 let compiled = compile_program_with_root(source, Some("Parent"));
35172 assert!(
35173 !compiled
35174 .diagnostics
35175 .iter()
35176 .any(|d| d.message.contains("reaches milestone")
35177 || d.message.contains("unknown payload class")),
35178 "{:?}",
35179 compiled.diagnostics
35180 );
35181 }
35182
35183 #[test]
35184 fn recurring_clock_source_requires_missed() {
35185 let source = r#"
35186workflow NeedsMissed
35187
35188signal triage.tick {
35189 scheduled_at time
35190}
35191
35192source clock as daily {
35193 every weekday at 09:00
35194 timezone "UTC"
35195
35196 observe as tick
35197 emit triage.tick {
35198 scheduled_at tick.scheduled_at
35199 }
35200}
35201"#;
35202 let compiled = compile_program(source);
35203 assert!(
35204 compiled.diagnostics.iter().any(|diagnostic| diagnostic
35205 .message
35206 .contains("must declare a `missed` policy")),
35207 "{:?}",
35208 compiled.diagnostics
35209 );
35210 }
35211
35212 #[test]
35213 fn calendar_clock_source_requires_timezone() {
35214 let source = r#"
35215workflow NeedsTimezone
35216
35217signal triage.tick {
35218 scheduled_at time
35219}
35220
35221source clock as daily {
35222 every weekday at 09:00
35223 missed skip
35224
35225 observe as tick
35226 emit triage.tick {
35227 scheduled_at tick.scheduled_at
35228 }
35229}
35230"#;
35231 let compiled = compile_program(source);
35232 assert!(
35233 compiled
35234 .diagnostics
35235 .iter()
35236 .any(|diagnostic| diagnostic.message.contains("should declare a `timezone`")),
35237 "{:?}",
35238 compiled.diagnostics
35239 );
35240 }
35241
35242 #[test]
35243 fn generic_source_block_lowers_to_signal_source() {
35244 let source = r#"
35245workflow Ingress
35246
35247signal deploy.finished {
35248 service string
35249}
35250
35251source webhook as deploys {
35252 observe as obs
35253 emit deploy.finished {
35254 service obs.service
35255 }
35256}
35257"#;
35258 let compiled = compile_program(source);
35259 assert_eq!(compiled.diagnostics, Vec::new());
35260 let ir = compiled.ir.expect("program compiles");
35261 assert_eq!(ir.sources.len(), 1);
35262 let decl = &ir.sources[0];
35263 assert!(!decl.is_clock);
35264 assert_eq!(decl.provider, "webhook");
35265 assert!(decl.recurrence.is_none());
35266 assert_eq!(decl.emit_signal, "deploy.finished");
35267 }
35268
35269 #[test]
35270 fn complete_field_reads_are_collected_per_field() {
35271 let source = r#"
35275@tool
35276workflow Producer {
35277 input request Req
35278 output result R
35279 class Req { id string }
35280 class A { x string }
35281 class B { y string }
35282 class R { id string note string }
35283
35284 rule combine
35285 when A as a
35286 when B as b
35287 => {
35288 complete result {
35289 id a.x
35290 note b.y
35291 }
35292 }
35293}
35294"#;
35295 let compiled = compile_program(source);
35296 let ir = compiled.ir.expect("program compiles");
35297 let rule = ir
35298 .rules
35299 .iter()
35300 .find(|r| r.name == "combine")
35301 .expect("combine rule");
35302 let per_field = rule
35303 .metadata
35304 .complete_field_reads
35305 .get("result")
35306 .expect("result has per-field reads");
35307 assert_eq!(
35308 per_field.get("id"),
35309 Some(&BTreeSet::from(["a".to_owned()])),
35310 "id references only a: {per_field:?}"
35311 );
35312 assert_eq!(
35313 per_field.get("note"),
35314 Some(&BTreeSet::from(["b".to_owned()])),
35315 "note references only b: {per_field:?}"
35316 );
35317 }
35318
35319 #[test]
35320 fn milestone_field_reads_are_collected_per_field() {
35321 let source = r#"
35324workflow Child {
35325 input request Req
35326 output result R
35327 class Req { id string }
35328 class A { x string }
35329 class B { y string }
35330 class R { ok bool }
35331 class Progress { hot string cold string }
35332
35333 rule report
35334 when A as a
35335 when B as b
35336 => {
35337 emit milestone "halfway" of Progress {
35338 hot a.x
35339 cold b.y
35340 }
35341 complete result { ok true }
35342 }
35343}
35344"#;
35345 let compiled = compile_program(source);
35346 let ir = compiled.ir.expect("program compiles");
35347 let rule = ir
35348 .rules
35349 .iter()
35350 .find(|r| r.name == "report")
35351 .expect("report rule");
35352 let per_field = rule
35353 .metadata
35354 .milestone_field_reads
35355 .get("halfway")
35356 .expect("milestone has per-field reads");
35357 assert_eq!(
35358 per_field.get("hot"),
35359 Some(&BTreeSet::from(["a".to_owned()])),
35360 "hot references only a: {per_field:?}"
35361 );
35362 assert_eq!(
35363 per_field.get("cold"),
35364 Some(&BTreeSet::from(["b".to_owned()])),
35365 "cold references only b: {per_field:?}"
35366 );
35367 }
35368}