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>,
1575 pub coerce_input_roots: BTreeMap<String, BTreeSet<String>>,
1583 pub after_aliases: BTreeMap<String, String>,
1588 pub egress_case_influence: BTreeMap<String, BTreeSet<String>>,
1594 pub complete_field_reads: BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
1603 pub milestone_field_reads: BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
1609 pub bounded_egresses: Vec<IrBoundedEgress>,
1613 pub max_after_depth: usize,
1617}
1618
1619#[derive(Clone, Debug, Eq, PartialEq)]
1626pub struct IrBoundedEgress {
1627 pub sink: String,
1629 pub source_schema: String,
1632 pub keep: Vec<String>,
1634}
1635
1636#[derive(Clone, Debug, Eq, PartialEq)]
1640pub struct IrRedaction {
1641 pub source: String,
1642 pub keep: Vec<String>,
1643 pub binding: String,
1644 pub source_schema: Option<String>,
1652}
1653
1654#[derive(Clone, Debug, Eq, PartialEq)]
1655pub struct IrRecordSource {
1656 pub schema: String,
1657 pub construct: String,
1658 pub span: SourceSpan,
1659}
1660
1661#[derive(Clone, Debug, Eq, PartialEq)]
1662pub struct IrProjectionRead {
1663 pub kind: QueryKind,
1664 pub head: String,
1665 pub guard: Option<String>,
1666}
1667
1668impl IrProjectionRead {
1669 fn to_snapshot(&self) -> String {
1670 let prefix = match self.kind {
1671 QueryKind::Fact => format!("fact:{}", self.head),
1672 QueryKind::Effect => format!("effect:{}", self.head),
1673 };
1674 match &self.guard {
1675 Some(guard) => format!("{prefix} where {guard}"),
1676 None => prefix,
1677 }
1678 }
1679}
1680
1681#[derive(Clone, Debug, Eq, PartialEq)]
1682pub struct IrEffectNode {
1683 pub id: String,
1684 pub kind: IrEffectKind,
1685 pub binding: Option<String>,
1686 pub required_capabilities: Vec<String>,
1687 pub construct_use: Option<IrConstructUse>,
1688 pub idempotency_key: String,
1689 pub span: SourceSpan,
1690 pub timeout_seconds: Option<u64>,
1692 pub access_grants: Vec<IrAccessGrant>,
1695 pub turn_skills: Vec<String>,
1699 pub resource: Option<String>,
1704 pub agent: Option<String>,
1708 pub workflow_target: Option<String>,
1712 pub endorsed: bool,
1716 pub declassified: bool,
1720 pub selected_by: Option<(String, String)>,
1726 pub exec_target: Option<IrExecTarget>,
1732}
1733
1734#[derive(Clone, Debug, Eq, PartialEq)]
1738pub enum IrExecTarget {
1739 Raw,
1740 Capability { name: String },
1741}
1742
1743#[derive(Clone, Debug, Eq, PartialEq)]
1746pub struct IrAccessGrant {
1747 pub resource: String,
1748 pub operations: Vec<IrAccessGrantOp>,
1749}
1750
1751#[derive(Clone, Debug, Eq, PartialEq)]
1752pub struct IrAccessGrantOp {
1753 pub operation: String,
1754 pub target: Option<String>,
1755 pub globs: Vec<String>,
1756}
1757
1758#[derive(Clone, Debug, Eq, PartialEq)]
1759pub struct IrConstructUse {
1760 pub keyword: String,
1761 pub scope: String,
1762 pub construct_family: String,
1763 pub lowering_target: String,
1764 pub target_capability: String,
1765}
1766
1767#[derive(Clone, Debug, Eq, PartialEq)]
1768pub enum IrEffectKind {
1769 AgentTell,
1770 SchemaCoerce,
1771 CapabilityCall,
1772 EventEmit,
1773 WorkflowInvoke,
1774 TimerWait,
1775 ExecCommand,
1776 TrackerFile,
1777 TrackerClaim,
1778 TrackerRenew,
1779 TrackerRelease,
1780 TrackerFinish,
1781 LeaseAcquire,
1782 LeaseRenew,
1783 LedgerAppend,
1784 CounterConsume,
1785 SignalEmit,
1786 FileRead,
1787 FileWrite,
1788 FileImport,
1789 FileExport,
1790}
1791
1792#[derive(Clone, Debug, Eq, PartialEq)]
1793pub struct IrEffectDependency {
1794 pub upstream: String,
1795 pub predicate: DependencyPredicate,
1796 pub downstream: String,
1797}
1798
1799#[derive(Clone, Debug, Eq, PartialEq)]
1800pub struct IrRuleCaseBranch {
1801 pub scrutinee: String,
1802 pub scrutinee_type: IrType,
1803 pub pattern: IrCasePattern,
1804 pub guard: Option<IrExpression>,
1805 pub body_hash: String,
1806 pub pattern_span: SourceSpan,
1807}
1808
1809#[derive(Clone, Debug, Eq, PartialEq)]
1810pub enum IrCasePattern {
1811 EnumVariant(String),
1812 LiteralString(String),
1813 Agent(String),
1814 OptionalSome { binding: String },
1815 OptionalNone,
1816 Wildcard,
1817}
1818
1819impl IrCasePattern {
1820 fn to_snapshot(&self) -> String {
1821 match self {
1822 IrCasePattern::EnumVariant(value) => format!("enum:{value}"),
1823 IrCasePattern::LiteralString(value) => format!("literal:\"{value}\""),
1824 IrCasePattern::Agent(value) => format!("agent:{value}"),
1825 IrCasePattern::OptionalSome { binding } => format!("some:{binding}"),
1826 IrCasePattern::OptionalNone => "none".to_owned(),
1827 IrCasePattern::Wildcard => "_".to_owned(),
1828 }
1829 }
1830}
1831
1832#[derive(Clone, Debug, Eq, PartialEq)]
1833pub struct IrTerminalOutput {
1834 pub binding: String,
1835 pub alternatives: Vec<IrTerminalAlternative>,
1836 pub span: SourceSpan,
1837}
1838
1839#[derive(Clone, Debug, Eq, PartialEq)]
1840pub struct IrTerminalAlternative {
1841 pub tag: String,
1842 pub payload_type: IrType,
1843 pub source_span: SourceSpan,
1844}
1845
1846#[derive(Clone, Debug, Eq, PartialEq)]
1847pub struct IrTerminalCaseBranch {
1848 pub scrutinee: String,
1849 pub tag: Option<String>,
1850 pub binding: Option<String>,
1851 pub guard: Option<IrExpression>,
1852 pub body_hash: String,
1853 pub pattern_span: SourceSpan,
1854}
1855
1856#[derive(Clone, Debug, Eq, PartialEq)]
1857pub enum DependencyPredicate {
1858 Succeeds,
1859 Fails,
1860 TimedOut,
1861 Cancelled,
1862 Completes,
1863}
1864
1865#[derive(Clone, Debug)]
1866struct SemanticContext {
1867 workflow: Option<String>,
1868 schemas: SchemaIndex,
1869 agents: BTreeSet<String>,
1870 agent_capabilities: BTreeMap<String, BTreeSet<String>>,
1871 coerce_outputs: BTreeMap<String, TypeSyntax>,
1872 coerce_params: BTreeMap<String, Vec<ParamDecl>>,
1873 workflow_inputs: BTreeMap<String, WorkflowInputSurface>,
1874 leases: BTreeSet<String>,
1876 ledgers: BTreeSet<String>,
1877 counters: BTreeSet<String>,
1878 channels: BTreeSet<String>,
1880 channel_providers: BTreeMap<String, String>,
1885 memory_pools: BTreeSet<String>,
1888}
1889
1890#[derive(Clone, Debug, Default)]
1891struct WorkflowInputSurface {
1892 inputs: BTreeMap<String, TypeSyntax>,
1893 outputs: BTreeMap<String, TypeSyntax>,
1898 failures: BTreeMap<String, TypeSyntax>,
1905 schemas: SchemaIndex,
1906 milestones: BTreeMap<String, String>,
1912}
1913
1914#[derive(Clone, Debug, Default)]
1915struct SchemaIndex {
1916 classes: BTreeMap<String, BTreeMap<String, TypeSyntax>>,
1917 enums: BTreeMap<String, BTreeSet<String>>,
1918 events: BTreeSet<String>,
1921 presence: BTreeMap<String, BTreeMap<String, (String, String)>>,
1925}
1926
1927#[derive(Clone, Debug, Eq, PartialEq)]
1928enum BlockFrame {
1929 After {
1930 binding: String,
1931 predicate: DependencyPredicate,
1932 },
1933}
1934
1935#[derive(Clone, Debug, Eq, PartialEq)]
1936enum LiteralExpr<'a> {
1937 String(&'a str),
1938 Number(&'a str),
1939 Bool,
1940 Null,
1941 Ident(&'a str),
1942}
1943
1944#[derive(Clone, Debug, Eq, PartialEq)]
1945enum ExprType {
1946 Bool,
1947 Int,
1948 Float,
1949 String,
1950 Duration,
1951 Time,
1952 Null,
1953 Object,
1954 Optional(Box<ExprType>),
1955 Array(Box<ExprType>),
1956 Map(Box<ExprType>),
1957 Finite { label: String, values: Vec<String> },
1958 Collection,
1959 Unknown,
1960}
1961
1962#[derive(Clone, Debug, Eq, PartialEq)]
1963pub enum Expr {
1964 Literal(ExprLiteral),
1965 Path(Vec<String>),
1966 Index {
1967 target: Box<Expr>,
1968 key: Box<Expr>,
1969 },
1970 Array(Vec<Expr>),
1971 Object(Vec<ExprObjectField>),
1972 Unary {
1973 op: UnaryOp,
1974 expr: Box<Expr>,
1975 },
1976 Binary {
1977 op: BinaryOp,
1978 left: Box<Expr>,
1979 right: Box<Expr>,
1980 },
1981 Call {
1982 name: String,
1983 args: Vec<Expr>,
1984 },
1985 Query {
1986 kind: QueryKind,
1987 head: String,
1988 guard: Option<Box<Expr>>,
1989 },
1990}
1991
1992#[derive(Clone, Debug, Eq, PartialEq)]
1993pub struct ExprObjectField {
1994 pub key: String,
1995 pub value: Expr,
1996}
1997
1998#[derive(Clone, Debug, Eq, PartialEq)]
1999pub enum ExprLiteral {
2000 String(String),
2001 Number(String),
2002 Bool(bool),
2003 Null,
2004 Ident(String),
2005}
2006
2007#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2008pub enum UnaryOp {
2009 Not,
2010}
2011
2012#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2013pub enum BinaryOp {
2014 Or,
2015 And,
2016 Eq,
2017 Ne,
2018 Lt,
2019 Le,
2020 Gt,
2021 Ge,
2022 In,
2023 NotIn,
2024 Add,
2025 Sub,
2026 Mul,
2027 Div,
2028}
2029
2030#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2031pub enum QueryKind {
2032 Fact,
2033 Effect,
2034}
2035
2036pub fn parse_expression(expr: &str) -> Result<Expr, String> {
2038 ExprParser::new(expr).parse()
2039}
2040
2041impl Expr {
2042 pub fn to_snapshot(&self) -> String {
2043 match self {
2044 Self::Literal(literal) => literal.to_snapshot(),
2045 Self::Path(path) => path.join("."),
2046 Self::Index { target, key } => {
2047 format!(
2048 "{}[{}]",
2049 target.to_snapshot_with_parentheses(),
2050 key.to_snapshot()
2051 )
2052 }
2053 Self::Array(items) => {
2054 let items = items
2055 .iter()
2056 .map(Self::to_snapshot)
2057 .collect::<Vec<_>>()
2058 .join(", ");
2059 format!("[{items}]")
2060 }
2061 Self::Object(fields) => {
2062 let fields = fields
2063 .iter()
2064 .map(|field| format!("{} {}", field.key, field.value.to_snapshot()))
2065 .collect::<Vec<_>>()
2066 .join(", ");
2067 format!("{{{fields}}}")
2068 }
2069 Self::Unary { op, expr } => match op {
2070 UnaryOp::Not => format!("!{}", expr.to_snapshot_with_parentheses()),
2071 },
2072 Self::Binary { op, left, right } => format!(
2073 "{} {} {}",
2074 left.to_snapshot_with_parentheses(),
2075 op.to_snapshot(),
2076 right.to_snapshot_with_parentheses()
2077 ),
2078 Self::Call { name, args } => {
2079 let args = args
2080 .iter()
2081 .map(Self::to_snapshot)
2082 .collect::<Vec<_>>()
2083 .join(", ");
2084 format!("{name}({args})")
2085 }
2086 Self::Query { kind, head, guard } => {
2087 let prefix = match kind {
2088 QueryKind::Fact => head.clone(),
2089 QueryKind::Effect => format!("effect {head}"),
2090 };
2091 match guard {
2092 Some(guard) => format!("{prefix} where {}", guard.to_snapshot()),
2093 None => prefix,
2094 }
2095 }
2096 }
2097 }
2098
2099 fn to_snapshot_with_parentheses(&self) -> String {
2100 match self {
2101 Self::Binary { .. } => format!("({})", self.to_snapshot()),
2102 _ => self.to_snapshot(),
2103 }
2104 }
2105}
2106
2107impl ExprLiteral {
2108 fn to_snapshot(&self) -> String {
2109 match self {
2110 Self::String(value) => format!("{value:?}"),
2111 Self::Number(value) | Self::Ident(value) => value.clone(),
2112 Self::Bool(value) => value.to_string(),
2113 Self::Null => "null".to_owned(),
2114 }
2115 }
2116}
2117
2118impl BinaryOp {
2119 fn to_snapshot(self) -> &'static str {
2120 match self {
2121 Self::Or => "||",
2122 Self::And => "&&",
2123 Self::Eq => "==",
2124 Self::Ne => "!=",
2125 Self::Lt => "<",
2126 Self::Le => "<=",
2127 Self::Gt => ">",
2128 Self::Ge => ">=",
2129 Self::In => "in",
2130 Self::NotIn => "not in",
2131 Self::Add => "+",
2132 Self::Sub => "-",
2133 Self::Mul => "*",
2134 Self::Div => "/",
2135 }
2136 }
2137}
2138
2139pub fn parse_program(source: &str) -> ParseOutput {
2141 let lexed = lex(source);
2142 let mut parser = Parser {
2143 source,
2144 tokens: lexed.tokens,
2145 pos: 0,
2146 diagnostics: lexed.diagnostics,
2147 pending_contract_classes: Vec::new(),
2148 };
2149
2150 let program = parser.parse_program();
2151 ParseOutput {
2152 program,
2153 diagnostics: parser.diagnostics,
2154 }
2155}
2156
2157pub fn compile_program(source: &str) -> CompileOutput {
2159 compile_program_with_root(source, None)
2160}
2161
2162pub fn compile_program_with_root(source: &str, root: Option<&str>) -> CompileOutput {
2165 let parsed = parse_program(source);
2166 if !parsed.diagnostics.is_empty() {
2167 return CompileOutput {
2168 ir: None,
2169 diagnostics: parsed.diagnostics,
2170 warnings: Vec::new(),
2171 };
2172 }
2173
2174 let mut invoke_recursion_diagnostics = Vec::new();
2179 detect_workflow_invoke_recursion(&parsed.program, &mut invoke_recursion_diagnostics);
2180 detect_private_workflow_invocations(&parsed.program, &mut invoke_recursion_diagnostics);
2181 if !invoke_recursion_diagnostics.is_empty() {
2182 return CompileOutput {
2183 ir: None,
2184 diagnostics: invoke_recursion_diagnostics,
2185 warnings: Vec::new(),
2186 };
2187 }
2188
2189 let workflow_inputs = collect_workflow_input_surfaces(&parsed.program);
2190 let shared_coordination_usage = collect_shared_coordination_usage(&parsed.program);
2191
2192 if parsed.program.workflows.len() > 1 {
2203 let global_names: BTreeSet<String> = parsed
2210 .program
2211 .items
2212 .iter()
2213 .filter_map(|item| referenced_decl_name(item).map(|(name, _)| name))
2214 .collect();
2215 let mut sibling_locals: BTreeMap<String, Vec<(String, SourceSpan)>> = BTreeMap::new();
2216 for workflow in &parsed.program.workflows {
2217 for item in &workflow.items {
2218 if let Some((name, span)) = referenced_decl_name(item) {
2219 sibling_locals
2220 .entry(name)
2221 .or_default()
2222 .push((workflow.name.name.clone(), span));
2223 }
2224 }
2225 }
2226
2227 let mut aggregated = Vec::new();
2228 for workflow in &parsed.program.workflows {
2229 let name = workflow.name.name.clone();
2230 let own_locals: BTreeSet<String> = workflow
2231 .items
2232 .iter()
2233 .filter_map(|item| referenced_decl_name(item).map(|(name, _)| name))
2234 .collect();
2235 let mut diagnostics = match select_root_workflow(parsed.program.clone(), Some(&name)) {
2236 Ok(scoped) => {
2237 lower_program(
2238 scoped,
2239 workflow_inputs.clone(),
2240 shared_coordination_usage.clone(),
2241 )
2242 .diagnostics
2243 }
2244 Err(diagnostics) => diagnostics,
2245 };
2246 for diagnostic in &mut diagnostics {
2247 annotate_cross_workflow_leak(
2248 diagnostic,
2249 &name,
2250 &own_locals,
2251 &global_names,
2252 &sibling_locals,
2253 );
2254 }
2255 aggregated.extend(diagnostics);
2256 }
2257 if !aggregated.is_empty() {
2258 return CompileOutput {
2259 ir: None,
2260 diagnostics: aggregated,
2261 warnings: Vec::new(),
2262 };
2263 }
2264 }
2265
2266 match select_root_workflow(parsed.program, root) {
2267 Ok(program) => lower_program(program, workflow_inputs, shared_coordination_usage),
2268 Err(diagnostics) => CompileOutput {
2269 ir: None,
2270 diagnostics,
2271 warnings: Vec::new(),
2272 },
2273 }
2274}
2275
2276#[derive(Clone, Debug, Eq, PartialEq)]
2279pub struct DeclSymbol {
2280 pub name: String,
2281 pub kind: &'static str,
2282 pub span: SourceSpan,
2283}
2284
2285pub fn document_symbols(source: &str) -> Vec<DeclSymbol> {
2288 let program = parse_program(source).program;
2289 let mut symbols = Vec::new();
2290 if let Some(workflow) = &program.workflow {
2291 symbols.push(DeclSymbol {
2292 name: workflow.name.clone(),
2293 kind: "workflow",
2294 span: workflow.span,
2295 });
2296 }
2297 for workflow in &program.workflows {
2298 symbols.push(DeclSymbol {
2299 name: workflow.name.name.clone(),
2300 kind: "workflow",
2301 span: workflow.span,
2302 });
2303 }
2304 for pattern in &program.patterns {
2305 symbols.push(DeclSymbol {
2306 name: pattern.name.name.clone(),
2307 kind: "pattern",
2308 span: pattern.span,
2309 });
2310 }
2311 for item in &program.items {
2312 let symbol = match item {
2313 Item::Class(decl) => ("class", decl.name.name.clone(), decl.span),
2314 Item::Enum(decl) => ("enum", decl.name.name.clone(), decl.span),
2315 Item::Agent(decl) => ("agent", decl.name.name.clone(), decl.span),
2316 Item::Rule(decl) => ("rule", decl.name.name.clone(), decl.span),
2317 Item::Coerce(decl) => ("coerce", decl.name.name.clone(), decl.span),
2318 Item::Action(decl) => ("action", decl.name.name.clone(), decl.span),
2319 Item::Lease(decl) => ("lease", decl.name.name.clone(), decl.span),
2320 Item::Ledger(decl) => ("ledger", decl.name.name.clone(), decl.span),
2321 Item::Counter(decl) => ("counter", decl.name.name.clone(), decl.span),
2322 Item::Tracker(decl) => ("tracker", decl.name.name.clone(), decl.span),
2323 Item::Channel(decl) => ("channel", decl.name.name.clone(), decl.span),
2324 Item::FileStore(decl) => ("file store", decl.name.name.clone(), decl.span),
2325 Item::MemoryPool(decl) => ("memory pool", decl.name.name.clone(), decl.span),
2326 Item::Event(decl) => ("signal", decl.name.clone(), decl.span),
2327 Item::Table(decl) => ("table", decl.name.name.clone(), decl.span),
2328 Item::Gauge(decl) => ("gauge", decl.name.name.clone(), decl.span),
2329 Item::Campaign(decl) => ("campaign", decl.name.name.clone(), decl.span),
2330 Item::Mark(decl) => ("mark", decl.name.value.clone(), decl.span),
2331 _ => continue,
2332 };
2333 symbols.push(DeclSymbol {
2334 name: symbol.1,
2335 kind: symbol.0,
2336 span: symbol.2,
2337 });
2338 }
2339 symbols
2340}
2341
2342pub fn format_program(source: &str) -> FormatOutput {
2344 let parsed = parse_program(source);
2345 if !parsed.diagnostics.is_empty() {
2346 return FormatOutput {
2347 formatted: None,
2348 diagnostics: parsed.diagnostics,
2349 };
2350 }
2351
2352 FormatOutput {
2353 formatted: Some(format_syntax(parsed.program)),
2354 diagnostics: Vec::new(),
2355 }
2356}
2357
2358pub fn format_program_preserving_comments(source: &str) -> Option<String> {
2372 let parsed = parse_program(source);
2373 if !parsed.diagnostics.is_empty() {
2374 return None;
2375 }
2376 let mut comments = lex_comments(source);
2377 if comments.is_empty() {
2378 return Some(format_syntax(parsed.program));
2379 }
2380 comments.sort_by_key(|comment| comment.span.start);
2383 let program = parsed.program;
2384
2385 let mut elements: Vec<(SourceSpan, String)> = Vec::new();
2387 if let Some(workflow) = program.workflow {
2388 let mut chunk = String::new();
2389 format_tags(&program.workflow_tags, &mut chunk);
2390 format_description(program.workflow_description.as_ref(), &mut chunk);
2391 push_line(&mut chunk, format!("workflow {}", workflow.name));
2392 elements.push((workflow.span, chunk));
2393 }
2394 for pattern in program.patterns {
2395 let span = pattern.span;
2396 let mut chunk = String::new();
2397 format_pattern(pattern, &mut chunk);
2398 elements.push((span, chunk));
2399 }
2400 for item in program.items {
2401 let span = item.span();
2402 let mut chunk = String::new();
2403 let placed = match &item {
2408 Item::Class(class_decl) => Some(try_format_class_with_comments(
2409 class_decl, source, &comments, &mut chunk,
2410 )),
2411 Item::Agent(agent) => Some(try_format_agent_with_comments(
2412 agent, source, &comments, &mut chunk,
2413 )),
2414 Item::Enum(enum_decl) => Some(try_format_enum_with_comments(
2415 enum_decl, source, &comments, &mut chunk,
2416 )),
2417 Item::Event(event) => Some(try_format_event_with_comments(
2418 event, source, &comments, &mut chunk,
2419 )),
2420 Item::Tracker(queue) => Some(try_format_tracker_with_comments(
2421 queue, source, &comments, &mut chunk,
2422 )),
2423 Item::FileStore(file_store) => Some(try_format_filestore_with_comments(
2424 file_store, source, &comments, &mut chunk,
2425 )),
2426 _ => None,
2427 };
2428 match placed {
2429 Some(true) => {}
2430 Some(false) => return None,
2431 None => format_item(item, &mut chunk),
2432 }
2433 elements.push((span, chunk));
2434 }
2435 for workflow in program.workflows {
2436 let span = workflow.span;
2437 let mut chunk = String::new();
2438 format_workflow(workflow, &mut chunk);
2439 elements.push((span, chunk));
2440 }
2441 elements.sort_by_key(|(span, _)| span.start);
2442
2443 let mut leading: Vec<&Comment> = Vec::new();
2453 let mut element_trailing: Vec<Option<&Comment>> = vec![None; elements.len()];
2454 for comment in &comments {
2455 let in_body = elements
2456 .iter()
2457 .any(|(span, _)| span.start < comment.span.start && comment.span.start < span.end);
2458 if in_body {
2459 continue;
2460 }
2461 let line_start = source[..comment.span.start]
2462 .rfind('\n')
2463 .map(|newline| newline + 1)
2464 .unwrap_or(0);
2465 if source[line_start..comment.span.start].trim().is_empty() {
2466 leading.push(comment);
2467 continue;
2468 }
2469 let comment_line = line_index(source, comment.span.start);
2470 let mut placed = false;
2471 for (index, (span, _)) in elements.iter().enumerate() {
2472 if line_index(source, span.end.saturating_sub(1)) == comment_line {
2473 if element_trailing[index].is_some() {
2474 return None;
2475 }
2476 element_trailing[index] = Some(comment);
2477 placed = true;
2478 break;
2479 }
2480 }
2481 if !placed {
2482 return None;
2483 }
2484 }
2485
2486 let mut out = String::new();
2487 let mut next_comment = 0;
2488 let element_count = elements.len();
2489 for (index, (span, chunk)) in elements.iter().enumerate() {
2490 while next_comment < leading.len() && leading[next_comment].span.start < span.start {
2491 push_line(&mut out, format_comment(leading[next_comment]));
2492 next_comment += 1;
2493 }
2494 match element_trailing[index] {
2495 Some(comment) => {
2496 out.push_str(chunk.strip_suffix('\n').unwrap_or(chunk));
2497 out.push_str(&format!(" {}\n", format_comment(comment)));
2498 }
2499 None => out.push_str(chunk),
2500 }
2501 if index + 1 < element_count {
2502 out.push('\n');
2503 }
2504 }
2505 if next_comment < leading.len() {
2506 if element_count > 0 {
2507 out.push('\n');
2508 }
2509 while next_comment < leading.len() {
2510 push_line(&mut out, format_comment(leading[next_comment]));
2511 next_comment += 1;
2512 }
2513 }
2514
2515 if lex_comments(&out).len() != comments.len() {
2520 return None;
2521 }
2522 Some(out)
2523}
2524
2525fn format_comment(comment: &Comment) -> String {
2526 let marker = match comment.marker {
2527 CommentMarker::Hash => "#",
2528 CommentMarker::Slash => "//",
2529 };
2530 let text = comment.text.trim();
2531 if text.is_empty() {
2532 marker.to_owned()
2533 } else {
2534 format!("{marker} {text}")
2535 }
2536}
2537
2538fn line_index(source: &str, offset: usize) -> usize {
2540 source.as_bytes()[..offset]
2541 .iter()
2542 .filter(|&&byte| byte == b'\n')
2543 .count()
2544}
2545
2546fn classify_body_comments<'a>(
2555 source: &str,
2556 body: SourceSpan,
2557 members: &[(SourceSpan, Vec<String>)],
2558 comments: &'a [Comment],
2559) -> Option<(Vec<&'a Comment>, Vec<Option<&'a Comment>>)> {
2560 let mut own_line: Vec<&Comment> = Vec::new();
2561 let mut trailing: Vec<Option<&Comment>> = vec![None; members.len()];
2562 for comment in comments {
2563 if comment.span.start <= body.start || comment.span.start >= body.end {
2564 continue;
2565 }
2566 if members.iter().any(|(span, lines)| {
2569 lines.len() > 1 && span.start < comment.span.start && comment.span.start < span.end
2570 }) {
2571 return None;
2572 }
2573 let line_start = source[..comment.span.start]
2574 .rfind('\n')
2575 .map(|index| index + 1)
2576 .unwrap_or(0);
2577 if source[line_start..comment.span.start].trim().is_empty() {
2578 own_line.push(comment);
2579 continue;
2580 }
2581 let comment_line = line_index(source, comment.span.start);
2583 let mut placed = false;
2584 for (index, (span, lines)) in members.iter().enumerate() {
2585 if lines.len() == 1 && line_index(source, span.start) == comment_line {
2586 if trailing[index].is_some() {
2587 return None;
2588 }
2589 trailing[index] = Some(comment);
2590 placed = true;
2591 break;
2592 }
2593 }
2594 if !placed {
2595 return None;
2596 }
2597 }
2598 Some((own_line, trailing))
2599}
2600
2601fn emit_members_with_comments(
2606 members: &[(SourceSpan, Vec<String>)],
2607 own_line: &[&Comment],
2608 trailing: &[Option<&Comment>],
2609 indent: &str,
2610 formatted: &mut String,
2611) {
2612 let mut next = 0;
2613 for (index, (span, lines)) in members.iter().enumerate() {
2614 while next < own_line.len() && own_line[next].span.start < span.start {
2615 push_line(
2616 formatted,
2617 format!("{indent}{}", format_comment(own_line[next])),
2618 );
2619 next += 1;
2620 }
2621 let last = lines.len().saturating_sub(1);
2622 for (offset, line) in lines.iter().enumerate() {
2623 match trailing[index] {
2624 Some(comment) if offset == last => {
2625 push_line(formatted, format!("{line} {}", format_comment(comment)));
2626 }
2627 _ => push_line(formatted, line.clone()),
2628 }
2629 }
2630 }
2631 while next < own_line.len() {
2632 push_line(
2633 formatted,
2634 format!("{indent}{}", format_comment(own_line[next])),
2635 );
2636 next += 1;
2637 }
2638}
2639
2640fn try_format_class_with_comments(
2644 class_decl: &ClassDecl,
2645 source: &str,
2646 comments: &[Comment],
2647 formatted: &mut String,
2648) -> bool {
2649 let members: Vec<(SourceSpan, Vec<String>)> = class_decl
2650 .fields
2651 .iter()
2652 .map(|field| {
2653 let key = if field.is_key { " @key" } else { "" };
2654 (
2655 field.span,
2656 vec![format!(
2657 " {} {}{key}",
2658 field.name.name,
2659 field.ty.to_source()
2660 )],
2661 )
2662 })
2663 .collect();
2664 let Some((own_line, trailing)) =
2665 classify_body_comments(source, class_decl.span, &members, comments)
2666 else {
2667 return false;
2668 };
2669 push_line(formatted, format!("class {} {{", class_decl.name.name));
2670 emit_members_with_comments(&members, &own_line, &trailing, " ", formatted);
2671 push_line(formatted, "}");
2672 true
2673}
2674
2675fn try_format_tracker_with_comments(
2679 queue: &TrackerDecl,
2680 source: &str,
2681 comments: &[Comment],
2682 formatted: &mut String,
2683) -> bool {
2684 let members: Vec<(SourceSpan, Vec<String>)> = vec![(
2685 queue.provider.span,
2686 vec![format!(" provider {}", queue.provider.name)],
2687 )];
2688 let Some((own_line, trailing)) = classify_body_comments(source, queue.span, &members, comments)
2689 else {
2690 return false;
2691 };
2692 push_line(formatted, format!("tracker {} {{", queue.name.name));
2693 emit_members_with_comments(&members, &own_line, &trailing, " ", formatted);
2694 push_line(formatted, "}");
2695 true
2696}
2697
2698fn try_format_filestore_with_comments(
2703 file_store: &FileStoreDecl,
2704 source: &str,
2705 comments: &[Comment],
2706 formatted: &mut String,
2707) -> bool {
2708 let render = |globs: &[String]| {
2709 globs
2710 .iter()
2711 .map(|glob| format!("{glob:?}"))
2712 .collect::<Vec<_>>()
2713 .join(", ")
2714 };
2715 let mut members: Vec<(SourceSpan, Vec<String>)> = Vec::new();
2716 if let Some(span) = file_store.root_span {
2717 members.push((span, vec![format!(" root {:?}", file_store.root)]));
2718 }
2719 if !file_store.read_globs.is_empty() {
2720 if let Some(span) = file_store.read_span {
2721 members.push((
2722 span,
2723 vec![format!(" allow read [{}]", render(&file_store.read_globs))],
2724 ));
2725 }
2726 }
2727 if !file_store.write_globs.is_empty() {
2728 if let Some(span) = file_store.write_span {
2729 members.push((
2730 span,
2731 vec![format!(
2732 " allow write [{}]",
2733 render(&file_store.write_globs)
2734 )],
2735 ));
2736 }
2737 }
2738 if let Some(provider) = &file_store.provider {
2739 if let Some(span) = file_store.provider_span {
2740 members.push((span, vec![format!(" provider {}", provider.name)]));
2741 }
2742 }
2743 members.sort_by_key(|(span, _)| span.start);
2744 let Some((own_line, trailing)) =
2745 classify_body_comments(source, file_store.span, &members, comments)
2746 else {
2747 return false;
2748 };
2749 push_line(formatted, format!("file store {} {{", file_store.name.name));
2750 emit_members_with_comments(&members, &own_line, &trailing, " ", formatted);
2751 push_line(formatted, "}");
2752 true
2753}
2754
2755fn try_format_event_with_comments(
2759 event: &EventDecl,
2760 source: &str,
2761 comments: &[Comment],
2762 formatted: &mut String,
2763) -> bool {
2764 let members: Vec<(SourceSpan, Vec<String>)> = event
2765 .fields
2766 .iter()
2767 .map(|field| {
2768 (
2769 field.span,
2770 vec![format!(" {} {}", field.name.name, field.ty.to_source())],
2771 )
2772 })
2773 .collect();
2774 let Some((own_line, trailing)) = classify_body_comments(source, event.span, &members, comments)
2775 else {
2776 return false;
2777 };
2778 push_line(formatted, format!("signal {} {{", event.name));
2779 emit_members_with_comments(&members, &own_line, &trailing, " ", formatted);
2780 push_line(formatted, "}");
2781 true
2782}
2783
2784fn agent_field_span(field: &AgentField) -> SourceSpan {
2785 match field {
2786 AgentField::Provider(ident) => ident.span,
2787 AgentField::Profile(profile) => profile.span,
2788 AgentField::Capacity(_, span)
2789 | AgentField::Skills(_, span)
2790 | AgentField::Capabilities(_, span)
2791 | AgentField::Requires(_, span)
2792 | AgentField::Tools(_, span) => *span,
2793 AgentField::Compaction(strategy) => strategy.span,
2794 AgentField::Thread(mode) => mode.span,
2795 AgentField::Settings(sources) => sources.span,
2796 AgentField::Unknown { span, .. } => *span,
2797 }
2798}
2799
2800fn agent_field_line(field: &AgentField) -> String {
2801 match field {
2802 AgentField::Provider(provider) => format!(" provider {}", provider.name),
2803 AgentField::Profile(profile) => format!(" profile {:?}", profile.value),
2804 AgentField::Capacity(capacity, _) => format!(" capacity {capacity}"),
2805 AgentField::Skills(skills, _) => {
2806 let skills = skills
2807 .iter()
2808 .map(|skill| format!("{:?}", skill.value))
2809 .collect::<Vec<_>>()
2810 .join(", ");
2811 format!(" skills [{skills}]")
2812 }
2813 AgentField::Capabilities(capabilities, _) => {
2814 let capabilities = capabilities
2815 .iter()
2816 .map(|capability| format!("{:?}", capability.value))
2817 .collect::<Vec<_>>()
2818 .join(", ");
2819 format!(" capabilities [{capabilities}]")
2820 }
2821 AgentField::Requires(classes, _) => {
2822 let classes = classes
2823 .iter()
2824 .map(|class| class.name.as_str())
2825 .collect::<Vec<_>>()
2826 .join(", ");
2827 format!(" requires [{classes}]")
2828 }
2829 AgentField::Tools(tools, _) => {
2830 let tools = tools
2831 .iter()
2832 .map(|tool| tool.name.as_str())
2833 .collect::<Vec<_>>()
2834 .join(", ");
2835 format!(" tools [{tools}]")
2836 }
2837 AgentField::Compaction(strategy) => format!(" compaction {}", strategy.name),
2838 AgentField::Thread(mode) => format!(" thread {}", mode.name),
2839 AgentField::Settings(sources) => format!(" settings {}", sources.name),
2840 AgentField::Unknown { name, .. } => format!(" {}", name.name),
2841 }
2842}
2843
2844fn try_format_agent_with_comments(
2848 agent: &AgentDecl,
2849 source: &str,
2850 comments: &[Comment],
2851 formatted: &mut String,
2852) -> bool {
2853 let members: Vec<(SourceSpan, Vec<String>)> = agent
2854 .fields
2855 .iter()
2856 .map(|field| (agent_field_span(field), vec![agent_field_line(field)]))
2857 .collect();
2858 let Some((own_line, trailing)) = classify_body_comments(source, agent.span, &members, comments)
2859 else {
2860 return false;
2861 };
2862 let harness = agent
2863 .harness
2864 .as_ref()
2865 .map(|harness| format!(" using {}", harness.name))
2866 .or_else(|| {
2867 agent
2868 .delegated_to
2869 .as_ref()
2870 .map(|delegate| format!(" delegated to {}", delegate.name))
2871 })
2872 .unwrap_or_default();
2873 push_line(
2874 formatted,
2875 format!("agent {}{} {{", agent.name.name, harness),
2876 );
2877 emit_members_with_comments(&members, &own_line, &trailing, " ", formatted);
2878 push_line(formatted, "}");
2879 true
2880}
2881
2882fn enum_variant_lines_with_comments(
2887 variant: &EnumVariantDecl,
2888 source: &str,
2889 comments: &[Comment],
2890) -> Option<Vec<String>> {
2891 if variant.fields.is_empty() {
2892 return Some(vec![format!(" {}", variant.name.name)]);
2893 }
2894 let members: Vec<(SourceSpan, Vec<String>)> = variant
2895 .fields
2896 .iter()
2897 .map(|field| {
2898 (
2899 field.span,
2900 vec![format!(" {} {}", field.name.name, field.ty.to_source())],
2901 )
2902 })
2903 .collect();
2904 let (own_line, trailing) = classify_body_comments(source, variant.span, &members, comments)?;
2906 let mut block = String::new();
2907 emit_members_with_comments(&members, &own_line, &trailing, " ", &mut block);
2908 let mut lines = vec![format!(" {} {{", variant.name.name)];
2909 lines.extend(block.lines().map(str::to_owned));
2910 lines.push(" }".to_owned());
2911 Some(lines)
2912}
2913
2914fn try_format_enum_with_comments(
2920 enum_decl: &EnumDecl,
2921 source: &str,
2922 comments: &[Comment],
2923 formatted: &mut String,
2924) -> bool {
2925 let mut members: Vec<(SourceSpan, Vec<String>)> = Vec::with_capacity(enum_decl.variants.len());
2926 for variant in &enum_decl.variants {
2927 let Some(lines) = enum_variant_lines_with_comments(variant, source, comments) else {
2928 return false;
2929 };
2930 members.push((variant.span, lines));
2931 }
2932 let body_level: Vec<Comment> = comments
2936 .iter()
2937 .filter(|comment| {
2938 !enum_decl.variants.iter().any(|variant| {
2939 !variant.fields.is_empty()
2940 && variant.span.start < comment.span.start
2941 && comment.span.start < variant.span.end
2942 })
2943 })
2944 .cloned()
2945 .collect();
2946 let Some((own_line, trailing)) =
2947 classify_body_comments(source, enum_decl.span, &members, &body_level)
2948 else {
2949 return false;
2950 };
2951 push_line(formatted, format!("enum {} {{", enum_decl.name.name));
2952 emit_members_with_comments(&members, &own_line, &trailing, " ", formatted);
2953 push_line(formatted, "}");
2954 true
2955}
2956
2957fn referenced_decl_name(item: &Item) -> Option<(String, SourceSpan)> {
2964 match item {
2965 Item::Class(decl) => Some((decl.name.name.clone(), decl.span)),
2966 Item::Enum(decl) => Some((decl.name.name.clone(), decl.span)),
2967 Item::Agent(decl) => Some((decl.name.name.clone(), decl.span)),
2968 Item::Coerce(decl) => Some((decl.name.name.clone(), decl.span)),
2969 Item::Lease(decl) => Some((decl.name.name.clone(), decl.span)),
2970 Item::Ledger(decl) => Some((decl.name.name.clone(), decl.span)),
2971 Item::Counter(decl) => Some((decl.name.name.clone(), decl.span)),
2972 Item::Tracker(decl) => Some((decl.name.name.clone(), decl.span)),
2973 Item::Channel(decl) => Some((decl.name.name.clone(), decl.span)),
2974 Item::FileStore(decl) => Some((decl.name.name.clone(), decl.span)),
2975 Item::MemoryPool(decl) => Some((decl.name.name.clone(), decl.span)),
2976 Item::Event(decl) => Some((decl.name.clone(), decl.span)),
2977 Item::Table(decl) => Some((decl.name.name.clone(), decl.span)),
2978 Item::Gauge(decl) => Some((decl.name.name.clone(), decl.span)),
2979 Item::Campaign(decl) => Some((decl.name.name.clone(), decl.span)),
2980 Item::Mark(decl) => Some((decl.name.value.clone(), decl.span)),
2981 _ => None,
2982 }
2983}
2984
2985fn annotate_cross_workflow_leak(
2992 diagnostic: &mut Diagnostic,
2993 current: &str,
2994 own_locals: &BTreeSet<String>,
2995 global_names: &BTreeSet<String>,
2996 sibling_locals: &BTreeMap<String, Vec<(String, SourceSpan)>>,
2997) {
2998 for (name, owners) in sibling_locals {
2999 if global_names.contains(name) || own_locals.contains(name) {
3000 continue;
3001 }
3002 if !diagnostic.message.contains(&format!("`{name}`")) {
3005 continue;
3006 }
3007 let Some((owner, span)) = owners.iter().find(|(owner, _)| owner != current) else {
3008 continue;
3009 };
3010 diagnostic.related.push(RelatedInfo {
3011 span: *span,
3012 message: format!(
3013 "`{name}` is declared inside workflow `{owner}`, which makes it \
3014 private to that workflow; move it to a top-level declaration to \
3015 share it across workflows"
3016 ),
3017 });
3018 return;
3019 }
3020}
3021
3022fn select_root_workflow(
3023 mut program: Program,
3024 root: Option<&str>,
3025) -> Result<Program, Vec<Diagnostic>> {
3026 if program.workflow.is_none() && program.workflows.is_empty() {
3032 return Err(vec![Diagnostic {
3033 related: Vec::new(),
3034 span: SourceSpan { start: 0, end: 0 },
3035 message: "program declares no `workflow`".to_owned(),
3036 suggestion: Some(
3037 "add an explicit `workflow Name { ... }` declaration; a runnable \
3038 program requires at least one workflow (files that only declare \
3039 shared types or patterns are libraries, meant to be `include`d)"
3040 .to_owned(),
3041 ),
3042 }]);
3043 }
3044
3045 if program.workflows.is_empty() {
3046 if let Some(root) = root {
3047 match program.workflow.as_ref() {
3048 Some(workflow) if workflow.name == root => {}
3049 Some(workflow) => {
3050 return Err(vec![Diagnostic {
3051 related: Vec::new(),
3052 span: workflow.span,
3053 message: format!("root workflow `{root}` was not found"),
3054 suggestion: Some(format!("available workflow: `{}`", workflow.name)),
3055 }]);
3056 }
3057 None => {
3058 return Err(vec![Diagnostic {
3059 related: Vec::new(),
3060 span: SourceSpan { start: 0, end: 0 },
3061 message: format!("root workflow `{root}` was not found"),
3062 suggestion: Some(
3063 "add an explicit `workflow Name { ... }` declaration".to_owned(),
3064 ),
3065 }]);
3066 }
3067 }
3068 }
3069 return Ok(program);
3070 }
3071
3072 let selected_index = match root {
3073 Some(root) => match program
3074 .workflows
3075 .iter()
3076 .position(|workflow| workflow.name.name == root)
3077 {
3078 Some(index) => index,
3079 None => {
3080 let names = program
3081 .workflows
3082 .iter()
3083 .map(|workflow| format!("`{}`", workflow.name.name))
3084 .collect::<Vec<_>>()
3085 .join(", ");
3086 return Err(vec![Diagnostic {
3087 related: Vec::new(),
3088 span: SourceSpan { start: 0, end: 0 },
3089 message: format!("root workflow `{root}` was not found"),
3090 suggestion: Some(format!("available workflows: {names}")),
3091 }]);
3092 }
3093 },
3094 None if program.workflows.len() == 1 => 0,
3095 None => {
3096 let names = program
3097 .workflows
3098 .iter()
3099 .map(|workflow| format!("`{}`", workflow.name.name))
3100 .collect::<Vec<_>>()
3101 .join(", ");
3102 return Err(vec![Diagnostic {
3103 related: Vec::new(),
3104 span: SourceSpan { start: 0, end: 0 },
3105 message: "multiple workflow declarations require an explicit root".to_owned(),
3106 suggestion: Some(format!(
3107 "pass `--root <name>`; available workflows: {names}"
3108 )),
3109 }]);
3110 }
3111 };
3112
3113 let selected = program.workflows.remove(selected_index);
3114 let mut items = program.items;
3115 let workflow_tags = selected.tags;
3116 let workflow_description = selected.description;
3117 items.extend(selected.items);
3118 Ok(Program {
3119 workflow: Some(selected.name),
3120 workflow_tags,
3121 workflow_description,
3122 explicit_workflow_body: true,
3123 workflows: Vec::new(),
3124 patterns: program.patterns,
3125 items,
3126 })
3127}
3128
3129impl IrProgram {
3130 pub fn construct_uses(&self) -> Vec<&IrConstructUse> {
3131 self.rules
3132 .iter()
3133 .flat_map(|rule| rule.metadata.effects.iter())
3134 .filter_map(|effect| effect.construct_use.as_ref())
3135 .collect()
3136 }
3137
3138 pub fn contract_registry(&self) -> ContractRegistry {
3139 let mut libraries = BTreeMap::<String, LibraryRegistration>::new();
3140 let mut contracts = BTreeMap::<(String, String), EffectContract>::new();
3141
3142 for use_decl in &self.uses {
3143 libraries
3144 .entry(use_decl.name.clone())
3145 .or_insert_with(|| LibraryRegistration {
3146 id: use_decl.name.clone(),
3147 version: "unlocked".to_owned(),
3148 standard: false,
3149 });
3150 }
3151
3152 if !self.harnesses.is_empty() || !self.agents.is_empty() {
3153 register_standard_library(&mut libraries, "std.agent");
3154 }
3155 if !self.trackers.is_empty() {
3156 register_standard_library(&mut libraries, "std.tracker");
3157 }
3158 if !self.events.is_empty() {
3159 register_standard_library(&mut libraries, "std.ingress");
3160 }
3161 if !self.leases.is_empty() || !self.ledgers.is_empty() || !self.counters.is_empty() {
3162 register_standard_library(&mut libraries, "std.coord");
3163 }
3164 if !self.channels.is_empty() {
3165 register_standard_library(&mut libraries, "std.messaging");
3166 }
3167 if !self.file_stores.is_empty() {
3171 register_standard_library(&mut libraries, "std.files");
3172 }
3173 if self.sources.iter().any(|source| source.is_clock) {
3174 register_standard_library(&mut libraries, "std.time");
3175 }
3176 if !self.coerces.is_empty() {
3177 register_standard_library(&mut libraries, "std.coercion");
3178 register_effect_contract(
3179 &mut libraries,
3180 &mut contracts,
3181 IrEffectKind::SchemaCoerce,
3182 Vec::new(),
3183 );
3184 }
3185
3186 for rule in &self.rules {
3187 for effect in &rule.metadata.effects {
3188 register_effect_contract(
3189 &mut libraries,
3190 &mut contracts,
3191 effect.kind.clone(),
3192 effect.required_capabilities.clone(),
3193 );
3194 }
3195 }
3196
3197 ContractRegistry {
3203 libraries: libraries.into_values().collect(),
3204 constructs: Vec::new(),
3205 effect_contracts: contracts.into_values().collect(),
3206 }
3207 }
3208
3209 pub fn to_snapshot(&self) -> String {
3210 let mut snapshot = String::new();
3211 push_line(&mut snapshot, format!("workflow {}", self.workflow));
3212
3213 if !self.source_tags.is_empty() {
3214 push_line(&mut snapshot, "source_tags");
3215 for tag in &self.source_tags {
3216 push_line(
3217 &mut snapshot,
3218 format!("@{} {} {}", tag.name, tag.target_kind, tag.target),
3219 );
3220 }
3221 }
3222
3223 if !self.source_descriptions.is_empty() {
3224 push_line(&mut snapshot, "source_descriptions");
3225 for description in &self.source_descriptions {
3226 push_line(
3227 &mut snapshot,
3228 format!(
3229 "{:?} {} {}",
3230 description.value, description.target_kind, description.target
3231 ),
3232 );
3233 }
3234 }
3235
3236 if !self.shared_coordination_usage.is_empty() {
3237 push_line(&mut snapshot, "shared_coordination_usage");
3238 for usage in &self.shared_coordination_usage {
3239 push_line(
3240 &mut snapshot,
3241 format!(
3242 "{} <- {}",
3243 usage.resource,
3244 usage.workflow_principals.join(",")
3245 ),
3246 );
3247 }
3248 }
3249
3250 if !self.includes.is_empty() {
3251 push_line(&mut snapshot, "includes");
3252 for include in &self.includes {
3253 match &include.source_hash {
3254 Some(source_hash) => {
3255 push_line(
3256 &mut snapshot,
3257 format!(" {} hash {}", include.path, source_hash),
3258 );
3259 }
3260 None => push_line(&mut snapshot, format!(" {}", include.path)),
3261 }
3262 }
3263 }
3264
3265 if !self.pattern_applications.is_empty() {
3266 push_line(&mut snapshot, "pattern_applications");
3267 for application in &self.pattern_applications {
3268 let type_args = application
3269 .type_args
3270 .iter()
3271 .map(IrType::to_snapshot)
3272 .collect::<Vec<_>>()
3273 .join(", ");
3274 push_line(
3275 &mut snapshot,
3276 format!(
3277 " {} as {}<{}>",
3278 application.pattern, application.alias, type_args
3279 ),
3280 );
3281 push_line(
3282 &mut snapshot,
3283 format!(
3284 " defined-at {}..{}",
3285 application.definition_span.start, application.definition_span.end
3286 ),
3287 );
3288 push_line(
3289 &mut snapshot,
3290 format!(
3291 " applied-at {}..{}",
3292 application.application_span.start, application.application_span.end
3293 ),
3294 );
3295 for argument in &application.value_args {
3296 push_line(
3297 &mut snapshot,
3298 format!(" arg {} {}", argument.name, argument.value),
3299 );
3300 }
3301 for generated in &application.generated {
3302 push_line(&mut snapshot, format!(" generated {generated}"));
3303 }
3304 }
3305 }
3306
3307 if !self.workflow_contracts.is_empty() {
3308 push_line(&mut snapshot, "workflow_contracts");
3309 for contract in &self.workflow_contracts {
3310 push_line(
3311 &mut snapshot,
3312 format!(
3313 " {} {} {}",
3314 contract.kind.as_str(),
3315 contract.name,
3316 contract.ty.to_snapshot()
3317 ),
3318 );
3319 }
3320 }
3321
3322 if !self.uses.is_empty() {
3323 push_line(&mut snapshot, "uses");
3324 for use_decl in &self.uses {
3325 push_line(
3326 &mut snapshot,
3327 format!(" {} {}", use_decl.kind.as_str(), use_decl.name),
3328 );
3329 }
3330 }
3331
3332 if !self.schemas.is_empty() {
3333 push_line(&mut snapshot, "schemas");
3334 for schema in &self.schemas {
3335 match schema {
3336 IrSchema::Enum(enum_decl) => {
3337 push_line(
3338 &mut snapshot,
3339 format!(
3340 " enum {} {{ {} }}",
3341 enum_decl.name,
3342 enum_decl.variants.join(", ")
3343 ),
3344 );
3345 }
3346 IrSchema::Class(class_decl) => {
3347 push_line(&mut snapshot, format!(" class {}", class_decl.name));
3348 for field in &class_decl.fields {
3349 let key = if field.is_key { " @key" } else { "" };
3352 push_line(
3353 &mut snapshot,
3354 format!(" {} {}{key}", field.name, field.ty.to_snapshot()),
3355 );
3356 }
3357 }
3358 }
3359 }
3360 }
3361
3362 if !self.harnesses.is_empty() {
3363 push_line(&mut snapshot, "harnesses");
3364 for harness in &self.harnesses {
3365 push_line(
3366 &mut snapshot,
3367 format!(" harness {} kind={}", harness.name, harness.kind),
3368 );
3369 }
3370 }
3371 if !self.trackers.is_empty() {
3372 push_line(&mut snapshot, "trackers");
3373 for queue in &self.trackers {
3374 push_line(
3375 &mut snapshot,
3376 format!(" tracker {} provider={}", queue.name, queue.provider),
3377 );
3378 }
3379 }
3380
3381 if !self.channels.is_empty() {
3382 push_line(&mut snapshot, "channels");
3383 for channel in &self.channels {
3384 let mut line = format!(" channel {} provider={}", channel.name, channel.provider);
3385 if let Some(workspace) = &channel.workspace {
3386 line.push_str(&format!(" workspace={workspace}"));
3387 }
3388 if let Some(destination) = &channel.destination {
3389 line.push_str(&format!(" destination={destination:?}"));
3390 }
3391 push_line(&mut snapshot, line);
3392 }
3393 }
3394
3395 if !self.gauges.is_empty() {
3396 push_line(&mut snapshot, "gauges");
3397 for gauge in &self.gauges {
3398 let mut line = format!(
3399 " gauge {} judge={}:{}",
3400 gauge.name, gauge.judge_kind, gauge.judge_target
3401 );
3402 if !gauge.judge_args.is_empty() {
3403 line.push_str(&format!(" args=({})", gauge.judge_args.join(",")));
3404 }
3405 if let Some(site) = &gauge.site {
3406 line.push_str(&format!(" site={site}"));
3407 }
3408 if let Some(bar) = &gauge.expect {
3409 line.push_str(&format!(
3410 " expect={}:{}{}{}",
3411 bar.form, bar.subject, bar.op, bar.threshold
3412 ));
3413 }
3414 if !gauge.inputs.is_empty() {
3415 line.push_str(&format!(" inputs={}", gauge.inputs.join(",")));
3416 }
3417 push_line(&mut snapshot, line);
3418 }
3419 }
3420
3421 if !self.marks.is_empty() {
3422 push_line(&mut snapshot, "marks");
3423 for mark in &self.marks {
3424 push_line(
3425 &mut snapshot,
3426 format!(" mark {:?} after {}", mark.name, mark.site),
3427 );
3428 }
3429 }
3430
3431 if !self.campaigns.is_empty() {
3432 push_line(&mut snapshot, "campaigns");
3433 for campaign in &self.campaigns {
3434 let mut line = format!(" campaign {}", campaign.name);
3435 if !campaign.ascend.is_empty() {
3436 line.push_str(&format!(" ascend={}", campaign.ascend.join(",")));
3437 }
3438 for reach in &campaign.reach {
3439 line.push_str(&format!(
3440 " reach={}{}{}{}",
3441 reach.gauge,
3442 reach.op,
3443 reach.threshold,
3444 reach.unit.as_deref().unwrap_or("")
3445 ));
3446 }
3447 for guard in &campaign.guard {
3448 line.push_str(&format!(
3449 " guard={}:within:{}%",
3450 guard.gauge, guard.band_percent
3451 ));
3452 }
3453 if !campaign.sacrifice.is_empty() {
3454 line.push_str(&format!(" sacrifice={}", campaign.sacrifice.join(",")));
3455 }
3456 if campaign.proposer_redacted {
3457 line.push_str(" proposer=redacted");
3458 }
3459 push_line(&mut snapshot, line);
3460 }
3461 }
3462
3463 if !self.file_stores.is_empty() {
3464 push_line(&mut snapshot, "file_stores");
3465 for file_store in &self.file_stores {
3466 push_line(
3467 &mut snapshot,
3468 format!(
3469 " file store {} root={:?}",
3470 file_store.name, file_store.root
3471 ),
3472 );
3473 if !file_store.read_globs.is_empty() {
3476 push_line(
3477 &mut snapshot,
3478 format!(" allow read {:?}", file_store.read_globs),
3479 );
3480 }
3481 if !file_store.write_globs.is_empty() {
3482 push_line(
3483 &mut snapshot,
3484 format!(" allow write {:?}", file_store.write_globs),
3485 );
3486 }
3487 if let Some(provider) = &file_store.provider {
3491 push_line(&mut snapshot, format!(" provider {provider}"));
3492 }
3493 }
3494 }
3495
3496 if !self.memory_pools.is_empty() {
3497 push_line(&mut snapshot, "memory_pools");
3498 for pool in &self.memory_pools {
3499 push_line(&mut snapshot, format!(" memory pool {}", pool.name));
3500 if let Some(limit) = pool.context_limit {
3503 push_line(&mut snapshot, format!(" context limit {limit}"));
3504 }
3505 }
3506 }
3507
3508 if !self.agents.is_empty() {
3509 push_line(&mut snapshot, "agents");
3510 for agent in &self.agents {
3511 let profile = agent.profile.as_deref().unwrap_or("<missing>");
3512 let harness = agent.harness.as_deref().unwrap_or("<fallback>");
3513 let provider = agent.provider.as_deref().unwrap_or("<fallback>");
3514 let capacity = agent
3515 .capacity
3516 .map(|capacity| capacity.to_string())
3517 .unwrap_or_else(|| "<missing>".to_owned());
3518 let skills = if agent.skills.is_empty() {
3519 "[]".to_owned()
3520 } else {
3521 format!("[{}]", agent.skills.join(", "))
3522 };
3523 let capabilities = if agent.capabilities.is_empty() {
3524 "[]".to_owned()
3525 } else {
3526 format!("[{}]", agent.capabilities.join(", "))
3527 };
3528 let tools = if agent.tools.is_empty() {
3529 "[]".to_owned()
3530 } else {
3531 format!("[{}]", agent.tools.join(", "))
3532 };
3533 let requires = if agent.requires.is_empty() {
3536 String::new()
3537 } else {
3538 format!(" requires=[{}]", agent.requires.join(", "))
3539 };
3540 let compaction = agent
3543 .compaction
3544 .as_deref()
3545 .map(|strategy| format!(" compaction={strategy}"))
3546 .unwrap_or_default();
3547 let settings = agent
3549 .settings
3550 .as_deref()
3551 .map(|sources| format!(" settings={sources}"))
3552 .unwrap_or_default();
3553 let thread = agent
3555 .thread
3556 .as_deref()
3557 .map(|mode| format!(" thread={mode}"))
3558 .unwrap_or_default();
3559 let class = match agent.harness_class {
3562 HarnessClass::Delegated => " class=delegated",
3563 HarnessClass::Managed => "",
3564 };
3565 push_line(
3566 &mut snapshot,
3567 format!(
3568 " agent {} harness={} provider={} profile={} capacity={} skills={} capabilities={} tools={}{}{}{}{}{}",
3569 agent.name, harness, provider, profile, capacity, skills, capabilities, tools, requires, compaction, settings, thread, class
3570 ),
3571 );
3572 }
3573 }
3574
3575 if !self.coerces.is_empty() {
3576 push_line(&mut snapshot, "coerces");
3577 for coerce in &self.coerces {
3578 let params = coerce
3579 .params
3580 .iter()
3581 .map(|param| format!("{} {}", param.name, param.ty.to_snapshot()))
3582 .collect::<Vec<_>>()
3583 .join(", ");
3584 push_line(
3585 &mut snapshot,
3586 format!(
3587 " coerce {}({}) -> {}",
3588 coerce.name,
3589 params,
3590 coerce.output.to_snapshot()
3591 ),
3592 );
3593 }
3594 }
3595
3596 if !self.assertions.is_empty() {
3597 push_line(&mut snapshot, "assertions");
3598 for assertion in &self.assertions {
3599 push_line(
3600 &mut snapshot,
3601 format!(" assert {}", assertion.expr.expr.to_snapshot()),
3602 );
3603 if !assertion.projection_reads.is_empty() {
3604 push_line(&mut snapshot, " reads");
3605 for read in &assertion.projection_reads {
3606 push_line(&mut snapshot, format!(" {}", read.to_snapshot()));
3607 }
3608 }
3609 }
3610 }
3611
3612 if !self.rules.is_empty() {
3613 push_line(&mut snapshot, "rules");
3614 for rule in &self.rules {
3615 push_line(&mut snapshot, format!(" rule {}", rule.name));
3616 for when in &rule.whens {
3617 match &when.guard {
3618 Some(guard) => push_line(
3619 &mut snapshot,
3620 format!(
3621 " when {} where {}",
3622 when.pattern,
3623 guard.expr.to_snapshot()
3624 ),
3625 ),
3626 None => push_line(&mut snapshot, format!(" when {}", when.pattern)),
3627 }
3628 }
3629 if !rule.metadata.fact_reads.is_empty() {
3630 push_line(&mut snapshot, " reads");
3631 for read in &rule.metadata.fact_reads {
3632 push_line(&mut snapshot, format!(" {}", read));
3633 }
3634 }
3635 if !rule.metadata.projection_reads.is_empty() {
3636 push_line(&mut snapshot, " projection_reads");
3637 for read in &rule.metadata.projection_reads {
3638 push_line(&mut snapshot, format!(" {}", read.to_snapshot()));
3639 }
3640 }
3641 if !rule.metadata.fact_writes.is_empty() {
3642 push_line(&mut snapshot, " writes");
3643 for write in &rule.metadata.fact_writes {
3644 push_line(&mut snapshot, format!(" {}", write));
3645 }
3646 }
3647 if !rule.metadata.record_sources.is_empty() {
3648 push_line(&mut snapshot, " record_sources");
3649 for source in &rule.metadata.record_sources {
3650 push_line(
3651 &mut snapshot,
3652 format!(
3653 " schema:{} construct={} span={}..{}",
3654 source.schema, source.construct, source.span.start, source.span.end
3655 ),
3656 );
3657 }
3658 }
3659 if !rule.metadata.fact_consumes.is_empty() {
3660 push_line(&mut snapshot, " consumes");
3661 for consumed in &rule.metadata.fact_consumes {
3662 push_line(&mut snapshot, format!(" {}", consumed));
3663 }
3664 }
3665 if !rule.metadata.effects.is_empty() {
3666 push_line(&mut snapshot, " effects");
3667 for effect in &rule.metadata.effects {
3668 let binding = effect.binding.as_deref().unwrap_or("-");
3669 let construct = effect
3670 .construct_use
3671 .as_ref()
3672 .map(|form| {
3673 format!(" construct={}->{}", form.keyword, form.target_capability)
3674 })
3675 .unwrap_or_default();
3676 let grants = if effect.access_grants.is_empty() {
3679 String::new()
3680 } else {
3681 let rendered = effect
3682 .access_grants
3683 .iter()
3684 .map(|grant| {
3685 let ops = grant
3686 .operations
3687 .iter()
3688 .map(|op| op.operation.as_str())
3689 .collect::<Vec<_>>()
3690 .join(",");
3691 format!("{}[{ops}]", grant.resource)
3692 })
3693 .collect::<Vec<_>>()
3694 .join(";");
3695 format!(" grants={rendered}")
3696 };
3697 let skills = if effect.turn_skills.is_empty() {
3700 String::new()
3701 } else {
3702 format!(" skills={}", effect.turn_skills.join(","))
3703 };
3704 push_line(
3705 &mut snapshot,
3706 format!(
3707 " {} kind={} binding={}{} key={}{}{}",
3708 effect.id,
3709 effect.kind.as_str(),
3710 binding,
3711 construct,
3712 effect.idempotency_key,
3713 grants,
3714 skills
3715 ),
3716 );
3717 }
3718 }
3719 if !rule.metadata.dependencies.is_empty() {
3720 push_line(&mut snapshot, " dependencies");
3721 for dependency in &rule.metadata.dependencies {
3722 push_line(
3723 &mut snapshot,
3724 format!(
3725 " {} --{}--> {}",
3726 dependency.upstream,
3727 dependency.predicate.as_str(),
3728 dependency.downstream
3729 ),
3730 );
3731 }
3732 }
3733 if !rule.metadata.case_branches.is_empty() {
3734 push_line(&mut snapshot, " case_branches");
3735 for branch in &rule.metadata.case_branches {
3736 let guard = branch
3737 .guard
3738 .as_ref()
3739 .map(|guard| guard.expr.to_snapshot())
3740 .unwrap_or_else(|| "-".to_owned());
3741 push_line(
3742 &mut snapshot,
3743 format!(
3744 " case {} type={} pattern={} guard={} body_hash={} span={}..{}",
3745 branch.scrutinee,
3746 branch.scrutinee_type.to_snapshot(),
3747 branch.pattern.to_snapshot(),
3748 guard,
3749 branch.body_hash,
3750 branch.pattern_span.start,
3751 branch.pattern_span.end
3752 ),
3753 );
3754 }
3755 }
3756 if !rule.metadata.terminal_outputs.is_empty() {
3757 push_line(&mut snapshot, " terminal_outputs");
3758 for output in &rule.metadata.terminal_outputs {
3759 push_line(
3760 &mut snapshot,
3761 format!(
3762 " {} span={}..{}",
3763 output.binding, output.span.start, output.span.end
3764 ),
3765 );
3766 for alternative in &output.alternatives {
3767 push_line(
3768 &mut snapshot,
3769 format!(
3770 " {} payload={} span={}..{}",
3771 alternative.tag,
3772 alternative.payload_type.to_snapshot(),
3773 alternative.source_span.start,
3774 alternative.source_span.end
3775 ),
3776 );
3777 }
3778 }
3779 }
3780 if !rule.metadata.terminal_branches.is_empty() {
3781 push_line(&mut snapshot, " terminal_branches");
3782 for branch in &rule.metadata.terminal_branches {
3783 let tag = branch.tag.as_deref().unwrap_or("_");
3784 let binding = branch.binding.as_deref().unwrap_or("-");
3785 let guard = branch
3786 .guard
3787 .as_ref()
3788 .map(|guard| guard.expr.to_snapshot())
3789 .unwrap_or_else(|| "-".to_owned());
3790 push_line(
3791 &mut snapshot,
3792 format!(
3793 " case {} {} binding={} guard={} body_hash={} span={}..{}",
3794 branch.scrutinee,
3795 tag,
3796 binding,
3797 guard,
3798 branch.body_hash,
3799 branch.pattern_span.start,
3800 branch.pattern_span.end
3801 ),
3802 );
3803 }
3804 }
3805 push_line(
3806 &mut snapshot,
3807 format!(" body_hash {}", stable_hash(&rule.body)),
3808 );
3809 }
3810 }
3811
3812 if !self.rule_dependencies.is_empty() {
3813 push_line(&mut snapshot, "rule_dependencies");
3814 for dependency in &self.rule_dependencies {
3815 push_line(
3816 &mut snapshot,
3817 format!(
3818 " {} --{}--> {}",
3819 dependency.producer, dependency.fact, dependency.consumer
3820 ),
3821 );
3822 }
3823 }
3824
3825 snapshot
3826 }
3827}
3828
3829fn register_standard_library(libraries: &mut BTreeMap<String, LibraryRegistration>, id: &str) {
3830 libraries
3831 .entry(id.to_owned())
3832 .or_insert_with(|| LibraryRegistration {
3833 id: id.to_owned(),
3834 version: "0.1.0".to_owned(),
3835 standard: true,
3836 });
3837}
3838
3839fn register_effect_contract(
3840 libraries: &mut BTreeMap<String, LibraryRegistration>,
3841 contracts: &mut BTreeMap<(String, String), EffectContract>,
3842 kind: IrEffectKind,
3843 required_capabilities: Vec<String>,
3844) {
3845 let contract = effect_contract_for_kind(kind, required_capabilities);
3846 register_standard_library(libraries, contract.library_id.as_str());
3847 contracts
3848 .entry((contract.id.clone(), contract.version.clone()))
3849 .and_modify(|existing| {
3850 merge_unique(
3851 &mut existing.required_capabilities,
3852 &contract.required_capabilities,
3853 );
3854 merge_unique(&mut existing.provider_kinds, &contract.provider_kinds);
3855 merge_unique(&mut existing.source_forms, &contract.source_forms);
3856 merge_unique(&mut existing.projected_facts, &contract.projected_facts);
3857 })
3858 .or_insert(contract);
3859}
3860
3861fn merge_unique(target: &mut Vec<String>, values: &[String]) {
3862 for value in values {
3863 if !target.contains(value) {
3864 target.push(value.clone());
3865 }
3866 }
3867 target.sort();
3868}
3869
3870fn strings(values: &[&str]) -> Vec<String> {
3871 values.iter().map(|value| (*value).to_owned()).collect()
3872}
3873
3874fn effect_contract_for_kind(
3875 kind: IrEffectKind,
3876 required_capabilities: Vec<String>,
3877) -> EffectContract {
3878 let mut required_capabilities = required_capabilities;
3879 required_capabilities.sort();
3880 required_capabilities.dedup();
3881 let effect_kind = kind.as_str().to_owned();
3882
3883 let (
3884 library_id,
3885 source_forms,
3886 input_schema,
3887 output_schema,
3888 default_capabilities,
3889 provider_kinds,
3890 projected_facts,
3891 validation,
3892 ) = match kind {
3893 IrEffectKind::AgentTell => (
3894 "std.agent",
3895 strings(&["tell"]),
3896 Some("agent.turn.request"),
3897 Some("AgentTurn"),
3898 strings(&["agent.turn"]),
3899 strings(&["agent"]),
3900 strings(&["effect.output"]),
3901 TypedOutputValidation::RuntimeBoundary,
3902 ),
3903 IrEffectKind::SchemaCoerce => (
3904 "std.coercion",
3905 strings(&["coerce", "decide", "prompt"]),
3906 Some("schema.coerce.input"),
3907 Some("typed-provider-output"),
3908 strings(&["schema.coerce"]),
3914 strings(&["schema_coercer"]),
3915 strings(&["effect.output"]),
3916 TypedOutputValidation::RuntimeBoundary,
3917 ),
3918 IrEffectKind::CapabilityCall => (
3919 "std.script",
3920 strings(&["call"]),
3921 Some("capability.call.input"),
3922 Some("capability.call.output"),
3923 Vec::new(),
3924 strings(&["capability"]),
3925 strings(&["effect.output"]),
3926 TypedOutputValidation::RuntimeBoundary,
3927 ),
3928 IrEffectKind::EventEmit => (
3929 "std.ingress",
3930 strings(&["emit"]),
3931 Some("event.emit.input"),
3932 None,
3933 Vec::new(),
3934 Vec::new(),
3935 Vec::new(),
3936 TypedOutputValidation::None,
3937 ),
3938 IrEffectKind::WorkflowInvoke => (
3939 "std.workflow",
3940 strings(&["invoke"]),
3941 Some("workflow.invoke.input"),
3942 Some("workflow.terminal"),
3943 Vec::new(),
3944 Vec::new(),
3945 strings(&["effect.output"]),
3946 TypedOutputValidation::RuntimeBoundary,
3947 ),
3948 IrEffectKind::TimerWait => (
3949 "std.time",
3950 strings(&["timer"]),
3951 Some("timer.wait.input"),
3952 Some("TimerElapsed"),
3953 Vec::new(),
3954 Vec::new(),
3955 strings(&["effect.output"]),
3956 TypedOutputValidation::None,
3957 ),
3958 IrEffectKind::ExecCommand => (
3959 "std.script",
3960 strings(&["exec"]),
3961 Some("exec.command.input"),
3962 Some("exec.command.output"),
3963 strings(&["exec.run"]),
3964 strings(&["script", "command"]),
3965 strings(&["effect.output"]),
3966 TypedOutputValidation::RuntimeBoundary,
3967 ),
3968 IrEffectKind::TrackerFile => (
3969 "std.tracker",
3970 strings(&["file"]),
3971 Some("tracker.file.input"),
3972 None,
3973 strings(&["tracker.file"]),
3974 Vec::new(),
3975 Vec::new(),
3976 TypedOutputValidation::None,
3977 ),
3978 IrEffectKind::TrackerClaim => (
3979 "std.tracker",
3980 strings(&["claim"]),
3981 Some("tracker.claim.input"),
3982 Some("TrackerClaim"),
3983 strings(&["tracker.claim"]),
3984 Vec::new(),
3985 strings(&["effect.output"]),
3986 TypedOutputValidation::None,
3987 ),
3988 IrEffectKind::TrackerRenew => (
3993 "std.tracker",
3994 strings(&["renew"]),
3995 Some("tracker.renew.input"),
3996 None,
3997 strings(&["tracker.renew"]),
3998 Vec::new(),
3999 Vec::new(),
4000 TypedOutputValidation::None,
4001 ),
4002 IrEffectKind::TrackerRelease => (
4003 "std.tracker",
4004 strings(&["release"]),
4005 Some("tracker.release.input"),
4006 None,
4007 strings(&["tracker.release"]),
4008 Vec::new(),
4009 Vec::new(),
4010 TypedOutputValidation::None,
4011 ),
4012 IrEffectKind::TrackerFinish => (
4013 "std.tracker",
4014 strings(&["finish"]),
4015 Some("tracker.finish.input"),
4016 None,
4017 strings(&["tracker.finish"]),
4018 Vec::new(),
4019 Vec::new(),
4020 TypedOutputValidation::None,
4021 ),
4022 IrEffectKind::LeaseAcquire => (
4023 "std.coord",
4024 strings(&["acquire"]),
4025 Some("lease.acquire.input"),
4026 Some("LeaseAcquireOutcome"),
4027 Vec::new(),
4028 Vec::new(),
4029 strings(&["effect.output"]),
4030 TypedOutputValidation::None,
4031 ),
4032 IrEffectKind::LeaseRenew => (
4033 "std.coord",
4034 strings(&["renew"]),
4035 Some("lease.renew.input"),
4036 Some("LeaseRenewOutcome"),
4037 Vec::new(),
4038 Vec::new(),
4039 strings(&["effect.output"]),
4040 TypedOutputValidation::None,
4041 ),
4042 IrEffectKind::LedgerAppend => (
4043 "std.coord",
4044 strings(&["append"]),
4045 Some("ledger.append.input"),
4046 None,
4047 Vec::new(),
4048 Vec::new(),
4049 Vec::new(),
4050 TypedOutputValidation::None,
4051 ),
4052 IrEffectKind::CounterConsume => (
4053 "std.coord",
4054 strings(&["consume"]),
4055 Some("counter.consume.input"),
4056 Some("CounterConsumeOutcome"),
4057 Vec::new(),
4058 Vec::new(),
4059 strings(&["effect.output"]),
4060 TypedOutputValidation::None,
4061 ),
4062 IrEffectKind::SignalEmit => (
4063 "std.ingress",
4064 strings(&["emit", "signal"]),
4065 Some("signal.emit.input"),
4066 None,
4067 Vec::new(),
4068 Vec::new(),
4069 Vec::new(),
4070 TypedOutputValidation::None,
4071 ),
4072 IrEffectKind::FileRead => (
4077 "std.files",
4078 strings(&["read"]),
4079 Some("file.read.input"),
4080 Some("FileReadResult"),
4081 strings(&["file.read"]),
4082 Vec::new(),
4083 strings(&["effect.output"]),
4084 TypedOutputValidation::RuntimeBoundary,
4085 ),
4086 IrEffectKind::FileWrite => (
4087 "std.files",
4088 strings(&["write"]),
4089 Some("file.write.input"),
4090 Some("FileWriteResult"),
4091 strings(&["file.write"]),
4092 Vec::new(),
4093 strings(&["effect.output"]),
4094 TypedOutputValidation::RuntimeBoundary,
4095 ),
4096 IrEffectKind::FileImport => (
4097 "std.files",
4098 strings(&["import"]),
4099 Some("file.import.input"),
4100 Some("FileImportResult"),
4101 strings(&["file.import"]),
4102 Vec::new(),
4103 strings(&["effect.output"]),
4104 TypedOutputValidation::RuntimeBoundary,
4105 ),
4106 IrEffectKind::FileExport => (
4107 "std.files",
4108 strings(&["export"]),
4109 Some("file.export.input"),
4110 Some("FileExportResult"),
4111 strings(&["file.export"]),
4112 Vec::new(),
4113 strings(&["effect.output"]),
4114 TypedOutputValidation::RuntimeBoundary,
4115 ),
4116 };
4117
4118 merge_unique(&mut required_capabilities, &default_capabilities);
4119
4120 EffectContract {
4121 id: effect_kind.clone(),
4122 library_id: library_id.to_owned(),
4123 version: "0.1.0".to_owned(),
4124 effect_kind,
4125 source_forms,
4126 input_schema: input_schema.map(str::to_owned),
4127 output_schema: output_schema.map(str::to_owned),
4128 required_capabilities,
4129 provider_kinds,
4130 projected_facts,
4131 validation,
4132 }
4133}
4134
4135impl IrEffectKind {
4136 pub fn as_str(&self) -> &'static str {
4139 match self {
4140 Self::AgentTell => "agent.tell",
4141 Self::SchemaCoerce => "schema.coerce",
4142 Self::CapabilityCall => "capability.call",
4143 Self::EventEmit => "event.emit",
4144 Self::WorkflowInvoke => "workflow.invoke",
4145 Self::TimerWait => "timer.wait",
4146 Self::ExecCommand => "exec.command",
4147 Self::TrackerFile => "tracker.file",
4148 Self::TrackerClaim => "tracker.claim",
4149 Self::TrackerRenew => "tracker.renew",
4150 Self::TrackerRelease => "tracker.release",
4151 Self::TrackerFinish => "tracker.finish",
4152 Self::LeaseAcquire => "lease.acquire",
4153 Self::LeaseRenew => "lease.renew",
4154 Self::LedgerAppend => "ledger.append",
4155 Self::CounterConsume => "counter.consume",
4156 Self::SignalEmit => "signal.emit",
4157 Self::FileRead => "file.read",
4158 Self::FileWrite => "file.write",
4159 Self::FileImport => "file.import",
4160 Self::FileExport => "file.export",
4161 }
4162 }
4163}
4164
4165impl DependencyPredicate {
4166 fn as_str(&self) -> &'static str {
4167 match self {
4168 Self::Succeeds => "succeeds",
4169 Self::Fails => "fails",
4170 Self::TimedOut => "timed_out",
4171 Self::Cancelled => "cancelled",
4172 Self::Completes => "completes",
4173 }
4174 }
4175}
4176
4177impl IrUseKind {
4178 fn as_str(&self) -> &'static str {
4179 match self {
4180 Self::Package => "package",
4181 }
4182 }
4183}
4184
4185impl IrType {
4186 pub fn display_label(&self) -> String {
4189 self.to_snapshot()
4190 }
4191
4192 fn to_snapshot(&self) -> String {
4193 match self {
4194 Self::Primitive(primitive) => primitive.as_str().to_owned(),
4195 Self::LiteralString(value) => format!("literal<{value:?}>"),
4196 Self::Ref(name) => format!("ref<{name}>"),
4197 Self::AgentRef(agents) => format!("agentref<{}>", agents.join(" | ")),
4198 Self::Object(fields) => {
4199 let fields = fields
4200 .iter()
4201 .map(|field| format!("{} {}", field.name, field.ty.to_snapshot()))
4202 .collect::<Vec<_>>()
4203 .join(", ");
4204 format!("object<{{{fields}}}>")
4205 }
4206 Self::Optional(inner) => format!("optional<{}>", inner.to_snapshot()),
4207 Self::Array(inner) => format!("array<{}>", inner.to_snapshot()),
4208 Self::Map(inner) => format!("map<{}>", inner.to_snapshot()),
4209 Self::Union(variants) => {
4210 let variants = variants
4211 .iter()
4212 .map(Self::to_snapshot)
4213 .collect::<Vec<_>>()
4214 .join(" | ");
4215 format!("union<{variants}>")
4216 }
4217 }
4218 }
4219}
4220
4221impl IrPrimitiveType {
4222 fn as_str(&self) -> &'static str {
4223 match self {
4224 Self::String => "string",
4225 Self::Int => "int",
4226 Self::Float => "float",
4227 Self::Bool => "bool",
4228 Self::Null => "null",
4229 Self::Duration => "duration",
4230 Self::Time => "time",
4231 Self::Image => "image",
4232 Self::Audio => "audio",
4233 Self::Pdf => "pdf",
4234 Self::Video => "video",
4235 }
4236 }
4237}
4238
4239fn lower_program(
4240 program: Program,
4241 workflow_inputs: BTreeMap<String, WorkflowInputSurface>,
4242 shared_coordination_usage: Vec<IrSharedCoordinationUsage>,
4243) -> CompileOutput {
4244 let mut diagnostics = Vec::new();
4245 let mut warnings = Vec::new();
4246 let (program, pattern_applications) = expand_pattern_applications(program, &mut diagnostics);
4247 let pending_regions: BTreeMap<String, IrRegion>;
4248 let program = {
4249 let mut program = program;
4250 let actions: Vec<ActionDecl> = program
4256 .items
4257 .iter()
4258 .filter_map(|item| match item {
4259 Item::Action(action) => Some(action.clone()),
4260 _ => None,
4261 })
4262 .collect();
4263 let mut expanded = Vec::with_capacity(program.items.len());
4264 for item in program.items {
4265 match item {
4266 Item::Action(_) => {}
4267 other => expanded.push(other),
4268 }
4269 }
4270 for item in &mut expanded {
4277 if let Item::Rule(rule) = item {
4278 if rule.body.text.contains('#') {
4279 rule.body.text = body::blank_full_line_comments(&rule.body.text);
4280 }
4281 }
4282 }
4283 action_expand::expand_action_calls(&mut expanded, &actions, &mut diagnostics);
4284 then_expand::expand_then_statements(&mut expanded, &mut diagnostics);
4291 pending_regions = extract_rule_regions(&mut expanded, &mut diagnostics);
4296 program.items = expanded;
4297 program
4298 };
4299 let schema_names = collect_schema_names(&program, &mut diagnostics);
4300 let harness_kinds = collect_harness_kinds(&program, &mut diagnostics);
4301 let agent_names = collect_agent_names(&program, &mut diagnostics);
4302 let workflow_contract_names = collect_workflow_contract_names(&program, &mut diagnostics);
4303 let mut semantic = SemanticContext::from_program(&program, workflow_inputs);
4304 let workflow = match program.workflow {
4305 Some(workflow) => workflow.name,
4306 None => {
4307 diagnostics.push(Diagnostic {
4308 related: Vec::new(),
4309 span: SourceSpan { start: 0, end: 0 },
4310 message: "expected workflow declaration".to_owned(),
4311 suggestion: Some("add `workflow Name` before declarations".to_owned()),
4312 });
4313 "<missing>".to_owned()
4314 }
4315 };
4316
4317 let mut ir = IrProgram {
4318 workflow,
4319 source_tags: Vec::new(),
4320 source_descriptions: Vec::new(),
4321 includes: Vec::new(),
4322 pattern_applications,
4323 workflow_contracts: Vec::new(),
4324 uses: Vec::new(),
4325 harnesses: Vec::new(),
4326 trackers: Vec::new(),
4327 channels: Vec::new(),
4328 gauges: Vec::new(),
4329 marks: Vec::new(),
4330 campaigns: Vec::new(),
4331 file_stores: Vec::new(),
4332 memory_pools: Vec::new(),
4333 events: Vec::new(),
4334 sources: Vec::new(),
4335 tests: Vec::new(),
4336 leases: Vec::new(),
4337 ledgers: Vec::new(),
4338 counters: Vec::new(),
4339 shared_coordination_usage,
4340 schemas: Vec::new(),
4341 agents: Vec::new(),
4342 coerces: Vec::new(),
4343 assertions: Vec::new(),
4344 rules: Vec::new(),
4345 rule_dependencies: Vec::new(),
4346 };
4347 let workflow_tag_target = ir.workflow.clone();
4348 lower_source_tags(
4349 &program.workflow_tags,
4350 "workflow",
4351 &workflow_tag_target,
4352 &mut ir,
4353 );
4354 lower_source_description(
4355 program.workflow_description.as_ref(),
4356 "workflow",
4357 &workflow_tag_target,
4358 &mut ir,
4359 );
4360
4361 collect_inline_decide_schemas(&program.items, &mut semantic, &mut ir);
4366
4367 collect_redact_schemas(&program.items, &mut semantic, &mut ir);
4372
4373 for item in program.items {
4374 match item {
4375 Item::Include(include) => lower_include(include, &mut ir),
4376 Item::WorkflowContract(contract) => lower_workflow_contract(
4377 contract,
4378 &mut ir,
4379 &schema_names,
4380 &agent_names,
4381 &mut diagnostics,
4382 ),
4383 Item::Use(use_decl) => lower_use(use_decl, &mut ir, &mut diagnostics),
4384 Item::Action(action) => {
4387 let _ = action;
4388 }
4389 Item::Pattern(pattern) => diagnostics.push(Diagnostic {
4390 related: Vec::new(),
4391 span: pattern.span,
4392 message: format!(
4393 "pattern `{}` is not allowed inside this declaration scope",
4394 pattern.name.name
4395 ),
4396 suggestion: Some("declare patterns at source top level".to_owned()),
4397 }),
4398 Item::Apply(apply) => diagnostics.push(Diagnostic {
4399 related: Vec::new(),
4400 span: apply.span,
4401 message: format!(
4402 "pattern application `{}` was not expanded",
4403 apply.alias.name
4404 ),
4405 suggestion: Some(
4406 "ensure the applied pattern is declared at source top level".to_owned(),
4407 ),
4408 }),
4409 Item::Harness(harness) => lower_harness(harness, &mut ir, &mut diagnostics),
4410 Item::Tracker(queue) => lower_tracker(queue, &mut ir, &mut diagnostics),
4411 Item::Channel(channel) => lower_channel(channel, &mut ir, &mut diagnostics),
4412 Item::Gauge(gauge) => lower_gauge(gauge, &mut ir, &mut diagnostics),
4413 Item::Mark(mark) => lower_mark(mark, &mut ir, &mut diagnostics),
4414 Item::Campaign(campaign) => lower_campaign(campaign, &mut ir, &mut diagnostics),
4415 Item::FileStore(file_store) => {
4419 if let Some(provider) = &file_store.provider {
4426 if !FILE_STORE_PROVIDERS.contains(&provider.name.as_str()) {
4427 diagnostics.push(Diagnostic {
4428 related: Vec::new(),
4429 span: provider.span,
4430 message: format!(
4431 "file store `{}` names unknown provider `{}`",
4432 file_store.name.name, provider.name
4433 ),
4434 suggestion: Some(format!(
4435 "declare one of the v1 file providers: {}",
4436 FILE_STORE_PROVIDERS.join(", ")
4437 )),
4438 });
4439 }
4442 }
4443 ir.file_stores.push(IrFileStore {
4444 name: file_store.name.name,
4445 root: file_store.root,
4446 read_globs: file_store.read_globs,
4447 write_globs: file_store.write_globs,
4448 provider: file_store.provider.map(|provider| provider.name),
4449 });
4450 }
4451 Item::MemoryPool(pool) => {
4455 ir.memory_pools.push(IrMemoryPool {
4456 name: pool.name.name,
4457 context_limit: pool.context_limit,
4458 });
4459 }
4460 Item::Agent(agent) => lower_agent(agent, &mut ir, &harness_kinds, &mut diagnostics),
4461 Item::Enum(enum_decl) => lower_enum(enum_decl, &mut ir, &mut diagnostics),
4462 Item::Event(event) => lower_event(event, &mut ir, &mut diagnostics),
4463 Item::Source(source) => {
4464 validate_source_emit_signal_declared(
4465 &source,
4466 &semantic.schemas.events,
4467 &mut diagnostics,
4468 );
4469 lower_source(*source, &mut ir, &mut diagnostics)
4470 }
4471 Item::Test(test) => lower_test(test, &mut ir, &mut diagnostics),
4472 Item::Lease(lease) => {
4473 if !schema_names.contains(&lease.key_type.name) {
4474 diagnostics.push(Diagnostic {
4475 related: Vec::new(),
4476 span: lease.key_type.span,
4477 message: format!(
4478 "lease `{}` keys on undeclared type `{}`",
4479 lease.name.name, lease.key_type.name
4480 ),
4481 suggestion: Some(
4482 "key a lease on an entity class the workflow already models".to_owned(),
4483 ),
4484 });
4485 }
4486 ir.leases.push(IrLease {
4487 name: lease.name.name,
4488 key_type: lease.key_type.name,
4489 slots: lease.slots.max(1),
4490 ttl_seconds: lease.ttl_seconds,
4491 shared: lease.shared,
4492 span: lease.span,
4493 });
4494 }
4495 Item::Ledger(ledger) => {
4496 if !schema_names.contains(&ledger.entry_schema.name) {
4497 diagnostics.push(Diagnostic {
4498 related: Vec::new(),
4499 span: ledger.entry_schema.span,
4500 message: format!(
4501 "ledger `{}` records undeclared entry type `{}`",
4502 ledger.name.name, ledger.entry_schema.name
4503 ),
4504 suggestion: Some("declare the entry class before the ledger".to_owned()),
4505 });
4506 }
4507 ir.ledgers.push(IrLedger {
4508 name: ledger.name.name,
4509 entry_schema: ledger.entry_schema.name,
4510 partition_field: ledger.partition_field.name,
4511 retain_seconds: ledger.retain_seconds,
4512 shared: ledger.shared,
4513 span: ledger.span,
4514 });
4515 }
4516 Item::Counter(counter) => {
4517 if !schema_names.contains(&counter.key_type.name) {
4518 diagnostics.push(Diagnostic {
4519 related: Vec::new(),
4520 span: counter.key_type.span,
4521 message: format!(
4522 "counter `{}` keys on undeclared type `{}`",
4523 counter.name.name, counter.key_type.name
4524 ),
4525 suggestion: Some(
4526 "key a counter on an entity class the workflow already models"
4527 .to_owned(),
4528 ),
4529 });
4530 }
4531 ir.counters.push(IrCounter {
4532 name: counter.name.name,
4533 key_type: counter.key_type.name,
4534 cap: counter.cap,
4535 reset: counter.reset,
4536 timezone: counter.timezone,
4537 shared: counter.shared,
4538 span: counter.span,
4539 });
4540 }
4541 Item::Class(class_decl) => lower_class(
4542 class_decl,
4543 &mut ir,
4544 &schema_names,
4545 &agent_names,
4546 &mut diagnostics,
4547 ),
4548 Item::Table(table) => {
4549 lower_source_tags(&table.tags, "table", &table.name.name, &mut ir);
4550 lower_source_description(
4551 table.description.as_ref(),
4552 "table",
4553 &table.name.name,
4554 &mut ir,
4555 );
4556 lower_table(
4557 table,
4558 &semantic,
4559 &workflow_contract_names,
4560 &mut ir,
4561 &mut diagnostics,
4562 )
4563 }
4564 Item::Coerce(coerce) => lower_coerce(
4565 coerce,
4566 &mut ir,
4567 &schema_names,
4568 &agent_names,
4569 &mut diagnostics,
4570 ),
4571 Item::Assert(assertion) => {
4572 let assertion_target = stable_hash(&assertion.expr);
4573 lower_source_tags(&assertion.tags, "assertion", &assertion_target, &mut ir);
4574 lower_source_description(
4575 assertion.description.as_ref(),
4576 "assertion",
4577 &assertion_target,
4578 &mut ir,
4579 );
4580 lower_assert(assertion, &semantic, &mut ir, &mut diagnostics)
4581 }
4582 Item::Rule(rule) => {
4583 lower_source_tags(&rule.tags, "rule", &rule.name.name, &mut ir);
4584 lower_source_description(
4585 rule.description.as_ref(),
4586 "rule",
4587 &rule.name.name,
4588 &mut ir,
4589 );
4590 lower_rule(
4591 rule,
4592 &semantic,
4593 &workflow_contract_names,
4594 &mut ir,
4595 &mut diagnostics,
4596 )
4597 }
4598 }
4599 }
4600
4601 ir.rule_dependencies = build_rule_dependencies(&ir.rules);
4602 validate_turn_access_grant_file_operations(&ir, &mut diagnostics);
4603 validate_turn_access_grant_memory_operations(&ir, &mut diagnostics);
4604 for rule in &mut ir.rules {
4606 if let Some(region) = pending_regions.get(&rule.name) {
4607 rule.metadata.region = Some(region.clone());
4608 }
4609 }
4610 expand_source_emit_from(&mut ir, &mut diagnostics);
4611 validate_file_store_write_policy(&ir, &mut diagnostics);
4612 warn_inert_memory_grant_on_native_adapter(&ir, &mut warnings);
4613 warn_counter_without_timezone(&ir, &mut warnings);
4614 warn_unhandled_effect_failures(&ir, &mut warnings);
4615 validate_improve_declarations(&ir, &mut diagnostics);
4616
4617 CompileOutput {
4618 ir: diagnostics.is_empty().then_some(ir),
4619 diagnostics,
4620 warnings,
4621 }
4622}
4623
4624fn validate_turn_access_grant_file_operations(ir: &IrProgram, diagnostics: &mut Vec<Diagnostic>) {
4632 const FILE_OPERATIONS: [&str; 4] = ["read", "write", "import", "export"];
4633 let file_stores: BTreeSet<&str> = ir
4634 .file_stores
4635 .iter()
4636 .map(|store| store.name.as_str())
4637 .collect();
4638 for rule in &ir.rules {
4639 for effect in &rule.metadata.effects {
4640 for grant in &effect.access_grants {
4641 if !file_stores.contains(grant.resource.as_str()) {
4642 continue;
4643 }
4644 for op in &grant.operations {
4645 if !FILE_OPERATIONS.contains(&op.operation.as_str()) {
4646 diagnostics.push(Diagnostic { related: Vec::new(),
4647 span: effect.span,
4648 message: format!(
4649 "rule `{}` grants `{}` on file store `{}`, which is not a file operation",
4650 rule.name, op.operation, grant.resource
4651 ),
4652 suggestion: Some(
4653 "file-store grants allow `read`, `write`, `import`, or `export`"
4654 .to_owned(),
4655 ),
4656 });
4657 }
4658 }
4659 }
4660 }
4661 }
4662}
4663
4664fn validate_turn_access_grant_memory_operations(ir: &IrProgram, diagnostics: &mut Vec<Diagnostic>) {
4673 const MEMORY_OPERATIONS: [&str; 3] = ["recall", "learn", "curate"];
4674 let memory_pools: BTreeSet<&str> = ir
4675 .memory_pools
4676 .iter()
4677 .map(|pool| pool.name.as_str())
4678 .collect();
4679 for rule in &ir.rules {
4680 for effect in &rule.metadata.effects {
4681 for grant in &effect.access_grants {
4682 if !memory_pools.contains(grant.resource.as_str()) {
4683 continue;
4684 }
4685 for op in &grant.operations {
4686 if !MEMORY_OPERATIONS.contains(&op.operation.as_str()) {
4687 diagnostics.push(Diagnostic {
4688 related: Vec::new(),
4689 span: effect.span,
4690 message: format!(
4691 "rule `{}` grants `{}` on memory pool `{}`, which is not a memory operation",
4692 rule.name, op.operation, grant.resource
4693 ),
4694 suggestion: Some(
4695 "memory-pool grants allow `recall`, `learn`, or `curate`".to_owned(),
4696 ),
4697 });
4698 }
4699 }
4700 }
4701 }
4702 }
4703}
4704
4705fn validate_file_store_write_policy(ir: &IrProgram, diagnostics: &mut Vec<Diagnostic>) {
4714 let read_only: BTreeSet<&str> = ir
4715 .file_stores
4716 .iter()
4717 .filter(|store| store.write_globs.is_empty())
4718 .map(|store| store.name.as_str())
4719 .collect();
4720 if read_only.is_empty() {
4721 return;
4722 }
4723 fn walk(
4724 statements: &[body::BodyStmt],
4725 rule_name: &str,
4726 read_only: &BTreeSet<&str>,
4727 diagnostics: &mut Vec<Diagnostic>,
4728 ) {
4729 for statement in statements {
4730 match statement {
4731 body::BodyStmt::Effect(effect) => {
4732 let store = match &effect.kind {
4733 body::BodyEffectKind::FileWrite { store, .. }
4734 | body::BodyEffectKind::FileExport { store, .. } => Some(store),
4735 _ => None,
4736 };
4737 if let Some(store) = store {
4738 if read_only.contains(store.as_str()) {
4739 diagnostics.push(Diagnostic {
4740 related: Vec::new(),
4741 span: effect.span,
4742 message: format!(
4743 "rule `{rule_name}` writes to store `{store}`, which permits \
4744 no writes — stores are read-only by default"
4745 ),
4746 suggestion: Some(format!(
4747 "declare `allow write [\"<glob>\", …]` on `file store {store}` \
4748 to permit (and bound) writes"
4749 )),
4750 });
4751 }
4752 }
4753 }
4754 body::BodyStmt::After(after) => {
4755 walk(&after.body, rule_name, read_only, diagnostics)
4756 }
4757 body::BodyStmt::Case(case) => {
4758 for branch in &case.branches {
4759 walk(&branch.body, rule_name, read_only, diagnostics);
4760 }
4761 }
4762 _ => {}
4763 }
4764 }
4765 }
4766 for rule in &ir.rules {
4767 let (ast, _) = body::parse_rule_body(&rule.body, 0);
4768 walk(&ast.statements, &rule.name, &read_only, diagnostics);
4769 }
4770}
4771
4772fn expand_source_emit_from(ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
4779 let events: BTreeMap<String, Vec<String>> = ir
4780 .events
4781 .iter()
4782 .map(|event| {
4783 (
4784 event.name.clone(),
4785 event
4786 .fields
4787 .iter()
4788 .map(|field| field.name.clone())
4789 .collect(),
4790 )
4791 })
4792 .collect();
4793 for source in &mut ir.sources {
4794 let Some(from) = source.emit_from.clone() else {
4795 continue;
4796 };
4797 if from != source.observe_binding {
4798 diagnostics.push(Diagnostic {
4799 related: Vec::new(),
4800 span: source.span,
4801 message: format!(
4802 "source `{}` emits `from {from}`, but the only binding in scope is the observe binding `{}`",
4803 source.name, source.observe_binding
4804 ),
4805 suggestion: Some(format!("write `emit {} from {}`", source.emit_signal, source.observe_binding)),
4806 });
4807 continue;
4808 }
4809 let Some(signal_fields) = events.get(&source.emit_signal) else {
4810 continue;
4812 };
4813 for field in signal_fields {
4814 if source
4815 .emit_fields
4816 .iter()
4817 .any(|existing| &existing.name == field)
4818 {
4819 continue;
4820 }
4821 source.emit_fields.push(IrSourceEmitField {
4822 name: field.clone(),
4823 value: SourceValue::Path {
4824 binding: Ident {
4825 name: from.clone(),
4826 span: source.span,
4827 },
4828 segments: vec![Ident {
4829 name: field.clone(),
4830 span: source.span,
4831 }],
4832 span: source.span,
4833 },
4834 span: source.span,
4835 });
4836 }
4837 }
4838}
4839
4840fn warn_unhandled_effect_failures(ir: &IrProgram, warnings: &mut Vec<Diagnostic>) {
4852 let service = ir
4853 .source_tags
4854 .iter()
4855 .any(|tag| tag.target_kind == "workflow" && tag.name == "service");
4856 if service {
4857 return;
4858 }
4859 for rule in &ir.rules {
4860 for effect in &rule.metadata.effects {
4861 let Some(binding) = effect.binding.as_deref() else {
4862 continue;
4863 };
4864 if effect.kind == IrEffectKind::TimerWait {
4865 continue;
4866 }
4867 if binding.starts_with(then_expand::THEN_BINDING_PREFIX) {
4870 continue;
4871 }
4872 let observed = rule.body.lines().any(|line| {
4873 let Some(rest) = line.trim().strip_prefix("after ") else {
4874 return false;
4875 };
4876 let mut parts = rest.split_whitespace();
4877 if parts.next() != Some(binding) {
4878 return false;
4879 }
4880 matches!(
4882 parts.next().map(|token| token.trim_end_matches('{')),
4883 Some("fails" | "times" | "completes" | "held" | "contended" | "ok" | "over")
4884 )
4885 });
4886 if observed {
4887 continue;
4888 }
4889 warnings.push(Diagnostic {
4890 related: Vec::new(),
4891 span: effect.span,
4892 message: format!(
4893 "effect `{binding}`'s failure is unhandled in rule `{}`; if it fails or \
4894 times out, the instance will auto-fail with a generic reason",
4895 rule.name
4896 ),
4897 suggestion: Some(format!(
4898 "handle it with `after {binding} fails {{ … }}` (typed failure or recovery) \
4899 or observe every outcome with `after {binding} completes`"
4900 )),
4901 });
4902 }
4903 }
4904}
4905
4906fn warn_counter_without_timezone(ir: &IrProgram, warnings: &mut Vec<Diagnostic>) {
4907 for counter in &ir.counters {
4908 if counter.timezone.is_none() {
4909 warnings.push(Diagnostic {
4910 related: Vec::new(),
4911 span: counter.span,
4912 message: format!(
4913 "counter `{}` declares no `timezone`; its `{}` reset boundary anchors to UTC",
4914 counter.name, counter.reset
4915 ),
4916 suggestion: Some(
4917 "declare `timezone \"<IANA zone>\"` (e.g. `timezone \"America/New_York\"`) to anchor the period locally"
4918 .to_owned(),
4919 ),
4920 });
4921 }
4922 }
4923}
4924
4925fn warn_inert_memory_grant_on_native_adapter(ir: &IrProgram, warnings: &mut Vec<Diagnostic>) {
4930 let memory_pools: BTreeSet<&str> = ir
4931 .memory_pools
4932 .iter()
4933 .map(|pool| pool.name.as_str())
4934 .collect();
4935 if memory_pools.is_empty() {
4936 return;
4937 }
4938 let harness_kind_of: BTreeMap<&str, &str> = ir
4939 .harnesses
4940 .iter()
4941 .map(|harness| (harness.name.as_str(), harness.kind.as_str()))
4942 .collect();
4943 let agent_harness_kind: BTreeMap<&str, &str> = ir
4944 .agents
4945 .iter()
4946 .filter_map(|agent| {
4947 let harness = agent.harness.as_deref()?;
4948 Some((agent.name.as_str(), *harness_kind_of.get(harness)?))
4949 })
4950 .collect();
4951 for rule in &ir.rules {
4952 for effect in &rule.metadata.effects {
4953 let Some(agent) = effect.agent.as_deref() else {
4954 continue;
4955 };
4956 let Some(kind) = agent_harness_kind.get(agent) else {
4957 continue;
4958 };
4959 if !matches!(*kind, "codex" | "claude" | "command") {
4960 continue;
4961 }
4962 for grant in &effect.access_grants {
4963 if memory_pools.contains(grant.resource.as_str()) {
4964 warnings.push(Diagnostic {
4965 related: Vec::new(),
4966 span: effect.span,
4967 message: format!(
4968 "rule `{}` grants memory pool `{}` on a tell to `{agent}`, whose \
4969 harness kind `{kind}` is a native adapter — memory grants only \
4970 take effect on the owned harness, so this grant is inert",
4971 rule.name, grant.resource
4972 ),
4973 suggestion: Some(
4974 "target an owned-harness agent, or drop the memory grant".to_owned(),
4975 ),
4976 });
4977 }
4978 }
4979 }
4980 }
4981}
4982
4983fn lower_source_tags(tags: &[TagDecl], target_kind: &str, target: &str, ir: &mut IrProgram) {
4984 for tag in tags {
4985 ir.source_tags.push(IrSourceTag {
4986 name: tag.name.clone(),
4987 target_kind: target_kind.to_owned(),
4988 target: target.to_owned(),
4989 span: tag.span,
4990 });
4991 }
4992}
4993
4994fn lower_source_description(
4995 description: Option<&StringLiteral>,
4996 target_kind: &str,
4997 target: &str,
4998 ir: &mut IrProgram,
4999) {
5000 if let Some(description) = description {
5001 ir.source_descriptions.push(IrSourceDescription {
5002 value: description.value.clone(),
5003 target_kind: target_kind.to_owned(),
5004 target: target.to_owned(),
5005 span: description.span,
5006 });
5007 }
5008}
5009
5010fn detect_pattern_recursion(
5020 patterns: &BTreeMap<String, PatternDecl>,
5021 diagnostics: &mut Vec<Diagnostic>,
5022) -> BTreeSet<String> {
5023 let mut edges: BTreeMap<&str, Vec<(&str, SourceSpan)>> = BTreeMap::new();
5025 for pattern in patterns.values() {
5026 let mut applied = Vec::new();
5027 for item in &pattern.items {
5028 if let Item::Apply(apply) = item {
5029 applied.push((apply.pattern.name.as_str(), apply.span));
5030 }
5031 }
5032 edges.insert(pattern.name.name.as_str(), applied);
5033 }
5034
5035 let find_cycle = |start: &str| -> Option<(Vec<String>, SourceSpan)> {
5038 let mut queue: VecDeque<&str> = VecDeque::new();
5039 let mut predecessor: BTreeMap<&str, (&str, SourceSpan)> = BTreeMap::new();
5041 for &(target, span) in edges.get(start).into_iter().flatten() {
5042 if target == start {
5043 return Some((vec![start.to_owned(), start.to_owned()], span));
5045 }
5046 if predecessor.insert(target, (start, span)).is_none() {
5047 queue.push_back(target);
5048 }
5049 }
5050 while let Some(node) = queue.pop_front() {
5051 for &(target, span) in edges.get(node).into_iter().flatten() {
5052 if target == start {
5053 let mut path = vec![node.to_owned()];
5055 let mut cursor = node;
5056 while cursor != start {
5057 let (from, _) = predecessor[cursor];
5058 path.push(from.to_owned());
5059 cursor = from;
5060 }
5061 path.reverse();
5062 path.push(start.to_owned());
5063 let first = &path[1];
5065 let entry_span = edges
5066 .get(start)
5067 .into_iter()
5068 .flatten()
5069 .find(|(target, _)| target == first)
5070 .map(|(_, span)| *span)
5071 .unwrap_or(span);
5072 return Some((path, entry_span));
5073 }
5074 if predecessor.insert(target, (node, span)).is_none() {
5075 queue.push_back(target);
5076 }
5077 }
5078 }
5079 None
5080 };
5081
5082 let mut recursive = BTreeSet::new();
5083 for name in patterns.keys() {
5086 if recursive.contains(name) {
5087 continue;
5088 }
5089 if let Some((cycle, span)) = find_cycle(name) {
5090 for member in &cycle {
5091 recursive.insert(member.clone());
5092 }
5093 diagnostics.push(Diagnostic { related: Vec::new(),
5094 span,
5095 message: format!(
5096 "recursive pattern application is not allowed (graph.unbounded_pattern_recursion): expansion cycle {}",
5097 cycle.join(" -> ")
5098 ),
5099 suggestion: Some(
5100 "break the cycle: pattern expansion must elaborate into a finite program"
5101 .to_owned(),
5102 ),
5103 });
5104 }
5105 }
5106 recursive
5107}
5108
5109fn detect_workflow_invoke_recursion(program: &Program, diagnostics: &mut Vec<Diagnostic>) {
5120 let mut edges: BTreeMap<String, Vec<(String, SourceSpan)>> = BTreeMap::new();
5125 let record_invokes =
5126 |name: &str, items: &[Item], edges: &mut BTreeMap<String, Vec<(String, SourceSpan)>>| {
5127 let entry = edges.entry(name.to_owned()).or_default();
5128 for item in items {
5129 let Item::Rule(rule) = item else {
5130 continue;
5131 };
5132 for statement in workflow_invoke_statements(&rule.body.text) {
5133 if let Some((target, _)) = invoke_statement_parts(&statement) {
5134 if target != name {
5135 entry.push((target.to_owned(), rule.body.span));
5136 }
5137 }
5138 }
5139 }
5140 };
5141 if let Some(root) = &program.workflow {
5142 record_invokes(&root.name, &program.items, &mut edges);
5143 }
5144 for workflow in &program.workflows {
5145 record_invokes(&workflow.name.name, &workflow.items, &mut edges);
5146 }
5147
5148 let find_cycle = |start: &str| -> Option<(Vec<String>, SourceSpan)> {
5151 let mut queue: VecDeque<&str> = VecDeque::new();
5152 let mut predecessor: BTreeMap<&str, (&str, SourceSpan)> = BTreeMap::new();
5153 for (target, span) in edges.get(start).into_iter().flatten() {
5154 if predecessor
5155 .insert(target.as_str(), (start, *span))
5156 .is_none()
5157 {
5158 queue.push_back(target.as_str());
5159 }
5160 }
5161 while let Some(node) = queue.pop_front() {
5162 for (target, span) in edges.get(node).into_iter().flatten() {
5163 if target == start {
5164 let mut path = vec![node.to_owned()];
5165 let mut cursor = node;
5166 while cursor != start {
5167 let (from, _) = predecessor[cursor];
5168 path.push(from.to_owned());
5169 cursor = from;
5170 }
5171 path.reverse();
5172 path.push(start.to_owned());
5173 let first = &path[1];
5174 let entry_span = edges
5175 .get(start)
5176 .into_iter()
5177 .flatten()
5178 .find(|(target, _)| target == first)
5179 .map(|(_, span)| *span)
5180 .unwrap_or(*span);
5181 return Some((path, entry_span));
5182 }
5183 if predecessor.insert(target.as_str(), (node, *span)).is_none() {
5184 queue.push_back(target.as_str());
5185 }
5186 }
5187 }
5188 None
5189 };
5190
5191 let mut flagged: BTreeSet<String> = BTreeSet::new();
5192 for name in edges.keys() {
5193 if flagged.contains(name) {
5194 continue;
5195 }
5196 if let Some((cycle, span)) = find_cycle(name) {
5197 for member in &cycle {
5198 flagged.insert(member.clone());
5199 }
5200 diagnostics.push(Diagnostic {
5201 related: Vec::new(),
5202 span,
5203 message: format!(
5204 "recursive workflow invocation is not allowed (graph.unbounded_workflow_invocation_recursion): invocation cycle {}",
5205 cycle.join(" -> ")
5206 ),
5207 suggestion: Some(
5208 "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"
5209 .to_owned(),
5210 ),
5211 });
5212 }
5213 }
5214}
5215
5216fn detect_private_workflow_invocations(program: &Program, diagnostics: &mut Vec<Diagnostic>) {
5217 let private_workflows = program
5218 .workflows
5219 .iter()
5220 .filter(|workflow| workflow.tags.iter().any(|tag| tag.name == "private"))
5221 .map(|workflow| workflow.name.name.as_str())
5222 .collect::<BTreeSet<_>>();
5223 if private_workflows.is_empty() {
5224 return;
5225 }
5226
5227 let mut record_private_invokes = |caller: &str, items: &[Item]| {
5228 for item in items {
5229 let Item::Rule(rule) = item else {
5230 continue;
5231 };
5232 for statement in workflow_invoke_statements(&rule.body.text) {
5233 let Some((target, _)) = invoke_statement_parts(&statement) else {
5234 continue;
5235 };
5236 if caller == target || !private_workflows.contains(target) {
5237 continue;
5238 }
5239 diagnostics.push(Diagnostic {
5240 related: Vec::new(),
5241 span: rule.body.span,
5242 message: format!(
5243 "rule `{}` invokes private workflow `{target}`",
5244 rule.name.name
5245 ),
5246 suggestion: Some(
5247 "remove `@private` from the target workflow or expose a public wrapper workflow"
5248 .to_owned(),
5249 ),
5250 });
5251 }
5252 }
5253 };
5254
5255 if let Some(root) = &program.workflow {
5256 record_private_invokes(&root.name, &program.items);
5257 }
5258 for workflow in &program.workflows {
5259 record_private_invokes(&workflow.name.name, &workflow.items);
5260 }
5261}
5262
5263fn expand_pattern_applications(
5264 mut program: Program,
5265 diagnostics: &mut Vec<Diagnostic>,
5266) -> (Program, Vec<IrPatternApplication>) {
5267 let mut patterns = BTreeMap::new();
5268 for pattern in &program.patterns {
5269 if patterns
5270 .insert(pattern.name.name.clone(), pattern.clone())
5271 .is_some()
5272 {
5273 diagnostics.push(Diagnostic {
5274 related: Vec::new(),
5275 span: pattern.name.span,
5276 message: format!("pattern `{}` is declared more than once", pattern.name.name),
5277 suggestion: Some("rename one pattern declaration".to_owned()),
5278 });
5279 }
5280 }
5281
5282 let recursive_patterns = detect_pattern_recursion(&patterns, diagnostics);
5289
5290 let mut expanded_items = Vec::new();
5291 let mut applications = Vec::new();
5292 for item in program.items {
5293 let Item::Apply(apply) = item else {
5294 expanded_items.push(item);
5295 continue;
5296 };
5297 let Some(pattern) = patterns.get(&apply.pattern.name) else {
5298 diagnostics.push(Diagnostic {
5299 related: Vec::new(),
5300 span: apply.pattern.span,
5301 message: format!("pattern `{}` was not found", apply.pattern.name),
5302 suggestion: Some("declare the pattern before applying it".to_owned()),
5303 });
5304 continue;
5305 };
5306 if pattern.type_params.len() != apply.type_args.len() {
5307 diagnostics.push(Diagnostic {
5308 related: Vec::new(),
5309 span: apply.span,
5310 message: format!(
5311 "pattern `{}` expects {} type arguments but got {}",
5312 pattern.name.name,
5313 pattern.type_params.len(),
5314 apply.type_args.len()
5315 ),
5316 suggestion: Some("match the pattern type parameter list".to_owned()),
5317 });
5318 continue;
5319 }
5320 let type_substitutions = pattern
5321 .type_params
5322 .iter()
5323 .map(|param| param.name.clone())
5324 .zip(apply.type_args.iter().cloned())
5325 .collect::<BTreeMap<_, _>>();
5326 let value_substitutions = parse_pattern_value_arguments(&apply, diagnostics);
5327 let local_names = pattern_local_names(pattern, &apply.alias.name);
5328 let definition_span = pattern.span;
5329 let application_span = apply.span;
5330 let mut generated = Vec::new();
5331 for pattern_item in pattern.items.iter().cloned() {
5332 if let Some(diagnostic) = pattern_body_admission(&pattern_item, &recursive_patterns) {
5336 diagnostics.push(diagnostic);
5337 continue;
5338 }
5339 if let Some((generated_name, item)) = expand_pattern_item(
5340 pattern_item,
5341 &apply.alias.name,
5342 &type_substitutions,
5343 &value_substitutions,
5344 &local_names,
5345 ) {
5346 generated.push(generated_name);
5347 expanded_items.push(item);
5348 }
5349 }
5350 applications.push(IrPatternApplication {
5351 pattern: pattern.name.name.clone(),
5352 alias: apply.alias.name,
5353 type_args: apply.type_args.into_iter().map(lower_type).collect(),
5354 value_args: value_substitutions
5355 .into_iter()
5356 .map(|(name, value)| IrPatternArgument { name, value })
5357 .collect(),
5358 generated,
5359 definition_span,
5360 application_span,
5361 });
5362 }
5363 program.items = expanded_items;
5364 (program, applications)
5365}
5366
5367fn pattern_local_names(pattern: &PatternDecl, alias: &str) -> BTreeMap<String, String> {
5368 let mut names = BTreeMap::new();
5369 for item in &pattern.items {
5370 match item {
5371 Item::Harness(harness) => {
5372 names.insert(
5373 harness.name.name.clone(),
5374 generated_pattern_name(alias, &harness.name.name),
5375 );
5376 }
5377 Item::Agent(agent) => {
5378 names.insert(
5379 agent.name.name.clone(),
5380 generated_pattern_name(alias, &agent.name.name),
5381 );
5382 }
5383 Item::Enum(enum_decl) => {
5384 names.insert(
5385 enum_decl.name.name.clone(),
5386 generated_pattern_name(alias, &enum_decl.name.name),
5387 );
5388 }
5389 Item::Class(class_decl) => {
5390 names.insert(
5391 class_decl.name.name.clone(),
5392 generated_pattern_name(alias, &class_decl.name.name),
5393 );
5394 }
5395 Item::Coerce(coerce) => {
5396 names.insert(
5397 coerce.name.name.clone(),
5398 generated_pattern_name(alias, &coerce.name.name),
5399 );
5400 }
5401 Item::Rule(rule) => {
5402 names.insert(
5403 rule.name.name.clone(),
5404 generated_pattern_name(alias, &rule.name.name),
5405 );
5406 }
5407 _ => {}
5408 }
5409 }
5410 names
5411}
5412
5413fn generated_pattern_name(alias: &str, name: &str) -> String {
5414 format!("{alias}_{name}")
5415}
5416
5417fn parse_pattern_value_arguments(
5418 apply: &ApplyDecl,
5419 diagnostics: &mut Vec<Diagnostic>,
5420) -> BTreeMap<String, String> {
5421 let mut args = BTreeMap::new();
5422 for line in apply
5423 .body
5424 .text
5425 .lines()
5426 .map(str::trim)
5427 .filter(|line| !line.is_empty())
5428 {
5429 let mut parts = line.splitn(2, char::is_whitespace);
5430 let Some(name) = parts.next().filter(|name| is_identifier(name)) else {
5431 diagnostics.push(Diagnostic {
5432 related: Vec::new(),
5433 span: apply.body.span,
5434 message: format!(
5435 "pattern application `{}` has malformed argument `{line}`",
5436 apply.alias.name
5437 ),
5438 suggestion: Some("write pattern arguments as `name value`".to_owned()),
5439 });
5440 continue;
5441 };
5442 let Some(value) = parts
5443 .next()
5444 .map(str::trim)
5445 .filter(|value| !value.is_empty())
5446 else {
5447 diagnostics.push(Diagnostic {
5448 related: Vec::new(),
5449 span: apply.body.span,
5450 message: format!(
5451 "pattern application `{}` argument `{name}` is missing a value",
5452 apply.alias.name
5453 ),
5454 suggestion: Some("write pattern arguments as `name value`".to_owned()),
5455 });
5456 continue;
5457 };
5458 if args.insert(name.to_owned(), value.to_owned()).is_some() {
5459 diagnostics.push(Diagnostic {
5460 related: Vec::new(),
5461 span: apply.body.span,
5462 message: format!(
5463 "pattern application `{}` passes argument `{name}` more than once",
5464 apply.alias.name
5465 ),
5466 suggestion: Some("remove the duplicate pattern argument".to_owned()),
5467 });
5468 }
5469 }
5470 args
5471}
5472
5473fn pattern_body_admission(
5488 item: &Item,
5489 recursive_patterns: &BTreeSet<String>,
5490) -> Option<Diagnostic> {
5491 match item {
5492 Item::WorkflowContract(contract) => Some(Diagnostic {
5493 related: Vec::new(),
5494 span: contract.span,
5495 message: "workflow contracts are not allowed in pattern bodies".to_owned(),
5496 suggestion: Some(
5497 "declare workflow inputs, outputs, and failures on the workflow".to_owned(),
5498 ),
5499 }),
5500 Item::Pattern(pattern) => Some(Diagnostic {
5501 related: Vec::new(),
5502 span: pattern.span,
5503 message: "nested pattern declarations are not supported in pattern bodies".to_owned(),
5504 suggestion: Some("declare reusable patterns at source top level".to_owned()),
5505 }),
5506 Item::Apply(apply) if !recursive_patterns.contains(&apply.pattern.name) => Some(Diagnostic {
5510 related: Vec::new(),
5511 span: apply.span,
5512 message: "pattern applications inside pattern bodies are not supported yet".to_owned(),
5513 suggestion: Some(
5514 "apply patterns from workflow bodies only in this implementation slice".to_owned(),
5515 ),
5516 }),
5517 Item::Gauge(gauge) => Some(Diagnostic {
5521 related: Vec::new(),
5522 span: gauge.span,
5523 message: "gauge declarations are not allowed in pattern bodies".to_owned(),
5524 suggestion: Some("declare gauges at source top level".to_owned()),
5525 }),
5526 Item::Campaign(campaign) => Some(Diagnostic {
5527 related: Vec::new(),
5528 span: campaign.span,
5529 message: "campaign declarations are not allowed in pattern bodies".to_owned(),
5530 suggestion: Some("declare campaigns at source top level".to_owned()),
5531 }),
5532 Item::Mark(mark) => Some(Diagnostic {
5533 related: Vec::new(),
5534 span: mark.span,
5535 message: "mark declarations are not allowed in pattern bodies".to_owned(),
5536 suggestion: Some("declare marks at source top level".to_owned()),
5537 }),
5538 Item::Rule(rule) => pattern_rule_terminal_span(rule).map(|span| Diagnostic {
5539 related: Vec::new(),
5540 span,
5541 message: format!(
5542 "rule `{}` in a pattern body cannot reach a workflow terminal (`complete`/`fail`)",
5543 rule.name.name
5544 ),
5545 suggestion: Some(
5546 "record a fact in the pattern rule and let a workflow rule decide the terminal outcome"
5547 .to_owned(),
5548 ),
5549 }),
5550 _ => None,
5551 }
5552}
5553
5554fn pattern_rule_terminal_span(rule: &RuleDecl) -> Option<SourceSpan> {
5557 let mut offset = 0usize;
5558 for line in rule.body.text.split_inclusive('\n') {
5559 let trimmed_start = line.trim_start();
5560 let leading = line.len() - trimmed_start.len();
5561 let statement = trimmed_start.trim_end();
5562 if is_pattern_terminal_statement(statement) {
5563 let start = rule.body.span.start + offset + leading;
5564 return Some(SourceSpan {
5565 start,
5566 end: start + statement.len(),
5567 });
5568 }
5569 offset += line.len();
5570 }
5571 None
5572}
5573
5574fn is_pattern_terminal_statement(line: &str) -> bool {
5577 for keyword in ["complete", "fail"] {
5578 if let Some(rest) = line.strip_prefix(keyword) {
5579 if rest.is_empty() || rest.starts_with('{') || rest.starts_with(char::is_whitespace) {
5580 return true;
5581 }
5582 }
5583 }
5584 false
5585}
5586
5587fn expand_pattern_item(
5588 item: Item,
5589 alias: &str,
5590 type_substitutions: &BTreeMap<String, TypeSyntax>,
5591 value_substitutions: &BTreeMap<String, String>,
5592 local_names: &BTreeMap<String, String>,
5593) -> Option<(String, Item)> {
5594 match item {
5595 Item::Include(include) => Some((
5596 format!("include:{}", include.path.value),
5597 Item::Include(include),
5598 )),
5599 Item::Use(use_decl) => Some((format!("use:{}", use_decl.name.value), Item::Use(use_decl))),
5600 Item::Tracker(queue) => {
5601 Some((format!("tracker:{}", queue.name.name), Item::Tracker(queue)))
5602 }
5603 Item::Channel(channel) => Some((
5604 format!("channel:{}", channel.name.name),
5605 Item::Channel(channel),
5606 )),
5607 Item::Gauge(_) | Item::Campaign(_) | Item::Mark(_) => None,
5611 Item::FileStore(file_store) => Some((
5612 format!("file-store:{}", file_store.name.name),
5613 Item::FileStore(file_store),
5614 )),
5615 Item::MemoryPool(pool) => Some((
5616 format!("memory-pool:{}", pool.name.name),
5617 Item::MemoryPool(pool),
5618 )),
5619 Item::Event(event) => Some((format!("event:{}", event.name), Item::Event(event))),
5620 Item::Source(source) => {
5621 Some((format!("source:{}", source.name.name), Item::Source(source)))
5622 }
5623 Item::Test(test) => Some((format!("test:{}", test.name.value), Item::Test(test))),
5624 Item::Lease(lease) => Some((format!("lease:{}", lease.name.name), Item::Lease(lease))),
5625 Item::Ledger(ledger) => {
5626 Some((format!("ledger:{}", ledger.name.name), Item::Ledger(ledger)))
5627 }
5628 Item::Counter(counter) => Some((
5629 format!("counter:{}", counter.name.name),
5630 Item::Counter(counter),
5631 )),
5632 Item::Action(action) => {
5633 Some((format!("action:{}", action.name.name), Item::Action(action)))
5634 }
5635 Item::Harness(mut harness) => {
5636 let name = rename_ident(harness.name, alias, local_names);
5637 let generated = format!("harness:{}", name.name);
5638 harness.name = name;
5639 Some((generated, Item::Harness(harness)))
5640 }
5641 Item::WorkflowContract(_) | Item::Pattern(_) | Item::Apply(_) => None,
5645 Item::Agent(mut agent) => {
5646 let name = rename_ident(agent.name, alias, local_names);
5647 let generated = format!("agent:{}", name.name);
5648 agent.name = name;
5649 if let Some(harness) = agent.harness {
5650 agent.harness = Some(Ident {
5651 name: local_names
5652 .get(&harness.name)
5653 .cloned()
5654 .unwrap_or(harness.name),
5655 span: harness.span,
5656 });
5657 }
5658 Some((generated, Item::Agent(agent)))
5659 }
5660 Item::Enum(mut enum_decl) => {
5661 let name = rename_ident(enum_decl.name, alias, local_names);
5662 let generated = format!("enum:{}", name.name);
5663 enum_decl.name = name;
5664 Some((generated, Item::Enum(enum_decl)))
5665 }
5666 Item::Class(mut class_decl) => {
5667 let name = rename_ident(class_decl.name, alias, local_names);
5668 let generated = format!("class:{}", name.name);
5669 class_decl.name = name;
5670 for field in &mut class_decl.fields {
5671 field.ty =
5672 substitute_pattern_type(field.ty.clone(), type_substitutions, local_names);
5673 }
5674 Some((generated, Item::Class(class_decl)))
5675 }
5676 Item::Table(mut table) => {
5677 let name = rename_ident(table.name, alias, local_names);
5678 let generated = format!("table:{}", name.name);
5679 table.name = name;
5680 for row in &mut table.rows {
5681 row.body.text = substitute_pattern_text(
5682 &row.body.text,
5683 type_substitutions,
5684 value_substitutions,
5685 local_names,
5686 );
5687 }
5688 Some((generated, Item::Table(table)))
5689 }
5690 Item::Coerce(mut coerce) => {
5691 let name = rename_ident(coerce.name, alias, local_names);
5692 let generated = format!("coerce:{}", name.name);
5693 coerce.name = name;
5694 for param in &mut coerce.params {
5695 param.ty =
5696 substitute_pattern_type(param.ty.clone(), type_substitutions, local_names);
5697 }
5698 coerce.output =
5699 substitute_pattern_type(coerce.output.clone(), type_substitutions, local_names);
5700 coerce.body.text = substitute_pattern_text(
5701 &coerce.body.text,
5702 type_substitutions,
5703 value_substitutions,
5704 local_names,
5705 );
5706 Some((generated, Item::Coerce(coerce)))
5707 }
5708 Item::Assert(mut assertion) => {
5709 assertion.expr = substitute_pattern_text(
5710 &assertion.expr,
5711 type_substitutions,
5712 value_substitutions,
5713 local_names,
5714 );
5715 Some((format!("assert:{alias}"), Item::Assert(assertion)))
5716 }
5717 Item::Rule(mut rule) => {
5718 let name = rename_ident(rule.name, alias, local_names);
5719 let generated = format!("rule:{}", name.name);
5720 rule.name = name;
5721 for when in &mut rule.whens {
5722 when.text = substitute_pattern_text(
5723 &when.text,
5724 type_substitutions,
5725 value_substitutions,
5726 local_names,
5727 );
5728 }
5729 rule.body.text = substitute_pattern_text(
5730 &rule.body.text,
5731 type_substitutions,
5732 value_substitutions,
5733 local_names,
5734 );
5735 Some((generated, Item::Rule(rule)))
5736 }
5737 }
5738}
5739
5740fn rename_ident(ident: Ident, alias: &str, local_names: &BTreeMap<String, String>) -> Ident {
5741 Ident {
5742 name: local_names
5743 .get(&ident.name)
5744 .cloned()
5745 .unwrap_or_else(|| generated_pattern_name(alias, &ident.name)),
5746 span: ident.span,
5747 }
5748}
5749
5750fn substitute_pattern_type(
5751 ty: TypeSyntax,
5752 type_substitutions: &BTreeMap<String, TypeSyntax>,
5753 local_names: &BTreeMap<String, String>,
5754) -> TypeSyntax {
5755 match ty {
5756 TypeSyntax::Ref { name } => {
5757 if let Some(replacement) = type_substitutions.get(&name.name) {
5758 return replacement.clone();
5759 }
5760 TypeSyntax::Ref {
5761 name: Ident {
5762 name: local_names.get(&name.name).cloned().unwrap_or(name.name),
5763 span: name.span,
5764 },
5765 }
5766 }
5767 TypeSyntax::AgentRef { agents, span } => TypeSyntax::AgentRef {
5768 agents: agents
5769 .into_iter()
5770 .map(|agent| Ident {
5771 name: local_names.get(&agent.name).cloned().unwrap_or(agent.name),
5772 span: agent.span,
5773 })
5774 .collect(),
5775 span,
5776 },
5777 TypeSyntax::Optional { inner, span } => TypeSyntax::Optional {
5778 inner: Box::new(substitute_pattern_type(
5779 *inner,
5780 type_substitutions,
5781 local_names,
5782 )),
5783 span,
5784 },
5785 TypeSyntax::Array { inner, span } => TypeSyntax::Array {
5786 inner: Box::new(substitute_pattern_type(
5787 *inner,
5788 type_substitutions,
5789 local_names,
5790 )),
5791 span,
5792 },
5793 TypeSyntax::Map { inner, span } => TypeSyntax::Map {
5794 inner: Box::new(substitute_pattern_type(
5795 *inner,
5796 type_substitutions,
5797 local_names,
5798 )),
5799 span,
5800 },
5801 TypeSyntax::Union { variants, span } => TypeSyntax::Union {
5802 variants: variants
5803 .into_iter()
5804 .map(|variant| substitute_pattern_type(variant, type_substitutions, local_names))
5805 .collect(),
5806 span,
5807 },
5808 other => other,
5809 }
5810}
5811
5812fn substitute_pattern_text(
5813 text: &str,
5814 type_substitutions: &BTreeMap<String, TypeSyntax>,
5815 value_substitutions: &BTreeMap<String, String>,
5816 local_names: &BTreeMap<String, String>,
5817) -> String {
5818 let mut output = text.to_owned();
5819 for (name, replacement) in type_substitutions {
5820 output = replace_identifier(&output, name, &replacement.to_source());
5821 }
5822 for (name, replacement) in local_names {
5823 output = replace_identifier(&output, name, replacement);
5824 }
5825 for (name, replacement) in value_substitutions {
5826 output = replace_identifier(&output, name, replacement);
5827 }
5828 output
5829}
5830
5831fn replace_identifier(source: &str, from: &str, to: &str) -> String {
5832 let mut output = String::new();
5833 let mut index = 0usize;
5834 while let Some(offset) = source[index..].find(from) {
5835 let start = index + offset;
5836 let end = start + from.len();
5837 output.push_str(&source[index..start]);
5838 let before = source[..start].chars().next_back();
5839 let after = source[end..].chars().next();
5840 if before.is_none_or(|ch| !is_identifier_char(ch))
5841 && after.is_none_or(|ch| !is_identifier_char(ch))
5842 {
5843 output.push_str(to);
5844 } else {
5845 output.push_str(&source[start..end]);
5846 }
5847 index = end;
5848 }
5849 output.push_str(&source[index..]);
5850 output
5851}
5852
5853fn is_identifier_char(ch: char) -> bool {
5854 ch.is_ascii_alphanumeric() || ch == '_' || ch == '-'
5855}
5856
5857fn lower_assert(
5858 assertion: AssertDecl,
5859 semantic: &SemanticContext,
5860 ir: &mut IrProgram,
5861 diagnostics: &mut Vec<Diagnostic>,
5862) {
5863 match parse_expression(&assertion.expr) {
5864 Ok(expr) => {
5865 validate_parsed_expression(
5866 &expr,
5867 semantic,
5868 &ExprScope::default(),
5869 &ExprValidationContext::assertion(assertion.span),
5870 "assertion",
5871 diagnostics,
5872 );
5873 let mut projection_reads = collect_projection_reads(&expr);
5874 sort_projection_reads(&mut projection_reads);
5875 ir.assertions.push(IrAssertion {
5876 expr: IrExpression {
5877 source: assertion.expr,
5878 expr,
5879 span: assertion.span,
5880 },
5881 projection_reads,
5882 });
5883 }
5884 Err(message) => diagnostics.push(Diagnostic {
5885 related: Vec::new(),
5886 span: assertion.span,
5887 message: format!("invalid assertion expression: {message}"),
5888 suggestion: Some(
5889 "use a deterministic expression such as `count(Fact) == 1`".to_owned(),
5890 ),
5891 }),
5892 }
5893}
5894
5895fn lower_expression(source: &str, span: SourceSpan) -> Option<IrExpression> {
5896 parse_expression(source).ok().map(|expr| IrExpression {
5897 source: source.to_owned(),
5898 expr,
5899 span,
5900 })
5901}
5902
5903fn collect_projection_reads(expr: &Expr) -> Vec<IrProjectionRead> {
5904 let mut reads = Vec::new();
5905 collect_projection_reads_into(expr, &mut reads);
5906 reads
5907}
5908
5909fn collect_projection_reads_into(expr: &Expr, reads: &mut Vec<IrProjectionRead>) {
5910 match expr {
5911 Expr::Literal(_) | Expr::Path(_) => {}
5912 Expr::Index { target, key } => {
5913 collect_projection_reads_into(target, reads);
5914 collect_projection_reads_into(key, reads);
5915 }
5916 Expr::Array(items) => {
5917 for item in items {
5918 collect_projection_reads_into(item, reads);
5919 }
5920 }
5921 Expr::Object(fields) => {
5922 for field in fields {
5923 collect_projection_reads_into(&field.value, reads);
5924 }
5925 }
5926 Expr::Unary { expr, .. } => collect_projection_reads_into(expr, reads),
5927 Expr::Binary { left, right, .. } => {
5928 collect_projection_reads_into(left, reads);
5929 collect_projection_reads_into(right, reads);
5930 }
5931 Expr::Call { args, .. } => {
5932 for arg in args {
5933 collect_projection_reads_into(arg, reads);
5934 }
5935 }
5936 Expr::Query { kind, head, guard } => {
5937 reads.push(IrProjectionRead {
5938 kind: *kind,
5939 head: head.clone(),
5940 guard: guard.as_ref().map(|guard| guard.to_snapshot()),
5941 });
5942 if let Some(guard) = guard {
5943 collect_projection_reads_into(guard, reads);
5944 }
5945 }
5946 }
5947}
5948
5949fn sort_projection_reads(reads: &mut Vec<IrProjectionRead>) {
5950 reads.sort_by_key(IrProjectionRead::to_snapshot);
5951 reads.dedup();
5952}
5953
5954fn collect_schema_names(program: &Program, diagnostics: &mut Vec<Diagnostic>) -> BTreeSet<String> {
5955 let mut names = BTreeSet::new();
5956 let mut first_spans: BTreeMap<String, SourceSpan> = BTreeMap::new();
5959 for item in &program.items {
5960 let name = match item {
5961 Item::Enum(enum_decl) => &enum_decl.name,
5962 Item::Class(class_decl) => &class_decl.name,
5963 _ => continue,
5964 };
5965
5966 if !names.insert(name.name.clone()) {
5967 let mut diagnostic = Diagnostic {
5968 related: Vec::new(),
5969 span: name.span,
5970 message: format!("schema `{}` is declared more than once", name.name),
5971 suggestion: Some("rename one declaration or merge the schemas".to_owned()),
5972 };
5973 if let Some(first) = first_spans.get(&name.name) {
5974 diagnostic = diagnostic.with_related(*first, "first declared here");
5975 }
5976 diagnostics.push(diagnostic);
5977 } else {
5978 first_spans.insert(name.name.clone(), name.span);
5979 }
5980 }
5981
5982 names
5983}
5984
5985fn collect_harness_kinds(
5986 program: &Program,
5987 diagnostics: &mut Vec<Diagnostic>,
5988) -> BTreeMap<String, String> {
5989 let mut kinds: BTreeMap<String, String> = BTreeMap::new();
5990 for item in &program.items {
5991 let Item::Harness(harness) = item else {
5992 continue;
5993 };
5994 if kinds
5995 .insert(harness.name.name.clone(), harness.kind.name.clone())
5996 .is_some()
5997 {
5998 diagnostics.push(Diagnostic {
5999 related: Vec::new(),
6000 span: harness.name.span,
6001 message: format!("harness `{}` is declared more than once", harness.name.name),
6002 suggestion: Some(
6003 "rename one harness declaration or merge the harness settings".to_owned(),
6004 ),
6005 });
6006 }
6007 }
6008 kinds
6009}
6010
6011fn collect_agent_names(program: &Program, diagnostics: &mut Vec<Diagnostic>) -> BTreeSet<String> {
6012 let mut names = BTreeSet::new();
6013 for item in &program.items {
6014 let Item::Agent(agent) = item else {
6015 continue;
6016 };
6017 if !names.insert(agent.name.name.clone()) {
6018 diagnostics.push(Diagnostic {
6019 related: Vec::new(),
6020 span: agent.name.span,
6021 message: format!("agent `{}` is declared more than once", agent.name.name),
6022 suggestion: Some("rename one agent declaration or merge the settings".to_owned()),
6023 });
6024 }
6025 }
6026 names
6027}
6028
6029#[derive(Clone, Debug, Default, Eq, PartialEq)]
6030struct WorkflowContractNames {
6031 inputs: BTreeMap<String, TypeSyntax>,
6032 outputs: BTreeMap<String, TypeSyntax>,
6033 failures: BTreeMap<String, TypeSyntax>,
6034}
6035
6036fn collect_workflow_contract_names(
6037 program: &Program,
6038 diagnostics: &mut Vec<Diagnostic>,
6039) -> WorkflowContractNames {
6040 let mut names = WorkflowContractNames::default();
6041 for item in &program.items {
6042 let Item::WorkflowContract(contract) = item else {
6043 continue;
6044 };
6045 let set = match contract.kind {
6046 WorkflowContractKind::Input => &mut names.inputs,
6047 WorkflowContractKind::Output => &mut names.outputs,
6048 WorkflowContractKind::Failure => &mut names.failures,
6049 };
6050 if set
6051 .insert(contract.name.name.clone(), contract.ty.clone())
6052 .is_some()
6053 {
6054 diagnostics.push(Diagnostic {
6055 related: Vec::new(),
6056 span: contract.name.span,
6057 message: format!(
6058 "workflow declares {} `{}` more than once",
6059 contract.kind.as_str(),
6060 contract.name.name
6061 ),
6062 suggestion: Some("remove the duplicate workflow contract".to_owned()),
6063 });
6064 }
6065 }
6066 names
6067}
6068
6069impl SemanticContext {
6070 fn from_program(
6071 program: &Program,
6072 workflow_inputs: BTreeMap<String, WorkflowInputSurface>,
6073 ) -> Self {
6074 let mut schemas = SchemaIndex::with_builtins();
6075 let mut agents = BTreeSet::new();
6076 let mut agent_capabilities = BTreeMap::new();
6077 let mut coerce_outputs = BTreeMap::new();
6078 let mut coerce_params = BTreeMap::new();
6079 let mut leases = BTreeSet::new();
6080 let mut ledgers = BTreeSet::new();
6081 let mut counters = BTreeSet::new();
6082 let mut channels = BTreeSet::new();
6083 let mut channel_providers = BTreeMap::new();
6084 let mut memory_pools = BTreeSet::new();
6085
6086 for item in &program.items {
6087 schemas.insert_item(item);
6088 match item {
6089 Item::Agent(agent) => {
6090 agents.insert(agent.name.name.clone());
6091 let capabilities = agent
6092 .fields
6093 .iter()
6094 .find_map(|field| match field {
6095 AgentField::Capabilities(capabilities, _) => Some(
6096 capabilities
6097 .iter()
6098 .map(|capability| capability.value.clone())
6099 .collect::<BTreeSet<_>>(),
6100 ),
6101 _ => None,
6102 })
6103 .unwrap_or_default();
6104 agent_capabilities.insert(agent.name.name.clone(), capabilities);
6105 }
6106 Item::Coerce(coerce) => {
6107 coerce_outputs.insert(coerce.name.name.clone(), coerce.output.clone());
6108 coerce_params.insert(coerce.name.name.clone(), coerce.params.clone());
6109 }
6110 Item::Lease(lease) => {
6111 leases.insert(lease.name.name.clone());
6112 }
6113 Item::Ledger(ledger) => {
6114 ledgers.insert(ledger.name.name.clone());
6115 }
6116 Item::Counter(counter) => {
6117 counters.insert(counter.name.name.clone());
6118 }
6119 Item::Channel(channel) => {
6120 channels.insert(channel.name.name.clone());
6121 channel_providers
6122 .insert(channel.name.name.clone(), channel.provider.name.clone());
6123 }
6124 Item::MemoryPool(pool) => {
6125 memory_pools.insert(pool.name.name.clone());
6126 }
6127 _ => {}
6128 }
6129 }
6130
6131 Self {
6132 workflow: program
6133 .workflow
6134 .as_ref()
6135 .map(|workflow| workflow.name.clone()),
6136 schemas,
6137 agents,
6138 agent_capabilities,
6139 coerce_outputs,
6140 coerce_params,
6141 workflow_inputs,
6142 leases,
6143 ledgers,
6144 counters,
6145 channels,
6146 channel_providers,
6147 memory_pools,
6148 }
6149 }
6150}
6151
6152fn collect_workflow_input_surfaces(program: &Program) -> BTreeMap<String, WorkflowInputSurface> {
6153 let mut surfaces = BTreeMap::new();
6154 let top_level_schemas = schema_index_for_items(&program.items);
6155
6156 if let Some(workflow) = &program.workflow {
6157 let inputs = workflow_inputs_for_items(&program.items);
6158 surfaces.insert(
6159 workflow.name.clone(),
6160 WorkflowInputSurface {
6161 inputs,
6162 outputs: workflow_outputs_for_items(&program.items),
6163 failures: workflow_failures_for_items(&program.items),
6164 schemas: top_level_schemas.clone(),
6165 milestones: collect_milestone_declarations(&program.items),
6166 },
6167 );
6168 }
6169
6170 for workflow in &program.workflows {
6171 let mut schemas = top_level_schemas.clone();
6172 schemas.merge(schema_index_for_items(&workflow.items));
6173 surfaces.insert(
6174 workflow.name.name.clone(),
6175 WorkflowInputSurface {
6176 inputs: workflow_inputs_for_items(&workflow.items),
6177 outputs: workflow_outputs_for_items(&workflow.items),
6178 failures: workflow_failures_for_items(&workflow.items),
6179 schemas,
6180 milestones: collect_milestone_declarations(&workflow.items),
6181 },
6182 );
6183 }
6184
6185 surfaces
6186}
6187
6188fn collect_shared_coordination_usage(program: &Program) -> Vec<IrSharedCoordinationUsage> {
6189 let global_shared = shared_coordination_declarations(&program.items);
6190 let mut usage: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
6191
6192 let mut record_workflow = |workflow_name: &str, local_items: &[Item]| {
6193 let mut shared = global_shared.clone();
6194 shared.extend(shared_coordination_declarations(local_items));
6195 if shared.is_empty() {
6196 return;
6197 }
6198 let principal = format!("workflow:local/{workflow_name}");
6199 for resource in coordination_resources_used_by_items(&program.items)
6200 .into_iter()
6201 .chain(coordination_resources_used_by_items(local_items))
6202 {
6203 if shared.contains(&resource) {
6204 usage.entry(resource).or_default().insert(principal.clone());
6205 }
6206 }
6207 };
6208
6209 if let Some(workflow) = &program.workflow {
6210 record_workflow(&workflow.name, &[]);
6211 }
6212 for workflow in &program.workflows {
6213 record_workflow(&workflow.name.name, &workflow.items);
6214 }
6215
6216 usage
6217 .into_iter()
6218 .map(|(resource, principals)| IrSharedCoordinationUsage {
6219 resource: format!("resource:{resource}"),
6220 workflow_principals: principals.into_iter().collect(),
6221 })
6222 .collect()
6223}
6224
6225fn shared_coordination_declarations(items: &[Item]) -> BTreeSet<String> {
6226 items
6227 .iter()
6228 .filter_map(|item| match item {
6229 Item::Lease(lease) if lease.shared => Some(lease.name.name.clone()),
6230 Item::Ledger(ledger) if ledger.shared => Some(ledger.name.name.clone()),
6231 Item::Counter(counter) if counter.shared => Some(counter.name.name.clone()),
6232 _ => None,
6233 })
6234 .collect()
6235}
6236
6237fn coordination_resources_used_by_items(items: &[Item]) -> BTreeSet<String> {
6238 let mut resources = BTreeSet::new();
6239 for item in items {
6240 let Item::Rule(rule) = item else {
6241 continue;
6242 };
6243 let (body, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
6244 collect_coordination_resources_from_statements(&body.statements, &mut resources);
6245 }
6246 resources
6247}
6248
6249fn collect_coordination_resources_from_statements(
6250 statements: &[body::BodyStmt],
6251 resources: &mut BTreeSet<String>,
6252) {
6253 for statement in statements {
6254 match statement {
6255 body::BodyStmt::Effect(effect) => match &effect.kind {
6256 body::BodyEffectKind::LeaseAcquire { resource, .. } => {
6257 resources.insert(resource.clone());
6258 }
6259 body::BodyEffectKind::LedgerAppend { ledger, .. } => {
6260 resources.insert(ledger.clone());
6261 }
6262 body::BodyEffectKind::CounterConsume { counter, .. } => {
6263 resources.insert(counter.clone());
6264 }
6265 _ => {}
6266 },
6267 body::BodyStmt::After(after) => {
6268 collect_coordination_resources_from_statements(&after.body, resources);
6269 }
6270 body::BodyStmt::Region(region) => {
6271 collect_coordination_resources_from_statements(®ion.body, resources);
6272 collect_coordination_resources_from_statements(®ion.lapse_body, resources);
6273 }
6274 body::BodyStmt::Case(case_stmt) => {
6275 for branch in &case_stmt.branches {
6276 collect_coordination_resources_from_statements(&branch.body, resources);
6277 }
6278 }
6279 body::BodyStmt::Record(_)
6280 | body::BodyStmt::Done { .. }
6281 | body::BodyStmt::Terminal(_)
6282 | body::BodyStmt::Cancel { .. }
6283 | body::BodyStmt::Milestone { .. }
6284 | body::BodyStmt::Redact { .. } => {}
6285 }
6286 }
6287}
6288
6289fn collect_milestone_declarations(items: &[Item]) -> BTreeMap<String, String> {
6295 let mut milestones = BTreeMap::new();
6296 for item in items {
6297 let Item::Rule(rule) = item else {
6298 continue;
6299 };
6300 for (name, class) in milestone_emissions_in_body(&rule.body.text) {
6301 milestones.entry(name).or_insert(class);
6302 }
6303 }
6304 milestones
6305}
6306
6307fn validate_milestone_statements(
6317 rule: &RuleDecl,
6318 semantic: &SemanticContext,
6319 diagnostics: &mut Vec<Diagnostic>,
6320) {
6321 for (name, class) in milestone_emissions_in_body(&rule.body.text) {
6324 if !class.is_empty() && !semantic.schemas.class_exists(&class) {
6325 diagnostics.push(Diagnostic {
6326 related: Vec::new(),
6327 span: rule.body.span,
6328 message: format!(
6329 "rule `{}` emits milestone `{name}` with unknown payload class `{class}`",
6330 rule.name.name
6331 ),
6332 suggestion: Some(format!("declare `class {class}` before projecting it")),
6333 });
6334 }
6335 }
6336
6337 for (binding, milestone) in milestone_reaches_in_body(&rule.body.text) {
6340 let Some(workflow) = invoke_binding_workflow(rule, &binding) else {
6341 diagnostics.push(Diagnostic {
6342 related: Vec::new(),
6343 span: rule.body.span,
6344 message: format!(
6345 "rule `{}` has `after {binding} reaches \"{milestone}\"` for `{binding}`, which is not a workflow-invoke binding in this rule",
6346 rule.name.name
6347 ),
6348 suggestion: Some(
6349 "`reaches` observes a child workflow milestone; bind the child with `invoke W { ... } as <binding>` first"
6350 .to_owned(),
6351 ),
6352 });
6353 continue;
6354 };
6355 let declared = semantic
6356 .workflow_inputs
6357 .get(&workflow)
6358 .map(|surface| surface.milestones.contains_key(&milestone))
6359 .unwrap_or(false);
6360 if !declared {
6361 let available = semantic
6362 .workflow_inputs
6363 .get(&workflow)
6364 .map(|surface| {
6365 surface
6366 .milestones
6367 .keys()
6368 .map(|name| format!("\"{name}\""))
6369 .collect::<Vec<_>>()
6370 .join(", ")
6371 })
6372 .unwrap_or_default();
6373 let suggestion = if available.is_empty() {
6374 format!("workflow `{workflow}` declares no milestones; add `emit milestone \"{milestone}\" ...` to it")
6375 } else {
6376 format!("workflow `{workflow}` declares: {available}")
6377 };
6378 diagnostics.push(Diagnostic {
6379 related: Vec::new(),
6380 span: rule.body.span,
6381 message: format!(
6382 "rule `{}` reaches milestone `{milestone}` that workflow `{workflow}` does not declare",
6383 rule.name.name
6384 ),
6385 suggestion: Some(suggestion),
6386 });
6387 }
6388 }
6389}
6390
6391fn milestone_reaches_in_body(body: &str) -> Vec<(String, String)> {
6394 let mut out = Vec::new();
6395 for raw in body.lines() {
6396 let trimmed = raw.trim();
6397 let Some(rest) = trimmed.strip_prefix("after ") else {
6398 continue;
6399 };
6400 let mut words = rest.split_whitespace();
6401 let Some(binding) = words.next() else {
6402 continue;
6403 };
6404 if words.next() != Some("reaches") {
6405 continue;
6406 }
6407 let Some(quoted) = words.next() else {
6408 continue;
6409 };
6410 if !(quoted.starts_with('"') && quoted.ends_with('"') && quoted.len() >= 2) {
6411 continue;
6412 }
6413 out.push((binding.to_owned(), quoted.trim_matches('"').to_owned()));
6414 }
6415 out
6416}
6417
6418fn invoke_binding_workflow(rule: &RuleDecl, binding: &str) -> Option<String> {
6422 for statement in workflow_invoke_statements(&rule.body.text) {
6423 let (target, _) = invoke_statement_parts(&statement)?;
6424 if let Some(as_binding) = binding_after_as(&statement) {
6425 if as_binding == binding {
6426 return Some(target.to_owned());
6427 }
6428 }
6429 }
6430 None
6431}
6432
6433fn milestone_payload_class(
6438 rule: &RuleDecl,
6439 binding: &str,
6440 milestone: &str,
6441 semantic: &SemanticContext,
6442) -> Option<String> {
6443 let workflow = invoke_binding_workflow(rule, binding)?;
6444 let surface = semantic.workflow_inputs.get(&workflow)?;
6445 surface.milestones.get(milestone).cloned()
6446}
6447
6448fn invoke_output_class(
6457 rule: &RuleDecl,
6458 binding: &str,
6459 semantic: &SemanticContext,
6460) -> Option<String> {
6461 let workflow = invoke_binding_workflow(rule, binding)?;
6462 let surface = semantic.workflow_inputs.get(&workflow)?;
6463 if surface.outputs.len() != 1 {
6464 return None;
6465 }
6466 match surface.outputs.values().next()? {
6467 TypeSyntax::Ref { name } if semantic.schemas.class_exists(&name.name) => {
6468 Some(name.name.clone())
6469 }
6470 _ => None,
6471 }
6472}
6473
6474fn invoke_failure_class(
6485 rule: &RuleDecl,
6486 binding: &str,
6487 semantic: &SemanticContext,
6488) -> Option<String> {
6489 let workflow = invoke_binding_workflow(rule, binding)?;
6490 let surface = semantic.workflow_inputs.get(&workflow)?;
6491 if surface.failures.len() != 1 {
6492 return None;
6493 }
6494 match surface.failures.values().next()? {
6495 TypeSyntax::Ref { name } if semantic.schemas.class_exists(&name.name) => {
6496 Some(name.name.clone())
6497 }
6498 _ => None,
6499 }
6500}
6501
6502fn milestone_emissions_in_body(body: &str) -> Vec<(String, String)> {
6507 let mut out = Vec::new();
6508 for raw in body.lines() {
6509 let trimmed = raw.trim();
6510 let Some(rest) = trimmed.strip_prefix("emit milestone ") else {
6511 continue;
6512 };
6513 let rest = rest.trim_start();
6516 if !rest.starts_with('"') {
6517 continue;
6518 }
6519 let Some(close) = rest[1..].find('"') else {
6520 continue;
6521 };
6522 let name = rest[1..=close].to_owned();
6523 let after_name = rest[close + 2..].trim_start();
6524 let class = after_name
6525 .strip_prefix("of ")
6526 .map(|tail| {
6527 tail.trim_start()
6528 .split(|c: char| c.is_whitespace() || c == '{')
6529 .next()
6530 .unwrap_or("")
6531 .to_owned()
6532 })
6533 .unwrap_or_default();
6534 out.push((name, class));
6535 }
6536 out
6537}
6538
6539fn schema_index_for_items(items: &[Item]) -> SchemaIndex {
6540 let mut schemas = SchemaIndex::with_builtins();
6541 for item in items {
6542 schemas.insert_item(item);
6543 }
6544 schemas
6545}
6546
6547fn workflow_inputs_for_items(items: &[Item]) -> BTreeMap<String, TypeSyntax> {
6548 items
6549 .iter()
6550 .filter_map(|item| match item {
6551 Item::WorkflowContract(contract) if contract.kind == WorkflowContractKind::Input => {
6552 Some((contract.name.name.clone(), contract.ty.clone()))
6553 }
6554 _ => None,
6555 })
6556 .collect()
6557}
6558
6559fn workflow_outputs_for_items(items: &[Item]) -> BTreeMap<String, TypeSyntax> {
6560 items
6561 .iter()
6562 .filter_map(|item| match item {
6563 Item::WorkflowContract(contract) if contract.kind == WorkflowContractKind::Output => {
6564 Some((contract.name.name.clone(), contract.ty.clone()))
6565 }
6566 _ => None,
6567 })
6568 .collect()
6569}
6570
6571fn workflow_failures_for_items(items: &[Item]) -> BTreeMap<String, TypeSyntax> {
6572 items
6573 .iter()
6574 .filter_map(|item| match item {
6575 Item::WorkflowContract(contract) if contract.kind == WorkflowContractKind::Failure => {
6576 Some((contract.name.name.clone(), contract.ty.clone()))
6577 }
6578 _ => None,
6579 })
6580 .collect()
6581}
6582
6583impl SchemaIndex {
6584 fn with_builtins() -> Self {
6585 let mut index = Self::default();
6586 index.insert_class(
6587 "AgentTurn",
6588 [
6589 ("id", string_ty()),
6590 ("summary", string_ty()),
6591 ("agent", string_ty()),
6592 ("provider", string_ty()),
6593 ("status", string_ty()),
6594 ("run_id", string_ty()),
6595 ("effect_id", string_ty()),
6596 ],
6597 );
6598 index.insert_class(
6599 "WorkItem",
6600 [
6601 ("id", string_ty()),
6602 ("title", string_ty()),
6603 ("body", string_ty()),
6604 ("queue", string_ty()),
6605 ("status", string_ty()),
6606 ("labels", array_ty(string_ty())),
6607 ],
6608 );
6609 index.insert_class(
6610 "Evidence",
6611 [
6612 ("title", string_ty()),
6613 ("path", string_ty()),
6614 ("summary", string_ty()),
6615 ],
6616 );
6617 index.insert_class(
6618 "TerminalFailed",
6619 [
6620 ("reason", string_ty()),
6621 ("summary", string_ty()),
6622 ("effect_id", string_ty()),
6623 ("run_id", string_ty()),
6624 ("kind", string_ty()),
6628 ],
6629 );
6630 index.insert_class(
6637 "TerminalFailedExec",
6638 [
6639 ("reason", string_ty()),
6640 ("summary", string_ty()),
6641 ("effect_id", string_ty()),
6642 ("run_id", string_ty()),
6643 ("kind", string_ty()),
6644 ("exit_code", optional_ty(int_ty())),
6648 ],
6649 );
6650 index.insert_class(
6651 "TerminalFailedCoerce",
6652 [
6653 ("reason", string_ty()),
6654 ("summary", string_ty()),
6655 ("effect_id", string_ty()),
6656 ("run_id", string_ty()),
6657 ("kind", string_ty()),
6658 ("error_class", string_ty()),
6659 ("http_status", optional_ty(int_ty())),
6660 ],
6661 );
6662 index.insert_class(
6663 "TerminalFailedTell",
6664 [
6665 ("reason", string_ty()),
6666 ("summary", string_ty()),
6667 ("effect_id", string_ty()),
6668 ("run_id", string_ty()),
6669 ("kind", string_ty()),
6670 ("error_class", string_ty()),
6671 ],
6672 );
6673 index.insert_class(
6674 "TerminalTimedOut",
6675 [
6676 ("summary", string_ty()),
6677 ("effect_id", string_ty()),
6678 ("run_id", string_ty()),
6679 ],
6680 );
6681 index.insert_class(
6682 "TerminalCancelled",
6683 [
6684 ("summary", string_ty()),
6685 ("effect_id", string_ty()),
6686 ("run_id", string_ty()),
6687 ],
6688 );
6689 index.insert_class(
6696 "TerminalOutcome",
6697 [
6698 ("tag", string_ty()),
6699 ("status", string_ty()),
6700 ("summary", string_ty()),
6701 ("effect_id", string_ty()),
6702 ("run_id", string_ty()),
6703 ],
6704 );
6705 index.insert_class(
6711 "Message",
6712 [
6713 ("message_id", string_ty()),
6714 ("channel", string_ty()),
6715 ("provider", string_ty()),
6716 ("received_at", string_ty()),
6717 ("sender", string_ty()),
6718 ("sender_claims", string_ty()),
6719 ("thread_id", string_ty()),
6720 ("text", string_ty()),
6721 ("markdown", string_ty()),
6722 ("attachments", array_ty(string_ty())),
6723 ("interaction", string_ty()),
6724 ("raw_ref", string_ty()),
6725 ("correlation", string_ty()),
6726 ],
6727 );
6728 index.insert_class(
6739 "MessageSendReceipt",
6740 [
6741 ("message_id", string_ty()),
6742 ("channel", string_ty()),
6743 ("provider", string_ty()),
6744 ("status", string_ty()),
6745 ("provider_message_id", string_ty()),
6746 ("thread_id", string_ty()),
6747 ("destination", string_ty()),
6748 ("accepted_at", string_ty()),
6749 ],
6750 );
6751 index
6752 }
6753
6754 fn insert_class<const N: usize>(&mut self, name: &str, fields: [(&str, TypeSyntax); N]) {
6755 self.classes.insert(
6756 name.to_owned(),
6757 fields
6758 .into_iter()
6759 .map(|(field, ty)| (field.to_owned(), ty))
6760 .collect(),
6761 );
6762 }
6763
6764 fn insert_item(&mut self, item: &Item) {
6765 match item {
6766 Item::Enum(enum_decl) => {
6767 self.enums.insert(
6768 enum_decl.name.name.clone(),
6769 enum_decl
6770 .variants
6771 .iter()
6772 .map(|variant| variant.name.name.clone())
6773 .collect(),
6774 );
6775 for variant in &enum_decl.variants {
6779 if variant.fields.is_empty() {
6780 continue;
6781 }
6782 let mut fields = BTreeMap::new();
6783 fields.insert(
6784 "variant".to_owned(),
6785 TypeSyntax::LiteralString {
6786 value: variant.name.name.clone(),
6787 span: variant.name.span,
6788 },
6789 );
6790 for field in &variant.fields {
6791 fields.insert(field.name.name.clone(), field.ty.clone());
6792 }
6793 self.classes.insert(
6794 format!("{}.{}", enum_decl.name.name, variant.name.name),
6795 fields,
6796 );
6797 }
6798 }
6799 Item::Class(class_decl) => {
6800 self.classes.insert(
6801 class_decl.name.name.clone(),
6802 class_decl
6803 .fields
6804 .iter()
6805 .map(|field| (field.name.name.clone(), field.ty.clone()))
6806 .collect(),
6807 );
6808 self.insert_presence(&class_decl.name.name, &class_decl.fields);
6809 }
6810 Item::Event(event) => {
6811 self.events.insert(event.name.clone());
6812 self.classes.insert(
6816 event.name.clone(),
6817 event
6818 .fields
6819 .iter()
6820 .map(|field| (field.name.name.clone(), field.ty.clone()))
6821 .collect(),
6822 );
6823 self.insert_presence(&event.name, &event.fields);
6824 }
6825 _ => {}
6826 }
6827 }
6828
6829 fn insert_presence(&mut self, schema: &str, fields: &[ClassField]) {
6831 let conditions: BTreeMap<String, (String, String)> = fields
6832 .iter()
6833 .filter_map(|field| {
6834 field
6835 .presence_condition
6836 .clone()
6837 .map(|condition| (field.name.name.clone(), condition))
6838 })
6839 .collect();
6840 if !conditions.is_empty() {
6841 self.presence.insert(schema.to_owned(), conditions);
6842 }
6843 }
6844
6845 fn field_presence(&self, schema: &str, field: &str) -> Option<&(String, String)> {
6847 self.presence
6848 .get(schema)
6849 .and_then(|fields| fields.get(field))
6850 }
6851
6852 fn merge(&mut self, other: SchemaIndex) {
6853 self.classes.extend(other.classes);
6854 self.enums.extend(other.enums);
6855 self.presence.extend(other.presence);
6856 }
6857
6858 fn class_exists(&self, name: &str) -> bool {
6859 self.classes.contains_key(name)
6860 }
6861
6862 fn resolve_field_path(&self, root_schema: &str, path: &[String]) -> Result<TypeSyntax, String> {
6863 if root_schema.contains('.') && !self.classes.contains_key(root_schema) {
6868 return Ok(TypeSyntax::Ref {
6869 name: Ident {
6870 name: root_schema.to_owned(),
6871 span: zero_span(),
6872 },
6873 });
6874 }
6875 let mut schema = root_schema.to_owned();
6876 let mut current = TypeSyntax::Ref {
6877 name: Ident {
6878 name: schema.clone(),
6879 span: zero_span(),
6880 },
6881 };
6882
6883 for field in path {
6884 let Some(fields) = self.classes.get(&schema) else {
6885 return Err(format!("schema `{schema}` has no declared fields"));
6886 };
6887 let Some(field_ty) = fields.get(field) else {
6888 return Err(format!("schema `{schema}` has no field `{field}`"));
6889 };
6890
6891 current = field_ty.clone();
6892 match schema_name_for_path(¤t) {
6893 Some(next_schema) => schema = next_schema,
6894 None if field != path.last().expect("path is non-empty") => {
6895 return Err(format!("field `{field}` is not a schema value"));
6896 }
6897 None => {}
6898 }
6899 }
6900
6901 Ok(current)
6902 }
6903}
6904
6905fn zero_span() -> SourceSpan {
6906 SourceSpan { start: 0, end: 0 }
6907}
6908
6909fn string_ty() -> TypeSyntax {
6910 TypeSyntax::Primitive {
6911 name: "string".to_owned(),
6912 span: zero_span(),
6913 }
6914}
6915
6916fn int_ty() -> TypeSyntax {
6917 TypeSyntax::Primitive {
6918 name: "int".to_owned(),
6919 span: zero_span(),
6920 }
6921}
6922
6923fn optional_ty(inner: TypeSyntax) -> TypeSyntax {
6924 TypeSyntax::Optional {
6925 inner: Box::new(inner),
6926 span: zero_span(),
6927 }
6928}
6929
6930fn array_ty(inner: TypeSyntax) -> TypeSyntax {
6931 TypeSyntax::Array {
6932 inner: Box::new(inner),
6933 span: zero_span(),
6934 }
6935}
6936
6937fn schema_name_for_path(ty: &TypeSyntax) -> Option<String> {
6938 match ty {
6939 TypeSyntax::Ref { name } => Some(name.name.clone()),
6940 TypeSyntax::Optional { inner, .. } => schema_name_for_path(inner),
6941 _ => None,
6942 }
6943}
6944
6945fn lower_include(include: IncludeDecl, ir: &mut IrProgram) {
6946 ir.includes.push(IrInclude {
6947 path: include.path.value,
6948 source_hash: None,
6949 });
6950}
6951
6952fn lower_workflow_contract(
6953 contract: WorkflowContractDecl,
6954 ir: &mut IrProgram,
6955 schema_names: &BTreeSet<String>,
6956 agent_names: &BTreeSet<String>,
6957 diagnostics: &mut Vec<Diagnostic>,
6958) {
6959 validate_type_refs(&contract.ty, schema_names, agent_names, diagnostics);
6960 let kind = match contract.kind {
6961 WorkflowContractKind::Input => IrWorkflowContractKind::Input,
6962 WorkflowContractKind::Output => IrWorkflowContractKind::Output,
6963 WorkflowContractKind::Failure => IrWorkflowContractKind::Failure,
6964 };
6965 ir.workflow_contracts.push(IrWorkflowContract {
6966 kind,
6967 name: contract.name.name,
6968 ty: lower_type(contract.ty),
6969 span: contract.span,
6970 });
6971}
6972
6973pub const STD_PACKAGE_IDS: &[&str] = &[
6979 "std.agent",
6980 "std.coercion",
6981 "std.coord",
6982 "std.files",
6983 "std.human",
6984 "std.ingress",
6985 "std.memory",
6986 "std.messaging",
6987 "std.script",
6988 "std.telemetry",
6989 "std.time",
6990 "std.tracker",
6991 "std.workflow",
6992];
6993
6994fn lower_use(use_decl: UseDecl, ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
6995 let std_family_known = |value: &str| {
6999 STD_PACKAGE_IDS.iter().any(|id| {
7000 value == *id
7001 || value
7002 .strip_prefix(id)
7003 .is_some_and(|rest| rest.starts_with('.'))
7004 })
7005 };
7006 if use_decl.name.value.starts_with("std.") && !std_family_known(&use_decl.name.value) {
7007 diagnostics.push(Diagnostic {
7008 related: Vec::new(),
7009 span: use_decl.name.span,
7010 message: format!("unknown standard package `{}`", use_decl.name.value),
7011 suggestion: Some(format!(
7012 "standard packages are {}",
7013 STD_PACKAGE_IDS.join(", ")
7014 )),
7015 });
7016 }
7017 let kind = IrUseKind::Package;
7018 ir.uses.push(IrUse {
7019 kind,
7020 name: use_decl.name.value,
7021 });
7022}
7023
7024fn lower_tracker(tracker: TrackerDecl, ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
7025 if tracker.provider.name != "builtin" {
7026 diagnostics.push(Diagnostic {
7027 related: Vec::new(),
7028 span: tracker.provider.span,
7029 message: format!(
7030 "tracker `{}` uses unavailable provider `{}`",
7031 tracker.name.name, tracker.provider.name
7032 ),
7033 suggestion: Some(
7034 "`builtin` is the available provider; github/linear/jira are deferred bindings"
7035 .to_owned(),
7036 ),
7037 });
7038 }
7039 ir.trackers.push(IrTracker {
7040 name: tracker.name.name,
7041 provider: tracker.provider.name,
7042 span: tracker.span,
7043 });
7044}
7045
7046fn lower_channel(channel: ChannelDecl, ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
7047 if let Some(existing) = ir
7050 .channels
7051 .iter()
7052 .find(|other| other.name == channel.name.name)
7053 {
7054 diagnostics.push(
7055 Diagnostic {
7056 related: Vec::new(),
7057 span: channel.name.span,
7058 message: format!("channel `{}` is declared more than once", channel.name.name),
7059 suggestion: Some("give each channel a unique name".to_owned()),
7060 }
7061 .with_related(existing.span, "first declared here"),
7062 );
7063 return;
7064 }
7065 if channel_provider_report(&channel.provider.name).is_none() {
7071 let known = CHANNEL_PROVIDER_REPORTS
7072 .iter()
7073 .map(|report| report.short_name)
7074 .collect::<Vec<_>>()
7075 .join(", ");
7076 diagnostics.push(Diagnostic {
7077 related: Vec::new(),
7078 span: channel.provider.span,
7079 message: format!(
7080 "channel `{}` names unknown messaging provider `{}`",
7081 channel.name.name, channel.provider.name
7082 ),
7083 suggestion: Some(format!("declare one of the v1 providers: {known}")),
7084 });
7085 }
7088 ir.channels.push(IrChannel {
7089 name: channel.name.name,
7090 provider: channel.provider.name,
7091 workspace: channel.workspace.map(|workspace| workspace.name),
7092 destination: channel.destination.map(|destination| destination.value),
7093 span: channel.span,
7094 });
7095}
7096
7097#[derive(Clone, Copy, Debug, Eq, PartialEq)]
7108pub struct ChannelProviderReport {
7109 pub short_name: &'static str,
7111 pub provider_id: &'static str,
7113 pub direction: &'static str,
7115 pub identity: &'static str,
7117 pub interactions: &'static [&'static str],
7119 pub content: &'static [&'static str],
7121 pub delivery_receipts: &'static [&'static str],
7123}
7124
7125pub const CHANNEL_PROVIDER_REPORTS: &[ChannelProviderReport] = &[
7127 ChannelProviderReport {
7128 short_name: "fixture",
7129 provider_id: "fixture",
7130 direction: "bidirectional",
7131 identity: "claimed_actor",
7132 interactions: &["buttons", "reactions"],
7133 content: &["text", "markdown"],
7134 delivery_receipts: &["accepted", "failed"],
7135 },
7136 ChannelProviderReport {
7137 short_name: "local",
7138 provider_id: "std.messaging.local",
7139 direction: "bidirectional",
7140 identity: "claimed_actor",
7141 interactions: &["buttons", "reactions"],
7142 content: &["text", "markdown"],
7143 delivery_receipts: &["accepted", "failed"],
7144 },
7145 ChannelProviderReport {
7146 short_name: "desktop",
7147 provider_id: "std.messaging.desktop",
7148 direction: "outbound_only",
7149 identity: "anonymous",
7150 interactions: &[],
7151 content: &["text"],
7152 delivery_receipts: &["accepted", "failed"],
7153 },
7154 ChannelProviderReport {
7155 short_name: "stdio",
7156 provider_id: "std.messaging.stdio",
7157 direction: "bidirectional",
7158 identity: "claimed_actor",
7159 interactions: &["buttons"],
7160 content: &["text", "markdown"],
7161 delivery_receipts: &["accepted", "failed"],
7162 },
7163];
7164
7165pub fn channel_provider_report(provider: &str) -> Option<&'static ChannelProviderReport> {
7171 CHANNEL_PROVIDER_REPORTS
7172 .iter()
7173 .find(|report| report.short_name == provider || report.provider_id == provider)
7174}
7175
7176pub const BUILTIN_GAUGES: &[&str] = &["std.spend", "std.latency", "std.tokens", "std.cache_hit"];
7182
7183pub const FILE_STORE_PROVIDERS: &[&str] = &["local"];
7189
7190fn lower_gauge(gauge: GaugeDecl, ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
7191 if let Some(existing) = ir.gauges.iter().find(|other| other.name == gauge.name.name) {
7192 diagnostics.push(
7193 Diagnostic {
7194 related: Vec::new(),
7195 span: gauge.name.span,
7196 message: format!("gauge `{}` is declared more than once", gauge.name.name),
7197 suggestion: Some("give each gauge a unique name".to_owned()),
7198 }
7199 .with_related(existing.span, "first declared here"),
7200 );
7201 return;
7202 }
7203 let (judge_kind, judge_target, judge_args) = match &gauge.judge {
7204 GaugeJudge::Coerce(target, args) => ("coerce", target.name.clone(), args.clone()),
7205 GaugeJudge::Prompt(template) => ("prompt", template.value.clone(), Vec::new()),
7206 GaugeJudge::Exec(command) => ("exec", command.value.clone(), Vec::new()),
7207 GaugeJudge::Labels(source) => ("labels", source.value.clone(), Vec::new()),
7208 };
7209 let expect = gauge.expect.as_ref().map(|bar| IrGaugeBar {
7210 form: match &bar.subject {
7211 GaugeBarSubject::Chance { .. } => "chance".to_owned(),
7212 GaugeBarSubject::Stat { .. } => "stat".to_owned(),
7213 },
7214 subject: match &bar.subject {
7215 GaugeBarSubject::Chance { field } => field.name.clone(),
7216 GaugeBarSubject::Stat { stat } => stat.name.clone(),
7217 },
7218 op: if bar.at_least { ">=" } else { "<=" }.to_owned(),
7219 threshold: bar.threshold.clone(),
7220 });
7221 ir.gauges.push(IrGauge {
7222 name: gauge.name.name,
7223 site: gauge.site,
7224 judge_kind: judge_kind.to_owned(),
7225 judge_target,
7226 judge_args,
7227 expect,
7228 inputs: gauge.inputs.into_iter().map(|input| input.name).collect(),
7229 span: gauge.span,
7230 });
7231}
7232
7233fn lower_mark(mark: MarkDecl, ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
7234 if let Some(existing) = ir.marks.iter().find(|other| other.name == mark.name.value) {
7235 diagnostics.push(
7236 Diagnostic {
7237 related: Vec::new(),
7238 span: mark.name.span,
7239 message: format!("mark `{}` is declared more than once", mark.name.value),
7240 suggestion: Some("give each mark a unique name".to_owned()),
7241 }
7242 .with_related(existing.span, "first declared here"),
7243 );
7244 return;
7245 }
7246 ir.marks.push(IrMark {
7247 name: mark.name.value,
7248 site: mark.site,
7249 span: mark.span,
7250 });
7251}
7252
7253fn lower_campaign(campaign: CampaignDecl, ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
7254 if let Some(existing) = ir
7255 .campaigns
7256 .iter()
7257 .find(|other| other.name == campaign.name.name)
7258 {
7259 diagnostics.push(
7260 Diagnostic {
7261 related: Vec::new(),
7262 span: campaign.name.span,
7263 message: format!(
7264 "campaign `{}` is declared more than once",
7265 campaign.name.name
7266 ),
7267 suggestion: Some("give each campaign a unique name".to_owned()),
7268 }
7269 .with_related(existing.span, "first declared here"),
7270 );
7271 return;
7272 }
7273 ir.campaigns.push(IrCampaign {
7274 name: campaign.name.name,
7275 ascend: campaign
7276 .ascend
7277 .into_iter()
7278 .map(|gauge| gauge.name)
7279 .collect(),
7280 reach: campaign
7281 .reach
7282 .into_iter()
7283 .map(|reach| IrCampaignReach {
7284 gauge: reach.gauge.name,
7285 op: if reach.at_least { ">=" } else { "<=" }.to_owned(),
7286 threshold: reach.threshold,
7287 unit: reach.unit,
7288 })
7289 .collect(),
7290 guard: campaign
7291 .guard
7292 .into_iter()
7293 .map(|guard| IrCampaignGuard {
7294 gauge: guard.gauge.name,
7295 band_percent: guard.band_percent,
7296 })
7297 .collect(),
7298 sacrifice: campaign
7299 .sacrifice
7300 .into_iter()
7301 .map(|gauge| gauge.name)
7302 .collect(),
7303 proposer_redacted: campaign.proposer_redacted,
7304 span: campaign.span,
7305 });
7306}
7307
7308fn validate_improve_declarations(ir: &IrProgram, diagnostics: &mut Vec<Diagnostic>) {
7314 for mark in &ir.marks {
7317 if !ir.rules.iter().any(|rule| rule.name == mark.site) {
7318 diagnostics.push(Diagnostic {
7319 related: Vec::new(),
7320 span: mark.span,
7321 message: format!("mark `{}` rides unknown site `{}`", mark.name, mark.site),
7322 suggestion: Some(format!(
7323 "declared rules: {}",
7324 ir.rules
7325 .iter()
7326 .map(|rule| rule.name.as_str())
7327 .collect::<Vec<_>>()
7328 .join(", ")
7329 )),
7330 });
7331 }
7332 }
7333 let gauge_names: BTreeSet<&str> = ir.gauges.iter().map(|gauge| gauge.name.as_str()).collect();
7334 let resolves = |name: &str| gauge_names.contains(name) || BUILTIN_GAUGES.contains(&name);
7335 let unknown = |name: &str, span: SourceSpan, diagnostics: &mut Vec<Diagnostic>| {
7336 diagnostics.push(Diagnostic {
7337 related: Vec::new(),
7338 span,
7339 message: format!("unknown gauge `{name}`"),
7340 suggestion: Some(format!(
7341 "declare `gauge {name} {{ ... }}` or use a built-in gauge ({})",
7342 BUILTIN_GAUGES.join(", ")
7343 )),
7344 });
7345 };
7346 for gauge in &ir.gauges {
7347 if gauge.judge_kind == "coerce" {
7348 match ir
7349 .coerces
7350 .iter()
7351 .find(|coerce| coerce.name == gauge.judge_target)
7352 {
7353 None => {
7354 diagnostics.push(Diagnostic {
7355 related: Vec::new(),
7356 span: gauge.span,
7357 message: format!(
7358 "gauge `{}` judges via undeclared coerce `{}`",
7359 gauge.name, gauge.judge_target
7360 ),
7361 suggestion: Some("declare the coerce this gauge judges with".to_owned()),
7362 });
7363 }
7364 Some(coerce) if !gauge.judge_args.is_empty() => {
7373 if gauge.judge_args.len() == 1 && gauge.judge_args[0] == "record" {
7374 if coerce.params.len() != 1 {
7375 diagnostics.push(Diagnostic {
7376 related: Vec::new(),
7377 span: gauge.span,
7378 message: format!(
7379 "gauge `{}`: the reserved `(record)` form needs a \
7380 single-parameter coerce; `{}` takes {}",
7381 gauge.name,
7382 gauge.judge_target,
7383 coerce.params.len()
7384 ),
7385 suggestion: Some(
7386 "give the coerce one record-shaped parameter, or bind each \
7387 parameter to an explicit path"
7388 .to_owned(),
7389 ),
7390 });
7391 }
7392 } else {
7393 for arg in &gauge.judge_args {
7394 let head = arg.split('.').next().unwrap_or_default();
7395 let valid = match head {
7396 "record" => false, "input" => true,
7398 "facts" => arg.splitn(3, '.').count() == 3,
7399 _ => false,
7400 };
7401 if !valid {
7402 diagnostics.push(Diagnostic {
7403 related: Vec::new(),
7404 span: gauge.span,
7405 message: format!(
7406 "gauge `{}`: judge argument `{arg}` is not a record \
7407 path",
7408 gauge.name
7409 ),
7410 suggestion: Some(
7411 "arguments are `input.<path>`, \
7412 `facts.<Class>.<field...>`, or the single reserved \
7413 `record`"
7414 .to_owned(),
7415 ),
7416 });
7417 }
7418 }
7419 if gauge.judge_args.len() != coerce.params.len() {
7420 diagnostics.push(Diagnostic {
7421 related: Vec::new(),
7422 span: gauge.span,
7423 message: format!(
7424 "gauge `{}`: judge passes {} argument{} but coerce `{}` \
7425 takes {}",
7426 gauge.name,
7427 gauge.judge_args.len(),
7428 if gauge.judge_args.len() == 1 { "" } else { "s" },
7429 gauge.judge_target,
7430 coerce.params.len()
7431 ),
7432 suggestion: Some(
7433 "bind one path per coerce parameter, in order".to_owned(),
7434 ),
7435 });
7436 }
7437 }
7438 }
7439 Some(_) => {}
7440 }
7441 }
7442 if !gauge.inputs.is_empty() && gauge.judge_kind != "exec" {
7443 diagnostics.push(Diagnostic {
7444 related: Vec::new(),
7445 span: gauge.span,
7446 message: format!(
7447 "derived gauge `{}` must judge via exec (its judge receives the input score vector)",
7448 gauge.name
7449 ),
7450 suggestion: Some("use `judge via exec \"<validator>\"`".to_owned()),
7451 });
7452 }
7453 for input in &gauge.inputs {
7454 if input == &gauge.name {
7455 diagnostics.push(Diagnostic {
7456 related: Vec::new(),
7457 span: gauge.span,
7458 message: format!("derived gauge `{}` cannot input itself", gauge.name),
7459 suggestion: None,
7460 });
7461 } else if !resolves(input) {
7462 unknown(input, gauge.span, diagnostics);
7463 }
7464 }
7465 }
7466 for campaign in &ir.campaigns {
7467 let mut named: Vec<(&str, &'static str)> = Vec::new();
7468 for name in &campaign.ascend {
7469 named.push((name, "ascend"));
7470 }
7471 for reach in &campaign.reach {
7472 named.push((&reach.gauge, "reach"));
7473 }
7474 for guard in &campaign.guard {
7475 named.push((&guard.gauge, "guard"));
7476 }
7477 for name in &campaign.sacrifice {
7478 named.push((name, "sacrifice"));
7479 }
7480 let mut seen: BTreeMap<&str, &'static str> = BTreeMap::new();
7481 for (name, role) in named {
7482 if !resolves(name) {
7483 unknown(name, campaign.span, diagnostics);
7484 }
7485 if let Some(previous) = seen.insert(name, role) {
7486 let message = if previous == role {
7487 format!(
7488 "campaign `{}` names gauge `{name}` twice in {role}",
7489 campaign.name
7490 )
7491 } else {
7492 format!(
7493 "campaign `{}` names gauge `{name}` as both {previous} and {role}",
7494 campaign.name
7495 )
7496 };
7497 diagnostics.push(Diagnostic {
7498 related: Vec::new(),
7499 span: campaign.span,
7500 message,
7501 suggestion: Some("name each gauge once, in at most one clause".to_owned()),
7502 });
7503 }
7504 }
7505 }
7506}
7507
7508fn lower_harness(harness: HarnessDecl, ir: &mut IrProgram, _diagnostics: &mut [Diagnostic]) {
7509 ir.harnesses.push(IrHarness {
7516 name: harness.name.name,
7517 kind: harness.kind.name,
7518 span: harness.span,
7519 });
7520}
7521
7522#[derive(Clone, Copy, Debug, Eq, PartialEq)]
7527pub enum HarnessClass {
7528 Managed,
7529 Delegated,
7530}
7531
7532impl HarnessClass {
7533 pub fn as_str(self) -> &'static str {
7534 match self {
7535 HarnessClass::Managed => "managed",
7536 HarnessClass::Delegated => "delegated",
7537 }
7538 }
7539}
7540
7541pub fn harness_class(kind: &str) -> HarnessClass {
7548 match kind {
7549 "owned" | "fixture" => HarnessClass::Managed,
7550 _ => HarnessClass::Delegated,
7551 }
7552}
7553
7554fn lower_agent(
7555 agent: AgentDecl,
7556 ir: &mut IrProgram,
7557 harness_kinds: &BTreeMap<String, String>,
7558 diagnostics: &mut Vec<Diagnostic>,
7559) {
7560 let mut lowered = IrAgent {
7561 name: agent.name.name.clone(),
7562 harness: agent.harness.as_ref().map(|harness| harness.name.clone()),
7563 provider: None,
7564 profile: None,
7565 capacity: None,
7566 skills: Vec::new(),
7567 capabilities: Vec::new(),
7568 requires: Vec::new(),
7569 tools: Vec::new(),
7570 compaction: None,
7571 thread: None,
7572 settings: None,
7573 harness_class: HarnessClass::Managed,
7575 };
7576
7577 if let Some(harness) = &agent.harness {
7578 if !harness_kinds.contains_key(&harness.name) {
7579 diagnostics.push(Diagnostic {
7580 related: Vec::new(),
7581 span: harness.span,
7582 message: format!(
7583 "agent `{}` uses unknown harness `{}`",
7584 agent.name.name, harness.name
7585 ),
7586 suggestion: Some(format!(
7587 "declare `harness {}: fixture` before using it",
7588 harness.name
7589 )),
7590 });
7591 }
7592 }
7593
7594 if let Some(delegate) = &agent.delegated_to {
7600 if harness_class(&delegate.name) != HarnessClass::Delegated {
7601 diagnostics.push(Diagnostic {
7602 related: Vec::new(),
7603 span: delegate.span,
7604 message: format!(
7605 "agent `{}` delegates to `{}`, which is a managed kind",
7606 agent.name.name, delegate.name
7607 ),
7608 suggestion: Some(
7609 "a plain `agent name { ... }` is managed by default; `delegated to` names a foreign runtime"
7610 .to_owned(),
7611 ),
7612 });
7613 }
7614 }
7615
7616 let mut compaction_span: Option<SourceSpan> = None;
7619 let mut thread_span: Option<SourceSpan> = None;
7620 let mut settings_span: Option<SourceSpan> = None;
7621
7622 for field in agent.fields {
7623 match field {
7624 AgentField::Provider(provider) => {
7625 if lowered.provider.is_some() {
7626 diagnostics.push(Diagnostic {
7627 related: Vec::new(),
7628 span: provider.span,
7629 message: format!(
7630 "agent `{}` declares provider more than once",
7631 agent.name.name
7632 ),
7633 suggestion: Some(
7634 "keep exactly one `provider` field in the agent block".to_owned(),
7635 ),
7636 });
7637 }
7638 if agent.harness.is_some() {
7639 diagnostics.push(Diagnostic { related: Vec::new(),
7640 span: provider.span,
7641 message: format!(
7642 "agent `{}` declares both `using` harness and direct provider `{}`",
7643 agent.name.name, provider.name
7644 ),
7645 suggestion: Some(
7646 "use either `agent name using harness { ... }` or `provider codex`, not both"
7647 .to_owned(),
7648 ),
7649 });
7650 }
7651 if agent.delegated_to.is_some() {
7652 diagnostics.push(Diagnostic { related: Vec::new(),
7653 span: provider.span,
7654 message: format!(
7655 "agent `{}` declares both `delegated to` and direct provider `{}`",
7656 agent.name.name, provider.name
7657 ),
7658 suggestion: Some(
7659 "use either `agent name delegated to <provider> { ... }` or a `provider` field, not both"
7660 .to_owned(),
7661 ),
7662 });
7663 }
7664 lowered.provider = Some(provider.name);
7668 }
7669 AgentField::Profile(profile) => lowered.profile = Some(profile.value),
7670 AgentField::Capacity(capacity, span) => {
7671 if capacity == 0 {
7672 diagnostics.push(Diagnostic {
7673 related: Vec::new(),
7674 span,
7675 message: format!(
7676 "agent `{}` capacity must be greater than zero",
7677 agent.name.name
7678 ),
7679 suggestion: Some("use `capacity 1` or a larger integer".to_owned()),
7680 });
7681 }
7682 lowered.capacity = Some(capacity);
7683 }
7684 AgentField::Skills(skills, _) => {
7685 let mut seen = BTreeSet::new();
7686 for skill in skills {
7687 if !seen.insert(skill.value.clone()) {
7688 diagnostics.push(Diagnostic {
7689 related: Vec::new(),
7690 span: skill.span,
7691 message: format!(
7692 "agent `{}` attaches skill `{}` more than once",
7693 agent.name.name, skill.value
7694 ),
7695 suggestion: Some("remove the duplicate skill entry".to_owned()),
7696 });
7697 }
7698 lowered.skills.push(skill.value);
7699 }
7700 }
7701 AgentField::Capabilities(capabilities, _) => {
7702 let mut seen = BTreeSet::new();
7703 for capability in capabilities {
7704 if !seen.insert(capability.value.clone()) {
7705 diagnostics.push(Diagnostic {
7706 related: Vec::new(),
7707 span: capability.span,
7708 message: format!(
7709 "agent `{}` declares capability `{}` more than once",
7710 agent.name.name, capability.value
7711 ),
7712 suggestion: Some("remove the duplicate capability entry".to_owned()),
7713 });
7714 }
7715 lowered.capabilities.push(capability.value);
7716 }
7717 }
7718 AgentField::Requires(classes, _) => {
7719 let mut seen = BTreeSet::new();
7720 for class in classes {
7721 if !whipplescript_core::AGENT_FEATURE_CLASS_TAXONOMY
7727 .contains(&class.name.as_str())
7728 {
7729 diagnostics.push(Diagnostic {
7730 related: Vec::new(),
7731 span: class.span,
7732 message: format!(
7733 "agent `{}` requires unknown feature class `{}`",
7734 agent.name.name, class.name
7735 ),
7736 suggestion: Some(format!(
7737 "feature classes come from the DR-0015 taxonomy: {}",
7738 whipplescript_core::AGENT_FEATURE_CLASS_TAXONOMY.join(", ")
7739 )),
7740 });
7741 }
7742 if !seen.insert(class.name.clone()) {
7743 diagnostics.push(Diagnostic {
7744 related: Vec::new(),
7745 span: class.span,
7746 message: format!(
7747 "agent `{}` requires feature class `{}` more than once",
7748 agent.name.name, class.name
7749 ),
7750 suggestion: Some("remove the duplicate requires entry".to_owned()),
7751 });
7752 }
7753 lowered.requires.push(class.name);
7754 }
7755 }
7756 AgentField::Tools(tools, _) => {
7757 let mut seen = BTreeSet::new();
7758 for tool in tools {
7759 if !seen.insert(tool.name.clone()) {
7760 diagnostics.push(Diagnostic {
7761 related: Vec::new(),
7762 span: tool.span,
7763 message: format!(
7764 "agent `{}` grants tool `{}` more than once",
7765 agent.name.name, tool.name
7766 ),
7767 suggestion: Some("remove the duplicate tool entry".to_owned()),
7768 });
7769 }
7770 lowered.tools.push(tool.name);
7771 }
7772 }
7773 AgentField::Compaction(strategy) => {
7774 const STRATEGIES: [&str; 4] = ["summarize", "hard_reset", "tool_results", "none"];
7775 if lowered.compaction.is_some() {
7776 diagnostics.push(Diagnostic {
7777 related: Vec::new(),
7778 span: strategy.span,
7779 message: format!(
7780 "agent `{}` declares compaction more than once",
7781 agent.name.name
7782 ),
7783 suggestion: Some("keep exactly one `compaction` field".to_owned()),
7784 });
7785 }
7786 if !STRATEGIES.contains(&strategy.name.as_str()) {
7787 diagnostics.push(Diagnostic {
7788 related: Vec::new(),
7789 span: strategy.span,
7790 message: format!(
7791 "agent `{}` uses unknown compaction strategy `{}`",
7792 agent.name.name, strategy.name
7793 ),
7794 suggestion: Some(
7795 "supported strategies are `summarize`, `hard_reset`, `tool_results`, and `none`"
7796 .to_owned(),
7797 ),
7798 });
7799 }
7800 compaction_span = Some(strategy.span);
7801 lowered.compaction = Some(strategy.name);
7802 }
7803 AgentField::Thread(mode) => {
7804 const MODES: [&str; 2] = ["continue", "fresh"];
7805 if lowered.thread.is_some() {
7806 diagnostics.push(Diagnostic {
7807 related: Vec::new(),
7808 span: mode.span,
7809 message: format!(
7810 "agent `{}` declares thread more than once",
7811 agent.name.name
7812 ),
7813 suggestion: Some("keep exactly one `thread` field".to_owned()),
7814 });
7815 }
7816 if !MODES.contains(&mode.name.as_str()) {
7817 diagnostics.push(Diagnostic {
7818 related: Vec::new(),
7819 span: mode.span,
7820 message: format!(
7821 "agent `{}` uses unknown thread mode `{}`",
7822 agent.name.name, mode.name
7823 ),
7824 suggestion: Some(
7825 "supported thread modes are `continue` and `fresh`".to_owned(),
7826 ),
7827 });
7828 }
7829 thread_span = Some(mode.span);
7830 lowered.thread = Some(mode.name);
7831 }
7832 AgentField::Settings(sources) => {
7833 const SOURCES: [&str; 3] = ["project", "user", "none"];
7834 if lowered.settings.is_some() {
7835 diagnostics.push(Diagnostic {
7836 related: Vec::new(),
7837 span: sources.span,
7838 message: format!(
7839 "agent `{}` declares settings more than once",
7840 agent.name.name
7841 ),
7842 suggestion: Some("keep exactly one `settings` field".to_owned()),
7843 });
7844 }
7845 if !SOURCES.contains(&sources.name.as_str()) {
7846 diagnostics.push(Diagnostic {
7847 related: Vec::new(),
7848 span: sources.span,
7849 message: format!(
7850 "agent `{}` uses unknown settings source `{}`",
7851 agent.name.name, sources.name
7852 ),
7853 suggestion: Some(
7854 "supported settings sources are `project`, `user`, and `none`"
7855 .to_owned(),
7856 ),
7857 });
7858 }
7859 settings_span = Some(sources.span);
7860 lowered.settings = Some(sources.name);
7861 }
7862 AgentField::Unknown { name, .. } => {
7863 diagnostics.push(Diagnostic { related: Vec::new(),
7864 span: name.span,
7865 message: format!(
7866 "unknown agent field `{}` on agent `{}`",
7867 name.name, agent.name.name
7868 ),
7869 suggestion: Some(
7870 "supported agent fields are `provider`, `profile`, `capacity`, `skills`, `capabilities`, `tools`, `compaction`, and `settings`".to_owned(),
7871 ),
7872 });
7873 }
7874 }
7875 }
7876
7877 if lowered.provider.is_none() {
7883 if let Some(delegate) = &agent.delegated_to {
7884 lowered.provider = Some(delegate.name.clone());
7885 } else if lowered.harness.is_none() {
7886 lowered.provider = Some("owned".to_owned());
7887 }
7888 }
7889
7890 let resolved_kind = lowered.provider.as_deref().or_else(|| {
7895 lowered
7896 .harness
7897 .as_deref()
7898 .and_then(|name| harness_kinds.get(name).map(String::as_str))
7899 });
7900 lowered.harness_class = resolved_kind
7901 .map(harness_class)
7902 .unwrap_or(HarnessClass::Managed);
7903
7904 if resolved_kind.is_some() {
7909 if lowered.harness_class == HarnessClass::Delegated {
7910 if let Some(span) = compaction_span {
7911 diagnostics.push(Diagnostic {
7912 related: Vec::new(),
7913 span,
7914 message: format!(
7915 "agent `{}` is delegated; `compaction` is a managed-harness knob",
7916 agent.name.name
7917 ),
7918 suggestion: Some(
7919 "remove `compaction` — a delegated harness compacts its own context"
7920 .to_owned(),
7921 ),
7922 });
7923 }
7924 if let Some(span) = thread_span {
7925 diagnostics.push(Diagnostic {
7926 related: Vec::new(),
7927 span,
7928 message: format!(
7929 "agent `{}` is delegated; `thread` is a managed-harness knob",
7930 agent.name.name
7931 ),
7932 suggestion: Some(
7933 "remove `thread` — a delegated harness owns its own conversation state"
7934 .to_owned(),
7935 ),
7936 });
7937 }
7938 } else if let Some(span) = settings_span {
7939 diagnostics.push(Diagnostic {
7940 related: Vec::new(),
7941 span,
7942 message: format!(
7943 "agent `{}` is managed; `settings` is a delegated-harness knob",
7944 agent.name.name
7945 ),
7946 suggestion: Some(
7947 "remove `settings` — WhippleScript assembles a managed agent's context"
7948 .to_owned(),
7949 ),
7950 });
7951 }
7952 }
7953
7954 if lowered.profile.is_none() {
7960 lowered.profile = Some("no-repo".to_owned());
7961 }
7962
7963 if lowered.capacity.is_none() {
7964 lowered.capacity = Some(1);
7965 }
7966
7967 ir.agents.push(lowered);
7968}
7969
7970fn lower_enum(enum_decl: EnumDecl, ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
7971 let mut variants = BTreeSet::new();
7972 for variant in &enum_decl.variants {
7973 if !variants.insert(variant.name.name.clone()) {
7974 diagnostics.push(Diagnostic {
7975 related: Vec::new(),
7976 span: variant.span,
7977 message: format!(
7978 "enum `{}` declares variant `{}` more than once",
7979 enum_decl.name.name, variant.name.name
7980 ),
7981 suggestion: Some(
7982 "remove the duplicate variant or give it a distinct name".to_owned(),
7983 ),
7984 });
7985 }
7986 for field in &variant.fields {
7989 if field.name.name == "variant" {
7990 diagnostics.push(Diagnostic {
7991 related: Vec::new(),
7992 span: field.name.span,
7993 message: format!(
7994 "variant `{}` of enum `{}` declares reserved field `variant`",
7995 variant.name.name, enum_decl.name.name
7996 ),
7997 suggestion: Some(
7998 "the discriminant is synthesized from the variant name; rename the field"
7999 .to_owned(),
8000 ),
8001 });
8002 }
8003 }
8004 }
8005
8006 for variant in &enum_decl.variants {
8010 if variant.fields.is_empty() {
8011 continue;
8012 }
8013 let mut fields = vec![IrClassField {
8014 name: "variant".to_owned(),
8015 ty: IrType::LiteralString(variant.name.name.clone()),
8016 is_key: false,
8017 presence_condition: None,
8018 span: variant.name.span,
8019 }];
8020 fields.extend(variant.fields.iter().map(|field| IrClassField {
8021 name: field.name.name.clone(),
8022 ty: lower_type(field.ty.clone()),
8023 is_key: false,
8024 presence_condition: field.presence_condition.clone(),
8025 span: field.span,
8026 }));
8027 ir.schemas.push(IrSchema::Class(IrClass {
8028 name: format!("{}.{}", enum_decl.name.name, variant.name.name),
8029 fields,
8030 span: variant.span,
8031 }));
8032 }
8033
8034 ir.schemas.push(IrSchema::Enum(IrEnum {
8035 name: enum_decl.name.name,
8036 variants: enum_decl
8037 .variants
8038 .into_iter()
8039 .map(|variant| variant.name.name)
8040 .collect(),
8041 span: enum_decl.span,
8042 }));
8043}
8044
8045fn validate_test_expr_source(
8046 label: &str,
8047 source: &str,
8048 span: SourceSpan,
8049 diagnostics: &mut Vec<Diagnostic>,
8050) {
8051 if source.trim().is_empty() {
8052 diagnostics.push(Diagnostic {
8053 related: Vec::new(),
8054 span,
8055 message: format!("{label} is empty"),
8056 suggestion: Some("provide an expression".to_owned()),
8057 });
8058 return;
8059 }
8060 if let Err(error) = parse_expression(source) {
8061 diagnostics.push(Diagnostic {
8062 related: Vec::new(),
8063 span,
8064 message: format!("{label} is not a valid expression: {error}"),
8065 suggestion: None,
8066 });
8067 }
8068}
8069
8070fn lower_test(test: TestDecl, ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
8071 if ir
8072 .tests
8073 .iter()
8074 .any(|existing| existing.name == test.name.value)
8075 {
8076 diagnostics.push(Diagnostic {
8077 related: Vec::new(),
8078 span: test.name.span,
8079 message: format!("test `{}` is declared more than once", test.name.value),
8080 suggestion: Some("give each test scenario a distinct name".to_owned()),
8081 });
8082 }
8083 if !test
8084 .clauses
8085 .iter()
8086 .any(|clause| matches!(clause, TestClause::Expect(_)))
8087 {
8088 diagnostics.push(Diagnostic {
8089 related: Vec::new(),
8090 span: test.span,
8091 message: format!("test `{}` has no `expect` clause", test.name.value),
8092 suggestion: Some("a test must assert at least one expected outcome".to_owned()),
8093 });
8094 }
8095 for clause in &test.clauses {
8098 match clause {
8099 TestClause::Given(
8100 GivenClause::Input { fields, .. }
8101 | GivenClause::Fact { fields, .. }
8102 | GivenClause::Signal { fields, .. },
8103 ) => {
8104 for field in fields {
8105 validate_test_expr_source(
8106 &format!("given field `{}`", field.name.name),
8107 &field.value,
8108 field.span,
8109 diagnostics,
8110 );
8111 }
8112 }
8113 TestClause::Expect(ExpectClause {
8114 target: ExpectTarget::Projection(query),
8115 ..
8116 }) => match &query.kind {
8117 ProjQueryKind::Count { predicate, .. } | ProjQueryKind::Where { predicate } => {
8118 validate_test_expr_source(
8119 &format!("predicate on `{}`", query.noun),
8120 predicate,
8121 query.span,
8122 diagnostics,
8123 );
8124 }
8125 ProjQueryKind::Exists => {}
8126 },
8127 _ => {}
8128 }
8129 }
8130 ir.tests.push(IrTest {
8131 name: test.name.value,
8132 workflow: test.workflow.map(|identifier| identifier.name),
8133 clauses: test.clauses,
8134 span: test.span,
8135 });
8136}
8137
8138fn lower_source(source: SourceDecl, ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
8139 if ir
8140 .sources
8141 .iter()
8142 .any(|existing| existing.name == source.name.name)
8143 {
8144 diagnostics.push(Diagnostic {
8145 related: Vec::new(),
8146 span: source.name.span,
8147 message: format!("source `{}` is declared more than once", source.name.name),
8148 suggestion: Some("remove the duplicate source declaration".to_owned()),
8149 });
8150 }
8151 if let Some(clock) = &source.clock {
8155 let recurring = !matches!(clock.recurrence, Recurrence::At { .. });
8156 if recurring && clock.missed.is_none() {
8157 diagnostics.push(Diagnostic {
8158 related: Vec::new(),
8159 span: clock.span,
8160 message: format!(
8161 "recurring source `{}` must declare a `missed` policy",
8162 source.name.name
8163 ),
8164 suggestion: Some(
8165 "add `missed skip`, `missed coalesce`, or `missed catch_up limit N`".to_owned(),
8166 ),
8167 });
8168 }
8169 if matches!(clock.recurrence, Recurrence::EveryCalendar { .. }) && clock.timezone.is_none()
8170 {
8171 diagnostics.push(Diagnostic { related: Vec::new(),
8172 span: clock.span,
8173 message: format!(
8174 "calendar source `{}` should declare a `timezone`",
8175 source.name.name
8176 ),
8177 suggestion: Some(
8178 "add `timezone \"America/New_York\"`; a calendar schedule without one defaults to UTC".to_owned(),
8179 ),
8180 });
8181 }
8182 }
8183 let is_file = source.provider.name == "file";
8189 if is_file && source.path.is_none() && source.watch.is_none() {
8190 diagnostics.push(Diagnostic {
8191 related: Vec::new(),
8192 span: source.span,
8193 message: format!(
8194 "`file` source `{}` requires a `path` or `watch` clause",
8195 source.name.name
8196 ),
8197 suggestion: Some(
8198 "add `path \"./inbox.txt\"` (one signal per line) or `watch \"./drops/*.json\"` \
8199 (one signal per new file-content occurrence)"
8200 .to_owned(),
8201 ),
8202 });
8203 }
8204 if is_file && source.path.is_some() && source.watch.is_some() {
8205 diagnostics.push(Diagnostic {
8206 related: Vec::new(),
8207 span: source
8208 .watch
8209 .as_ref()
8210 .map(|watch| watch.span)
8211 .unwrap_or(source.span),
8212 message: format!(
8213 "`file` source `{}` declares both `path` and `watch`; the modes are exclusive",
8214 source.name.name
8215 ),
8216 suggestion: Some(
8217 "keep `path` for line-by-line admission or `watch` for per-file-content \
8218 occurrences, not both"
8219 .to_owned(),
8220 ),
8221 });
8222 }
8223 if !is_file {
8224 if let Some(path) = &source.path {
8225 diagnostics.push(Diagnostic {
8226 related: Vec::new(),
8227 span: path.span,
8228 message: format!(
8229 "source `{}` declares a `path` clause but its provider is `{}`, not `file`",
8230 source.name.name, source.provider.name
8231 ),
8232 suggestion: Some(
8233 "use `source file as ...` for a `path`, or remove the clause".to_owned(),
8234 ),
8235 });
8236 }
8237 if let Some(watch) = &source.watch {
8238 diagnostics.push(Diagnostic {
8239 related: Vec::new(),
8240 span: watch.span,
8241 message: format!(
8242 "source `{}` declares a `watch` clause but its provider is `{}`, not `file`",
8243 source.name.name, source.provider.name
8244 ),
8245 suggestion: Some(
8246 "use `source file as ...` for a `watch` glob, or remove the clause".to_owned(),
8247 ),
8248 });
8249 }
8250 }
8251 let is_http = source.provider.name == "http";
8255 if is_http && source.url.is_none() {
8256 diagnostics.push(Diagnostic {
8257 related: Vec::new(),
8258 span: source.span,
8259 message: format!(
8260 "`http` source `{}` requires a `url` clause",
8261 source.name.name
8262 ),
8263 suggestion: Some("add `url \"https://example.com/feed.json\"`".to_owned()),
8264 });
8265 }
8266 if is_http {
8270 if let Some(url) = &source.url {
8271 let scheme_ok = url.value.starts_with("http://") || url.value.starts_with("https://");
8272 if !scheme_ok {
8273 diagnostics.push(Diagnostic {
8274 related: Vec::new(),
8275 span: url.span,
8276 message: format!(
8277 "`http` source `{}` url `{}` is not an absolute http(s) URL",
8278 source.name.name, url.value
8279 ),
8280 suggestion: Some(
8281 "use an absolute `http://` or `https://` URL the runtime can GET"
8282 .to_owned(),
8283 ),
8284 });
8285 }
8286 }
8287 }
8288 if !is_http {
8289 if let Some(url) = &source.url {
8290 diagnostics.push(Diagnostic {
8291 related: Vec::new(),
8292 span: url.span,
8293 message: format!(
8294 "source `{}` declares a `url` clause but its provider is `{}`, not `http`",
8295 source.name.name, source.provider.name
8296 ),
8297 suggestion: Some(
8298 "use `source http as ...` for a `url`, or remove the clause".to_owned(),
8299 ),
8300 });
8301 }
8302 }
8303 let is_clock = source.clock.is_some();
8304 let is_file = source.provider.name == "file";
8305 let is_http = source.provider.name == "http";
8306 let mut dedup_field = None;
8314 if let Some(dedup) = &source.dedup {
8315 let span = match dedup {
8316 SourceValue::Path { span, .. } => *span,
8317 SourceValue::String(literal) => literal.span,
8318 SourceValue::Number(_, span) => *span,
8319 };
8320 if !(is_http || is_file && source.watch.is_none()) {
8321 diagnostics.push(Diagnostic {
8322 related: Vec::new(),
8323 span,
8324 message: format!(
8325 "source `{}` declares a `dedup` clause but its provider is `{}`{}",
8326 source.name.name,
8327 source.provider.name,
8328 if is_file {
8329 " in `watch` mode, which is already content-keyed"
8330 } else {
8331 "; `dedup` applies to `file` (line mode) and `http` sources"
8332 }
8333 ),
8334 suggestion: Some("remove the `dedup` clause".to_owned()),
8335 });
8336 } else {
8337 match dedup {
8338 SourceValue::Path {
8339 binding, segments, ..
8340 } if binding.name == source.observe_binding.name && segments.len() == 1 => {
8341 dedup_field = Some(segments[0].name.clone());
8342 }
8343 _ => {
8344 diagnostics.push(Diagnostic {
8345 related: Vec::new(),
8346 span,
8347 message: format!(
8348 "source `{}` `dedup` must name one observation field off the \
8349 `observe` binding (e.g. `dedup {}.line`)",
8350 source.name.name, source.observe_binding.name
8351 ),
8352 suggestion: Some(format!(
8353 "the observation binding is `{}` (declared by `observe as {}`)",
8354 source.observe_binding.name, source.observe_binding.name
8355 )),
8356 });
8357 }
8358 }
8359 }
8360 }
8361 let path = source.path.as_ref().map(|literal| literal.value.clone());
8362 let watch = source.watch.as_ref().map(|literal| literal.value.clone());
8363 let url = source.url.as_ref().map(|literal| literal.value.clone());
8364 let recurrence = source.clock.as_ref().map(|clock| clock.recurrence.clone());
8365 let timezone = source
8366 .clock
8367 .as_ref()
8368 .and_then(|clock| clock.timezone.as_ref().map(|tz| tz.value.clone()));
8369 let missed = source.clock.as_ref().and_then(|clock| clock.missed);
8370 ir.sources.push(IrSource {
8371 name: source.name.name,
8372 provider: source.provider.name,
8373 is_clock,
8374 is_file,
8375 is_http,
8376 recurrence,
8377 timezone,
8378 missed,
8379 path,
8380 watch,
8381 url,
8382 dedup_field,
8383 observe_binding: source.observe_binding.name,
8384 emit_signal: source.emit.signal,
8385 emit_from: source.emit.from.as_ref().map(|ident| ident.name.clone()),
8386 emit_fields: source
8387 .emit
8388 .fields
8389 .into_iter()
8390 .map(|field| IrSourceEmitField {
8391 name: field.name.name,
8392 value: field.value,
8393 span: field.span,
8394 })
8395 .collect(),
8396 span: source.span,
8397 });
8398}
8399
8400fn lower_event(event: EventDecl, ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
8401 if ir.events.iter().any(|existing| existing.name == event.name) {
8402 diagnostics.push(Diagnostic {
8403 related: Vec::new(),
8404 span: event.name_span,
8405 message: format!("signal `{}` is declared more than once", event.name),
8406 suggestion: Some("remove the duplicate signal declaration".to_owned()),
8407 });
8408 }
8409 let mut fields = BTreeSet::new();
8410 for field in &event.fields {
8411 if !fields.insert(field.name.name.clone()) {
8412 diagnostics.push(Diagnostic {
8413 related: Vec::new(),
8414 span: field.name.span,
8415 message: format!(
8416 "signal `{}` declares field `{}` more than once",
8417 event.name, field.name.name
8418 ),
8419 suggestion: Some(
8420 "remove the duplicate field or give it a distinct name".to_owned(),
8421 ),
8422 });
8423 }
8424 }
8425 validate_presence_conditions(&event.name, &event.fields, diagnostics);
8426
8427 ir.events.push(IrEvent {
8428 name: event.name,
8429 fields: event
8430 .fields
8431 .into_iter()
8432 .map(|field| IrClassField {
8433 name: field.name.name,
8434 ty: lower_type(field.ty),
8435 is_key: false,
8436 presence_condition: field.presence_condition,
8437 span: field.span,
8438 })
8439 .collect(),
8440 span: event.span,
8441 });
8442}
8443
8444fn literal_union_values(ty: &TypeSyntax) -> Option<Vec<String>> {
8448 match ty {
8449 TypeSyntax::LiteralString { value, .. } => Some(vec![value.clone()]),
8450 TypeSyntax::Union { variants, .. } => {
8451 let values = variants
8452 .iter()
8453 .filter_map(|variant| match variant {
8454 TypeSyntax::LiteralString { value, .. } => Some(value.clone()),
8455 _ => None,
8456 })
8457 .collect::<Vec<_>>();
8458 (!values.is_empty() && values.len() == variants.len()).then_some(values)
8459 }
8460 _ => None,
8461 }
8462}
8463
8464fn validate_presence_conditions(
8468 container: &str,
8469 fields: &[ClassField],
8470 diagnostics: &mut Vec<Diagnostic>,
8471) {
8472 for field in fields {
8473 let Some((disc, literal)) = &field.presence_condition else {
8474 continue;
8475 };
8476 let Some(disc_field) = fields.iter().find(|candidate| &candidate.name.name == disc) else {
8477 diagnostics.push(Diagnostic {
8478 related: Vec::new(),
8479 span: field.span,
8480 message: format!(
8481 "`{container}` field `{}` is conditioned on unknown discriminant `{disc}`",
8482 field.name.name
8483 ),
8484 suggestion: Some(
8485 "`when <field> is \"...\"` must name a literal-union field of the same schema"
8486 .to_owned(),
8487 ),
8488 });
8489 continue;
8490 };
8491 match literal_union_values(&disc_field.ty) {
8492 Some(values) if values.iter().any(|value| value == literal) => {}
8493 Some(values) => diagnostics.push(Diagnostic {
8494 related: Vec::new(),
8495 span: field.span,
8496 message: format!(
8497 "`{container}` field `{}` is conditioned on `{disc} is \"{literal}\"`, which is not a value of `{disc}`",
8498 field.name.name
8499 ),
8500 suggestion: Some(format!("use one of: {}", values.join(", "))),
8501 }),
8502 None => diagnostics.push(Diagnostic {
8503 related: Vec::new(),
8504 span: field.span,
8505 message: format!(
8506 "`{container}` field `{}` is conditioned on `{disc}`, which is not a string-literal discriminant",
8507 field.name.name
8508 ),
8509 suggestion: Some(
8510 "the discriminant must be a string-literal union, e.g. `kind \"a\" | \"b\"`"
8511 .to_owned(),
8512 ),
8513 }),
8514 }
8515 }
8516}
8517
8518fn lower_class(
8519 class_decl: ClassDecl,
8520 ir: &mut IrProgram,
8521 schema_names: &BTreeSet<String>,
8522 agent_names: &BTreeSet<String>,
8523 diagnostics: &mut Vec<Diagnostic>,
8524) {
8525 let mut fields = BTreeSet::new();
8526 for field in &class_decl.fields {
8527 if !fields.insert(field.name.name.clone()) {
8528 diagnostics.push(Diagnostic {
8529 related: Vec::new(),
8530 span: field.name.span,
8531 message: format!(
8532 "class `{}` declares field `{}` more than once",
8533 class_decl.name.name, field.name.name
8534 ),
8535 suggestion: Some(
8536 "remove the duplicate field or give it a distinct name".to_owned(),
8537 ),
8538 });
8539 }
8540 validate_type_refs(&field.ty, schema_names, agent_names, diagnostics);
8541 }
8542
8543 let key_fields = class_decl
8545 .fields
8546 .iter()
8547 .filter(|field| field.is_key)
8548 .collect::<Vec<_>>();
8549 if key_fields.len() > 1 {
8550 for field in key_fields.into_iter().skip(1) {
8551 diagnostics.push(Diagnostic {
8552 related: Vec::new(),
8553 span: field.span,
8554 message: format!(
8555 "class `{}` declares more than one `@key` field",
8556 class_decl.name.name
8557 ),
8558 suggestion: Some("a class has at most one `@key` natural key in v0".to_owned()),
8559 });
8560 }
8561 }
8562
8563 validate_presence_conditions(&class_decl.name.name, &class_decl.fields, diagnostics);
8564
8565 ir.schemas.push(IrSchema::Class(IrClass {
8566 name: class_decl.name.name,
8567 span: class_decl.span,
8568 fields: class_decl
8569 .fields
8570 .into_iter()
8571 .map(|field| IrClassField {
8572 name: field.name.name,
8573 ty: lower_type(field.ty),
8574 is_key: field.is_key,
8575 presence_condition: field.presence_condition,
8576 span: field.span,
8577 })
8578 .collect(),
8579 }));
8580}
8581
8582fn lower_table(
8583 table: TableDecl,
8584 semantic: &SemanticContext,
8585 workflow_contract_names: &WorkflowContractNames,
8586 ir: &mut IrProgram,
8587 diagnostics: &mut Vec<Diagnostic>,
8588) {
8589 if !semantic.schemas.class_exists(&table.schema.name) {
8590 diagnostics.push(Diagnostic {
8591 related: Vec::new(),
8592 span: table.schema.span,
8593 message: format!(
8594 "table `{}` targets unknown class `{}`",
8595 table.name.name, table.schema.name
8596 ),
8597 suggestion: Some("declare the class before seeding rows for it".to_owned()),
8598 });
8599 return;
8600 }
8601
8602 if table.rows.is_empty() {
8603 diagnostics.push(Diagnostic {
8604 related: Vec::new(),
8605 span: table.span,
8606 message: format!("table `{}` has no rows", table.name.name),
8607 suggestion: Some("add at least one `{ ... }` row".to_owned()),
8608 });
8609 return;
8610 }
8611
8612 let mut body = String::new();
8613 for row in &table.rows {
8614 push_line(&mut body, format!("record {} {{", table.schema.name));
8615 push_block_body(&row.body.text, &mut body);
8616 push_line(&mut body, "}");
8617 body.push('\n');
8618 }
8619 if body.ends_with('\n') {
8620 body.pop();
8621 }
8622
8623 let rule = RuleDecl {
8624 name: Ident {
8625 name: format!("table_{}", table.name.name),
8626 span: table.name.span,
8627 },
8628 tags: Vec::new(),
8629 description: None,
8630 whens: vec![WhenClause {
8631 text: "started".to_owned(),
8632 span: table.name.span,
8633 }],
8634 body: BlockSource {
8635 text: body,
8636 span: table.span,
8637 },
8638 span: table.span,
8639 };
8640
8641 let record_sources = table
8642 .rows
8643 .iter()
8644 .map(|row| IrRecordSource {
8645 schema: table.schema.name.clone(),
8646 construct: "table_row".to_owned(),
8647 span: row.span,
8648 })
8649 .collect::<Vec<_>>();
8650
8651 let rule_name = rule.name.name.clone();
8652 lower_rule(rule, semantic, workflow_contract_names, ir, diagnostics);
8653 if let Some(rule) = ir
8654 .rules
8655 .iter_mut()
8656 .rev()
8657 .find(|rule| rule.name == rule_name)
8658 {
8659 rule.metadata.record_sources = record_sources;
8660 }
8661}
8662
8663fn validate_coerce_body_fields(coerce: &CoerceDecl, diagnostics: &mut Vec<Diagnostic>) {
8668 let mut in_prompt = false;
8669 let mut awaiting_opener = false;
8670 for line in coerce.body.text.lines() {
8671 let trimmed = line.trim();
8672 if in_prompt {
8673 if trimmed.matches("\"\"\"").count() % 2 == 1 {
8675 in_prompt = false;
8676 }
8677 continue;
8678 }
8679 if awaiting_opener {
8680 if trimmed.is_empty() {
8683 continue;
8684 }
8685 awaiting_opener = false;
8686 if let Some(after_opener) = trimmed.strip_prefix("\"\"\"") {
8687 if after_opener.matches("\"\"\"").count() % 2 == 0 {
8688 in_prompt = true;
8689 }
8690 continue;
8691 }
8692 }
8694 if trimmed.is_empty() || trimmed.starts_with('#') {
8695 continue;
8696 }
8697 if trimmed == "prompt" {
8698 awaiting_opener = true;
8699 continue;
8700 }
8701 if let Some(rest) = trimmed.strip_prefix("prompt ") {
8702 let rest = rest.trim_start();
8703 if let Some(after_opener) = rest.strip_prefix("\"\"\"") {
8704 if after_opener.matches("\"\"\"").count() % 2 == 0 {
8707 in_prompt = true;
8708 }
8709 }
8710 continue;
8712 }
8713 if let Some(rest) = trimmed.strip_prefix("provider ") {
8714 if rest.split_whitespace().count() != 1 {
8715 diagnostics.push(Diagnostic {
8716 related: Vec::new(),
8717 span: coerce.name.span,
8718 message: format!(
8719 "coerce `{}` has a malformed `provider` clause: `{trimmed}`",
8720 coerce.name.name
8721 ),
8722 suggestion: Some("write `provider <name>`".to_owned()),
8723 });
8724 }
8725 continue;
8726 }
8727 let field = trimmed.split_whitespace().next().unwrap_or(trimmed);
8728 diagnostics.push(Diagnostic {
8729 related: Vec::new(),
8730 span: coerce.name.span,
8731 message: format!(
8732 "unknown coerce field `{field}` on coerce `{}`",
8733 coerce.name.name
8734 ),
8735 suggestion: Some("supported coerce fields are `prompt` and `provider`".to_owned()),
8736 });
8737 }
8738}
8739
8740fn lower_coerce(
8741 coerce: CoerceDecl,
8742 ir: &mut IrProgram,
8743 schema_names: &BTreeSet<String>,
8744 agent_names: &BTreeSet<String>,
8745 diagnostics: &mut Vec<Diagnostic>,
8746) {
8747 let mut params = BTreeSet::new();
8748 for param in &coerce.params {
8749 if !params.insert(param.name.name.clone()) {
8750 diagnostics.push(Diagnostic {
8751 related: Vec::new(),
8752 span: param.name.span,
8753 message: format!(
8754 "coerce `{}` declares parameter `{}` more than once",
8755 coerce.name.name, param.name.name
8756 ),
8757 suggestion: Some(
8758 "remove the duplicate parameter or give it a distinct name".to_owned(),
8759 ),
8760 });
8761 }
8762 validate_type_refs(¶m.ty, schema_names, agent_names, diagnostics);
8763 }
8764 validate_type_refs(&coerce.output, schema_names, agent_names, diagnostics);
8765 validate_coerce_prompt_content_type_annotations(&coerce, diagnostics);
8766 validate_coerce_body_fields(&coerce, diagnostics);
8767
8768 ir.coerces.push(IrCoerce {
8769 name: coerce.name.name,
8770 params: coerce
8771 .params
8772 .into_iter()
8773 .map(|param| IrParam {
8774 name: param.name.name,
8775 ty: lower_type(param.ty),
8776 })
8777 .collect(),
8778 output: lower_type(coerce.output),
8779 body: coerce.body.text,
8780 });
8781}
8782
8783fn validate_type_refs(
8784 ty: &TypeSyntax,
8785 schema_names: &BTreeSet<String>,
8786 agent_names: &BTreeSet<String>,
8787 diagnostics: &mut Vec<Diagnostic>,
8788) {
8789 match ty {
8790 TypeSyntax::Primitive { .. } | TypeSyntax::LiteralString { .. } => {}
8791 TypeSyntax::Ref { name } => {
8792 if !schema_names.contains(&name.name) && !is_builtin_schema_ref(&name.name) {
8793 diagnostics.push(Diagnostic {
8794 related: Vec::new(),
8795 span: name.span,
8796 message: format!("unknown schema reference `{}`", name.name),
8797 suggestion: Some(format!(
8798 "declare `class {}` or `enum {}` before using it",
8799 name.name, name.name
8800 )),
8801 });
8802 }
8803 }
8804 TypeSyntax::AgentRef { agents, .. } => {
8805 let mut seen = BTreeSet::new();
8806 for agent in agents {
8807 if !seen.insert(agent.name.clone()) {
8808 diagnostics.push(Diagnostic {
8809 related: Vec::new(),
8810 span: agent.span,
8811 message: format!("AgentRef lists agent `{}` more than once", agent.name),
8812 suggestion: Some(
8813 "remove the duplicate agent from the AgentRef domain".to_owned(),
8814 ),
8815 });
8816 }
8817 if !agent_names.contains(&agent.name) {
8818 diagnostics.push(Diagnostic {
8819 related: Vec::new(),
8820 span: agent.span,
8821 message: format!("AgentRef references unknown agent `{}`", agent.name),
8822 suggestion: Some(format!(
8823 "declare `agent {}` before using it in AgentRef",
8824 agent.name
8825 )),
8826 });
8827 }
8828 }
8829 }
8830 TypeSyntax::Optional { inner, .. }
8831 | TypeSyntax::Array { inner, .. }
8832 | TypeSyntax::Map { inner, .. } => {
8833 validate_type_refs(inner, schema_names, agent_names, diagnostics)
8834 }
8835 TypeSyntax::Union { variants, .. } => {
8836 for variant in variants {
8837 validate_type_refs(variant, schema_names, agent_names, diagnostics);
8838 }
8839 }
8840 }
8841}
8842
8843fn is_builtin_schema_ref(name: &str) -> bool {
8844 matches!(
8845 name,
8846 "AgentTurn"
8847 | "WorkItem"
8848 | "Evidence"
8849 | "TerminalFailed"
8850 | "TerminalTimedOut"
8851 | "TerminalCancelled"
8852 | "TerminalOutcome"
8853 )
8854}
8855
8856fn is_observer_only_schema(name: &str) -> bool {
8863 matches!(
8864 name,
8865 "TerminalFailed" | "TerminalTimedOut" | "TerminalCancelled" | "TerminalOutcome"
8866 )
8867}
8868
8869fn lower_rule(
8870 rule: RuleDecl,
8871 semantic: &SemanticContext,
8872 workflow_contract_names: &WorkflowContractNames,
8873 ir: &mut IrProgram,
8874 diagnostics: &mut Vec<Diagnostic>,
8875) {
8876 validate_canonical_rule_body_syntax(&rule, diagnostics);
8877 let metadata = analyze_rule(&rule, semantic, diagnostics);
8878 validate_workflow_terminal_actions(
8879 &rule,
8880 semantic,
8881 &binding_types_for_rule(&rule),
8882 &known_roots_for_rule(&rule),
8883 workflow_contract_names,
8884 diagnostics,
8885 );
8886 validate_effectful_self_trigger(&rule, &metadata, diagnostics);
8887 validate_send_channels(&rule, semantic, diagnostics);
8888 validate_message_from_channels(&rule, semantic, diagnostics);
8889 validate_evidence_fact_not_matched(&rule, diagnostics);
8890 validate_turn_access_grants(&rule, &metadata, diagnostics);
8891 ir.rules.push(IrRule {
8892 name: rule.name.name,
8893 whens: rule.whens.into_iter().map(lower_when_clause).collect(),
8894 body: rule.body.text,
8895 metadata,
8896 });
8897}
8898
8899fn lower_when_clause(when: WhenClause) -> IrWhen {
8900 let source = when.text;
8901 let (pattern, guard_source) = split_when_guard(&source);
8902 let pattern = pattern.to_owned();
8903 let guard = guard_source.and_then(|guard_source| {
8904 let guard_offset = source.find(guard_source).unwrap_or(0);
8905 lower_expression(
8906 guard_source,
8907 SourceSpan {
8908 start: when.span.start + guard_offset,
8909 end: when.span.start + guard_offset + guard_source.len(),
8910 },
8911 )
8912 });
8913 IrWhen {
8914 source,
8915 pattern,
8916 guard,
8917 span: when.span,
8918 }
8919}
8920
8921fn validate_canonical_rule_body_syntax(rule: &RuleDecl, diagnostics: &mut Vec<Diagnostic>) {
8922 for line in rule.body.text.lines().map(str::trim) {
8923 if line.starts_with("then ") {
8924 diagnostics.push(Diagnostic {
8925 related: Vec::new(),
8926 span: rule.body.span,
8927 message: format!(
8928 "rule `{}` uses unsupported `then` sequencing",
8929 rule.name.name
8930 ),
8931 suggestion: Some(
8932 "use `after <effect> succeeds { ... }` blocks for effect sequencing".to_owned(),
8933 ),
8934 });
8935 }
8936 if line.starts_with("after ") && line.contains("=>") {
8937 diagnostics.push(Diagnostic {
8938 related: Vec::new(),
8939 span: rule.body.span,
8940 message: format!(
8941 "rule `{}` uses unsupported `after ... =>` sequencing",
8942 rule.name.name
8943 ),
8944 suggestion: Some("write `after <effect> succeeds { ... }`".to_owned()),
8945 });
8946 }
8947 }
8948}
8949
8950fn build_rule_dependencies(rules: &[IrRule]) -> Vec<IrRuleDependency> {
8951 let mut dependencies = Vec::new();
8952 for producer in rules {
8953 for produced_fact in &producer.metadata.fact_writes {
8954 for consumer in rules {
8955 if consumer.metadata.fact_reads.contains(produced_fact) {
8956 dependencies.push(IrRuleDependency {
8957 producer: producer.name.clone(),
8958 consumer: consumer.name.clone(),
8959 fact: produced_fact.clone(),
8960 });
8961 }
8962 }
8963 }
8964 }
8965 dependencies.sort_by(|left, right| {
8966 (&left.producer, &left.consumer, &left.fact).cmp(&(
8967 &right.producer,
8968 &right.consumer,
8969 &right.fact,
8970 ))
8971 });
8972 dependencies
8973}
8974
8975fn validate_message_from_channels(
8982 rule: &RuleDecl,
8983 semantic: &SemanticContext,
8984 diagnostics: &mut Vec<Diagnostic>,
8985) {
8986 for when in &rule.whens {
8987 let (pattern, _) = split_when_guard(&when.text);
8988 let Some(rest) = pattern.trim_start().strip_prefix("message from ") else {
8989 continue;
8990 };
8991 let Some(channel) = rest.split_whitespace().next() else {
8992 continue;
8993 };
8994 if !semantic.channels.iter().any(|c| c.as_str() == channel) {
8995 diagnostics.push(Diagnostic {
8996 related: Vec::new(),
8997 span: when.span,
8998 message: format!("`when message from {channel}` names an unknown channel"),
8999 suggestion: Some(
9000 "declare it with `channel <name> { provider … }`, or correct the channel name"
9001 .to_owned(),
9002 ),
9003 });
9004 continue;
9005 }
9006 if let Some(report) = semantic
9013 .channel_providers
9014 .get(channel)
9015 .and_then(|provider| channel_provider_report(provider))
9016 {
9017 if report.direction == "outbound_only" {
9018 diagnostics.push(Diagnostic {
9019 related: Vec::new(),
9020 span: when.span,
9021 message: format!(
9022 "`when message from {channel}` observes a channel whose provider `{}` is outbound-only (its capability report cannot deliver inbound messages)",
9023 report.short_name
9024 ),
9025 suggestion: Some(
9026 "route inbound observation through an inbound-capable provider (`local`, `stdio`, `fixture`)"
9027 .to_owned(),
9028 ),
9029 });
9030 }
9031 }
9032 }
9033}
9034
9035fn validate_send_channels(
9036 rule: &RuleDecl,
9037 semantic: &SemanticContext,
9038 diagnostics: &mut Vec<Diagnostic>,
9039) {
9040 let (ast, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
9041 fn walk(
9042 statements: &[body::BodyStmt],
9043 semantic: &SemanticContext,
9044 diagnostics: &mut Vec<Diagnostic>,
9045 ) {
9046 for statement in statements {
9047 match statement {
9048 body::BodyStmt::Effect(effect) => {
9049 if let body::BodyEffectKind::ConstructCapabilityCall {
9050 keyword, fields, ..
9051 } = &effect.kind
9052 {
9053 if keyword == "send" {
9054 if let Some(channel) =
9055 fields.iter().find(|field| field.name == "channel")
9056 {
9057 if !semantic.channels.contains(&channel.source) {
9058 diagnostics.push(Diagnostic {
9059 related: Vec::new(),
9060 span: effect.span,
9061 message: format!(
9062 "`send via {}` names an unknown channel",
9063 channel.source
9064 ),
9065 suggestion: Some(
9066 "declare it with `channel <name> { provider … }`, or correct the channel name"
9067 .to_owned(),
9068 ),
9069 });
9070 } else if let Some(report) = semantic
9071 .channel_providers
9072 .get(&channel.source)
9073 .and_then(|provider| channel_provider_report(provider))
9074 {
9075 if report.direction == "inbound_only" {
9085 diagnostics.push(Diagnostic {
9086 related: Vec::new(),
9087 span: effect.span,
9088 message: format!(
9089 "`send via {}` targets a channel whose provider `{}` is inbound-only (its capability report cannot accept outbound sends)",
9090 channel.source, report.short_name
9091 ),
9092 suggestion: Some(
9093 "send through an outbound-capable provider (`local`, `desktop`, `stdio`, `fixture`)"
9094 .to_owned(),
9095 ),
9096 });
9097 }
9098 }
9099 }
9100 }
9101 if matches!(keyword.as_str(), "recall" | "learn" | "curate") {
9104 if let Some(pool) = fields.iter().find(|field| field.name == "pool") {
9105 if !semantic.memory_pools.contains(&pool.source) {
9106 diagnostics.push(Diagnostic {
9107 related: Vec::new(),
9108 span: effect.span,
9109 message: format!(
9110 "`{keyword}` names unknown memory pool `{}`",
9111 pool.source
9112 ),
9113 suggestion: Some(
9114 "declare it with `memory pool <name> { … }`, or correct the pool name"
9115 .to_owned(),
9116 ),
9117 });
9118 }
9119 }
9120 }
9121 }
9122 }
9123 body::BodyStmt::After(after) => walk(&after.body, semantic, diagnostics),
9124 body::BodyStmt::Case(case) => {
9125 for branch in &case.branches {
9126 walk(&branch.body, semantic, diagnostics);
9127 }
9128 }
9129 _ => {}
9130 }
9131 }
9132 }
9133 walk(&ast.statements, semantic, diagnostics);
9134}
9135
9136const EVIDENCE_ONLY_TURN_FACTS: [&str; 3] = [
9143 "agent.turn.streamed",
9144 "agent.turn.tool_requested",
9145 "agent.turn.artifact_captured",
9146];
9147
9148fn validate_turn_access_grants(
9155 rule: &RuleDecl,
9156 metadata: &IrRuleMetadata,
9157 diagnostics: &mut Vec<Diagnostic>,
9158) {
9159 for effect in &metadata.effects {
9160 if effect.access_grants.is_empty() {
9161 continue;
9162 }
9163 let mut seen = BTreeSet::new();
9164 for grant in &effect.access_grants {
9165 if grant.operations.is_empty() {
9166 diagnostics.push(Diagnostic {
9167 related: Vec::new(),
9168 span: effect.span,
9169 message: format!(
9170 "rule `{}` has a `with access to {}` grant that grants no operations",
9171 rule.name.name, grant.resource
9172 ),
9173 suggestion: Some(
9174 "list at least one operation in the grant block, or drop the grant"
9175 .to_owned(),
9176 ),
9177 });
9178 }
9179 if !seen.insert(grant.resource.clone()) {
9180 diagnostics.push(Diagnostic {
9181 related: Vec::new(),
9182 span: effect.span,
9183 message: format!(
9184 "rule `{}` lists access resource `{}` more than once on one effect",
9185 rule.name.name, grant.resource
9186 ),
9187 suggestion: Some(
9188 "merge the grant clauses for a resource into a single block".to_owned(),
9189 ),
9190 });
9191 }
9192 }
9193 }
9194}
9195
9196fn validate_evidence_fact_not_matched(rule: &RuleDecl, diagnostics: &mut Vec<Diagnostic>) {
9197 for when in &rule.whens {
9198 let (pattern, _) = split_when_guard(&when.text);
9199 let Some(name) = runtime_fact_name_for_pattern(pattern) else {
9200 continue;
9201 };
9202 if EVIDENCE_ONLY_TURN_FACTS.contains(&name.as_str()) {
9203 diagnostics.push(Diagnostic { related: Vec::new(),
9204 span: when.span,
9205 message: format!(
9206 "rule `{}` matches evidence-only fact `{name}`: in-turn observations are evidence, not rule-matchable facts",
9207 rule.name.name
9208 ),
9209 suggestion: Some(
9210 "match a lifecycle fact (`agent.turn.completed`/`failed`/`timed_out`/`cancelled`) and read in-turn detail from its evidence".to_owned(),
9211 ),
9212 });
9213 }
9214 }
9215}
9216
9217fn extract_rule_regions(
9224 items: &mut [Item],
9225 diagnostics: &mut Vec<Diagnostic>,
9226) -> BTreeMap<String, IrRegion> {
9227 let mut pending = BTreeMap::new();
9228 for item in items.iter_mut() {
9229 let Item::Rule(rule) = item else {
9230 continue;
9231 };
9232 let (ast, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
9233 let mut regions = Vec::new();
9234 collect_region_blocks(&ast.statements, &mut regions);
9235 if regions.is_empty() {
9236 continue;
9237 }
9238 if regions.len() > 1 {
9239 diagnostics.push(Diagnostic {
9240 related: Vec::new(),
9241 span: regions[1].span,
9242 message: format!(
9243 "rule `{}` declares more than one `during`/`until` region",
9244 rule.name.name
9245 ),
9246 suggestion: Some(
9247 "v1 supports one region per rule (including nested regions); split the \
9248 rule or merge the conditions"
9249 .to_owned(),
9250 ),
9251 });
9252 continue;
9253 }
9254 let region = regions[0].clone();
9255 if count_effect_statements(®ion.body) == 0 {
9256 diagnostics.push(Diagnostic {
9257 related: Vec::new(),
9258 span: region.span,
9259 message: format!(
9260 "the `{}` region in rule `{}` contains no progression",
9261 if region.until { "until" } else { "during" },
9262 rule.name.name
9263 ),
9264 suggestion: Some(
9265 "a region around purely-atomic actions commits with admission and can \
9266 never lapse between steps; it needs at least one effect with a \
9267 continuation"
9268 .to_owned(),
9269 ),
9270 });
9271 continue;
9272 }
9273 let mut region_bindings = BTreeSet::new();
9278 collect_all_binding_names(®ion.body, &mut region_bindings);
9279 if let Some(view) = ®ion.lapse_binding {
9280 region_bindings.remove(view);
9281 }
9282 let mut arm_roots = BTreeSet::new();
9283 collect_statement_roots(®ion.lapse_body, &mut arm_roots);
9284 for root in &arm_roots {
9285 if region_bindings.contains(root) {
9286 diagnostics.push(Diagnostic {
9287 related: Vec::new(),
9288 span: region.span,
9289 message: format!(
9290 "the `on lapse` arm of rule `{}` references `{root}`, a binding the \
9291 region introduces — it may not exist when the arm runs",
9292 rule.name.name
9293 ),
9294 suggestion: Some(
9295 "reference only bindings from before the region, or bind the \
9296 progress view (`on lapse as got`) and read `got.<binding>` — its \
9297 fields are present exactly if that step settled"
9298 .to_owned(),
9299 ),
9300 });
9301 }
9302 }
9303 let base = rule.body.span.start;
9305 let text = rule.body.text.clone();
9306 let clamp = |offset: usize| offset.saturating_sub(base).min(text.len());
9307 let (r_start, r_end) = (clamp(region.span.start), clamp(region.span.end));
9308 let (b_start, b_end) = (clamp(region.body_span.start), clamp(region.body_span.end));
9309 let (l_start, l_end) = (clamp(region.lapse_span.start), clamp(region.lapse_span.end));
9310 if !(r_start <= b_start
9311 && b_start <= b_end
9312 && b_end <= l_start
9313 && l_start <= l_end
9314 && l_end <= r_end)
9315 {
9316 diagnostics.push(Diagnostic {
9317 related: Vec::new(),
9318 span: region.span,
9319 message: format!(
9320 "internal: region span reconstruction failed for rule `{}`",
9321 rule.name.name
9322 ),
9323 suggestion: None,
9324 });
9325 continue;
9326 }
9327 let body_content = &text[b_start..b_end];
9328 let arm_content = &text[l_start..l_end];
9329 let variant_holds = format!("{}{}{}", &text[..r_start], body_content, &text[r_end..]);
9330 let variant_removed = format!("{}{}", &text[..r_start], &text[r_end..]);
9331 let variant_lapsed = format!("{}{}{}", &text[..r_start], arm_content, &text[r_end..]);
9332 let mut effect_bindings = BTreeSet::new();
9336 collect_effect_binding_names(®ion.body, &mut effect_bindings);
9337 let (holds_ast, _) = body::parse_rule_body(&variant_holds, 0);
9338 let mut region_effects = Vec::new();
9339 assign_region_effect_scopes(
9340 &holds_ast.statements,
9341 None,
9342 &effect_bindings,
9343 &mut region_effects,
9344 );
9345 pending.insert(
9346 rule.name.name.clone(),
9347 IrRegion {
9348 until: region.until,
9349 condition: region.condition.clone(),
9350 lapse_binding: region.lapse_binding.clone(),
9351 effects: region_effects,
9352 body_removed: variant_removed,
9353 body_lapsed: variant_lapsed,
9354 },
9355 );
9356 rule.body.text = variant_holds;
9357 }
9358 pending
9359}
9360
9361fn collect_region_blocks(statements: &[body::BodyStmt], out: &mut Vec<body::RegionBlock>) {
9362 for statement in statements {
9363 match statement {
9364 body::BodyStmt::Region(region) => {
9365 out.push(region.clone());
9366 collect_region_blocks(®ion.body, out);
9367 collect_region_blocks(®ion.lapse_body, out);
9368 }
9369 body::BodyStmt::After(after) => collect_region_blocks(&after.body, out),
9370 body::BodyStmt::Case(case) => {
9371 for branch in &case.branches {
9372 collect_region_blocks(&branch.body, out);
9373 }
9374 }
9375 _ => {}
9376 }
9377 }
9378}
9379
9380fn count_effect_statements(statements: &[body::BodyStmt]) -> usize {
9381 let mut count = 0;
9382 for statement in statements {
9383 match statement {
9384 body::BodyStmt::Effect(_) => count += 1,
9385 body::BodyStmt::After(after) => count += count_effect_statements(&after.body),
9386 body::BodyStmt::Case(case) => {
9387 for branch in &case.branches {
9388 count += count_effect_statements(&branch.body);
9389 }
9390 }
9391 body::BodyStmt::Region(region) => {
9392 count += count_effect_statements(®ion.body);
9393 }
9394 _ => {}
9395 }
9396 }
9397 count
9398}
9399
9400fn collect_effect_binding_names(statements: &[body::BodyStmt], out: &mut BTreeSet<String>) {
9401 for statement in statements {
9402 match statement {
9403 body::BodyStmt::Effect(effect) => {
9404 if let Some(binding) = &effect.binding {
9405 out.insert(binding.clone());
9406 }
9407 }
9408 body::BodyStmt::After(after) => collect_effect_binding_names(&after.body, out),
9409 body::BodyStmt::Case(case) => {
9410 for branch in &case.branches {
9411 collect_effect_binding_names(&branch.body, out);
9412 }
9413 }
9414 body::BodyStmt::Region(region) => {
9415 collect_effect_binding_names(®ion.body, out);
9416 }
9417 _ => {}
9418 }
9419 }
9420}
9421
9422fn assign_region_effect_scopes(
9426 statements: &[body::BodyStmt],
9427 level1: Option<&(String, String)>,
9428 region_bindings: &BTreeSet<String>,
9429 out: &mut Vec<IrRegionEffect>,
9430) {
9431 for statement in statements {
9432 match statement {
9433 body::BodyStmt::Effect(effect) => {
9434 if let Some(binding) = &effect.binding {
9435 if region_bindings.contains(binding)
9436 && !out.iter().any(|known| &known.binding == binding)
9437 {
9438 out.push(IrRegionEffect {
9439 binding: binding.clone(),
9440 scope: level1.cloned(),
9441 });
9442 }
9443 }
9444 }
9445 body::BodyStmt::After(after) => {
9446 let own = (
9447 after.binding.clone(),
9448 after.predicate.kernel_str().to_owned(),
9449 );
9450 let next = level1.cloned().unwrap_or(own);
9451 assign_region_effect_scopes(&after.body, Some(&next), region_bindings, out);
9452 }
9453 body::BodyStmt::Case(case) => {
9454 for branch in &case.branches {
9455 assign_region_effect_scopes(&branch.body, level1, region_bindings, out);
9456 }
9457 }
9458 body::BodyStmt::Region(region) => {
9459 assign_region_effect_scopes(®ion.body, level1, region_bindings, out);
9460 }
9461 _ => {}
9462 }
9463 }
9464}
9465
9466fn collect_statement_roots(statements: &[body::BodyStmt], out: &mut BTreeSet<String>) {
9470 fn roots_in_expr(source: &str, out: &mut BTreeSet<String>) {
9471 let bytes = source.as_bytes();
9472 let mut i = 0;
9473 let mut in_string = false;
9474 while i < bytes.len() {
9475 let c = bytes[i] as char;
9476 if c == '"' {
9477 in_string = !in_string;
9478 i += 1;
9479 continue;
9480 }
9481 if in_string {
9482 i += 1;
9483 continue;
9484 }
9485 if c.is_ascii_alphabetic() || c == '_' {
9486 let start = i;
9487 while i < bytes.len() {
9488 let cj = bytes[i] as char;
9489 if cj.is_ascii_alphanumeric() || cj == '_' {
9490 i += 1;
9491 } else {
9492 break;
9493 }
9494 }
9495 let preceded_by_dot = start > 0 && bytes[start - 1] as char == '.';
9496 if !preceded_by_dot {
9497 out.insert(source[start..i].to_owned());
9498 }
9499 continue;
9500 }
9501 i += 1;
9502 }
9503 }
9504 fn roots_in_fields(fields: &[body::FieldAssign], out: &mut BTreeSet<String>) {
9505 for field in fields {
9506 match &field.value {
9507 body::FieldValue::Expr { source, .. } => roots_in_expr(source, out),
9508 body::FieldValue::Nested { fields, .. } => roots_in_fields(fields, out),
9509 body::FieldValue::Shorthand => {
9510 out.insert(field.name.clone());
9511 }
9512 }
9513 }
9514 }
9515 fn roots_in_prompt(text: &str, out: &mut BTreeSet<String>) {
9516 let mut rest = text;
9517 while let Some(open) = rest.find("{{") {
9518 let tail = &rest[open + 2..];
9519 let Some(close) = tail.find("}}") else {
9520 break;
9521 };
9522 roots_in_expr(&tail[..close], out);
9523 rest = &tail[close + 2..];
9524 }
9525 }
9526 for statement in statements {
9527 match statement {
9528 body::BodyStmt::Record(record) => roots_in_fields(&record.fields, out),
9529 body::BodyStmt::Done {
9530 binding,
9531 replacement,
9532 ..
9533 } => {
9534 out.insert(binding.clone());
9535 if let Some(record) = replacement {
9536 roots_in_fields(&record.fields, out);
9537 }
9538 }
9539 body::BodyStmt::Cancel { binding, .. } => {
9540 out.insert(binding.clone());
9541 }
9542 body::BodyStmt::Effect(effect) => {
9543 if let Some(prompt) = &effect.prompt {
9544 roots_in_prompt(&prompt.text, out);
9545 }
9546 match &effect.kind {
9547 body::BodyEffectKind::Coerce { args, .. } => {
9548 for arg in args {
9549 roots_in_expr(arg, out);
9550 }
9551 }
9552 body::BodyEffectKind::TrackerFinish { item, fields } => {
9553 out.insert(item.clone());
9554 roots_in_fields(fields, out);
9555 }
9556 body::BodyEffectKind::TrackerRelease { item } => {
9557 out.insert(item.clone());
9558 }
9559 _ => {}
9560 }
9561 }
9562 body::BodyStmt::Terminal(terminal) => {
9563 roots_in_fields(&terminal.fields, out);
9564 if let Some(body::FieldValue::Expr { source, .. }) = &terminal.scalar {
9565 roots_in_expr(source, out);
9566 }
9567 }
9568 body::BodyStmt::Milestone { fields, .. } => roots_in_fields(fields, out),
9569 body::BodyStmt::After(after) => collect_statement_roots(&after.body, out),
9570 body::BodyStmt::Case(case) => {
9571 roots_in_expr(&case.scrutinee, out);
9572 for branch in &case.branches {
9573 collect_statement_roots(&branch.body, out);
9574 }
9575 }
9576 body::BodyStmt::Region(region) => {
9577 collect_statement_roots(®ion.body, out);
9578 collect_statement_roots(®ion.lapse_body, out);
9579 }
9580 body::BodyStmt::Redact { source, .. } => {
9581 out.insert(source.clone());
9582 }
9583 }
9584 }
9585}
9586
9587fn validate_effectful_self_trigger(
9588 rule: &RuleDecl,
9589 metadata: &IrRuleMetadata,
9590 diagnostics: &mut Vec<Diagnostic>,
9591) {
9592 if metadata.effects.is_empty() {
9593 return;
9594 }
9595
9596 for written_fact in &metadata.fact_writes {
9597 if metadata.fact_reads.contains(written_fact)
9598 && !metadata.fact_consumes.contains(written_fact)
9599 {
9600 diagnostics.push(Diagnostic { related: Vec::new(),
9601 span: rule.body.span,
9602 message: format!(
9603 "effectful rule `{}` preserves trigger fact `{written_fact}`",
9604 rule.name.name
9605 ),
9606 suggestion: Some(
9607 "consume or advance the triggering fact, or move the next effect behind an external completion event"
9608 .to_owned(),
9609 ),
9610 });
9611 }
9612 }
9613}
9614
9615fn binding_types_for_rule(rule: &RuleDecl) -> BTreeMap<String, String> {
9616 let mut binding_types = BTreeMap::new();
9617 for when in &rule.whens {
9618 if let Some((binding, schema)) = binding_from_when(&when.text) {
9619 binding_types.insert(binding, schema);
9620 }
9621 }
9622 binding_types
9623}
9624
9625fn validate_workflow_terminal_actions(
9626 rule: &RuleDecl,
9627 semantic: &SemanticContext,
9628 binding_types: &BTreeMap<String, String>,
9629 known_roots: &BTreeSet<String>,
9630 contracts: &WorkflowContractNames,
9631 diagnostics: &mut Vec<Diagnostic>,
9632) {
9633 for line in rule.body.text.lines().map(str::trim) {
9634 let terminal = line
9635 .strip_prefix("complete ")
9636 .map(|rest| ("complete", rest, &contracts.outputs))
9637 .or_else(|| {
9638 line.strip_prefix("fail ")
9639 .map(|rest| ("fail", rest, &contracts.failures))
9640 });
9641 let Some((action, rest, declared)) = terminal else {
9642 continue;
9643 };
9644 if !rest.contains('{') {
9649 let tokens: Vec<&str> = rest.split_whitespace().collect();
9650 let is_from = matches!(tokens.as_slice(), [_, "from", ..]) && action == "complete";
9651 if tokens.len() >= 2 && !is_from {
9652 let name = tokens[0];
9653 let value = rest.trim().get(name.len()..).unwrap_or("").trim();
9654 if !declared.contains_key(name) {
9655 diagnostics.push(Diagnostic {
9656 related: Vec::new(),
9657 span: rule.body.span,
9658 message: format!(
9659 "rule `{}` {action}s unknown workflow terminal `{name}`",
9660 rule.name.name
9661 ),
9662 suggestion: Some(format!(
9663 "declare `{kind} {name} Type` on the workflow first",
9664 kind = if action == "complete" {
9665 "output"
9666 } else {
9667 "failure"
9668 }
9669 )),
9670 });
9671 continue;
9672 }
9673 if let Some(contract_ty) = declared.get(name) {
9674 validate_scalar_terminal_payload(
9675 rule,
9676 action,
9677 name,
9678 value,
9679 contract_ty,
9680 semantic,
9681 binding_types,
9682 known_roots,
9683 diagnostics,
9684 );
9685 }
9686 continue;
9687 }
9688 }
9689 let Some(name) = rest.split('{').next().and_then(|header| {
9692 let mut parts = header.split_whitespace();
9693 match (parts.next(), parts.next(), parts.next()) {
9694 (Some(name), None, _) => Some(name),
9695 (Some(name), Some("from"), Some(binding))
9696 if action == "complete" && is_identifier(binding) =>
9697 {
9698 Some(name)
9699 }
9700 _ => None,
9701 }
9702 }) else {
9703 diagnostics.push(Diagnostic {
9704 related: Vec::new(),
9705 span: rule.body.span,
9706 message: format!("rule `{}` has malformed `{action}` action", rule.name.name),
9707 suggestion: Some(format!(
9708 "{action} a declared workflow terminal with a payload block"
9709 )),
9710 });
9711 continue;
9712 };
9713 if !declared.contains_key(name) {
9714 diagnostics.push(Diagnostic {
9715 related: Vec::new(),
9716 span: rule.body.span,
9717 message: format!(
9718 "rule `{}` {action}s unknown workflow terminal `{name}`",
9719 rule.name.name
9720 ),
9721 suggestion: Some(format!(
9722 "declare `{kind} {name} Type` on the workflow first",
9723 kind = if action == "complete" {
9724 "output"
9725 } else {
9726 "failure"
9727 }
9728 )),
9729 });
9730 continue;
9731 }
9732 let Some(contract_ty) = declared.get(name) else {
9733 continue;
9734 };
9735 validate_workflow_terminal_payload(
9736 rule,
9737 action,
9738 name,
9739 contract_ty,
9740 semantic,
9741 binding_types,
9742 known_roots,
9743 diagnostics,
9744 );
9745 }
9746}
9747
9748#[allow(clippy::too_many_arguments)]
9749fn validate_workflow_terminal_payload(
9750 rule: &RuleDecl,
9751 action: &str,
9752 terminal_name: &str,
9753 contract_ty: &TypeSyntax,
9754 semantic: &SemanticContext,
9755 binding_types: &BTreeMap<String, String>,
9756 known_roots: &BTreeSet<String>,
9757 diagnostics: &mut Vec<Diagnostic>,
9758) {
9759 let Some((_, _, body)) = workflow_terminal_blocks(&rule.body.text).into_iter().find(
9760 |(candidate_action, candidate_name, _)| {
9761 candidate_action == action && candidate_name == terminal_name
9762 },
9763 ) else {
9764 return;
9765 };
9766 let schema = match contract_ty {
9767 TypeSyntax::Ref { name } if semantic.schemas.class_exists(&name.name) => &name.name,
9768 TypeSyntax::Primitive { .. }
9769 | TypeSyntax::LiteralString { .. }
9770 | TypeSyntax::Union { .. } => {
9771 diagnostics.push(Diagnostic {
9774 related: Vec::new(),
9775 span: rule.body.span,
9776 message: format!(
9777 "workflow terminal `{terminal_name}` has a scalar payload contract but is given a field block"
9778 ),
9779 suggestion: Some(format!(
9780 "write a bare scalar value: `{action} {terminal_name} <value>`"
9781 )),
9782 });
9783 return;
9784 }
9785 _ => {
9786 diagnostics.push(Diagnostic { related: Vec::new(),
9787 span: rule.body.span,
9788 message: format!(
9789 "workflow terminal `{terminal_name}` uses an unsupported payload contract type"
9790 ),
9791 suggestion: Some(
9792 "declare the terminal payload as a class (field block) or a scalar type (number/string/bool)"
9793 .to_owned(),
9794 ),
9795 });
9796 return;
9797 }
9798 };
9799 for assignment in collect_field_assignments(&body) {
9800 let (field, value) = match assignment {
9801 RecordFieldAssignment::Value { field, value } => (field, value),
9802 RecordFieldAssignment::Shorthand { field } => (field.clone(), field),
9803 };
9804 let line = format!("{field} {value}");
9805 validate_record_field(
9806 rule,
9807 &line,
9808 schema,
9809 semantic,
9810 binding_types,
9811 known_roots,
9812 diagnostics,
9813 );
9814 }
9815 validate_required_terminal_fields(rule, schema, terminal_name, &body, semantic, diagnostics);
9816}
9817
9818#[allow(clippy::too_many_arguments)]
9824fn validate_scalar_terminal_payload(
9825 rule: &RuleDecl,
9826 action: &str,
9827 terminal_name: &str,
9828 value: &str,
9829 contract_ty: &TypeSyntax,
9830 semantic: &SemanticContext,
9831 binding_types: &BTreeMap<String, String>,
9832 known_roots: &BTreeSet<String>,
9833 diagnostics: &mut Vec<Diagnostic>,
9834) {
9835 if let TypeSyntax::Ref { name } = contract_ty {
9836 if semantic.schemas.class_exists(&name.name) {
9837 diagnostics.push(Diagnostic {
9838 related: Vec::new(),
9839 span: rule.body.span,
9840 message: format!(
9841 "workflow terminal `{terminal_name}` has a class payload contract `{}` but is given a bare scalar value",
9842 name.name
9843 ),
9844 suggestion: Some(format!("write a field block: `{action} {terminal_name} {{ … }}`")),
9845 });
9846 return;
9847 }
9848 }
9849 if value.is_empty() {
9850 diagnostics.push(Diagnostic {
9851 related: Vec::new(),
9852 span: rule.body.span,
9853 message: format!("workflow terminal `{terminal_name}` is missing its scalar value"),
9854 suggestion: Some(format!("write `{action} {terminal_name} <value>`")),
9855 });
9856 return;
9857 }
9858 validate_literal_assignment(
9861 rule,
9862 terminal_name,
9863 "value",
9864 contract_ty,
9865 value,
9866 semantic,
9867 diagnostics,
9868 );
9869 if let Some(root) = dangling_value_root(value, known_roots) {
9870 diagnostics.push(Diagnostic {
9871 related: Vec::new(),
9872 span: rule.body.span,
9873 message: format!(
9874 "rule `{}` has unknown binding `{root}` in `{action} {terminal_name}` value",
9875 rule.name.name
9876 ),
9877 suggestion: Some(
9878 "reference a binding from a `when ... as name` clause, an effect `as` binding, or a `case` pattern"
9879 .to_owned(),
9880 ),
9881 });
9882 } else if let Some((root, path)) = expression_path(value) {
9883 if let Some(schema) = binding_types.get(&root) {
9884 if semantic.schemas.class_exists(schema) {
9885 if let Err(message) = semantic.schemas.resolve_field_path(schema, &path) {
9886 diagnostics.push(Diagnostic {
9887 related: Vec::new(),
9888 span: rule.body.span,
9889 message: format!(
9890 "rule `{}` has invalid field path `{root}.{}`: {message}",
9891 rule.name.name,
9892 path.join(".")
9893 ),
9894 suggestion: Some(
9895 "use a field declared on the bound schema or add it to the class declaration"
9896 .to_owned(),
9897 ),
9898 });
9899 }
9900 }
9901 }
9902 }
9903}
9904
9905fn validate_required_terminal_fields(
9906 rule: &RuleDecl,
9907 schema: &str,
9908 terminal_name: &str,
9909 body: &str,
9910 semantic: &SemanticContext,
9911 diagnostics: &mut Vec<Diagnostic>,
9912) {
9913 let Some(schema_fields) = semantic.schemas.classes.get(schema) else {
9914 return;
9915 };
9916 let seen = collect_field_assignments(body)
9917 .into_iter()
9918 .map(|assignment| match assignment {
9919 RecordFieldAssignment::Value { field, .. }
9920 | RecordFieldAssignment::Shorthand { field } => field,
9921 })
9922 .collect::<BTreeSet<_>>();
9923 for (required, ty) in schema_fields {
9924 if seen.contains(required) || matches!(ty, TypeSyntax::Optional { .. }) {
9925 continue;
9926 }
9927 diagnostics.push(Diagnostic { related: Vec::new(),
9928 span: rule.body.span,
9929 message: format!(
9930 "workflow terminal `{terminal_name}` is missing required field `{schema}.{required}`"
9931 ),
9932 suggestion: Some(format!("add `{required}` to the `{terminal_name}` payload")),
9933 });
9934 }
9935}
9936
9937fn max_after_depth(statements: &[body::BodyStmt]) -> usize {
9943 use body::BodyStmt;
9944 statements
9945 .iter()
9946 .map(|statement| match statement {
9947 BodyStmt::After(after) => 1 + max_after_depth(&after.body),
9948 BodyStmt::Case(case) => case
9949 .branches
9950 .iter()
9951 .map(|branch| max_after_depth(&branch.body))
9952 .max()
9953 .unwrap_or(0),
9954 _ => 0,
9955 })
9956 .max()
9957 .unwrap_or(0)
9958}
9959
9960fn analyze_rule(
9961 rule: &RuleDecl,
9962 semantic: &SemanticContext,
9963 diagnostics: &mut Vec<Diagnostic>,
9964) -> IrRuleMetadata {
9965 let (body_ast, body_diagnostics) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
9969 diagnostics.extend(body_diagnostics);
9970 let mut metadata = IrRuleMetadata {
9971 fact_reads: rule
9972 .whens
9973 .iter()
9974 .map(|when| fact_read_from_when(&when.text))
9975 .collect(),
9976 max_after_depth: max_after_depth(&body_ast.statements),
9977 ..IrRuleMetadata::default()
9978 };
9979 let mut seen_bindings = BTreeSet::new();
9980 let mut binding_types = BTreeMap::new();
9981 for when in &rule.whens {
9982 let (pattern_text, _) = split_when_guard(&when.text);
9985 if binding_after_as(pattern_text).is_some()
9986 && binding_from_when(&when.text).is_none()
9987 && !pattern_text.ends_with(" is available")
9988 {
9989 diagnostics.push(Diagnostic {
9990 related: Vec::new(),
9991 span: when.span,
9992 message: format!(
9993 "rule `{}` has unknown readiness pattern `{pattern_text}`",
9994 rule.name.name
9995 ),
9996 suggestion: Some(
9997 "match a class (`when Class as x`) or a runtime fact (`when fact <name> as x`)"
9998 .to_owned(),
9999 ),
10000 });
10001 }
10002 if let Some((binding, schema)) = binding_from_when(&when.text) {
10003 validate_binding_name(rule, &binding, when.span, diagnostics);
10004 if !schema.contains('.') && !semantic.schemas.class_exists(&schema) {
10005 let suggestion = match closest_name(&schema, semantic.schemas.classes.keys()) {
10006 Some(candidate) => {
10007 format!("did you mean `{candidate}`? otherwise declare `class {schema}`")
10008 }
10009 None => format!("declare `class {schema}` before matching it"),
10010 };
10011 diagnostics.push(Diagnostic {
10012 related: Vec::new(),
10013 span: when.span,
10014 message: format!("rule `{}` matches unknown class `{schema}`", rule.name.name),
10015 suggestion: Some(suggestion),
10016 });
10017 }
10018 if schema.contains('.')
10022 && !pattern_text.trim_start().starts_with("fact ")
10023 && !semantic.schemas.events.contains(&schema)
10024 {
10025 diagnostics.push(Diagnostic { related: Vec::new(),
10026 span: when.span,
10027 message: format!(
10028 "rule `{}` reacts to undeclared signal `{schema}`",
10029 rule.name.name
10030 ),
10031 suggestion: Some(format!(
10032 "declare `signal {schema} {{ ... }}` for a typed reaction, or use `when fact {schema} as ...` for an untyped one"
10033 )),
10034 });
10035 }
10036 binding_types.insert(binding, schema);
10037 }
10038 }
10039 let mut effect_payload_types = collect_effect_payload_types(rule, semantic, diagnostics);
10040 collect_exec_payload_types(&body_ast.statements, semantic, &mut effect_payload_types);
10044 collect_decide_payload_types(
10048 &body_ast.statements,
10049 &rule.name.name,
10050 &mut effect_payload_types,
10051 );
10052 collect_prompt_payload_types(&body_ast.statements, &mut effect_payload_types);
10053 collect_redact_payload_types(
10057 &body_ast.statements,
10058 &rule.name.name,
10059 &mut effect_payload_types,
10060 );
10061 for (binding, payload_type) in &effect_payload_types {
10062 if let IrType::Ref(schema) = payload_type {
10063 binding_types.insert(binding.clone(), schema.clone());
10064 }
10065 }
10066 let mut effect_binding_kinds: BTreeMap<String, IrEffectKind> = rule
10072 .body
10073 .text
10074 .lines()
10075 .filter_map(|line| {
10076 let line = line.trim();
10077 if line.starts_with("exec ") {
10080 return Some((binding_after_as(line)?, IrEffectKind::ExecCommand));
10081 }
10082 let (kind, binding) = parse_effect_line(line)?;
10083 Some((binding?, kind))
10084 })
10085 .collect();
10086 for statement in effect_payload_statements(&rule.body.text) {
10087 if let Some((kind, Some(binding))) = parse_effect_line(statement.trim()) {
10088 effect_binding_kinds.insert(binding, kind);
10089 }
10090 }
10091 for line in rule.body.text.lines() {
10095 let Some(rest) = line.trim().strip_prefix("after ") else {
10096 continue;
10097 };
10098 let mut words = rest.split_whitespace();
10099 let Some(binding) = words.next() else {
10100 continue;
10101 };
10102 let Some(predicate) = words.next() else {
10103 continue;
10104 };
10105 if predicate == "succeeds" {
10112 match effect_binding_kinds.get(binding) {
10113 Some(IrEffectKind::LeaseAcquire) => {
10114 diagnostics.push(Diagnostic {
10115 related: Vec::new(),
10116 span: rule.body.span,
10117 message: format!(
10118 "rule `{}` observes acquire `{binding}` with `succeeds`, which also \
10119 matches a Contended outcome (the acquire op completes either way)",
10120 rule.name.name
10121 ),
10122 suggestion: Some(format!(
10123 "use `after {binding} held` / `after {binding} contended` for the \
10124 outcome variants, or `after {binding} completes` for any settled \
10125 outcome"
10126 )),
10127 });
10128 }
10129 Some(IrEffectKind::CounterConsume) => {
10130 diagnostics.push(Diagnostic {
10131 related: Vec::new(),
10132 span: rule.body.span,
10133 message: format!(
10134 "rule `{}` observes counter consume `{binding}` with `succeeds`, \
10135 which also matches an Over outcome (the consume op completes \
10136 either way)",
10137 rule.name.name
10138 ),
10139 suggestion: Some(format!(
10140 "use `after {binding} ok` / `after {binding} over` for the outcome \
10141 variants, or `after {binding} completes` for any settled outcome"
10142 )),
10143 });
10144 }
10145 _ => {}
10146 }
10147 }
10148 if predicate == "reaches" {
10152 let Some(quoted) = words.next() else {
10153 continue;
10154 };
10155 let milestone = quoted.trim_matches('"');
10156 let (Some("as"), Some(alias)) = (words.next(), words.next()) else {
10157 continue;
10158 };
10159 let alias = alias.trim_end_matches('{').trim();
10160 if alias.is_empty() {
10161 continue;
10162 }
10163 if let Some(class) = milestone_payload_class(rule, binding, milestone, semantic) {
10164 if !class.is_empty() {
10165 binding_types.insert(alias.to_owned(), class);
10166 }
10167 }
10168 continue;
10169 }
10170 if predicate == "times" && words.next() != Some("out") {
10173 continue;
10174 }
10175 let (Some(keyword), Some(alias)) = (words.next(), words.next()) else {
10176 continue;
10177 };
10178 if keyword != "as" {
10179 continue;
10180 }
10181 let alias = alias.trim_end_matches('{').trim();
10182 if alias.is_empty() {
10183 continue;
10184 }
10185 match predicate {
10191 "times" => {
10192 binding_types.insert(alias.to_owned(), "TerminalTimedOut".to_owned());
10193 }
10194 "cancelled" => {
10195 binding_types.insert(alias.to_owned(), "TerminalCancelled".to_owned());
10196 }
10197 "completes" => {
10203 binding_types.insert(alias.to_owned(), "TerminalOutcome".to_owned());
10204 }
10205 "fails" => {
10219 if let Some(class) = invoke_failure_class(rule, binding, semantic) {
10220 binding_types.insert(alias.to_owned(), class);
10221 } else {
10222 let schema = match effect_binding_kinds.get(binding) {
10223 Some(IrEffectKind::ExecCommand) => "TerminalFailedExec",
10224 Some(IrEffectKind::SchemaCoerce) => "TerminalFailedCoerce",
10225 Some(IrEffectKind::AgentTell) => "TerminalFailedTell",
10226 _ => "TerminalFailed",
10227 };
10228 binding_types.insert(alias.to_owned(), schema.to_owned());
10229 }
10230 }
10231 _ => {
10232 if let Some(IrType::Ref(schema)) = effect_payload_types.get(binding) {
10233 binding_types.insert(alias.to_owned(), schema.clone());
10234 } else if let Some(class) = invoke_output_class(rule, binding, semantic) {
10235 binding_types.insert(alias.to_owned(), class);
10241 }
10242 }
10243 }
10244 }
10245 for when in &rule.whens {
10246 if let (_, Some(guard)) = split_when_guard(&when.text) {
10247 validate_expression(rule, guard, semantic, &binding_types, "guard", diagnostics);
10248 validate_known_field_paths(rule, guard, semantic, &binding_types, diagnostics);
10249 if let Some(expr) = lower_expression(guard, when.span) {
10250 metadata
10251 .projection_reads
10252 .extend(collect_projection_reads(&expr.expr));
10253 }
10254 }
10255 validate_availability_when(rule, &when.text, semantic, &binding_types, diagnostics);
10256 }
10257 validate_case_blocks(rule, semantic, &binding_types, diagnostics);
10258 metadata.case_branches =
10259 collect_rule_case_metadata(rule, semantic, &binding_types, diagnostics);
10260 let terminal_metadata = collect_terminal_case_metadata(
10261 rule,
10262 semantic,
10263 &binding_types,
10264 &effect_payload_types,
10265 diagnostics,
10266 );
10267 let mut known_roots: BTreeSet<String> = binding_types.keys().cloned().collect();
10271 collect_all_binding_names(&body_ast.statements, &mut known_roots);
10272 validate_record_blocks(rule, semantic, &binding_types, &known_roots, diagnostics);
10273 validate_effect_payloads(rule, semantic, &binding_types, &known_roots, diagnostics);
10274 validate_effect_field_roots(rule, &body_ast.statements, &known_roots, diagnostics);
10275 validate_emit_signal_declarations(
10276 rule,
10277 &body_ast.statements,
10278 &semantic.schemas.events,
10279 diagnostics,
10280 );
10281 validate_workflow_invocations(rule, semantic, &binding_types, &known_roots, diagnostics);
10282 validate_milestone_statements(rule, semantic, diagnostics);
10283 let mut block_stack: Vec<BlockFrame> = Vec::new();
10284 let mut misplaced_effect_bindings = BTreeSet::new();
10285 seed_ast_only_effect_bindings(&body_ast.statements, &mut seen_bindings, &mut binding_types);
10286 validate_body_effect_operands(
10287 rule,
10288 &body_ast.statements,
10289 semantic,
10290 &binding_types,
10291 diagnostics,
10292 );
10293 validate_coordination_discipline(rule, &body_ast.statements, diagnostics);
10294 validate_redactions(
10297 rule,
10298 &body_ast.statements,
10299 semantic,
10300 &binding_types,
10301 diagnostics,
10302 );
10303 validate_conditioned_field_reads(
10306 rule,
10307 &body_ast.statements,
10308 semantic,
10309 &binding_types,
10310 &BTreeSet::new(),
10311 diagnostics,
10312 );
10313 let mut anonymous_effects = 0usize;
10314 let mut record_depth = 0i32;
10315
10316 for raw_line in rule.body.text.lines() {
10317 let line = raw_line.trim();
10318 if line.is_empty() {
10319 continue;
10320 }
10321
10322 if record_depth > 0 {
10323 record_depth += brace_delta(line);
10324 continue;
10325 }
10326
10327 if let Some(binding) = binding_after_multiline_string_end(line) {
10328 misplaced_effect_bindings.insert(binding.clone());
10329 diagnostics.push(Diagnostic { related: Vec::new(),
10330 span: rule.body.span,
10331 message: format!(
10332 "rule `{}` places effect binding `{binding}` after a multiline string delimiter",
10333 rule.name.name
10334 ),
10335 suggestion: Some(format!(
10336 "move `as {binding}` onto the effect line, before the multiline string body"
10337 )),
10338 });
10339 continue;
10340 }
10341 validate_rule_prompt_content_type_annotation(rule, line, diagnostics);
10342
10343 if line.starts_with('}') {
10344 block_stack.pop();
10345 continue;
10346 }
10347
10348 if line.starts_with("case ") || (!line.starts_with("after ") && is_case_branch_start(line))
10349 {
10350 validate_known_field_paths(rule, line, semantic, &binding_types, diagnostics);
10351 continue;
10352 }
10353
10354 let active_afters = after_scopes(&block_stack);
10355 validate_binding_uses(rule, line, &seen_bindings, &active_afters, diagnostics);
10356 validate_known_field_paths(rule, line, semantic, &binding_types, diagnostics);
10357
10358 if let Some(binding) = parse_consume_line(line) {
10359 match binding_types.get(&binding) {
10360 Some(schema) => metadata.fact_consumes.push(format!("schema:{schema}")),
10361 None => diagnostics.push(Diagnostic {
10362 related: Vec::new(),
10363 span: rule.body.span,
10364 message: format!(
10365 "rule `{}` consumes unknown fact binding `{binding}`",
10366 rule.name.name
10367 ),
10368 suggestion: Some(
10369 "consume a binding introduced by a `when Class as binding` clause"
10370 .to_owned(),
10371 ),
10372 }),
10373 }
10374 if !line.contains("->") {
10375 continue;
10376 }
10377 }
10378
10379 if line.starts_with("after ") {
10380 if let Some(alias) = binding_after_as(line) {
10381 validate_binding_name(rule, &alias, rule.body.span, diagnostics);
10382 }
10383 match parse_after_line(line) {
10384 Some((binding, predicate)) => {
10385 if !seen_bindings.contains(&binding) {
10386 let suggestion = if misplaced_effect_bindings.contains(&binding) {
10387 format!(
10388 "move `as {binding}` onto the effect line before the multiline string"
10389 )
10390 } else {
10391 format!("create an effect with `as {binding}` before the `after` block")
10392 };
10393 diagnostics.push(Diagnostic { related: Vec::new(),
10394 span: rule.body.span,
10395 message: format!(
10396 "rule `{}` has `after` block for unknown effect binding `{binding}`",
10397 rule.name.name
10398 ),
10399 suggestion: Some(suggestion),
10400 });
10401 }
10402 block_stack.push(BlockFrame::After { binding, predicate });
10403 }
10404 None => {
10405 diagnostics.push(Diagnostic { related: Vec::new(),
10406 span: rule.body.span,
10407 message: format!(
10408 "rule `{}` has unsupported `after` dependency predicate",
10409 rule.name.name
10410 ),
10411 suggestion: Some(
10412 "use `after name succeeds`, `after name fails`, `after name completes`, `after name times out`, or `after name cancelled`"
10413 .to_owned(),
10414 ),
10415 });
10416 }
10417 }
10418 continue;
10419 }
10420
10421 if let Some((schema, _)) = parse_record_start(line) {
10422 if is_observer_only_schema(&schema) {
10423 diagnostics.push(Diagnostic {
10424 related: Vec::new(),
10425 span: rule.body.span,
10426 message: format!(
10427 "rule `{}` cannot record kernel-owned terminal schema `{schema}`",
10428 rule.name.name
10429 ),
10430 suggestion: Some(
10431 "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`"
10432 .to_owned(),
10433 ),
10434 });
10435 } else if !semantic.schemas.class_exists(&schema) {
10436 diagnostics.push(Diagnostic {
10437 related: Vec::new(),
10438 span: rule.body.span,
10439 message: format!("rule `{}` records unknown class `{schema}`", rule.name.name),
10440 suggestion: Some(format!("declare `class {schema}` before recording it")),
10441 });
10442 }
10443 metadata.fact_writes.push(format!("schema:{schema}"));
10444 record_depth = brace_delta(line).max(1);
10445 continue;
10446 }
10447
10448 if let Some((kind, binding)) = parse_effect_line(line) {
10449 validate_agent_tell_target(
10450 rule,
10451 line,
10452 &kind,
10453 semantic,
10454 &binding_types,
10455 &known_roots,
10456 diagnostics,
10457 );
10458 anonymous_effects += 1;
10459 let id = binding
10460 .clone()
10461 .unwrap_or_else(|| format!("effect{anonymous_effects}"));
10462 if let Some(binding) = &binding {
10463 validate_binding_name(rule, binding, rule.body.span, diagnostics);
10464 seen_bindings.insert(binding.clone());
10465 if let Some(schema) = effect_binding_schema(line, &kind, semantic) {
10466 binding_types.insert(binding.clone(), schema);
10467 }
10468 }
10469 for (upstream, predicate) in after_scopes(&block_stack) {
10470 metadata.dependencies.push(IrEffectDependency {
10471 upstream,
10472 predicate,
10473 downstream: id.clone(),
10474 });
10475 }
10476 let idempotency_key = effect_idempotency_key(&rule.name.name, &id, &kind, &binding);
10477 metadata.effects.push(IrEffectNode {
10478 id,
10479 kind,
10480 binding,
10481 required_capabilities: parse_required_capabilities(line),
10482 construct_use: None,
10483 idempotency_key,
10484 span: rule.body.span,
10485 timeout_seconds: None,
10486 access_grants: Vec::new(),
10489 turn_skills: Vec::new(),
10490 resource: None,
10491 agent: None,
10492 workflow_target: None,
10493 endorsed: false,
10494 declassified: false,
10495 selected_by: None,
10496 exec_target: None,
10497 });
10498 }
10499 }
10500
10501 let (ast_effects, ast_dependencies) =
10502 collect_effects_from_ast(&body_ast.statements, &rule.name.name);
10503 metadata.effects = ast_effects;
10504 metadata.dependencies = ast_dependencies;
10505
10506 push_ingest_fact_writes(&body_ast.statements, &mut metadata.fact_writes);
10510
10511 metadata.fact_reads.sort();
10512 metadata.fact_reads.dedup();
10513 sort_projection_reads(&mut metadata.projection_reads);
10514 metadata.fact_writes.sort();
10515 metadata.fact_writes.dedup();
10516 metadata.fact_consumes.sort();
10517 metadata.fact_consumes.dedup();
10518 metadata.terminal_outputs = terminal_metadata.outputs;
10519 metadata.terminal_branches = terminal_metadata.branches;
10520 for branch in &metadata.case_branches {
10527 if let Some(guard) = &branch.guard {
10528 metadata
10529 .projection_reads
10530 .extend(collect_projection_reads(&guard.expr));
10531 }
10532 }
10533 for branch in &metadata.terminal_branches {
10534 if let Some(guard) = &branch.guard {
10535 metadata
10536 .projection_reads
10537 .extend(collect_projection_reads(&guard.expr));
10538 }
10539 }
10540 sort_projection_reads(&mut metadata.projection_reads);
10541 collect_terminal_complete_bindings(&body_ast.statements, &mut metadata.terminal_completes);
10542 metadata.terminal_completes.sort();
10543 metadata.terminal_completes.dedup();
10544 collect_redaction_metadata(
10545 &body_ast.statements,
10546 &binding_types,
10547 &mut metadata.redactions,
10548 );
10549 collect_bounded_egresses(
10550 &body_ast.statements,
10551 &binding_types,
10552 &mut metadata.bounded_egresses,
10553 );
10554 let mut egress_reads = Vec::new();
10555 collect_egress_payload_reads(&body_ast.statements, &mut egress_reads);
10556 for (sink, roots) in egress_reads {
10557 metadata
10558 .egress_payload_reads
10559 .entry(sink)
10560 .or_default()
10561 .extend(roots);
10562 }
10563 collect_complete_field_reads(&body_ast.statements, &mut metadata.complete_field_reads);
10564 collect_milestone_field_reads(&body_ast.statements, &mut metadata.milestone_field_reads);
10565 collect_crossing_roots(
10566 &body_ast.statements,
10567 &mut metadata.declassified_roots,
10568 &mut metadata.endorsed_roots,
10569 );
10570 collect_provenance_metadata(
10571 &body_ast.statements,
10572 &mut metadata.coerce_input_roots,
10573 &mut metadata.after_aliases,
10574 );
10575 collect_egress_case_influence(
10576 &body_ast.statements,
10577 &mut Vec::new(),
10578 &mut metadata.egress_case_influence,
10579 );
10580 loop {
10587 let mut changed = false;
10588 for redaction in &metadata.redactions {
10589 if metadata.declassified_roots.contains(&redaction.source)
10590 && metadata
10591 .declassified_roots
10592 .insert(redaction.binding.clone())
10593 {
10594 changed = true;
10595 }
10596 if metadata.endorsed_roots.contains(&redaction.source)
10597 && metadata.endorsed_roots.insert(redaction.binding.clone())
10598 {
10599 changed = true;
10600 }
10601 }
10602 if !changed {
10603 break;
10604 }
10605 }
10606 metadata
10607}
10608
10609fn collect_egress_case_influence(
10614 statements: &[body::BodyStmt],
10615 active: &mut Vec<BTreeSet<String>>,
10616 out: &mut BTreeMap<String, BTreeSet<String>>,
10617) {
10618 let record_sink = |sink: String,
10619 active: &[BTreeSet<String>],
10620 out: &mut BTreeMap<String, BTreeSet<String>>| {
10621 if active.is_empty() {
10622 return;
10623 }
10624 let entry = out.entry(sink).or_default();
10625 for roots in active {
10626 entry.extend(roots.iter().cloned());
10627 }
10628 };
10629 for statement in statements {
10630 match statement {
10631 body::BodyStmt::Terminal(terminal) if terminal.kind == body::TerminalKind::Complete => {
10632 record_sink(terminal.name.clone(), active, out);
10633 }
10634 body::BodyStmt::Record(record) => {
10635 record_sink(format!("fact:{}", record.schema), active, out);
10636 }
10637 body::BodyStmt::Done {
10638 replacement: Some(record),
10639 ..
10640 } => {
10641 record_sink(format!("fact:{}", record.schema), active, out);
10642 }
10643 body::BodyStmt::Milestone { name, .. } => {
10644 record_sink(format!("milestone:{name}"), active, out);
10645 }
10646 body::BodyStmt::Effect(effect) => match &effect.kind {
10647 body::BodyEffectKind::ConstructCapabilityCall {
10648 keyword, fields, ..
10649 } if keyword == "send" => {
10650 if let Some(channel) = fields
10651 .iter()
10652 .find(|field| field.name == "channel")
10653 .map(|field| field.source.clone())
10654 {
10655 record_sink(channel, active, out);
10656 }
10657 }
10658 body::BodyEffectKind::FileWrite { store, .. } => {
10659 record_sink(store.clone(), active, out);
10660 }
10661 _ => {}
10662 },
10663 body::BodyStmt::After(after) => {
10664 collect_egress_case_influence(&after.body, active, out);
10665 }
10666 body::BodyStmt::Case(case) => {
10667 let mut roots = BTreeSet::new();
10668 if let Ok(expr) = parse_expression(&case.scrutinee) {
10669 collect_expr_binding_roots(&expr, &mut roots);
10670 } else {
10671 collect_template_binding_roots(&case.scrutinee, &mut roots);
10672 }
10673 active.push(roots);
10674 for branch in &case.branches {
10675 collect_egress_case_influence(&branch.body, active, out);
10676 }
10677 active.pop();
10678 }
10679 _ => {}
10680 }
10681 }
10682}
10683
10684fn collect_provenance_metadata(
10689 statements: &[body::BodyStmt],
10690 coerce_input_roots: &mut BTreeMap<String, BTreeSet<String>>,
10691 after_aliases: &mut BTreeMap<String, String>,
10692) {
10693 for statement in statements {
10694 match statement {
10695 body::BodyStmt::Effect(effect) => {
10696 if let body::BodyEffectKind::Coerce { args, .. } = &effect.kind {
10697 if let Some(binding) = &effect.binding {
10698 let mut roots = BTreeSet::new();
10699 for arg in args {
10700 if let Ok(expr) = parse_expression(arg) {
10701 collect_expr_binding_roots(&expr, &mut roots);
10702 } else {
10703 collect_template_binding_roots(arg, &mut roots);
10704 }
10705 }
10706 coerce_input_roots
10707 .entry(binding.clone())
10708 .or_default()
10709 .extend(roots);
10710 }
10711 }
10712 }
10713 body::BodyStmt::After(after) => {
10714 if matches!(
10715 after.predicate,
10716 body::AfterPredicate::Succeeds | body::AfterPredicate::Completes
10717 ) {
10718 if let Some(alias) = &after.alias {
10719 after_aliases.insert(alias.clone(), after.binding.clone());
10720 }
10721 }
10722 collect_provenance_metadata(&after.body, coerce_input_roots, after_aliases);
10723 }
10724 body::BodyStmt::Case(case) => {
10725 for branch in &case.branches {
10726 collect_provenance_metadata(&branch.body, coerce_input_roots, after_aliases);
10727 }
10728 }
10729 _ => {}
10730 }
10731 }
10732}
10733
10734fn collect_crossing_roots(
10741 statements: &[body::BodyStmt],
10742 declassified: &mut BTreeSet<String>,
10743 endorsed: &mut BTreeSet<String>,
10744) {
10745 fn collect_marked(
10746 statements: &[body::BodyStmt],
10747 declassified: &mut BTreeSet<String>,
10748 endorsed: &mut BTreeSet<String>,
10749 ) {
10750 for statement in statements {
10751 match statement {
10752 body::BodyStmt::Effect(effect) => {
10753 if let body::BodyEffectKind::Coerce {
10754 declassified: is_declassified,
10755 endorsed: is_endorsed,
10756 ..
10757 } = &effect.kind
10758 {
10759 if let Some(binding) = &effect.binding {
10760 if *is_declassified {
10761 declassified.insert(binding.clone());
10762 }
10763 if *is_endorsed {
10764 endorsed.insert(binding.clone());
10765 }
10766 }
10767 }
10768 }
10769 body::BodyStmt::After(after) => collect_marked(&after.body, declassified, endorsed),
10770 body::BodyStmt::Case(case) => {
10771 for branch in &case.branches {
10772 collect_marked(&branch.body, declassified, endorsed);
10773 }
10774 }
10775 _ => {}
10776 }
10777 }
10778 }
10779 fn collect_aliases(
10780 statements: &[body::BodyStmt],
10781 declassified: &mut BTreeSet<String>,
10782 endorsed: &mut BTreeSet<String>,
10783 ) {
10784 for statement in statements {
10785 match statement {
10786 body::BodyStmt::After(after) => {
10787 if matches!(
10788 after.predicate,
10789 body::AfterPredicate::Succeeds | body::AfterPredicate::Completes
10790 ) {
10791 if let Some(alias) = &after.alias {
10792 if declassified.contains(&after.binding) {
10793 declassified.insert(alias.clone());
10794 }
10795 if endorsed.contains(&after.binding) {
10796 endorsed.insert(alias.clone());
10797 }
10798 }
10799 }
10800 collect_aliases(&after.body, declassified, endorsed);
10801 }
10802 body::BodyStmt::Case(case) => {
10803 for branch in &case.branches {
10804 collect_aliases(&branch.body, declassified, endorsed);
10805 }
10806 }
10807 _ => {}
10808 }
10809 }
10810 }
10811 collect_marked(statements, declassified, endorsed);
10812 loop {
10816 let before = (declassified.len(), endorsed.len());
10817 collect_aliases(statements, declassified, endorsed);
10818 if (declassified.len(), endorsed.len()) == before {
10819 break;
10820 }
10821 }
10822}
10823
10824fn collect_complete_field_reads(
10832 statements: &[body::BodyStmt],
10833 out: &mut BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
10834) {
10835 for statement in statements {
10836 match statement {
10837 body::BodyStmt::Terminal(terminal) if terminal.kind == body::TerminalKind::Complete => {
10838 let per_field = out.entry(terminal.name.clone()).or_default();
10839 for field in &terminal.fields {
10840 let mut roots = BTreeSet::new();
10841 match &field.value {
10842 body::FieldValue::Shorthand => {
10843 if let Some(root) = &terminal.from {
10844 roots.insert(root.clone());
10845 }
10846 }
10847 body::FieldValue::Expr { expr, .. } => {
10848 collect_expr_binding_roots(expr, &mut roots)
10849 }
10850 body::FieldValue::Nested { fields, .. } => collect_payload_field_roots(
10851 fields,
10852 terminal.from.as_deref(),
10853 &mut roots,
10854 ),
10855 }
10856 per_field
10857 .entry(field.name.clone())
10858 .or_default()
10859 .extend(roots);
10860 }
10861 }
10862 body::BodyStmt::After(after) => collect_complete_field_reads(&after.body, out),
10863 body::BodyStmt::Case(case) => {
10864 for branch in &case.branches {
10865 collect_complete_field_reads(&branch.body, out);
10866 }
10867 }
10868 _ => {}
10869 }
10870 }
10871}
10872
10873fn collect_milestone_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::Milestone { name, fields, .. } => {
10885 let per_field = out.entry(name.clone()).or_default();
10886 for field in fields {
10887 let mut roots = BTreeSet::new();
10888 match &field.value {
10889 body::FieldValue::Shorthand => {}
10890 body::FieldValue::Expr { expr, .. } => {
10891 collect_expr_binding_roots(expr, &mut roots)
10892 }
10893 body::FieldValue::Nested { fields, .. } => {
10894 collect_payload_field_roots(fields, None, &mut roots)
10895 }
10896 }
10897 per_field
10898 .entry(field.name.clone())
10899 .or_default()
10900 .extend(roots);
10901 }
10902 }
10903 body::BodyStmt::After(after) => collect_milestone_field_reads(&after.body, out),
10904 body::BodyStmt::Case(case) => {
10905 for branch in &case.branches {
10906 collect_milestone_field_reads(&branch.body, out);
10907 }
10908 }
10909 _ => {}
10910 }
10911 }
10912}
10913
10914fn collect_redaction_metadata(
10921 statements: &[body::BodyStmt],
10922 binding_types: &BTreeMap<String, String>,
10923 out: &mut Vec<IrRedaction>,
10924) {
10925 let mut redacts = Vec::new();
10926 collect_redact_effects(statements, &mut redacts);
10927 for (source, keep, binding, _span) in redacts {
10928 out.push(IrRedaction {
10929 source: source.to_owned(),
10930 keep: keep.to_vec(),
10931 binding: binding.to_owned(),
10932 source_schema: binding_types.get(source).cloned(),
10933 });
10934 }
10935}
10936
10937fn push_bounded_projection(
10951 from: Option<&str>,
10952 fields: &[body::FieldAssign],
10953 sink: String,
10954 binding_types: &BTreeMap<String, String>,
10955 out: &mut Vec<IrBoundedEgress>,
10956) {
10957 let Some(source_schema) = from.and_then(|src| binding_types.get(src)) else {
10958 return;
10959 };
10960 if fields.is_empty()
10961 || !fields
10962 .iter()
10963 .all(|field| matches!(field.value, body::FieldValue::Shorthand))
10964 {
10965 return;
10966 }
10967 out.push(IrBoundedEgress {
10968 sink,
10969 source_schema: source_schema.clone(),
10970 keep: fields.iter().map(|field| field.name.clone()).collect(),
10971 });
10972}
10973
10974fn push_bounded_record(
10975 record: &body::RecordStmt,
10976 binding_types: &BTreeMap<String, String>,
10977 out: &mut Vec<IrBoundedEgress>,
10978) {
10979 push_bounded_projection(
10980 record.from.as_deref(),
10981 &record.fields,
10982 format!("fact:{}", record.schema),
10983 binding_types,
10984 out,
10985 );
10986}
10987
10988fn collect_bounded_egresses(
10989 statements: &[body::BodyStmt],
10990 binding_types: &BTreeMap<String, String>,
10991 out: &mut Vec<IrBoundedEgress>,
10992) {
10993 for statement in statements {
10994 match statement {
10995 body::BodyStmt::Record(record) => push_bounded_record(record, binding_types, out),
10996 body::BodyStmt::Done {
10997 replacement: Some(record),
10998 ..
10999 } => push_bounded_record(record, binding_types, out),
11000 body::BodyStmt::Terminal(terminal)
11003 if terminal.kind == body::TerminalKind::Complete && terminal.from.is_some() =>
11004 {
11005 push_bounded_projection(
11006 terminal.from.as_deref(),
11007 &terminal.fields,
11008 terminal.name.clone(),
11009 binding_types,
11010 out,
11011 );
11012 }
11013 body::BodyStmt::After(after) => {
11014 collect_bounded_egresses(&after.body, binding_types, out)
11015 }
11016 body::BodyStmt::Case(case) => {
11017 for branch in &case.branches {
11018 collect_bounded_egresses(&branch.body, binding_types, out);
11019 }
11020 }
11021 _ => {}
11022 }
11023 }
11024}
11025
11026fn collect_expr_binding_roots(expr: &Expr, out: &mut BTreeSet<String>) {
11034 match expr {
11035 Expr::Literal(ExprLiteral::String(text)) => collect_template_binding_roots(text, out),
11036 Expr::Literal(ExprLiteral::Ident(name)) => {
11037 out.insert(name.clone());
11038 }
11039 Expr::Literal(ExprLiteral::Number(_) | ExprLiteral::Bool(_) | ExprLiteral::Null) => {}
11040 Expr::Path(segments) => {
11041 if let Some(root) = segments.first() {
11042 out.insert(root.clone());
11043 }
11044 }
11045 Expr::Index { target, key } => {
11046 collect_expr_binding_roots(target, out);
11047 collect_expr_binding_roots(key, out);
11048 }
11049 Expr::Array(items) => {
11050 for item in items {
11051 collect_expr_binding_roots(item, out);
11052 }
11053 }
11054 Expr::Object(fields) => {
11055 for field in fields {
11056 collect_expr_binding_roots(&field.value, out);
11057 }
11058 }
11059 Expr::Unary { expr, .. } => collect_expr_binding_roots(expr, out),
11060 Expr::Binary { left, right, .. } => {
11061 collect_expr_binding_roots(left, out);
11062 collect_expr_binding_roots(right, out);
11063 }
11064 Expr::Call { args, .. } => {
11065 for arg in args {
11066 collect_expr_binding_roots(arg, out);
11067 }
11068 }
11069 Expr::Query { head, guard, .. } => {
11070 out.insert(head.clone());
11071 if let Some(guard) = guard {
11072 collect_expr_binding_roots(guard, out);
11073 }
11074 }
11075 }
11076}
11077
11078fn collect_template_binding_roots(text: &str, out: &mut BTreeSet<String>) {
11084 let mut rest = text;
11085 while let Some(open) = rest.find("{{") {
11086 let after_open = &rest[open + 2..];
11087 let Some(close) = after_open.find("}}") else {
11088 break;
11089 };
11090 let body = after_open[..close].trim();
11091 if let Ok(expr) = parse_expression(body) {
11092 collect_expr_binding_roots(&expr, out);
11093 } else {
11094 for token in body.split(|ch: char| !ch.is_alphanumeric() && ch != '_') {
11095 if token
11096 .as_bytes()
11097 .first()
11098 .is_some_and(|byte| is_ident_start(*byte))
11099 {
11100 out.insert(token.to_owned());
11101 }
11102 }
11103 }
11104 rest = &after_open[close + 2..];
11105 }
11106}
11107
11108fn collect_payload_field_roots(
11111 fields: &[body::FieldAssign],
11112 from_binding: Option<&str>,
11113 out: &mut BTreeSet<String>,
11114) {
11115 for field in fields {
11116 match &field.value {
11117 body::FieldValue::Shorthand => {
11118 if let Some(root) = from_binding {
11119 out.insert(root.to_owned());
11120 }
11121 }
11122 body::FieldValue::Expr { expr, .. } => collect_expr_binding_roots(expr, out),
11123 body::FieldValue::Nested { fields, .. } => {
11124 collect_payload_field_roots(fields, from_binding, out)
11125 }
11126 }
11127 }
11128}
11129
11130fn collect_egress_payload_reads(
11139 statements: &[body::BodyStmt],
11140 out: &mut Vec<(String, BTreeSet<String>)>,
11141) {
11142 for statement in statements {
11143 match statement {
11144 body::BodyStmt::Terminal(terminal) if terminal.kind == body::TerminalKind::Complete => {
11145 let mut roots = BTreeSet::new();
11146 collect_payload_field_roots(&terminal.fields, None, &mut roots);
11147 if let Some(body::FieldValue::Expr { expr, .. }) = &terminal.scalar {
11151 collect_expr_binding_roots(expr, &mut roots);
11152 }
11153 out.push((terminal.name.clone(), roots));
11154 }
11155 body::BodyStmt::Record(record) => out.push(record_payload_reads(record)),
11156 body::BodyStmt::Done {
11158 replacement: Some(record),
11159 ..
11160 } => out.push(record_payload_reads(record)),
11161 body::BodyStmt::Milestone { name, fields, .. } => {
11162 let mut roots = BTreeSet::new();
11163 collect_payload_field_roots(fields, None, &mut roots);
11164 out.push((format!("milestone:{name}"), roots));
11165 }
11166 body::BodyStmt::Effect(effect) => match &effect.kind {
11173 body::BodyEffectKind::ConstructCapabilityCall {
11174 keyword, fields, ..
11175 } if keyword == "send" => {
11176 if let Some(reads) = send_payload_reads(fields) {
11177 out.push(reads);
11178 }
11179 }
11180 body::BodyEffectKind::FileWrite {
11181 store, path, body, ..
11182 } => {
11183 let mut roots = BTreeSet::new();
11184 for source in [path, body] {
11185 if let Ok(expr) = parse_expression(source) {
11186 collect_expr_binding_roots(&expr, &mut roots);
11187 } else {
11188 collect_template_binding_roots(source, &mut roots);
11189 }
11190 }
11191 out.push((store.clone(), roots));
11192 }
11193 _ => {}
11194 },
11195 body::BodyStmt::After(after) => collect_egress_payload_reads(&after.body, out),
11196 body::BodyStmt::Case(case) => {
11197 for branch in &case.branches {
11198 collect_egress_payload_reads(&branch.body, out);
11199 }
11200 }
11201 _ => {}
11202 }
11203 }
11204}
11205
11206fn send_payload_reads(fields: &[body::ConstructUseField]) -> Option<(String, BTreeSet<String>)> {
11211 let channel = fields
11212 .iter()
11213 .find(|field| field.name == "channel")
11214 .map(|field| field.source.clone())?;
11215 let mut roots = BTreeSet::new();
11216 for field in fields.iter().filter(|field| field.name != "channel") {
11217 if let Ok(expr) = parse_expression(&field.source) {
11218 collect_expr_binding_roots(&expr, &mut roots);
11219 } else {
11220 collect_template_binding_roots(&field.source, &mut roots);
11222 }
11223 }
11224 Some((channel, roots))
11225}
11226
11227fn record_payload_reads(record: &body::RecordStmt) -> (String, BTreeSet<String>) {
11231 let mut roots = BTreeSet::new();
11232 if let Some(from) = &record.from {
11233 roots.insert(from.clone());
11234 }
11235 collect_payload_field_roots(&record.fields, record.from.as_deref(), &mut roots);
11236 (format!("fact:{}", record.schema), roots)
11237}
11238
11239#[derive(Clone, Debug, Default)]
11240struct TerminalMetadata {
11241 outputs: Vec<IrTerminalOutput>,
11242 branches: Vec<IrTerminalCaseBranch>,
11243}
11244
11245#[derive(Clone, Debug)]
11246struct TerminalBranchSource {
11247 scrutinee: String,
11248 pattern: String,
11249 guard: Option<String>,
11250 body: String,
11251 pattern_span: SourceSpan,
11252}
11253
11254#[derive(Clone, Debug)]
11255struct RuleCaseBranchSource {
11256 scrutinee: String,
11257 scrutinee_type: TypeSyntax,
11258 pattern: String,
11259 guard: Option<String>,
11260 body: String,
11261 pattern_span: SourceSpan,
11262}
11263
11264fn collect_effect_payload_types(
11265 rule: &RuleDecl,
11266 semantic: &SemanticContext,
11267 diagnostics: &mut Vec<Diagnostic>,
11268) -> BTreeMap<String, IrType> {
11269 let mut payloads = BTreeMap::new();
11270 for statement in effect_payload_statements(&rule.body.text) {
11271 let line = statement.trim();
11272 let Some((kind, Some(binding))) = parse_effect_line(line) else {
11273 continue;
11274 };
11275 let payload = terminal_completed_payload_type(line, &kind, semantic);
11276 match payloads.get(&binding) {
11281 Some(existing) if existing != &payload => {
11282 diagnostics.push(Diagnostic {
11283 related: Vec::new(),
11284 span: rule.body.span,
11285 message: format!(
11286 "rule `{}` reuses effect binding `{binding}` for effects with conflicting result types",
11287 rule.name.name
11288 ),
11289 suggestion: Some(format!(
11290 "give each effect a distinct binding — `as {binding}` is reused with a different result type, so `after {binding} …` is ambiguous"
11291 )),
11292 });
11293 }
11294 Some(_) => {}
11295 None => {
11296 payloads.insert(binding, payload);
11297 }
11298 }
11299 }
11300
11301 payloads
11302}
11303
11304fn terminal_completed_payload_type(
11305 line: &str,
11306 kind: &IrEffectKind,
11307 semantic: &SemanticContext,
11308) -> IrType {
11309 match kind {
11310 IrEffectKind::SchemaCoerce if line.starts_with("prompt ") => {
11311 IrType::Primitive(IrPrimitiveType::String)
11312 }
11313 IrEffectKind::SchemaCoerce => parse_coerce_call_name(line)
11314 .and_then(|name| semantic.coerce_outputs.get(name))
11315 .cloned()
11316 .map(lower_type)
11317 .unwrap_or_else(terminal_unknown_payload_type),
11318 IrEffectKind::AgentTell => IrType::Ref("AgentTurn".to_owned()),
11319 IrEffectKind::CapabilityCall
11320 | IrEffectKind::EventEmit
11321 | IrEffectKind::WorkflowInvoke
11322 | IrEffectKind::TimerWait
11323 | IrEffectKind::ExecCommand
11324 | IrEffectKind::TrackerFile
11325 | IrEffectKind::TrackerClaim
11326 | IrEffectKind::TrackerRenew
11327 | IrEffectKind::TrackerRelease
11328 | IrEffectKind::TrackerFinish
11329 | IrEffectKind::LeaseAcquire
11330 | IrEffectKind::LeaseRenew
11331 | IrEffectKind::LedgerAppend
11332 | IrEffectKind::CounterConsume
11333 | IrEffectKind::SignalEmit
11334 | IrEffectKind::FileRead
11335 | IrEffectKind::FileWrite
11336 | IrEffectKind::FileImport
11337 | IrEffectKind::FileExport => terminal_unknown_payload_type(),
11338 }
11339}
11340
11341fn collect_rule_case_metadata(
11342 rule: &RuleDecl,
11343 semantic: &SemanticContext,
11344 binding_types: &BTreeMap<String, String>,
11345 diagnostics: &mut Vec<Diagnostic>,
11346) -> Vec<IrRuleCaseBranch> {
11347 let mut branches = Vec::new();
11348 for branch in rule_case_branch_sources(rule, semantic, binding_types) {
11349 let mut branch_scope = binding_types.clone();
11350 if let Some((binding, schema)) =
11351 case_branch_payload_binding(&branch.pattern, &branch.scrutinee_type, semantic)
11352 {
11353 branch_scope.insert(binding, schema);
11354 }
11355 if let Some(guard) = &branch.guard {
11356 validate_expression(
11357 rule,
11358 guard,
11359 semantic,
11360 &branch_scope,
11361 "case guard",
11362 diagnostics,
11363 );
11364 validate_known_field_paths_at_span(
11365 rule,
11366 guard,
11367 branch.pattern_span,
11368 semantic,
11369 &branch_scope,
11370 diagnostics,
11371 );
11372 }
11373 validate_known_field_paths_at_span(
11374 rule,
11375 &branch.body,
11376 branch.pattern_span,
11377 semantic,
11378 &branch_scope,
11379 diagnostics,
11380 );
11381 if let Some(pattern) = lower_case_pattern(&branch.pattern, &branch.scrutinee_type, semantic)
11382 {
11383 branches.push(IrRuleCaseBranch {
11384 scrutinee: branch.scrutinee,
11385 scrutinee_type: lower_type(branch.scrutinee_type),
11386 pattern,
11387 guard: branch.guard.as_ref().and_then(|guard| {
11388 lower_expression(
11389 guard,
11390 SourceSpan {
11391 start: branch.pattern_span.start,
11392 end: branch.pattern_span.end,
11393 },
11394 )
11395 }),
11396 body_hash: stable_hash(&branch.body),
11397 pattern_span: branch.pattern_span,
11398 });
11399 }
11400 }
11401 branches.sort_by(|left, right| {
11402 (left.scrutinee.as_str(), left.pattern_span.start)
11403 .cmp(&(right.scrutinee.as_str(), right.pattern_span.start))
11404 });
11405 branches
11406}
11407
11408fn rule_case_branch_sources(
11409 rule: &RuleDecl,
11410 semantic: &SemanticContext,
11411 binding_types: &BTreeMap<String, String>,
11412) -> Vec<RuleCaseBranchSource> {
11413 let lines = rule
11414 .body
11415 .text
11416 .lines()
11417 .scan(0usize, |offset, line| {
11418 let current = *offset;
11419 *offset += line.len() + 1;
11420 Some((line, current))
11421 })
11422 .collect::<Vec<_>>();
11423 let text_lines = lines.iter().map(|(line, _)| *line).collect::<Vec<_>>();
11424 let mut branches = Vec::new();
11425 let mut index = 0usize;
11426 while index < lines.len() {
11427 let (line, _) = lines[index];
11428 let trimmed = line.trim();
11429 let Some(scrutinee) = case_scrutinee(trimmed) else {
11430 index += 1;
11431 continue;
11432 };
11433 if active_completes_binding_for_case(&text_lines, index, scrutinee) {
11434 index += 1;
11435 continue;
11436 }
11437 let Some(scrutinee_type) = expression_type(scrutinee, semantic, binding_types) else {
11438 index += 1;
11439 continue;
11440 };
11441 let mut depth = brace_delta(trimmed).max(1);
11442 index += 1;
11443 while index < lines.len() && depth > 0 {
11444 let (branch_line, branch_line_offset) = lines[index];
11445 let branch_trimmed = branch_line.trim();
11446 if depth == 1 {
11447 if let Some((pattern, guard, body_start)) = terminal_branch_header(branch_trimmed) {
11448 let pattern_column = case_pattern_column(branch_line, pattern);
11449 let pattern_span = SourceSpan {
11450 start: rule_body_text_start(rule) + branch_line_offset + pattern_column,
11451 end: rule_body_text_start(rule)
11452 + branch_line_offset
11453 + pattern_column
11454 + pattern.len(),
11455 };
11456 let mut body_lines = Vec::new();
11457 let mut branch_depth = brace_delta(body_start).max(1);
11458 index += 1;
11459 while index < lines.len() && branch_depth > 0 {
11460 let body_line = lines[index].0;
11461 let next_depth = branch_depth + brace_delta(body_line);
11462 if next_depth >= 1 {
11463 body_lines.push(body_line.to_owned());
11464 }
11465 branch_depth = next_depth;
11466 index += 1;
11467 }
11468 branches.push(RuleCaseBranchSource {
11469 scrutinee: scrutinee.to_owned(),
11470 scrutinee_type: scrutinee_type.clone(),
11471 pattern: pattern.to_owned(),
11472 guard,
11473 body: body_lines.join("\n"),
11474 pattern_span,
11475 });
11476 continue;
11477 }
11478 }
11479 depth += brace_delta(branch_trimmed);
11480 index += 1;
11481 }
11482 }
11483 branches
11484}
11485
11486fn lower_case_pattern(
11487 pattern: &str,
11488 scrutinee_type: &TypeSyntax,
11489 semantic: &SemanticContext,
11490) -> Option<IrCasePattern> {
11491 if is_fallback_pattern(pattern) {
11492 return Some(IrCasePattern::Wildcard);
11493 }
11494 if pattern == "None" {
11495 return Some(IrCasePattern::OptionalNone);
11496 }
11497 if let Some(binding) = pattern.strip_prefix("Some ").map(str::trim) {
11498 if !binding.is_empty() {
11499 return Some(IrCasePattern::OptionalSome {
11500 binding: binding.to_owned(),
11501 });
11502 }
11503 }
11504 match scrutinee_type {
11505 TypeSyntax::Ref { name } if semantic.schemas.enums.contains_key(&name.name) => {
11506 let (variant, _) = sum_case_pattern_parts(pattern);
11509 Some(IrCasePattern::EnumVariant(variant.to_owned()))
11510 }
11511 TypeSyntax::Union { .. } => parse_literal_expr(pattern).and_then(|literal| match literal {
11512 LiteralExpr::String(value) => Some(IrCasePattern::LiteralString(value.to_owned())),
11513 LiteralExpr::Ident(value) => Some(IrCasePattern::LiteralString(value.to_owned())),
11514 _ => None,
11515 }),
11516 TypeSyntax::AgentRef { .. } => {
11517 parse_literal_expr(pattern).and_then(|literal| match literal {
11518 LiteralExpr::String(value) | LiteralExpr::Ident(value) => {
11519 Some(IrCasePattern::Agent(value.to_owned()))
11520 }
11521 _ => None,
11522 })
11523 }
11524 TypeSyntax::Optional { inner, .. } => lower_case_pattern(pattern, inner, semantic),
11525 _ => None,
11526 }
11527}
11528
11529fn case_branch_payload_binding(
11530 pattern: &str,
11531 scrutinee_type: &TypeSyntax,
11532 semantic: &SemanticContext,
11533) -> Option<(String, String)> {
11534 if let TypeSyntax::Ref { name } = scrutinee_type {
11537 if semantic.schemas.enums.contains_key(&name.name) {
11538 let (variant, binding) = sum_case_pattern_parts(pattern);
11539 let binding = binding?;
11540 let generated = format!("{}.{variant}", name.name);
11541 if binding.is_empty() || !semantic.schemas.class_exists(&generated) {
11542 return None;
11543 }
11544 return Some((binding.to_owned(), generated));
11545 }
11546 }
11547 let binding = pattern.strip_prefix("Some ").map(str::trim)?;
11548 if binding.is_empty() {
11549 return None;
11550 }
11551 let TypeSyntax::Optional { inner, .. } = scrutinee_type else {
11552 return None;
11553 };
11554 let schema = match inner.as_ref() {
11555 TypeSyntax::Ref { name } if semantic.schemas.class_exists(&name.name) => {
11556 Some(name.name.clone())
11557 }
11558 _ => None,
11559 }?;
11560 Some((binding.to_owned(), schema))
11561}
11562
11563fn collect_terminal_case_metadata(
11564 rule: &RuleDecl,
11565 semantic: &SemanticContext,
11566 binding_types: &BTreeMap<String, String>,
11567 effect_payload_types: &BTreeMap<String, IrType>,
11568 diagnostics: &mut Vec<Diagnostic>,
11569) -> TerminalMetadata {
11570 let mut metadata = TerminalMetadata::default();
11571 let mut output_bindings = BTreeSet::new();
11572
11573 for branch in terminal_case_branch_sources(rule) {
11574 if output_bindings.insert(branch.scrutinee.clone()) {
11575 let completed_payload = effect_payload_types
11576 .get(&branch.scrutinee)
11577 .cloned()
11578 .unwrap_or_else(terminal_unknown_payload_type);
11579 metadata.outputs.push(IrTerminalOutput {
11580 binding: branch.scrutinee.clone(),
11581 alternatives: terminal_alternatives(completed_payload, branch.pattern_span),
11582 span: branch.pattern_span,
11583 });
11584 }
11585
11586 let (tag, binding) = parse_terminal_pattern_parts(&branch.pattern);
11587 let mut branch_scope = binding_types.clone();
11588 if let (Some(tag), Some(binding)) = (&tag, &binding) {
11589 if let Some(schema) =
11590 terminal_payload_schema_for_tag(tag, &branch.scrutinee, effect_payload_types)
11591 {
11592 branch_scope.insert(binding.clone(), schema);
11593 }
11594 }
11595 if let Some(guard) = &branch.guard {
11596 validate_expression(
11597 rule,
11598 guard,
11599 semantic,
11600 &branch_scope,
11601 "case guard",
11602 diagnostics,
11603 );
11604 validate_known_field_paths(rule, guard, semantic, &branch_scope, diagnostics);
11605 }
11606 validate_known_field_paths(rule, &branch.body, semantic, &branch_scope, diagnostics);
11607 metadata.branches.push(IrTerminalCaseBranch {
11608 scrutinee: branch.scrutinee,
11609 tag,
11610 binding,
11611 guard: branch.guard.as_ref().and_then(|guard| {
11612 lower_expression(
11613 guard,
11614 SourceSpan {
11615 start: branch.pattern_span.start,
11616 end: branch.pattern_span.end,
11617 },
11618 )
11619 }),
11620 body_hash: stable_hash(&branch.body),
11621 pattern_span: branch.pattern_span,
11622 });
11623 }
11624
11625 metadata
11626 .outputs
11627 .sort_by(|left, right| left.binding.cmp(&right.binding));
11628 metadata.branches.sort_by(|left, right| {
11629 (left.scrutinee.as_str(), left.pattern_span.start)
11630 .cmp(&(right.scrutinee.as_str(), right.pattern_span.start))
11631 });
11632 metadata
11633}
11634
11635fn terminal_case_branch_sources(rule: &RuleDecl) -> Vec<TerminalBranchSource> {
11636 let lines = rule
11637 .body
11638 .text
11639 .lines()
11640 .scan(0usize, |offset, line| {
11641 let current = *offset;
11642 *offset += line.len() + 1;
11643 Some((line, current))
11644 })
11645 .collect::<Vec<_>>();
11646 let text_lines = lines.iter().map(|(line, _)| *line).collect::<Vec<_>>();
11647 let mut branches = Vec::new();
11648 let mut index = 0usize;
11649 while index < lines.len() {
11650 let (line, line_offset) = lines[index];
11651 let trimmed = line.trim();
11652 let Some(scrutinee) = case_scrutinee(trimmed) else {
11653 index += 1;
11654 continue;
11655 };
11656 if !active_completes_binding_for_case(&text_lines, index, scrutinee) {
11657 index += 1;
11658 continue;
11659 }
11660 let mut depth = brace_delta(trimmed).max(1);
11661 index += 1;
11662 while index < lines.len() && depth > 0 {
11663 let (branch_line, branch_line_offset) = lines[index];
11664 let branch_trimmed = branch_line.trim();
11665 if depth == 1 {
11666 if let Some((pattern, guard, body_start)) = terminal_branch_header(branch_trimmed) {
11667 let pattern_column = case_pattern_column(branch_line, pattern);
11668 let pattern_span = SourceSpan {
11669 start: rule_body_text_start(rule) + branch_line_offset + pattern_column,
11670 end: rule_body_text_start(rule)
11671 + branch_line_offset
11672 + pattern_column
11673 + pattern.len(),
11674 };
11675 let mut body_lines = Vec::new();
11676 let mut branch_depth = brace_delta(body_start).max(1);
11677 index += 1;
11678 while index < lines.len() && branch_depth > 0 {
11679 let body_line = lines[index].0;
11680 let next_depth = branch_depth + brace_delta(body_line);
11681 if next_depth >= 1 {
11682 body_lines.push(body_line.to_owned());
11683 }
11684 branch_depth = next_depth;
11685 index += 1;
11686 }
11687 branches.push(TerminalBranchSource {
11688 scrutinee: scrutinee.to_owned(),
11689 pattern: pattern.to_owned(),
11690 guard,
11691 body: body_lines.join("\n"),
11692 pattern_span,
11693 });
11694 continue;
11695 }
11696 }
11697 depth += brace_delta(branch_trimmed);
11698 index += 1;
11699 }
11700 let _ = line_offset;
11701 }
11702 branches
11703}
11704
11705fn rule_body_text_start(rule: &RuleDecl) -> usize {
11706 rule.body.span.end.saturating_sub(2 + rule.body.text.len())
11707}
11708
11709fn terminal_branch_header(line: &str) -> Option<(&str, Option<String>, &str)> {
11710 let (head, body_start) = line.split_once("=>")?;
11711 let body_start = body_start.trim();
11712 if !body_start.starts_with('{') {
11713 return None;
11714 }
11715 let head = head.trim();
11716 let (pattern, guard) = match head.split_once(" where ") {
11717 Some((pattern, guard)) => (pattern.trim(), Some(guard.trim().to_owned())),
11718 None => (head, None),
11719 };
11720 Some((pattern, guard, body_start))
11721}
11722
11723fn case_pattern_column(line: &str, pattern: &str) -> usize {
11724 line.find(pattern).unwrap_or_else(|| {
11725 let indent = line.len().saturating_sub(line.trim_start().len());
11726 indent + line.trim_start().find(pattern).unwrap_or(0)
11727 })
11728}
11729
11730fn parse_terminal_pattern_parts(pattern: &str) -> (Option<String>, Option<String>) {
11731 if is_fallback_pattern(pattern) {
11732 return (None, None);
11733 }
11734 let mut parts = pattern.split_whitespace();
11735 let tag = parts.next().map(str::to_owned);
11736 let second = parts.next();
11738 let binding = match second {
11739 Some("as") => parts.next().map(str::to_owned),
11740 Some(_) => return (tag, None),
11741 None => None,
11742 };
11743 if parts.next().is_some() {
11744 return (tag, None);
11745 }
11746 (tag, binding)
11747}
11748
11749fn terminal_payload_schema_for_tag(
11750 tag: &str,
11751 scrutinee: &str,
11752 effect_payload_types: &BTreeMap<String, IrType>,
11753) -> Option<String> {
11754 match tag {
11755 "Completed" => match effect_payload_types.get(scrutinee) {
11756 Some(IrType::Ref(schema)) => Some(schema.clone()),
11757 _ => None,
11758 },
11759 "Failed" => Some("TerminalFailed".to_owned()),
11760 "TimedOut" => Some("TerminalTimedOut".to_owned()),
11761 "Cancelled" => Some("TerminalCancelled".to_owned()),
11762 _ => None,
11763 }
11764}
11765
11766fn terminal_alternatives(
11767 completed_payload: IrType,
11768 span: SourceSpan,
11769) -> Vec<IrTerminalAlternative> {
11770 [
11771 ("Completed", completed_payload),
11772 ("Failed", terminal_failure_payload_type()),
11773 ("TimedOut", terminal_timeout_payload_type()),
11774 ("Cancelled", terminal_cancelled_payload_type()),
11775 ]
11776 .into_iter()
11777 .map(|(tag, payload_type)| IrTerminalAlternative {
11778 tag: tag.to_owned(),
11779 payload_type,
11780 source_span: span,
11781 })
11782 .collect()
11783}
11784
11785fn terminal_failure_payload_type() -> IrType {
11786 IrType::Object(vec![
11787 ir_field("reason", IrType::Primitive(IrPrimitiveType::String)),
11788 ir_field("summary", IrType::Primitive(IrPrimitiveType::String)),
11789 ir_field("effect_id", IrType::Primitive(IrPrimitiveType::String)),
11790 ir_field("run_id", IrType::Primitive(IrPrimitiveType::String)),
11791 ])
11792}
11793
11794fn terminal_timeout_payload_type() -> IrType {
11795 IrType::Object(vec![
11796 ir_field("summary", IrType::Primitive(IrPrimitiveType::String)),
11797 ir_field("effect_id", IrType::Primitive(IrPrimitiveType::String)),
11798 ir_field("run_id", IrType::Primitive(IrPrimitiveType::String)),
11799 ])
11800}
11801
11802fn terminal_cancelled_payload_type() -> IrType {
11803 IrType::Object(vec![
11804 ir_field("summary", IrType::Primitive(IrPrimitiveType::String)),
11805 ir_field("effect_id", IrType::Primitive(IrPrimitiveType::String)),
11806 ir_field("run_id", IrType::Primitive(IrPrimitiveType::String)),
11807 ])
11808}
11809
11810fn terminal_unknown_payload_type() -> IrType {
11811 IrType::Object(vec![
11812 ir_field("summary", IrType::Primitive(IrPrimitiveType::String)),
11813 ir_field("effect_id", IrType::Primitive(IrPrimitiveType::String)),
11814 ir_field("run_id", IrType::Primitive(IrPrimitiveType::String)),
11815 ])
11816}
11817
11818fn ir_field(name: &str, ty: IrType) -> IrClassField {
11819 IrClassField {
11820 name: name.to_owned(),
11821 ty,
11822 is_key: false,
11823 presence_condition: None,
11824 span: SourceSpan { start: 0, end: 0 },
11825 }
11826}
11827
11828fn ir_access_grants_for_body(kind: &body::BodyEffectKind) -> Vec<IrAccessGrant> {
11831 match kind {
11832 body::BodyEffectKind::Tell { access_grants, .. }
11833 | body::BodyEffectKind::Invoke { access_grants, .. } => access_grants
11834 .iter()
11835 .map(|grant| IrAccessGrant {
11836 resource: grant.resource.clone(),
11837 operations: grant
11838 .operations
11839 .iter()
11840 .map(|op| IrAccessGrantOp {
11841 operation: op.operation.clone(),
11842 target: op.target.clone(),
11843 globs: op.globs.clone(),
11844 })
11845 .collect(),
11846 })
11847 .collect(),
11848 _ => Vec::new(),
11849 }
11850}
11851
11852fn ir_effect_kind_for_body(kind: &body::BodyEffectKind) -> IrEffectKind {
11853 match kind {
11854 body::BodyEffectKind::Tell { .. } => IrEffectKind::AgentTell,
11855 body::BodyEffectKind::Coerce { .. }
11856 | body::BodyEffectKind::Prompt { .. }
11857 | body::BodyEffectKind::Decide { .. } => IrEffectKind::SchemaCoerce,
11858 body::BodyEffectKind::Call { .. }
11859 | body::BodyEffectKind::ConstructCapabilityCall { .. } => IrEffectKind::CapabilityCall,
11860 body::BodyEffectKind::Invoke { .. } => IrEffectKind::WorkflowInvoke,
11861 body::BodyEffectKind::Timer { .. } => IrEffectKind::TimerWait,
11862 body::BodyEffectKind::Exec { .. } => IrEffectKind::ExecCommand,
11863 body::BodyEffectKind::TrackerFile { .. } => IrEffectKind::TrackerFile,
11864 body::BodyEffectKind::TrackerClaim { .. } => IrEffectKind::TrackerClaim,
11865 body::BodyEffectKind::TrackerRelease { .. } => IrEffectKind::TrackerRelease,
11866 body::BodyEffectKind::TrackerFinish { .. } => IrEffectKind::TrackerFinish,
11867 body::BodyEffectKind::LeaseAcquire { .. } => IrEffectKind::LeaseAcquire,
11868 body::BodyEffectKind::LeaseRenew { .. } => IrEffectKind::LeaseRenew,
11869 body::BodyEffectKind::LedgerAppend { .. } => IrEffectKind::LedgerAppend,
11870 body::BodyEffectKind::CounterConsume { .. } => IrEffectKind::CounterConsume,
11871 body::BodyEffectKind::Notify { .. } => IrEffectKind::SignalEmit,
11872 body::BodyEffectKind::FileRead { .. } => IrEffectKind::FileRead,
11873 body::BodyEffectKind::FileWrite { .. } => IrEffectKind::FileWrite,
11874 body::BodyEffectKind::FileImport { .. } => IrEffectKind::FileImport,
11875 body::BodyEffectKind::FileExport { .. } => IrEffectKind::FileExport,
11876 }
11877}
11878
11879fn agent_for_body(kind: &body::BodyEffectKind) -> Option<String> {
11882 match kind {
11883 body::BodyEffectKind::Tell { target, .. } => Some(target.clone()),
11884 _ => None,
11885 }
11886}
11887
11888fn turn_skills_for_body(kind: &body::BodyEffectKind) -> Vec<String> {
11890 match kind {
11891 body::BodyEffectKind::Tell { skills, .. } => skills.clone(),
11892 _ => Vec::new(),
11893 }
11894}
11895
11896fn workflow_target_for_body(kind: &body::BodyEffectKind) -> Option<String> {
11898 match kind {
11899 body::BodyEffectKind::Invoke { workflow, .. } => Some(workflow.clone()),
11900 _ => None,
11901 }
11902}
11903
11904fn exec_target_for_body(kind: &body::BodyEffectKind) -> Option<IrExecTarget> {
11907 match kind {
11908 body::BodyEffectKind::Exec { target, .. } => Some(match target {
11909 body::ExecTarget::RawCommand(_) => IrExecTarget::Raw,
11910 body::ExecTarget::Capability { name, .. } => {
11911 IrExecTarget::Capability { name: name.clone() }
11912 }
11913 }),
11914 _ => None,
11915 }
11916}
11917
11918fn endorsed_for_body(kind: &body::BodyEffectKind) -> bool {
11921 matches!(kind, body::BodyEffectKind::Coerce { endorsed: true, .. })
11922}
11923
11924fn declassified_for_body(kind: &body::BodyEffectKind) -> bool {
11927 matches!(
11928 kind,
11929 body::BodyEffectKind::Coerce {
11930 declassified: true,
11931 ..
11932 }
11933 )
11934}
11935
11936fn resource_for_body(kind: &body::BodyEffectKind) -> Option<String> {
11939 match kind {
11940 body::BodyEffectKind::FileRead { store, .. }
11941 | body::BodyEffectKind::FileWrite { store, .. }
11942 | body::BodyEffectKind::FileImport { store, .. }
11943 | body::BodyEffectKind::FileExport { store, .. } => Some(store.clone()),
11944 body::BodyEffectKind::ConstructCapabilityCall {
11946 keyword, fields, ..
11947 } if keyword == "send" => fields
11948 .iter()
11949 .find(|field| field.name == "channel")
11950 .map(|field| field.source.clone()),
11951 body::BodyEffectKind::Notify { event, .. } => Some(format!("signal:{event}")),
11955 body::BodyEffectKind::LeaseAcquire { resource, .. } => Some(format!("resource:{resource}")),
11958 body::BodyEffectKind::LedgerAppend { ledger, .. } => Some(format!("resource:{ledger}")),
11959 body::BodyEffectKind::CounterConsume { counter, .. } => Some(format!("resource:{counter}")),
11960 _ => None,
11961 }
11962}
11963
11964fn construct_use_for_body(kind: &body::BodyEffectKind) -> Option<IrConstructUse> {
11965 match kind {
11966 body::BodyEffectKind::ConstructCapabilityCall {
11967 keyword,
11968 target_capability,
11969 ..
11970 } => Some(IrConstructUse {
11971 keyword: keyword.clone(),
11972 scope: "rule_body".to_owned(),
11973 construct_family: "effect_operation".to_owned(),
11974 lowering_target: "capability_call".to_owned(),
11975 target_capability: target_capability.clone(),
11976 }),
11977 _ => None,
11978 }
11979}
11980
11981fn is_ast_only_effect_kind(kind: &body::BodyEffectKind) -> bool {
11982 if let body::BodyEffectKind::ConstructCapabilityCall { keyword, .. } = kind {
11987 return keyword == "send";
11988 }
11989 matches!(
11990 kind,
11991 body::BodyEffectKind::Prompt { .. }
11992 | body::BodyEffectKind::Timer { .. }
11993 | body::BodyEffectKind::Exec { .. }
11994 | body::BodyEffectKind::Decide { .. }
11995 | body::BodyEffectKind::TrackerFile { .. }
11996 | body::BodyEffectKind::TrackerClaim { .. }
11997 | body::BodyEffectKind::TrackerRelease { .. }
11998 | body::BodyEffectKind::TrackerFinish { .. }
11999 | body::BodyEffectKind::LeaseAcquire { .. }
12000 | body::BodyEffectKind::LeaseRenew { .. }
12001 | body::BodyEffectKind::LedgerAppend { .. }
12002 | body::BodyEffectKind::CounterConsume { .. }
12003 | body::BodyEffectKind::Notify { .. }
12004 | body::BodyEffectKind::Invoke { .. }
12007 | body::BodyEffectKind::FileWrite { .. }
12011 | body::BodyEffectKind::FileExport { .. }
12012 )
12013}
12014
12015fn seed_ast_only_effect_bindings(
12019 statements: &[body::BodyStmt],
12020 seen_bindings: &mut BTreeSet<String>,
12021 binding_types: &mut BTreeMap<String, String>,
12022) {
12023 for statement in statements {
12024 match statement {
12025 body::BodyStmt::Effect(effect) if is_ast_only_effect_kind(&effect.kind) => {
12026 if let Some(binding) = &effect.binding {
12027 seen_bindings.insert(binding.clone());
12028 let _ = binding_types;
12029 }
12030 }
12031 body::BodyStmt::After(after) => {
12032 seed_ast_only_effect_bindings(&after.body, seen_bindings, binding_types)
12033 }
12034 body::BodyStmt::Case(case) => {
12035 for branch in &case.branches {
12036 seed_ast_only_effect_bindings(&branch.body, seen_bindings, binding_types);
12037 }
12038 }
12039 _ => {}
12040 }
12041 }
12042}
12043
12044fn collect_terminal_complete_bindings(statements: &[body::BodyStmt], out: &mut Vec<String>) {
12053 for statement in statements {
12054 match statement {
12055 body::BodyStmt::Terminal(terminal) if terminal.kind == body::TerminalKind::Complete => {
12056 out.push(terminal.name.clone());
12057 }
12058 body::BodyStmt::After(after) => collect_terminal_complete_bindings(&after.body, out),
12059 body::BodyStmt::Case(case) => {
12060 for branch in &case.branches {
12061 collect_terminal_complete_bindings(&branch.body, out);
12062 }
12063 }
12064 _ => {}
12065 }
12066 }
12067}
12068
12069fn collect_effects_from_ast(
12070 statements: &[body::BodyStmt],
12071 rule_name: &str,
12072) -> (Vec<IrEffectNode>, Vec<IrEffectDependency>) {
12073 let mut effects = Vec::new();
12074 let mut dependencies = Vec::new();
12075 let mut counter = 0usize;
12076 let mut after_stack: Vec<(String, DependencyPredicate)> = Vec::new();
12077 let mut case_stack: Vec<(String, String)> = Vec::new();
12078 let claim_bindings = collect_claim_bindings(statements);
12084 walk_effects(
12085 statements,
12086 rule_name,
12087 &claim_bindings,
12088 &mut counter,
12089 &mut after_stack,
12090 &mut case_stack,
12091 &mut effects,
12092 &mut dependencies,
12093 );
12094 (effects, dependencies)
12095}
12096
12097fn collect_claim_bindings(statements: &[body::BodyStmt]) -> BTreeSet<String> {
12101 let mut bindings = BTreeSet::new();
12102 for_each_body(statements, &mut |stmt| {
12103 if let body::BodyStmt::Effect(effect) = stmt {
12104 if matches!(effect.kind, body::BodyEffectKind::TrackerClaim { .. }) {
12105 if let Some(binding) = &effect.binding {
12106 bindings.insert(binding.clone());
12107 }
12108 }
12109 }
12110 });
12111 bindings
12112}
12113
12114#[allow(clippy::too_many_arguments)]
12115fn walk_effects(
12116 statements: &[body::BodyStmt],
12117 rule_name: &str,
12118 claim_bindings: &BTreeSet<String>,
12119 counter: &mut usize,
12120 after_stack: &mut Vec<(String, DependencyPredicate)>,
12121 case_stack: &mut Vec<(String, String)>,
12122 effects: &mut Vec<IrEffectNode>,
12123 dependencies: &mut Vec<IrEffectDependency>,
12124) {
12125 for statement in statements {
12126 match statement {
12127 body::BodyStmt::Effect(effect) => {
12128 *counter += 1;
12129 let id = effect
12130 .binding
12131 .clone()
12132 .unwrap_or_else(|| format!("effect{counter}"));
12133 let kind = match &effect.kind {
12136 body::BodyEffectKind::LeaseRenew {
12137 acquire_binding, ..
12138 } if claim_bindings.contains(acquire_binding) => IrEffectKind::TrackerRenew,
12139 other => ir_effect_kind_for_body(other),
12140 };
12141 for (upstream, predicate) in after_stack.iter() {
12142 dependencies.push(IrEffectDependency {
12143 upstream: upstream.clone(),
12144 predicate: predicate.clone(),
12145 downstream: id.clone(),
12146 });
12147 }
12148 let idempotency_key =
12149 effect_idempotency_key(rule_name, &id, &kind, &effect.binding);
12150 let mut required_capabilities = effect.requires.clone();
12151 match &effect.kind {
12152 body::BodyEffectKind::Call { capability, .. } => {
12153 required_capabilities.push(capability.clone());
12154 }
12155 body::BodyEffectKind::ConstructCapabilityCall {
12156 target_capability, ..
12157 } => {
12158 required_capabilities.push(target_capability.clone());
12159 }
12160 _ => {}
12161 }
12162 required_capabilities.sort();
12163 required_capabilities.dedup();
12164 let construct_use = construct_use_for_body(&effect.kind);
12165 let access_grants = ir_access_grants_for_body(&effect.kind);
12166 let turn_skills = turn_skills_for_body(&effect.kind);
12167 let resource = resource_for_body(&effect.kind);
12168 let agent = agent_for_body(&effect.kind);
12169 let workflow_target = workflow_target_for_body(&effect.kind);
12170 let endorsed = endorsed_for_body(&effect.kind);
12171 let declassified = declassified_for_body(&effect.kind);
12172 let exec_target = exec_target_for_body(&effect.kind);
12173 effects.push(IrEffectNode {
12174 id,
12175 kind,
12176 binding: effect.binding.clone(),
12177 required_capabilities,
12178 construct_use,
12179 idempotency_key,
12180 span: effect.span,
12181 timeout_seconds: effect.timeout_seconds,
12182 access_grants,
12183 turn_skills,
12184 resource,
12185 agent,
12186 workflow_target,
12187 endorsed,
12188 declassified,
12189 selected_by: case_stack.last().cloned(),
12190 exec_target,
12191 });
12192 }
12193 body::BodyStmt::After(after) => {
12194 let predicate = match after.predicate {
12195 body::AfterPredicate::Succeeds => DependencyPredicate::Succeeds,
12196 body::AfterPredicate::Fails => DependencyPredicate::Fails,
12197 body::AfterPredicate::TimedOut => DependencyPredicate::TimedOut,
12202 body::AfterPredicate::Cancelled => DependencyPredicate::Cancelled,
12203 body::AfterPredicate::Completes
12207 | body::AfterPredicate::Held
12208 | body::AfterPredicate::Contended
12209 | body::AfterPredicate::Ok
12210 | body::AfterPredicate::Over => DependencyPredicate::Completes,
12211 body::AfterPredicate::Reaches => DependencyPredicate::Completes,
12218 };
12219 after_stack.push((after.binding.clone(), predicate));
12220 walk_effects(
12221 &after.body,
12222 rule_name,
12223 claim_bindings,
12224 counter,
12225 after_stack,
12226 case_stack,
12227 effects,
12228 dependencies,
12229 );
12230 after_stack.pop();
12231 }
12232 body::BodyStmt::Case(case) => {
12233 for branch in &case.branches {
12234 case_stack.push((case.scrutinee.clone(), branch.pattern.clone()));
12237 walk_effects(
12238 &branch.body,
12239 rule_name,
12240 claim_bindings,
12241 counter,
12242 after_stack,
12243 case_stack,
12244 effects,
12245 dependencies,
12246 );
12247 case_stack.pop();
12248 }
12249 }
12250 _ => {}
12251 }
12252 }
12253}
12254
12255fn effect_idempotency_key(
12256 rule_name: &str,
12257 effect_id: &str,
12258 kind: &IrEffectKind,
12259 binding: &Option<String>,
12260) -> String {
12261 stable_hash(&format!(
12262 "rule={rule_name};effect={effect_id};kind={};binding={}",
12263 kind.as_str(),
12264 binding.as_deref().unwrap_or("-")
12265 ))
12266}
12267
12268fn validate_coerce_call(
12269 rule: &RuleDecl,
12270 line: &str,
12271 semantic: &SemanticContext,
12272 binding_types: &BTreeMap<String, String>,
12273 known_roots: &BTreeSet<String>,
12274 diagnostics: &mut Vec<Diagnostic>,
12275) {
12276 let Some((function_name, args)) = parse_coerce_call(line) else {
12277 diagnostics.push(Diagnostic {
12278 related: Vec::new(),
12279 span: rule.body.span,
12280 message: format!("rule `{}` has malformed coerce call", rule.name.name),
12281 suggestion: Some("write `coerce functionName(arg, ...) as name`".to_owned()),
12282 });
12283 return;
12284 };
12285 let Some(params) = semantic.coerce_params.get(function_name) else {
12286 diagnostics.push(Diagnostic {
12287 related: Vec::new(),
12288 span: rule.body.span,
12289 message: format!(
12290 "rule `{}` calls unknown coerce function `{function_name}`",
12291 rule.name.name
12292 ),
12293 suggestion: Some(format!(
12294 "declare `coerce {function_name}(...) -> Output {{ ... }}` before using it"
12295 )),
12296 });
12297 return;
12298 };
12299 if args.len() != params.len() {
12300 diagnostics.push(Diagnostic {
12301 related: Vec::new(),
12302 span: rule.body.span,
12303 message: format!(
12304 "rule `{}` calls coerce `{function_name}` with {} argument(s), expected {}",
12305 rule.name.name,
12306 args.len(),
12307 params.len()
12308 ),
12309 suggestion: Some("pass one argument for each declared coerce parameter".to_owned()),
12310 });
12311 return;
12312 }
12313 let scope = ExprScope::from_bindings(binding_types);
12314 for (arg, param) in args.iter().zip(params) {
12315 if let Some(root) = dangling_value_root(arg, known_roots) {
12319 diagnostics.push(Diagnostic { related: Vec::new(),
12320 span: rule.body.span,
12321 message: format!(
12322 "rule `{}` has unknown binding `{root}` in coerce `{function_name}` argument",
12323 rule.name.name
12324 ),
12325 suggestion: Some(
12326 "reference a binding from a `when ... as name` clause, an effect `as` binding, or a `case` pattern"
12327 .to_owned(),
12328 ),
12329 });
12330 }
12331 validate_expr_source_against_type(
12332 rule,
12333 &format!("coerce `{function_name}`"),
12334 ¶m.name.name,
12335 ¶m.ty,
12336 arg,
12337 semantic,
12338 &scope,
12339 diagnostics,
12340 );
12341 }
12342}
12343
12344fn validate_effect_payloads(
12345 rule: &RuleDecl,
12346 semantic: &SemanticContext,
12347 binding_types: &BTreeMap<String, String>,
12348 known_roots: &BTreeSet<String>,
12349 diagnostics: &mut Vec<Diagnostic>,
12350) {
12351 for statement in effect_payload_statements(&rule.body.text) {
12352 let trimmed = statement.trim();
12353 if trimmed.starts_with("coerce ") {
12354 validate_coerce_call(
12355 rule,
12356 trimmed,
12357 semantic,
12358 binding_types,
12359 known_roots,
12360 diagnostics,
12361 );
12362 }
12363 }
12364}
12365
12366fn validate_workflow_invocations(
12367 rule: &RuleDecl,
12368 semantic: &SemanticContext,
12369 binding_types: &BTreeMap<String, String>,
12370 known_roots: &BTreeSet<String>,
12371 diagnostics: &mut Vec<Diagnostic>,
12372) {
12373 for statement in workflow_invoke_statements(&rule.body.text) {
12374 let Some((target, body)) = invoke_statement_parts(&statement) else {
12375 diagnostics.push(Diagnostic {
12376 related: Vec::new(),
12377 span: rule.body.span,
12378 message: format!(
12379 "rule `{}` has malformed workflow invocation",
12380 rule.name.name
12381 ),
12382 suggestion: Some("write `invoke Workflow { input value } as binding`".to_owned()),
12383 });
12384 continue;
12385 };
12386 if semantic.workflow.as_deref() == Some(target) {
12387 diagnostics.push(Diagnostic {
12388 related: Vec::new(),
12389 span: rule.body.span,
12390 message: format!(
12391 "rule `{}` recursively invokes workflow `{target}`",
12392 rule.name.name
12393 ),
12394 suggestion: Some(
12395 "split recursive orchestration into an explicit bounded scheduler workflow"
12396 .to_owned(),
12397 ),
12398 });
12399 continue;
12400 }
12401 let Some(surface) = semantic.workflow_inputs.get(target) else {
12402 diagnostics.push(Diagnostic {
12403 related: Vec::new(),
12404 span: rule.body.span,
12405 message: format!(
12406 "rule `{}` invokes unknown workflow `{target}`",
12407 rule.name.name
12408 ),
12409 suggestion: Some("invoke a workflow declared in this source bundle".to_owned()),
12410 });
12411 continue;
12412 };
12413
12414 let mut invocation_semantic = semantic.clone();
12415 invocation_semantic.schemas.merge(surface.schemas.clone());
12416 let assignments = collect_field_assignments(body);
12417 let mut seen = BTreeSet::new();
12418 for assignment in assignments {
12419 let (field, value) = match assignment {
12420 RecordFieldAssignment::Value { field, value } => (field, value),
12421 RecordFieldAssignment::Shorthand { field } => (field.clone(), field),
12422 };
12423 if !seen.insert(field.clone()) {
12424 diagnostics.push(Diagnostic {
12425 related: Vec::new(),
12426 span: rule.body.span,
12427 message: format!("workflow invocation `{target}` repeats input `{field}`"),
12428 suggestion: Some("remove the duplicate invocation input".to_owned()),
12429 });
12430 continue;
12431 }
12432 let Some(input_ty) = surface.inputs.get(&field) else {
12433 let known = surface
12434 .inputs
12435 .keys()
12436 .map(|input| format!("`{input}`"))
12437 .collect::<Vec<_>>()
12438 .join(", ");
12439 diagnostics.push(Diagnostic {
12440 related: Vec::new(),
12441 span: rule.body.span,
12442 message: format!("workflow `{target}` has no input `{field}`"),
12443 suggestion: Some(if known.is_empty() {
12444 "remove the invocation payload; the target declares no inputs".to_owned()
12445 } else {
12446 format!("pass one of: {known}")
12447 }),
12448 });
12449 continue;
12450 };
12451 if let Some(root) = dangling_value_root(&value, known_roots) {
12452 diagnostics.push(Diagnostic { related: Vec::new(),
12453 span: rule.body.span,
12454 message: format!(
12455 "rule `{}` has unknown binding `{root}` in `invoke {target}` input `{field}`",
12456 rule.name.name
12457 ),
12458 suggestion: Some(
12459 "reference a binding from a `when ... as name` clause, an effect `as` binding, or a `case` pattern"
12460 .to_owned(),
12461 ),
12462 });
12463 }
12464 validate_expr_source_against_type(
12465 rule,
12466 target,
12467 &field,
12468 input_ty,
12469 &value,
12470 &invocation_semantic,
12471 &ExprScope::from_bindings(binding_types),
12472 diagnostics,
12473 );
12474 }
12475 for input in surface.inputs.keys() {
12476 if seen.contains(input) {
12477 continue;
12478 }
12479 diagnostics.push(Diagnostic {
12480 related: Vec::new(),
12481 span: rule.body.span,
12482 message: format!("workflow invocation `{target}` is missing input `{input}`"),
12483 suggestion: Some(format!(
12484 "add `{input}` to the `{target}` invocation payload"
12485 )),
12486 });
12487 }
12488 }
12489}
12490
12491fn validate_agent_tell_target(
12492 rule: &RuleDecl,
12493 line: &str,
12494 kind: &IrEffectKind,
12495 semantic: &SemanticContext,
12496 binding_types: &BTreeMap<String, String>,
12497 known_roots: &BTreeSet<String>,
12498 diagnostics: &mut Vec<Diagnostic>,
12499) {
12500 if kind != &IrEffectKind::AgentTell {
12501 return;
12502 }
12503 let Some(target) = parse_tell_target(line) else {
12504 diagnostics.push(Diagnostic {
12505 related: Vec::new(),
12506 span: rule.body.span,
12507 message: format!("rule `{}` has malformed tell target", rule.name.name),
12508 suggestion: Some("write `tell agentName ...` or `tell task.agentRef ...`".to_owned()),
12509 });
12510 return;
12511 };
12512 if target.starts_with('"') {
12513 diagnostics.push(Diagnostic {
12514 related: Vec::new(),
12515 span: rule.body.span,
12516 message: format!(
12517 "rule `{}` uses a string literal as a tell target",
12518 rule.name.name
12519 ),
12520 suggestion: Some("use a declared agent name or an AgentRef field".to_owned()),
12521 });
12522 return;
12523 }
12524 let required_capabilities = parse_required_capabilities(line);
12525 if target.contains('.') {
12526 let Some(ty) = expression_type(target, semantic, binding_types) else {
12527 if let Some(root) = dangling_value_root(target, known_roots) {
12531 diagnostics.push(Diagnostic { related: Vec::new(),
12532 span: rule.body.span,
12533 message: format!(
12534 "rule `{}` has unknown binding `{root}` in tell target `{target}`",
12535 rule.name.name
12536 ),
12537 suggestion: Some(
12538 "reference a binding from a `when ... as name` clause or an effect `as` binding"
12539 .to_owned(),
12540 ),
12541 });
12542 }
12543 return;
12544 };
12545 if let TypeSyntax::AgentRef { agents, .. } = ty {
12546 for agent in agents {
12547 validate_agent_capabilities(
12548 rule,
12549 &agent.name,
12550 &required_capabilities,
12551 semantic,
12552 diagnostics,
12553 );
12554 }
12555 } else {
12556 diagnostics.push(Diagnostic {
12557 related: Vec::new(),
12558 span: rule.body.span,
12559 message: format!(
12560 "rule `{}` uses non-AgentRef dynamic tell target `{target}`",
12561 rule.name.name
12562 ),
12563 suggestion: Some(
12564 "declare the field as `AgentRef<...>` before using it as a tell target"
12565 .to_owned(),
12566 ),
12567 });
12568 }
12569 return;
12570 }
12571 if !semantic.agents.contains(target) {
12572 diagnostics.push(Diagnostic {
12573 related: Vec::new(),
12574 span: rule.body.span,
12575 message: format!("rule `{}` tells unknown agent `{target}`", rule.name.name),
12576 suggestion: Some("declare the target agent before telling it".to_owned()),
12577 });
12578 return;
12579 }
12580 validate_agent_capabilities(rule, target, &required_capabilities, semantic, diagnostics);
12581}
12582
12583fn validate_agent_capabilities(
12584 rule: &RuleDecl,
12585 agent: &str,
12586 required_capabilities: &[String],
12587 semantic: &SemanticContext,
12588 diagnostics: &mut Vec<Diagnostic>,
12589) {
12590 if required_capabilities.is_empty() {
12591 return;
12592 }
12593 let declared = semantic
12594 .agent_capabilities
12595 .get(agent)
12596 .cloned()
12597 .unwrap_or_default();
12598 for capability in required_capabilities {
12599 if !declared.contains(capability) {
12600 diagnostics.push(Diagnostic { related: Vec::new(),
12601 span: rule.body.span,
12602 message: format!(
12603 "rule `{}` tells agent `{agent}` requiring undeclared capability `{capability}`",
12604 rule.name.name
12605 ),
12606 suggestion: Some(format!(
12607 "add `{capability}` to agent `{agent}` capabilities or choose another AgentRef target"
12608 )),
12609 });
12610 }
12611 }
12612}
12613
12614fn validate_availability_when(
12615 rule: &RuleDecl,
12616 when: &str,
12617 semantic: &SemanticContext,
12618 binding_types: &BTreeMap<String, String>,
12619 diagnostics: &mut Vec<Diagnostic>,
12620) {
12621 let (pattern, _) = split_when_guard(when);
12622 let Some(target) = pattern.strip_suffix(" is available").map(str::trim) else {
12623 return;
12624 };
12625 if target.contains('.') {
12626 let Some(ty) = expression_type(target, semantic, binding_types) else {
12627 return;
12628 };
12629 if !matches!(ty, TypeSyntax::AgentRef { .. }) {
12630 diagnostics.push(Diagnostic {
12631 related: Vec::new(),
12632 span: rule.body.span,
12633 message: format!(
12634 "rule `{}` checks availability for non-AgentRef `{target}`",
12635 rule.name.name
12636 ),
12637 suggestion: Some(
12638 "availability checks must name a declared agent or an AgentRef field"
12639 .to_owned(),
12640 ),
12641 });
12642 }
12643 return;
12644 }
12645 if !semantic.agents.contains(target) {
12646 diagnostics.push(Diagnostic {
12647 related: Vec::new(),
12648 span: rule.body.span,
12649 message: format!("rule `{}` checks unknown agent `{target}`", rule.name.name),
12650 suggestion: Some("declare the target agent before checking availability".to_owned()),
12651 });
12652 }
12653}
12654
12655#[derive(Clone, Debug, Default)]
12656struct ExprScope {
12657 binding_types: BTreeMap<String, String>,
12658 implicit_schema: Option<String>,
12659}
12660
12661impl ExprScope {
12662 fn from_bindings(binding_types: &BTreeMap<String, String>) -> Self {
12663 Self {
12664 binding_types: binding_types.clone(),
12665 implicit_schema: None,
12666 }
12667 }
12668
12669 fn with_implicit_schema(&self, schema: String) -> Self {
12670 let mut scope = self.clone();
12671 scope.implicit_schema = Some(schema);
12672 scope
12673 }
12674}
12675
12676#[derive(Clone, Debug)]
12677struct ExprValidationContext {
12678 subject: String,
12679 span: SourceSpan,
12680}
12681
12682impl ExprValidationContext {
12683 fn rule(rule: &RuleDecl) -> Self {
12684 Self {
12685 subject: format!("rule `{}`", rule.name.name),
12686 span: rule.body.span,
12687 }
12688 }
12689
12690 fn assertion(span: SourceSpan) -> Self {
12691 Self {
12692 subject: "assertion".to_owned(),
12693 span,
12694 }
12695 }
12696}
12697
12698fn validate_expression(
12699 rule: &RuleDecl,
12700 expr: &str,
12701 semantic: &SemanticContext,
12702 binding_types: &BTreeMap<String, String>,
12703 label: &str,
12704 diagnostics: &mut Vec<Diagnostic>,
12705) {
12706 match parse_expression(expr) {
12707 Ok(expr) => {
12708 validate_parsed_expression(
12709 &expr,
12710 semantic,
12711 &ExprScope::from_bindings(binding_types),
12712 &ExprValidationContext::rule(rule),
12713 label,
12714 diagnostics,
12715 );
12716 }
12717 Err(message) => diagnostics.push(Diagnostic { related: Vec::new(),
12718 span: rule.body.span,
12719 message: format!("rule `{}` has invalid {label} expression: {message}", rule.name.name),
12720 suggestion: Some("use deterministic field paths, literals, boolean operators, comparisons, membership, count, or exists".to_owned()),
12721 }),
12722 }
12723}
12724
12725fn validate_parsed_expression(
12726 expr: &Expr,
12727 semantic: &SemanticContext,
12728 scope: &ExprScope,
12729 context: &ExprValidationContext,
12730 label: &str,
12731 diagnostics: &mut Vec<Diagnostic>,
12732) {
12733 let presence_proofs = BTreeSet::new();
12734 validate_expr_node(
12735 expr,
12736 semantic,
12737 scope,
12738 context,
12739 &presence_proofs,
12740 diagnostics,
12741 );
12742 let ty = infer_expr_type(expr, semantic, scope, context, diagnostics);
12743 if ty != ExprType::Bool && ty != ExprType::Unknown {
12744 diagnostics.push(Diagnostic {
12745 related: Vec::new(),
12746 span: context.span,
12747 message: format!("{} has non-boolean {label} expression", context.subject),
12748 suggestion: Some(format!("{label} expressions must evaluate to bool")),
12749 });
12750 }
12751}
12752
12753fn validate_expr_node(
12754 expr: &Expr,
12755 semantic: &SemanticContext,
12756 scope: &ExprScope,
12757 context: &ExprValidationContext,
12758 presence_proofs: &BTreeSet<String>,
12759 diagnostics: &mut Vec<Diagnostic>,
12760) {
12761 match expr {
12762 Expr::Path(path) => {
12763 if path.len() < 2 {
12764 return;
12765 }
12766 let root = &path[0];
12767 let Some(schema) = scope.binding_types.get(root) else {
12768 if let Some(schema) = &scope.implicit_schema {
12769 if let Err(message) =
12770 validate_optional_path_access(schema, path, semantic, presence_proofs)
12771 {
12772 diagnostics.push(Diagnostic {
12773 related: Vec::new(),
12774 span: context.span,
12775 message: format!(
12776 "{} has unsafe optional path `{}`: {message}",
12777 context.subject,
12778 path.join(".")
12779 ),
12780 suggestion: Some(
12781 "prove the optional value is present before reading through it"
12782 .to_owned(),
12783 ),
12784 });
12785 return;
12786 }
12787 if let Err(message) = semantic.schemas.resolve_field_path(schema, path) {
12788 diagnostics.push(Diagnostic {
12789 related: Vec::new(),
12790 span: context.span,
12791 message: format!(
12792 "{} has invalid expression path `{}`: {message}",
12793 context.subject,
12794 path.join(".")
12795 ),
12796 suggestion: Some(
12797 "use a field declared on the queried schema".to_owned(),
12798 ),
12799 });
12800 }
12801 return;
12802 }
12803 diagnostics.push(Diagnostic {
12804 related: Vec::new(),
12805 span: context.span,
12806 message: format!("{} has unknown expression root `{root}`", context.subject),
12807 suggestion: Some(
12808 "use a binding introduced by a `when ... as name` clause".to_owned(),
12809 ),
12810 });
12811 return;
12812 };
12813 if let Err(message) =
12814 validate_optional_path_access(schema, &path[1..], semantic, presence_proofs)
12815 {
12816 diagnostics.push(Diagnostic {
12817 related: Vec::new(),
12818 span: context.span,
12819 message: format!(
12820 "{} has unsafe optional path `{}`: {message}",
12821 context.subject,
12822 path.join(".")
12823 ),
12824 suggestion: Some(
12825 "prove the optional value is present before reading through it".to_owned(),
12826 ),
12827 });
12828 return;
12829 }
12830 if let Err(message) = semantic.schemas.resolve_field_path(schema, &path[1..]) {
12831 diagnostics.push(Diagnostic {
12832 related: Vec::new(),
12833 span: context.span,
12834 message: format!(
12835 "{} has invalid expression path `{}`: {message}",
12836 context.subject,
12837 path.join(".")
12838 ),
12839 suggestion: Some("use a field declared on the bound schema".to_owned()),
12840 });
12841 }
12842 }
12843 Expr::Index { target, key } => {
12844 validate_expr_node(
12845 target,
12846 semantic,
12847 scope,
12848 context,
12849 presence_proofs,
12850 diagnostics,
12851 );
12852 validate_expr_node(key, semantic, scope, context, presence_proofs, diagnostics);
12853 let key_ty = infer_expr_type(key, semantic, scope, context, diagnostics);
12854 if !matches!(key_ty, ExprType::String | ExprType::Unknown) {
12855 diagnostics.push(Diagnostic {
12856 related: Vec::new(),
12857 span: context.span,
12858 message: format!("{} indexes a map with a non-string key", context.subject),
12859 suggestion: Some(
12860 "use a string literal or string expression as the map key".to_owned(),
12861 ),
12862 });
12863 }
12864 }
12865 Expr::Array(items) => {
12866 for item in items {
12867 validate_expr_node(item, semantic, scope, context, presence_proofs, diagnostics);
12868 }
12869 }
12870 Expr::Object(fields) => {
12871 diagnostics.push(Diagnostic {
12872 related: Vec::new(),
12873 span: context.span,
12874 message: format!(
12875 "{} uses an object literal without an expected object or map type",
12876 context.subject
12877 ),
12878 suggestion: Some(
12879 "use object literals only in typed record fields or typed effect arguments"
12880 .to_owned(),
12881 ),
12882 });
12883 for field in fields {
12884 validate_expr_node(
12885 &field.value,
12886 semantic,
12887 scope,
12888 context,
12889 presence_proofs,
12890 diagnostics,
12891 );
12892 }
12893 }
12894 Expr::Unary { expr, .. } => {
12895 validate_expr_node(expr, semantic, scope, context, presence_proofs, diagnostics)
12896 }
12897 Expr::Binary {
12898 op: BinaryOp::And,
12899 left,
12900 right,
12901 } => {
12902 validate_expr_node(left, semantic, scope, context, presence_proofs, diagnostics);
12903 let mut right_proofs = presence_proofs.clone();
12904 collect_presence_proofs(left, &mut right_proofs);
12905 validate_expr_node(right, semantic, scope, context, &right_proofs, diagnostics);
12906 }
12907 Expr::Binary { op, left, right } => {
12908 validate_expr_node(left, semantic, scope, context, presence_proofs, diagnostics);
12909 validate_expr_node(
12910 right,
12911 semantic,
12912 scope,
12913 context,
12914 presence_proofs,
12915 diagnostics,
12916 );
12917 validate_unknown_implicit_idents(
12918 *op,
12919 left,
12920 right,
12921 semantic,
12922 scope,
12923 context,
12924 diagnostics,
12925 );
12926 validate_finite_domain_expr(*op, left, right, semantic, scope, context, diagnostics);
12927 }
12928 Expr::Call { name, args } => {
12929 validate_function_call(name, args, semantic, scope, context, diagnostics);
12930 for arg in args {
12931 validate_expr_node(arg, semantic, scope, context, presence_proofs, diagnostics);
12932 }
12933 }
12934 Expr::Query { guard, .. } => {
12935 validate_query_expr(expr, semantic, scope, context, diagnostics);
12936 if let Some(guard) = guard {
12937 let guard_scope = query_guard_scope(expr, semantic, scope);
12938 validate_expr_node(
12939 guard,
12940 semantic,
12941 &guard_scope,
12942 context,
12943 presence_proofs,
12944 diagnostics,
12945 );
12946 }
12947 }
12948 Expr::Literal(_) => {}
12949 }
12950}
12951
12952fn validate_unknown_implicit_idents(
12953 op: BinaryOp,
12954 left: &Expr,
12955 right: &Expr,
12956 semantic: &SemanticContext,
12957 scope: &ExprScope,
12958 context: &ExprValidationContext,
12959 diagnostics: &mut Vec<Diagnostic>,
12960) {
12961 if !matches!(
12962 op,
12963 BinaryOp::Eq | BinaryOp::Ne | BinaryOp::Lt | BinaryOp::Le | BinaryOp::Gt | BinaryOp::Ge
12964 ) {
12965 return;
12966 }
12967 validate_unknown_implicit_ident(left, right, semantic, scope, context, diagnostics);
12968 validate_unknown_implicit_ident(right, left, semantic, scope, context, diagnostics);
12969}
12970
12971fn validate_unknown_implicit_ident(
12972 expr: &Expr,
12973 other: &Expr,
12974 semantic: &SemanticContext,
12975 scope: &ExprScope,
12976 context: &ExprValidationContext,
12977 diagnostics: &mut Vec<Diagnostic>,
12978) {
12979 let Expr::Literal(ExprLiteral::Ident(name)) = expr else {
12980 return;
12981 };
12982 let Some(schema) = &scope.implicit_schema else {
12983 return;
12984 };
12985 let field_exists = semantic
12986 .schemas
12987 .classes
12988 .get(schema)
12989 .is_some_and(|fields| fields.contains_key(name));
12990 if field_exists
12991 || expr_domain(other, semantic, scope).is_some()
12992 || implicit_ident_field_exists(other, semantic, scope)
12993 {
12994 return;
12995 }
12996 diagnostics.push(Diagnostic {
12997 related: Vec::new(),
12998 span: context.span,
12999 message: format!(
13000 "{} fact query `{schema}` has unknown field `{name}`",
13001 context.subject
13002 ),
13003 suggestion: Some(format!(
13004 "use a field declared on `{schema}` inside the query `where` expression"
13005 )),
13006 });
13007}
13008
13009fn implicit_ident_field_exists(expr: &Expr, semantic: &SemanticContext, scope: &ExprScope) -> bool {
13010 let Expr::Literal(ExprLiteral::Ident(name)) = expr else {
13011 return false;
13012 };
13013 let Some(schema) = &scope.implicit_schema else {
13014 return false;
13015 };
13016 semantic
13017 .schemas
13018 .classes
13019 .get(schema)
13020 .is_some_and(|fields| fields.contains_key(name))
13021}
13022
13023fn validate_function_call(
13024 name: &str,
13025 args: &[Expr],
13026 semantic: &SemanticContext,
13027 scope: &ExprScope,
13028 context: &ExprValidationContext,
13029 diagnostics: &mut Vec<Diagnostic>,
13030) {
13031 match name {
13032 "count" => {
13033 if args.len() != 1 {
13034 diagnostics.push(Diagnostic { related: Vec::new(),
13035 span: context.span,
13036 message: format!(
13037 "{} calls `count` with {} arguments, expected 1",
13038 context.subject,
13039 args.len()
13040 ),
13041 suggestion: Some(
13042 "call `count` with exactly one array, map, fact query, or effect query argument"
13043 .to_owned(),
13044 ),
13045 });
13046 return;
13047 }
13048 let ty = infer_expr_type(&args[0], semantic, scope, context, diagnostics);
13049 if !is_countable_type(&ty) {
13050 diagnostics.push(Diagnostic {
13051 related: Vec::new(),
13052 span: context.span,
13053 message: format!(
13054 "{} calls `count` with unsupported argument type `{}`",
13055 context.subject,
13056 expr_type_label(&ty)
13057 ),
13058 suggestion: Some(
13059 "use `count` only with arrays, maps, fact queries, or effect queries"
13060 .to_owned(),
13061 ),
13062 });
13063 }
13064 }
13065 "exists" => {
13066 if args.len() != 1 {
13067 diagnostics.push(Diagnostic {
13068 related: Vec::new(),
13069 span: context.span,
13070 message: format!(
13071 "{} calls `exists` with {} arguments, expected 1",
13072 context.subject,
13073 args.len()
13074 ),
13075 suggestion: Some("call `exists` with exactly one argument".to_owned()),
13076 });
13077 return;
13078 }
13079 let ty = infer_expr_type(&args[0], semantic, scope, context, diagnostics);
13080 if !matches!(args[0], Expr::Index { .. }) && !is_exists_type(&ty) {
13081 diagnostics.push(Diagnostic { related: Vec::new(),
13082 span: context.span,
13083 message: format!(
13084 "{} calls `exists` with unsupported argument type `{}`",
13085 context.subject,
13086 expr_type_label(&ty)
13087 ),
13088 suggestion: Some(
13089 "use `exists path` for optional/map presence checks or pass an array, map, fact query, or effect query"
13090 .to_owned(),
13091 ),
13092 });
13093 }
13094 }
13095 "empty" => {
13096 if args.len() != 1 {
13097 diagnostics.push(Diagnostic {
13098 related: Vec::new(),
13099 span: context.span,
13100 message: format!(
13101 "{} calls `empty` with {} arguments, expected 1",
13102 context.subject,
13103 args.len()
13104 ),
13105 suggestion: Some(
13106 "call `empty` with exactly one array, map, string, fact query, or effect query argument"
13107 .to_owned(),
13108 ),
13109 });
13110 return;
13111 }
13112 let ty = infer_expr_type(&args[0], semantic, scope, context, diagnostics);
13113 if !is_emptiable_type(&ty) {
13114 let optional = matches!(ty, ExprType::Optional(_));
13118 diagnostics.push(Diagnostic {
13119 related: Vec::new(),
13120 span: context.span,
13121 message: format!(
13122 "{} calls `empty` with unsupported {}argument type `{}`",
13123 context.subject,
13124 if optional { "optional " } else { "" },
13125 expr_type_label(&ty)
13126 ),
13127 suggestion: Some(
13128 "use `empty` only with arrays, maps, strings, fact queries, effect queries, null, or supported optional values"
13129 .to_owned(),
13130 ),
13131 });
13132 }
13133 }
13134 _ => {}
13135 }
13136}
13137
13138fn validate_query_expr(
13139 expr: &Expr,
13140 semantic: &SemanticContext,
13141 scope: &ExprScope,
13142 context: &ExprValidationContext,
13143 diagnostics: &mut Vec<Diagnostic>,
13144) {
13145 let Expr::Query { kind, head, guard } = expr else {
13146 return;
13147 };
13148 if *kind == QueryKind::Fact {
13149 let Some(schema) = query_head_schema(head, semantic) else {
13150 diagnostics.push(Diagnostic {
13151 related: Vec::new(),
13152 span: context.span,
13153 message: format!(
13154 "{} queries unknown fact schema `{}`",
13155 context.subject,
13156 head.trim()
13157 ),
13158 suggestion: Some("use a declared class name in fact queries".to_owned()),
13159 });
13160 return;
13161 };
13162 if let Some(guard) = guard {
13163 let guard_scope = scope.with_implicit_schema(schema);
13164 let ty = infer_expr_type(guard, semantic, &guard_scope, context, diagnostics);
13165 if !matches!(ty, ExprType::Bool | ExprType::Unknown) {
13166 diagnostics.push(Diagnostic {
13167 related: Vec::new(),
13168 span: context.span,
13169 message: format!(
13170 "{} fact query `{}` has non-boolean `where` expression",
13171 context.subject,
13172 head.trim()
13173 ),
13174 suggestion: Some("query `where` expressions must evaluate to bool".to_owned()),
13175 });
13176 }
13177 }
13178 }
13179}
13180
13181fn validate_optional_path_access(
13182 root_schema: &str,
13183 path: &[String],
13184 semantic: &SemanticContext,
13185 presence_proofs: &BTreeSet<String>,
13186) -> Result<(), String> {
13187 let mut schema = root_schema.to_owned();
13188 let mut prefix = Vec::new();
13189 for (index, field) in path.iter().enumerate() {
13190 let Some(fields) = semantic.schemas.classes.get(&schema) else {
13191 return Ok(());
13192 };
13193 let Some(field_ty) = fields.get(field) else {
13194 return Ok(());
13195 };
13196 prefix.push(field.clone());
13197 if let TypeSyntax::Optional { inner, .. } = field_ty {
13198 if index + 1 < path.len() && !presence_proofs.contains(&prefix.join(".")) {
13199 return Err(format!(
13200 "`{}` must be proven present before accessing `{}`",
13201 prefix.join("."),
13202 path[index + 1..].join(".")
13203 ));
13204 }
13205 if let Some(next_schema) = schema_name_for_path(inner) {
13206 schema = next_schema;
13207 }
13208 continue;
13209 }
13210 if let Some(next_schema) = schema_name_for_path(field_ty) {
13211 schema = next_schema;
13212 }
13213 }
13214 Ok(())
13215}
13216
13217fn collect_presence_proofs(expr: &Expr, proofs: &mut BTreeSet<String>) {
13218 match expr {
13219 Expr::Binary {
13220 op: BinaryOp::Ne,
13221 left,
13222 right,
13223 } => {
13224 if matches!(**right, Expr::Literal(ExprLiteral::Null)) {
13225 if let Some(path) = expr_path_key(left) {
13226 proofs.insert(path);
13227 }
13228 }
13229 if matches!(**left, Expr::Literal(ExprLiteral::Null)) {
13230 if let Some(path) = expr_path_key(right) {
13231 proofs.insert(path);
13232 }
13233 }
13234 }
13235 Expr::Unary {
13236 op: UnaryOp::Not,
13237 expr,
13238 } => {
13239 if let Expr::Binary {
13240 op: BinaryOp::Eq,
13241 left,
13242 right,
13243 } = expr.as_ref()
13244 {
13245 if matches!(**right, Expr::Literal(ExprLiteral::Null)) {
13246 if let Some(path) = expr_path_key(left) {
13247 proofs.insert(path);
13248 }
13249 }
13250 if matches!(**left, Expr::Literal(ExprLiteral::Null)) {
13251 if let Some(path) = expr_path_key(right) {
13252 proofs.insert(path);
13253 }
13254 }
13255 }
13256 }
13257 Expr::Call { name, args } if name == "exists" && args.len() == 1 => {
13258 if let Some(path) = expr_path_key(&args[0]) {
13259 proofs.insert(path);
13260 }
13261 }
13262 Expr::Binary {
13263 op: BinaryOp::And,
13264 left,
13265 right,
13266 } => {
13267 collect_presence_proofs(left, proofs);
13268 collect_presence_proofs(right, proofs);
13269 }
13270 _ => {}
13271 }
13272}
13273
13274fn expr_path_key(expr: &Expr) -> Option<String> {
13275 match expr {
13276 Expr::Literal(ExprLiteral::Ident(name)) => Some(name.clone()),
13277 Expr::Path(path) if path.len() >= 2 => Some(path[1..].join(".")),
13278 Expr::Index { target, key } => {
13279 let target = expr_path_key(target)?;
13280 let key = match key.as_ref() {
13281 Expr::Literal(ExprLiteral::String(value) | ExprLiteral::Ident(value)) => value,
13282 _ => return None,
13283 };
13284 Some(format!("{target}[{key:?}]"))
13285 }
13286 _ => None,
13287 }
13288}
13289
13290fn query_guard_scope(expr: &Expr, semantic: &SemanticContext, scope: &ExprScope) -> ExprScope {
13291 let Expr::Query {
13292 kind: QueryKind::Fact,
13293 head,
13294 ..
13295 } = expr
13296 else {
13297 return scope.clone();
13298 };
13299 query_head_schema(head, semantic)
13300 .map(|schema| scope.with_implicit_schema(schema))
13301 .unwrap_or_else(|| scope.clone())
13302}
13303
13304fn query_head_schema(head: &str, semantic: &SemanticContext) -> Option<String> {
13305 let mut parts = head.split_whitespace();
13306 let schema = parts.next()?;
13307 if parts.next().is_some() {
13308 return None;
13309 }
13310 semantic
13311 .schemas
13312 .class_exists(schema)
13313 .then(|| schema.to_owned())
13314}
13315
13316fn implicit_field_type(
13317 name: &str,
13318 semantic: &SemanticContext,
13319 scope: &ExprScope,
13320) -> Option<TypeSyntax> {
13321 let schema = scope.implicit_schema.as_ref()?;
13322 semantic
13323 .schemas
13324 .resolve_field_path(schema, &[name.to_owned()])
13325 .ok()
13326}
13327
13328fn infer_expr_type(
13329 expr: &Expr,
13330 semantic: &SemanticContext,
13331 scope: &ExprScope,
13332 context: &ExprValidationContext,
13333 diagnostics: &mut Vec<Diagnostic>,
13334) -> ExprType {
13335 match expr {
13336 Expr::Literal(ExprLiteral::Ident(name)) => implicit_field_type(name, semantic, scope)
13337 .map(|ty| expr_type_from_type_syntax(&ty, semantic))
13338 .unwrap_or_else(|| expr_literal_type(&ExprLiteral::Ident(name.clone()))),
13339 Expr::Literal(literal) => expr_literal_type(literal),
13340 Expr::Path(path) => expr_path_type(path, semantic, scope).unwrap_or(ExprType::Unknown),
13341 Expr::Index { target, key } => {
13342 let target_ty = infer_expr_type(target, semantic, scope, context, diagnostics);
13343 let key_ty = infer_expr_type(key, semantic, scope, context, diagnostics);
13344 if !matches!(key_ty, ExprType::String | ExprType::Unknown) {
13345 diagnostics.push(Diagnostic {
13346 related: Vec::new(),
13347 span: context.span,
13348 message: format!("{} indexes a map with a non-string key", context.subject),
13349 suggestion: Some(
13350 "use a string literal or string expression as the map key".to_owned(),
13351 ),
13352 });
13353 }
13354 match target_ty {
13355 ExprType::Map(inner) => *inner,
13356 ExprType::Unknown => ExprType::Unknown,
13357 _ => {
13358 diagnostics.push(Diagnostic {
13359 related: Vec::new(),
13360 span: context.span,
13361 message: format!("{} indexes a non-map expression", context.subject),
13362 suggestion: Some("use indexing only on map values".to_owned()),
13363 });
13364 ExprType::Unknown
13365 }
13366 }
13367 }
13368 Expr::Array(items) => infer_array_type(items, semantic, scope, context, diagnostics),
13369 Expr::Object(fields) => {
13370 for field in fields {
13371 infer_expr_type(&field.value, semantic, scope, context, diagnostics);
13372 }
13373 ExprType::Object
13374 }
13375 Expr::Unary {
13376 op: UnaryOp::Not,
13377 expr,
13378 } => {
13379 let inner = infer_expr_type(expr, semantic, scope, context, diagnostics);
13380 if !matches!(inner, ExprType::Bool | ExprType::Unknown) {
13381 diagnostics.push(Diagnostic {
13382 related: Vec::new(),
13383 span: context.span,
13384 message: format!(
13385 "{} applies `!` to a non-boolean expression",
13386 context.subject
13387 ),
13388 suggestion: Some("use `!` only with boolean expressions".to_owned()),
13389 });
13390 }
13391 ExprType::Bool
13392 }
13393 Expr::Binary { op, left, right } => {
13394 infer_binary_type(*op, left, right, semantic, scope, context, diagnostics)
13395 }
13396 Expr::Call { name, args } => match name.as_str() {
13397 "count" => ExprType::Int,
13398 "exists" => ExprType::Bool,
13399 "empty" => ExprType::Bool,
13400 _ => {
13401 diagnostics.push(Diagnostic {
13402 related: Vec::new(),
13403 span: context.span,
13404 message: format!(
13405 "{} calls unsupported expression function `{name}`",
13406 context.subject
13407 ),
13408 suggestion: Some("use `count`, `exists`, or `empty`".to_owned()),
13409 });
13410 for arg in args {
13411 infer_expr_type(arg, semantic, scope, context, diagnostics);
13412 }
13413 ExprType::Unknown
13414 }
13415 },
13416 Expr::Query { guard, .. } => {
13417 if let Some(guard) = guard {
13418 let guard_scope = query_guard_scope(expr, semantic, scope);
13419 infer_expr_type(guard, semantic, &guard_scope, context, diagnostics);
13420 }
13421 ExprType::Collection
13422 }
13423 }
13424}
13425
13426fn infer_binary_type(
13427 op: BinaryOp,
13428 left: &Expr,
13429 right: &Expr,
13430 semantic: &SemanticContext,
13431 scope: &ExprScope,
13432 context: &ExprValidationContext,
13433 diagnostics: &mut Vec<Diagnostic>,
13434) -> ExprType {
13435 let left_ty = infer_expr_type(left, semantic, scope, context, diagnostics);
13436 let right_ty = infer_expr_type(right, semantic, scope, context, diagnostics);
13437 match op {
13438 BinaryOp::And | BinaryOp::Or => {
13439 for ty in [&left_ty, &right_ty] {
13440 if !matches!(ty, ExprType::Bool | ExprType::Unknown) {
13441 diagnostics.push(Diagnostic {
13442 related: Vec::new(),
13443 span: context.span,
13444 message: format!(
13445 "{} uses boolean operator with non-boolean operand",
13446 context.subject
13447 ),
13448 suggestion: Some(
13449 "use `&&` and `||` only with boolean expressions".to_owned(),
13450 ),
13451 });
13452 break;
13453 }
13454 }
13455 ExprType::Bool
13456 }
13457 BinaryOp::Eq | BinaryOp::Ne => {
13458 if !types_comparable(&left_ty, &right_ty) {
13459 diagnostics.push(Diagnostic {
13460 related: Vec::new(),
13461 span: context.span,
13462 message: format!("{} compares incompatible expression types", context.subject),
13463 suggestion: Some(
13464 "compare values with compatible scalar or finite-domain types".to_owned(),
13465 ),
13466 });
13467 }
13468 ExprType::Bool
13469 }
13470 BinaryOp::Lt | BinaryOp::Le | BinaryOp::Gt | BinaryOp::Ge => {
13471 if !is_orderable_pair(&left_ty, &right_ty) {
13472 diagnostics.push(Diagnostic {
13473 related: Vec::new(),
13474 span: context.span,
13475 message: format!("{} orders non-orderable expression values", context.subject),
13476 suggestion: Some(
13477 "use ordering only with int, float, duration, or time values".to_owned(),
13478 ),
13479 });
13480 }
13481 ExprType::Bool
13482 }
13483 BinaryOp::In | BinaryOp::NotIn => {
13484 match &right_ty {
13485 ExprType::Array(item_ty) => {
13486 if !types_comparable(&left_ty, item_ty) {
13487 diagnostics.push(Diagnostic {
13488 related: Vec::new(),
13489 span: context.span,
13490 message: format!(
13491 "{} uses membership with incompatible item type",
13492 context.subject
13493 ),
13494 suggestion: Some(
13495 "make the left value compatible with the array item type"
13496 .to_owned(),
13497 ),
13498 });
13499 }
13500 }
13501 ExprType::Map(_) => {
13502 if !is_string_like_key_type(&left_ty) {
13503 diagnostics.push(Diagnostic {
13504 related: Vec::new(),
13505 span: context.span,
13506 message: format!(
13507 "{} uses map membership with a non-string key",
13508 context.subject
13509 ),
13510 suggestion: Some(
13511 "use a string value on the left side of map membership".to_owned(),
13512 ),
13513 });
13514 }
13515 }
13516 ExprType::Unknown => {}
13517 _ => diagnostics.push(Diagnostic {
13518 related: Vec::new(),
13519 span: context.span,
13520 message: format!(
13521 "{} uses membership against a non-array/non-map expression",
13522 context.subject
13523 ),
13524 suggestion: Some(
13525 "use `in` with an array literal, array value, or map value".to_owned(),
13526 ),
13527 }),
13528 }
13529 ExprType::Bool
13530 }
13531 BinaryOp::Add | BinaryOp::Sub | BinaryOp::Mul | BinaryOp::Div => {
13532 for ty in [&left_ty, &right_ty] {
13533 if !matches!(ty, ExprType::Int | ExprType::Float | ExprType::Unknown) {
13534 diagnostics.push(Diagnostic {
13535 related: Vec::new(),
13536 span: context.span,
13537 message: format!(
13538 "{} uses arithmetic with a non-numeric operand",
13539 context.subject
13540 ),
13541 suggestion: Some("use `+ - * /` only with int or float values".to_owned()),
13542 });
13543 break;
13544 }
13545 }
13546 if matches!(left_ty, ExprType::Float) || matches!(right_ty, ExprType::Float) {
13547 ExprType::Float
13548 } else if matches!(left_ty, ExprType::Int) && matches!(right_ty, ExprType::Int) {
13549 ExprType::Int
13550 } else {
13551 ExprType::Unknown
13552 }
13553 }
13554 }
13555}
13556
13557fn infer_array_type(
13558 items: &[Expr],
13559 semantic: &SemanticContext,
13560 scope: &ExprScope,
13561 context: &ExprValidationContext,
13562 diagnostics: &mut Vec<Diagnostic>,
13563) -> ExprType {
13564 let mut item_ty: Option<ExprType> = None;
13565 for item in items {
13566 let ty = infer_expr_type(item, semantic, scope, context, diagnostics);
13567 if matches!(ty, ExprType::Unknown) {
13568 continue;
13569 }
13570 match &item_ty {
13571 None => item_ty = Some(ty),
13572 Some(existing) if types_comparable(existing, &ty) => {}
13573 Some(_) => {
13574 diagnostics.push(Diagnostic {
13575 related: Vec::new(),
13576 span: context.span,
13577 message: format!("{} has mixed-type array literal", context.subject),
13578 suggestion: Some("use array literals whose elements share one type".to_owned()),
13579 });
13580 return ExprType::Array(Box::new(ExprType::Unknown));
13581 }
13582 }
13583 }
13584 ExprType::Array(Box::new(item_ty.unwrap_or(ExprType::Unknown)))
13585}
13586
13587fn expr_path_type(
13588 path: &[String],
13589 semantic: &SemanticContext,
13590 scope: &ExprScope,
13591) -> Option<ExprType> {
13592 if path.len() < 2 {
13593 return None;
13594 }
13595 if let Some(schema) = scope.binding_types.get(&path[0]) {
13596 if schema.contains('.') {
13597 return Some(ExprType::Unknown);
13599 }
13600 return semantic
13601 .schemas
13602 .resolve_field_path(schema, &path[1..])
13603 .ok()
13604 .map(|ty| expr_type_from_type_syntax(&ty, semantic));
13605 }
13606 let schema = scope.implicit_schema.as_ref()?;
13607 if schema.contains('.') {
13608 return Some(ExprType::Unknown);
13609 }
13610 semantic
13611 .schemas
13612 .resolve_field_path(schema, path)
13613 .ok()
13614 .map(|ty| expr_type_from_type_syntax(&ty, semantic))
13615}
13616
13617fn expr_type_from_type_syntax(ty: &TypeSyntax, semantic: &SemanticContext) -> ExprType {
13618 match ty {
13619 TypeSyntax::Primitive { name, .. } => match name.as_str() {
13620 "bool" => ExprType::Bool,
13621 "int" => ExprType::Int,
13622 "float" => ExprType::Float,
13623 "string" => ExprType::String,
13624 "duration" => ExprType::Duration,
13625 "time" => ExprType::Time,
13626 _ => ExprType::Unknown,
13627 },
13628 TypeSyntax::LiteralString { value, .. } => ExprType::Finite {
13629 label: "literal".to_owned(),
13630 values: vec![value.clone()],
13631 },
13632 TypeSyntax::AgentRef { agents, .. } => ExprType::Finite {
13633 label: "AgentRef".to_owned(),
13634 values: agents.iter().map(|agent| agent.name.clone()).collect(),
13635 },
13636 TypeSyntax::Ref { name } => semantic
13637 .schemas
13638 .enums
13639 .get(&name.name)
13640 .map(|variants| ExprType::Finite {
13641 label: format!("enum `{}`", name.name),
13642 values: variants.iter().cloned().collect(),
13643 })
13644 .unwrap_or(ExprType::Object),
13645 TypeSyntax::Optional { inner, .. } => {
13646 ExprType::Optional(Box::new(expr_type_from_type_syntax(inner, semantic)))
13647 }
13648 TypeSyntax::Array { inner, .. } => {
13649 ExprType::Array(Box::new(expr_type_from_type_syntax(inner, semantic)))
13650 }
13651 TypeSyntax::Map { inner, .. } => {
13652 ExprType::Map(Box::new(expr_type_from_type_syntax(inner, semantic)))
13653 }
13654 TypeSyntax::Union { variants, .. } => {
13655 let values = variants
13656 .iter()
13657 .filter_map(|variant| match variant {
13658 TypeSyntax::LiteralString { value, .. } => Some(value.clone()),
13659 _ => None,
13660 })
13661 .collect::<Vec<_>>();
13662 if values.len() == variants.len() && !values.is_empty() {
13663 ExprType::Finite {
13664 label: "literal union".to_owned(),
13665 values,
13666 }
13667 } else {
13668 ExprType::Unknown
13669 }
13670 }
13671 }
13672}
13673
13674fn expr_literal_type(literal: &ExprLiteral) -> ExprType {
13675 match literal {
13676 ExprLiteral::String(_) | ExprLiteral::Ident(_) => ExprType::String,
13677 ExprLiteral::Number(value) if value.contains('.') => ExprType::Float,
13678 ExprLiteral::Number(_) => ExprType::Int,
13679 ExprLiteral::Bool(_) => ExprType::Bool,
13680 ExprLiteral::Null => ExprType::Null,
13681 }
13682}
13683
13684fn types_comparable(left: &ExprType, right: &ExprType) -> bool {
13685 if matches!(left, ExprType::Unknown) || matches!(right, ExprType::Unknown) {
13686 return true;
13687 }
13688 if matches!(left, ExprType::Null) || matches!(right, ExprType::Null) {
13689 return true;
13690 }
13691 if is_numeric_type(left) && is_numeric_type(right) {
13692 return true;
13693 }
13694 match (left, right) {
13695 (ExprType::Optional(left), right) | (right, ExprType::Optional(left)) => {
13696 types_comparable(left, right)
13697 }
13698 (ExprType::Finite { .. }, ExprType::String)
13699 | (ExprType::String, ExprType::Finite { .. })
13700 | (ExprType::Finite { .. }, ExprType::Finite { .. }) => true,
13701 _ => left == right,
13702 }
13703}
13704
13705fn is_numeric_type(ty: &ExprType) -> bool {
13706 matches!(ty, ExprType::Int | ExprType::Float)
13707}
13708
13709fn is_string_like_key_type(ty: &ExprType) -> bool {
13710 match ty {
13711 ExprType::String | ExprType::Unknown | ExprType::Finite { .. } => true,
13712 ExprType::Optional(inner) => is_string_like_key_type(inner),
13713 _ => false,
13714 }
13715}
13716
13717fn is_orderable_pair(left: &ExprType, right: &ExprType) -> bool {
13718 if matches!(left, ExprType::Unknown) || matches!(right, ExprType::Unknown) {
13719 return true;
13720 }
13721 (is_numeric_type(left) && is_numeric_type(right))
13722 || matches!(
13723 (left, right),
13724 (ExprType::Duration, ExprType::Duration)
13725 | (ExprType::Time, ExprType::Time)
13726 | (ExprType::Time, ExprType::String)
13729 | (ExprType::String, ExprType::Time)
13730 )
13731}
13732
13733fn is_countable_type(ty: &ExprType) -> bool {
13734 matches!(
13735 ty,
13736 ExprType::Array(_) | ExprType::Map(_) | ExprType::Collection | ExprType::Unknown
13737 )
13738}
13739
13740fn is_exists_type(ty: &ExprType) -> bool {
13741 matches!(
13742 ty,
13743 ExprType::Array(_)
13744 | ExprType::Map(_)
13745 | ExprType::Collection
13746 | ExprType::Optional(_)
13747 | ExprType::Unknown
13748 )
13749}
13750
13751fn is_emptiable_type(ty: &ExprType) -> bool {
13756 match ty {
13757 ExprType::Array(_)
13758 | ExprType::Map(_)
13759 | ExprType::String
13760 | ExprType::Collection
13761 | ExprType::Null
13762 | ExprType::Unknown => true,
13763 ExprType::Optional(inner) => is_emptiable_type(inner),
13764 _ => false,
13765 }
13766}
13767
13768fn expr_type_label(ty: &ExprType) -> String {
13769 match ty {
13770 ExprType::Bool => "bool".to_owned(),
13771 ExprType::Int => "int".to_owned(),
13772 ExprType::Float => "float".to_owned(),
13773 ExprType::String => "string".to_owned(),
13774 ExprType::Finite { label, values } => format!("{label}<{}>", values.join(" | ")),
13775 ExprType::Duration => "duration".to_owned(),
13776 ExprType::Time => "time".to_owned(),
13777 ExprType::Null => "null".to_owned(),
13778 ExprType::Object => "object".to_owned(),
13779 ExprType::Array(inner) => format!("{}[]", expr_type_label(inner)),
13780 ExprType::Map(inner) => format!("map<{}>", expr_type_label(inner)),
13781 ExprType::Optional(inner) => format!("{}?", expr_type_label(inner)),
13782 ExprType::Collection => "query".to_owned(),
13783 ExprType::Unknown => "unknown".to_owned(),
13784 }
13785}
13786
13787fn validate_finite_domain_expr(
13788 op: BinaryOp,
13789 left: &Expr,
13790 right: &Expr,
13791 semantic: &SemanticContext,
13792 scope: &ExprScope,
13793 context: &ExprValidationContext,
13794 diagnostics: &mut Vec<Diagnostic>,
13795) {
13796 if !matches!(
13797 op,
13798 BinaryOp::Eq | BinaryOp::Ne | BinaryOp::In | BinaryOp::NotIn
13799 ) {
13800 return;
13801 }
13802 let Some((domain, literals)) = finite_domain_comparison(left, right, semantic, scope)
13803 .or_else(|| finite_domain_comparison(right, left, semantic, scope))
13804 else {
13805 validate_finite_domain_relation(op, left, right, semantic, scope, context, diagnostics);
13806 return;
13807 };
13808 for literal in literals.into_iter().flatten() {
13809 if !domain.iter().any(|value| value == &literal) {
13810 diagnostics.push(Diagnostic {
13811 related: Vec::new(),
13812 span: context.span,
13813 message: format!(
13814 "{} compares finite-domain value to unknown `{literal}`",
13815 context.subject
13816 ),
13817 suggestion: Some(format!("use one of: {}", domain.join(", "))),
13818 });
13819 }
13820 }
13821 validate_finite_domain_relation(op, left, right, semantic, scope, context, diagnostics);
13822}
13823
13824fn validate_finite_domain_relation(
13825 op: BinaryOp,
13826 left: &Expr,
13827 right: &Expr,
13828 semantic: &SemanticContext,
13829 scope: &ExprScope,
13830 context: &ExprValidationContext,
13831 diagnostics: &mut Vec<Diagnostic>,
13832) {
13833 match op {
13834 BinaryOp::Eq => {
13835 let Some(left_domain) = expr_domain(left, semantic, scope) else {
13836 return;
13837 };
13838 let Some(right_domain) = expr_domain(right, semantic, scope) else {
13839 return;
13840 };
13841 if left_domain
13842 .iter()
13843 .all(|value| !right_domain.iter().any(|right| right == value))
13844 {
13845 diagnostics.push(Diagnostic {
13846 related: Vec::new(),
13847 span: context.span,
13848 message: format!(
13849 "{} has statically unsatisfiable finite-domain equality",
13850 context.subject
13851 ),
13852 suggestion: Some(format!(
13853 "compare domains with at least one shared value; left: {}, right: {}",
13854 left_domain.join(", "),
13855 right_domain.join(", ")
13856 )),
13857 });
13858 }
13859 }
13860 BinaryOp::In => {
13861 let Some(domain) = expr_domain(left, semantic, scope) else {
13862 return;
13863 };
13864 let Some(literals) = literal_array_values(right) else {
13865 return;
13866 };
13867 if literals
13868 .iter()
13869 .all(|literal| !domain.iter().any(|value| value == literal))
13870 {
13871 diagnostics.push(Diagnostic {
13872 related: Vec::new(),
13873 span: context.span,
13874 message: format!(
13875 "{} has statically unsatisfiable finite-domain membership",
13876 context.subject
13877 ),
13878 suggestion: Some(format!("use one of: {}", domain.join(", "))),
13879 });
13880 }
13881 }
13882 BinaryOp::NotIn => {
13883 let Some(domain) = expr_domain(left, semantic, scope) else {
13884 return;
13885 };
13886 let Some(literals) = literal_array_values(right) else {
13887 return;
13888 };
13889 if !domain.is_empty()
13890 && domain
13891 .iter()
13892 .all(|value| literals.iter().any(|literal| literal == value))
13893 {
13894 diagnostics.push(Diagnostic {
13895 related: Vec::new(),
13896 span: context.span,
13897 message: format!(
13898 "{} has statically unsatisfiable finite-domain exclusion",
13899 context.subject
13900 ),
13901 suggestion: Some(
13902 "leave at least one domain value outside the exclusion set".to_owned(),
13903 ),
13904 });
13905 }
13906 }
13907 _ => {}
13908 }
13909}
13910
13911fn finite_domain_comparison(
13912 domain_expr: &Expr,
13913 literal_expr: &Expr,
13914 semantic: &SemanticContext,
13915 scope: &ExprScope,
13916) -> Option<(Vec<String>, Vec<Option<String>>)> {
13917 let domain = expr_domain(domain_expr, semantic, scope)?;
13918 let literals = match literal_expr {
13919 Expr::Literal(literal) => vec![expr_literal_name(literal)],
13920 Expr::Array(items) => items
13921 .iter()
13922 .filter_map(|item| match item {
13923 Expr::Literal(literal) => Some(expr_literal_name(literal)),
13924 _ => None,
13925 })
13926 .collect(),
13927 _ => Vec::new(),
13928 };
13929 Some((domain, literals))
13930}
13931
13932fn expr_domain(expr: &Expr, semantic: &SemanticContext, scope: &ExprScope) -> Option<Vec<String>> {
13933 let ty = match expr {
13934 Expr::Path(path) => {
13935 let root = path.first()?;
13936 if let Some(schema) = scope.binding_types.get(root) {
13937 semantic
13938 .schemas
13939 .resolve_field_path(schema, path.get(1..)?)
13940 .ok()?
13941 } else {
13942 let schema = scope.implicit_schema.as_ref()?;
13943 semantic.schemas.resolve_field_path(schema, path).ok()?
13944 }
13945 }
13946 Expr::Literal(ExprLiteral::Ident(name)) => implicit_field_type(name, semantic, scope)?,
13947 _ => return None,
13948 };
13949 finite_expr_domain(&ty, semantic)
13950}
13951
13952fn finite_expr_domain(ty: &TypeSyntax, semantic: &SemanticContext) -> Option<Vec<String>> {
13953 match ty {
13954 TypeSyntax::Ref { name } => semantic
13955 .schemas
13956 .enums
13957 .get(&name.name)
13958 .map(|variants| variants.iter().cloned().collect()),
13959 TypeSyntax::Union { variants, .. } => {
13960 let values = variants
13961 .iter()
13962 .filter_map(|variant| match variant {
13963 TypeSyntax::LiteralString { value, .. } => Some(value.clone()),
13964 _ => None,
13965 })
13966 .collect::<Vec<_>>();
13967 (!values.is_empty()).then_some(values)
13968 }
13969 TypeSyntax::AgentRef { agents, .. } => {
13970 Some(agents.iter().map(|agent| agent.name.clone()).collect())
13971 }
13972 _ => None,
13973 }
13974}
13975
13976fn expr_literal_name(literal: &ExprLiteral) -> Option<String> {
13977 match literal {
13978 ExprLiteral::String(value) | ExprLiteral::Ident(value) => Some(value.clone()),
13979 _ => None,
13980 }
13981}
13982
13983fn literal_array_values(expr: &Expr) -> Option<Vec<String>> {
13984 let Expr::Array(items) = expr else {
13985 return None;
13986 };
13987 items
13988 .iter()
13989 .map(|item| match item {
13990 Expr::Literal(literal) => expr_literal_name(literal),
13991 _ => None,
13992 })
13993 .collect()
13994}
13995
13996fn parse_tell_target(line: &str) -> Option<&str> {
13997 line.strip_prefix("tell ")?
13998 .split_whitespace()
13999 .next()
14000 .filter(|target| !target.is_empty())
14001}
14002
14003fn parse_required_capabilities(line: &str) -> Vec<String> {
14004 let Some(rest) = line.split_once(" requires ") else {
14005 return Vec::new();
14006 };
14007 let Some(list) = rest.1.trim_start().strip_prefix('[') else {
14008 return Vec::new();
14009 };
14010 let Some((items, _)) = list.split_once(']') else {
14011 return Vec::new();
14012 };
14013 let mut capabilities = items
14014 .split(',')
14015 .filter_map(|item| {
14016 let value = item.trim().trim_matches('"');
14017 (!value.is_empty()).then(|| value.to_owned())
14018 })
14019 .collect::<Vec<_>>();
14020 capabilities.sort();
14021 capabilities.dedup();
14022 capabilities
14023}
14024
14025fn validate_case_blocks(
14026 rule: &RuleDecl,
14027 semantic: &SemanticContext,
14028 binding_types: &BTreeMap<String, String>,
14029 diagnostics: &mut Vec<Diagnostic>,
14030) {
14031 let lines = rule
14032 .body
14033 .text
14034 .lines()
14035 .scan(0usize, |offset, line| {
14036 let current = *offset;
14037 *offset += line.len() + 1;
14038 Some((line, current))
14039 })
14040 .collect::<Vec<_>>();
14041 let text_lines = lines.iter().map(|(line, _)| *line).collect::<Vec<_>>();
14042 let mut index = 0usize;
14043 while index < lines.len() {
14044 let trimmed = lines[index].0.trim();
14045 let Some(scrutinee) = case_scrutinee(trimmed) else {
14046 index += 1;
14047 continue;
14048 };
14049 let scrutinee_ty = expression_type(scrutinee, semantic, binding_types);
14050 let terminal_case = scrutinee_ty.is_none()
14051 && active_completes_binding_for_case(&text_lines, index, scrutinee);
14052 if scrutinee_ty.is_none() && !terminal_case {
14053 diagnostics.push(Diagnostic {
14054 related: Vec::new(),
14055 span: rule.body.span,
14056 message: format!(
14057 "rule `{}` has case scrutinee `{scrutinee}` that is not a typed path",
14058 rule.name.name
14059 ),
14060 suggestion: Some("match on a bound field such as `task.provider`".to_owned()),
14061 });
14062 }
14063 let mut depth = brace_delta(trimmed).max(1);
14064 let mut case_index = index + 1;
14065 let mut branches = Vec::new();
14066 while case_index < lines.len() && depth > 0 {
14067 let (raw_line, line_offset) = lines[case_index];
14068 let line = raw_line.trim();
14069 if depth == 1 {
14070 if let Some(branch) = parse_case_branch_head(line) {
14071 let pattern_column = case_pattern_column(raw_line, branch.pattern);
14072 let branch = SpanCaseBranchHead {
14073 pattern: branch.pattern,
14074 guard: branch.guard,
14075 pattern_span: SourceSpan {
14076 start: rule_body_text_start(rule) + line_offset + pattern_column,
14077 end: rule_body_text_start(rule)
14078 + line_offset
14079 + pattern_column
14080 + branch.pattern.len(),
14081 },
14082 };
14083 branches.push(branch);
14084 if terminal_case {
14085 validate_terminal_case_pattern(
14086 rule,
14087 branch.pattern,
14088 branch.pattern_span,
14089 diagnostics,
14090 );
14091 } else {
14092 validate_case_pattern(
14093 rule,
14094 branch.pattern,
14095 scrutinee_ty.as_ref(),
14096 branch.pattern_span,
14097 semantic,
14098 diagnostics,
14099 );
14100 }
14101 if let Some(guard) = branch.guard.filter(|_| !terminal_case) {
14108 let mut branch_scope = binding_types.clone();
14109 if let Some(scrutinee_ty) = scrutinee_ty.as_ref() {
14110 if let Some((binding, schema)) =
14111 case_branch_payload_binding(branch.pattern, scrutinee_ty, semantic)
14112 {
14113 branch_scope.insert(binding, schema);
14114 }
14115 }
14116 validate_expression(
14117 rule,
14118 guard,
14119 semantic,
14120 &branch_scope,
14121 "case guard",
14122 diagnostics,
14123 );
14124 validate_known_field_paths_at_span(
14125 rule,
14126 guard,
14127 branch.pattern_span,
14128 semantic,
14129 &branch_scope,
14130 diagnostics,
14131 );
14132 }
14133 }
14134 }
14135 depth += brace_delta(line);
14136 case_index += 1;
14137 }
14138 if terminal_case {
14139 validate_terminal_case_coverage(rule, &branches, diagnostics);
14140 } else {
14141 validate_case_coverage(
14142 rule,
14143 scrutinee_ty.as_ref(),
14144 &branches,
14145 semantic,
14146 diagnostics,
14147 );
14148 }
14149 index += 1;
14150 }
14151}
14152
14153fn active_completes_binding_for_case(lines: &[&str], case_index: usize, scrutinee: &str) -> bool {
14154 let mut scopes: Vec<(String, DependencyPredicate, i32)> = Vec::new();
14155 for line in lines.iter().take(case_index) {
14156 let trimmed = line.trim();
14157 if let Some((binding, predicate)) = parse_after_line(trimmed) {
14158 scopes.push((binding, predicate, brace_delta(trimmed).max(1)));
14159 } else {
14160 let delta = brace_delta(trimmed);
14161 for (_, _, depth) in &mut scopes {
14162 *depth += delta;
14163 }
14164 scopes.retain(|(_, _, depth)| *depth > 0);
14165 }
14166 }
14167 scopes.iter().any(|(binding, predicate, _)| {
14168 binding == scrutinee && predicate == &DependencyPredicate::Completes
14169 })
14170}
14171
14172fn brace_delta(line: &str) -> i32 {
14173 line.chars().fold(0, |depth, ch| match ch {
14174 '{' => depth + 1,
14175 '}' => depth - 1,
14176 _ => depth,
14177 })
14178}
14179
14180fn case_scrutinee(line: &str) -> Option<&str> {
14181 let rest = line.strip_prefix("case ")?;
14182 let expr = rest.strip_suffix('{').unwrap_or(rest).trim();
14183 (!expr.is_empty()).then_some(expr)
14184}
14185
14186fn is_case_branch_start(line: &str) -> bool {
14187 line.contains("=>")
14188}
14189
14190#[derive(Clone, Copy)]
14191struct CaseBranchHead<'a> {
14192 pattern: &'a str,
14193 guard: Option<&'a str>,
14194}
14195
14196#[derive(Clone, Copy)]
14197struct SpanCaseBranchHead<'a> {
14198 pattern: &'a str,
14199 guard: Option<&'a str>,
14200 pattern_span: SourceSpan,
14201}
14202
14203fn parse_case_branch_head(line: &str) -> Option<CaseBranchHead<'_>> {
14204 let (pattern, _) = line.split_once("=>")?;
14205 let pattern = pattern.trim();
14206 if pattern.is_empty() {
14207 return None;
14208 }
14209 match pattern.split_once(" where ") {
14210 Some((pattern, guard)) => Some(CaseBranchHead {
14211 pattern: pattern.trim(),
14212 guard: Some(guard.trim()),
14213 }),
14214 None => Some(CaseBranchHead {
14215 pattern,
14216 guard: None,
14217 }),
14218 }
14219}
14220
14221fn expression_type(
14222 expr: &str,
14223 semantic: &SemanticContext,
14224 binding_types: &BTreeMap<String, String>,
14225) -> Option<TypeSyntax> {
14226 let is_bare_ident = !expr.is_empty()
14230 && expr.chars().all(|ch| ch.is_alphanumeric() || ch == '_')
14231 && expr.chars().next().is_some_and(char::is_alphabetic);
14232 if is_bare_ident {
14233 let schema = binding_types.get(expr)?;
14234 if semantic.schemas.enums.contains_key(schema) {
14235 return Some(TypeSyntax::Ref {
14236 name: Ident {
14237 name: schema.clone(),
14238 span: zero_span(),
14239 },
14240 });
14241 }
14242 return None;
14243 }
14244 let (root, path) = expression_path(expr)?;
14245 let schema = binding_types.get(&root)?;
14246 semantic.schemas.resolve_field_path(schema, &path).ok()
14247}
14248
14249fn validate_case_pattern(
14250 rule: &RuleDecl,
14251 pattern: &str,
14252 scrutinee_ty: Option<&TypeSyntax>,
14253 span: SourceSpan,
14254 semantic: &SemanticContext,
14255 diagnostics: &mut Vec<Diagnostic>,
14256) {
14257 if matches!(pattern, "_" | "default") {
14258 return;
14259 }
14260 if pattern == "None" {
14261 if !matches!(scrutinee_ty, Some(TypeSyntax::Optional { .. })) {
14262 diagnostics.push(Diagnostic {
14263 related: Vec::new(),
14264 span,
14265 message: format!(
14266 "rule `{}` uses `None` for a non-optional case",
14267 rule.name.name
14268 ),
14269 suggestion: Some("use `None` only when matching an optional field".to_owned()),
14270 });
14271 }
14272 return;
14273 }
14274 if pattern.starts_with("Some ") {
14275 if !matches!(scrutinee_ty, Some(TypeSyntax::Optional { .. })) {
14276 diagnostics.push(Diagnostic {
14277 related: Vec::new(),
14278 span,
14279 message: format!(
14280 "rule `{}` uses `Some` for a non-optional case",
14281 rule.name.name
14282 ),
14283 suggestion: Some("use `Some name` only when matching an optional field".to_owned()),
14284 });
14285 }
14286 return;
14287 }
14288 let Some(scrutinee_ty) = scrutinee_ty else {
14289 return;
14290 };
14291 match scrutinee_ty {
14292 TypeSyntax::Ref { name } => {
14293 let Some(variants) = semantic.schemas.enums.get(&name.name) else {
14294 return;
14295 };
14296 let (variant, binding) = sum_case_pattern_parts(pattern);
14297 if !variants.contains(variant) {
14298 diagnostics.push(Diagnostic {
14299 related: Vec::new(),
14300 span,
14301 message: format!("enum `{}` has no variant `{variant}`", name.name),
14302 suggestion: Some(format!(
14303 "use one of: {}",
14304 variants.iter().cloned().collect::<Vec<_>>().join(", ")
14305 )),
14306 });
14307 return;
14308 }
14309 if binding.is_some()
14312 && !semantic
14313 .schemas
14314 .class_exists(&format!("{}.{variant}", name.name))
14315 {
14316 diagnostics.push(Diagnostic {
14317 related: Vec::new(),
14318 span,
14319 message: format!(
14320 "variant `{variant}` of enum `{}` carries no payload to bind",
14321 name.name
14322 ),
14323 suggestion: Some(format!("write `{variant} => {{ ... }}` without `as`")),
14324 });
14325 }
14326 }
14327 TypeSyntax::Union { variants, .. } => {
14328 let Some(literal) = parse_literal_expr(pattern) else {
14329 diagnostics.push(Diagnostic {
14330 related: Vec::new(),
14331 span,
14332 message: format!(
14333 "rule `{}` has unsupported case pattern `{pattern}`",
14334 rule.name.name
14335 ),
14336 suggestion: Some("use a literal branch value or `_`".to_owned()),
14337 });
14338 return;
14339 };
14340 validate_union_case_pattern(rule, variants, &literal, span, diagnostics);
14341 }
14342 TypeSyntax::AgentRef { agents, .. } => {
14343 let Some(literal) = parse_literal_expr(pattern) else {
14344 diagnostics.push(Diagnostic {
14345 related: Vec::new(),
14346 span,
14347 message: format!(
14348 "rule `{}` has unsupported AgentRef case pattern `{pattern}`",
14349 rule.name.name
14350 ),
14351 suggestion: Some(
14352 "use a declared agent name, a string literal, or `_`".to_owned(),
14353 ),
14354 });
14355 return;
14356 };
14357 validate_agent_ref_case_pattern(rule, agents, &literal, span, diagnostics);
14358 }
14359 TypeSyntax::Optional { inner, .. } => {
14360 validate_case_pattern(rule, pattern, Some(inner), span, semantic, diagnostics);
14361 }
14362 TypeSyntax::Primitive { name, .. } if name == "bool" => {
14365 if !matches!(pattern, "true" | "false") {
14366 diagnostics.push(Diagnostic {
14367 related: Vec::new(),
14368 span,
14369 message: format!(
14370 "rule `{}` has case pattern `{pattern}` that is not a `bool` value",
14371 rule.name.name
14372 ),
14373 suggestion: Some("match `true`, `false`, or `_`".to_owned()),
14374 });
14375 }
14376 }
14377 _ => {
14378 diagnostics.push(Diagnostic {
14379 related: Vec::new(),
14380 span,
14381 message: format!(
14382 "rule `{}` cannot pattern-match this scrutinee type",
14383 rule.name.name
14384 ),
14385 suggestion: Some(
14386 "match an enum, literal union, optional, or tagged output union".to_owned(),
14387 ),
14388 });
14389 }
14390 }
14391}
14392
14393fn terminal_case_tags() -> [&'static str; 4] {
14394 ["Completed", "Failed", "TimedOut", "Cancelled"]
14395}
14396
14397fn validate_terminal_case_pattern(
14398 rule: &RuleDecl,
14399 pattern: &str,
14400 span: SourceSpan,
14401 diagnostics: &mut Vec<Diagnostic>,
14402) {
14403 if is_fallback_pattern(pattern) {
14404 return;
14405 }
14406 let mut parts = pattern.split_whitespace();
14407 let Some(tag) = parts.next() else {
14408 return;
14409 };
14410 let second = parts.next();
14413 let binding = match second {
14414 Some("as") => parts.next(),
14415 other => other,
14416 };
14417 let uses_as = matches!(second, Some("as"));
14418 if parts.next().is_some() || binding.is_none() || !uses_as {
14419 diagnostics.push(Diagnostic { related: Vec::new(),
14420 span,
14421 message: format!(
14422 "rule `{}` has malformed terminal-output case pattern `{pattern}`",
14423 rule.name.name
14424 ),
14425 suggestion: Some("write `Completed as result`, `Failed as failure`, `TimedOut as timeout`, or `Cancelled as cancel` (the `as` is required)".to_owned()),
14426 });
14427 return;
14428 }
14429 let tags = terminal_case_tags();
14430 if !tags.contains(&tag) {
14431 diagnostics.push(Diagnostic {
14432 related: Vec::new(),
14433 span,
14434 message: format!(
14435 "rule `{}` terminal-output case pattern cannot be `{tag}`",
14436 rule.name.name
14437 ),
14438 suggestion: Some(format!("use one of: {}", tags.join(", "))),
14439 });
14440 }
14441}
14442
14443fn validate_terminal_case_coverage(
14444 rule: &RuleDecl,
14445 branches: &[SpanCaseBranchHead<'_>],
14446 diagnostics: &mut Vec<Diagnostic>,
14447) {
14448 validate_unreachable_after_fallback(rule, branches, diagnostics);
14449 if branches.is_empty()
14450 || branches
14451 .iter()
14452 .any(|branch| is_fallback_pattern(branch.pattern))
14453 {
14454 validate_duplicate_terminal_case_patterns(rule, branches, diagnostics);
14455 return;
14456 }
14457 validate_duplicate_terminal_case_patterns(rule, branches, diagnostics);
14458 let covered = branches
14459 .iter()
14460 .filter(|branch| branch.guard.is_none())
14461 .filter_map(|branch| normalized_terminal_case_pattern(branch.pattern))
14462 .collect::<BTreeSet<_>>();
14463 let missing = terminal_case_tags()
14464 .iter()
14465 .filter(|tag| !covered.contains(**tag))
14466 .copied()
14467 .collect::<Vec<_>>();
14468 if !missing.is_empty() {
14469 diagnostics.push(Diagnostic {
14470 related: Vec::new(),
14471 span: rule.body.span,
14472 message: format!(
14473 "rule `{}` has non-exhaustive terminal-output case; missing {}",
14474 rule.name.name,
14475 missing.join(", ")
14476 ),
14477 suggestion: Some(
14478 "add terminal branches for every value or add `_ => { ... }`".to_owned(),
14479 ),
14480 });
14481 }
14482}
14483
14484fn validate_duplicate_terminal_case_patterns(
14485 rule: &RuleDecl,
14486 branches: &[SpanCaseBranchHead<'_>],
14487 diagnostics: &mut Vec<Diagnostic>,
14488) {
14489 let mut seen = BTreeSet::new();
14490 for branch in branches.iter().filter(|branch| branch.guard.is_none()) {
14491 let Some(pattern) = normalized_terminal_case_pattern(branch.pattern) else {
14492 continue;
14493 };
14494 if !seen.insert(pattern.to_owned()) {
14495 diagnostics.push(Diagnostic {
14496 related: Vec::new(),
14497 span: branch.pattern_span,
14498 message: format!(
14499 "rule `{}` has duplicate unguarded terminal-output case pattern `{pattern}`",
14500 rule.name.name
14501 ),
14502 suggestion: Some(
14503 "remove the duplicate branch or add mutually exclusive `where` guards"
14504 .to_owned(),
14505 ),
14506 });
14507 }
14508 }
14509}
14510
14511fn validate_case_coverage(
14512 rule: &RuleDecl,
14513 scrutinee_ty: Option<&TypeSyntax>,
14514 branches: &[SpanCaseBranchHead<'_>],
14515 semantic: &SemanticContext,
14516 diagnostics: &mut Vec<Diagnostic>,
14517) {
14518 validate_unreachable_after_fallback(rule, branches, diagnostics);
14519 if branches.is_empty()
14520 || branches
14521 .iter()
14522 .any(|branch| is_fallback_pattern(branch.pattern))
14523 {
14524 validate_duplicate_case_patterns(rule, branches, diagnostics);
14525 return;
14526 }
14527 validate_duplicate_case_patterns(rule, branches, diagnostics);
14528
14529 let Some(domain) = finite_case_domain(scrutinee_ty, semantic) else {
14530 return;
14531 };
14532 let covered = branches
14533 .iter()
14534 .filter(|branch| branch.guard.is_none())
14535 .filter_map(|branch| normalized_case_pattern(branch.pattern))
14536 .collect::<BTreeSet<_>>();
14537 let missing = domain
14538 .iter()
14539 .filter(|value| !covered.contains(value.as_str()))
14540 .cloned()
14541 .collect::<Vec<_>>();
14542 if !missing.is_empty() {
14543 diagnostics.push(Diagnostic {
14544 related: Vec::new(),
14545 span: rule.body.span,
14546 message: format!(
14547 "rule `{}` has non-exhaustive case; missing {}",
14548 rule.name.name,
14549 missing.join(", ")
14550 ),
14551 suggestion: Some("add branches for every value or add `_ => { ... }`".to_owned()),
14552 });
14553 }
14554}
14555
14556fn validate_duplicate_case_patterns(
14557 rule: &RuleDecl,
14558 branches: &[SpanCaseBranchHead<'_>],
14559 diagnostics: &mut Vec<Diagnostic>,
14560) {
14561 let mut seen = BTreeSet::new();
14562 for branch in branches.iter().filter(|branch| branch.guard.is_none()) {
14563 let Some(pattern) = normalized_case_pattern(branch.pattern) else {
14564 continue;
14565 };
14566 if !seen.insert(pattern.to_owned()) {
14567 diagnostics.push(Diagnostic {
14568 related: Vec::new(),
14569 span: branch.pattern_span,
14570 message: format!(
14571 "rule `{}` has duplicate unguarded case pattern `{pattern}`",
14572 rule.name.name
14573 ),
14574 suggestion: Some(
14575 "remove the duplicate branch or add mutually exclusive `where` guards"
14576 .to_owned(),
14577 ),
14578 });
14579 }
14580 }
14581}
14582
14583fn validate_unreachable_after_fallback(
14589 rule: &RuleDecl,
14590 branches: &[SpanCaseBranchHead<'_>],
14591 diagnostics: &mut Vec<Diagnostic>,
14592) {
14593 let mut ordered: Vec<&SpanCaseBranchHead<'_>> = branches.iter().collect();
14594 ordered.sort_by_key(|branch| branch.pattern_span.start);
14595 let mut fallback_span: Option<SourceSpan> = None;
14596 for branch in ordered {
14597 if let Some(prior) = fallback_span {
14598 diagnostics.push(
14599 Diagnostic {
14600 related: Vec::new(),
14601 span: branch.pattern_span,
14602 message: format!(
14603 "rule `{}` has an unreachable case branch after the `_` wildcard",
14604 rule.name.name
14605 ),
14606 suggestion: Some(
14607 "move this branch before the wildcard, or remove it".to_owned(),
14608 ),
14609 }
14610 .with_related(
14611 prior,
14612 "this unguarded wildcard already matches every remaining value",
14613 ),
14614 );
14615 } else if branch.guard.is_none() && is_fallback_pattern(branch.pattern) {
14616 fallback_span = Some(branch.pattern_span);
14617 }
14618 }
14619}
14620
14621fn finite_case_domain(
14622 scrutinee_ty: Option<&TypeSyntax>,
14623 semantic: &SemanticContext,
14624) -> Option<Vec<String>> {
14625 match scrutinee_ty? {
14626 TypeSyntax::Ref { name } => semantic
14627 .schemas
14628 .enums
14629 .get(&name.name)
14630 .map(|variants| variants.iter().cloned().collect()),
14631 TypeSyntax::Union { variants, .. } => {
14632 let values = variants
14633 .iter()
14634 .filter_map(|variant| match variant {
14635 TypeSyntax::LiteralString { value, .. } => Some(value.clone()),
14636 _ => None,
14637 })
14638 .collect::<Vec<_>>();
14639 (!values.is_empty()).then_some(values)
14640 }
14641 TypeSyntax::Optional { .. } => Some(vec!["Some".to_owned(), "None".to_owned()]),
14642 TypeSyntax::AgentRef { agents, .. } => {
14643 Some(agents.iter().map(|agent| agent.name.clone()).collect())
14644 }
14645 TypeSyntax::Primitive { name, .. } if name == "bool" => {
14648 Some(vec!["true".to_owned(), "false".to_owned()])
14649 }
14650 _ => None,
14651 }
14652}
14653
14654fn sum_case_pattern_parts(pattern: &str) -> (&str, Option<&str>) {
14657 match pattern.split_once(" as ") {
14658 Some((variant, binding)) => (variant.trim(), Some(binding.trim())),
14659 None => (pattern.trim(), None),
14660 }
14661}
14662
14663fn normalized_case_pattern(pattern: &str) -> Option<&str> {
14664 if is_fallback_pattern(pattern) {
14665 return None;
14666 }
14667 if pattern.starts_with("Some ") {
14668 return Some("Some");
14669 }
14670 if pattern == "None" {
14671 return Some("None");
14672 }
14673 let (pattern, _) = sum_case_pattern_parts(pattern);
14675 if matches!(pattern, "true" | "false") {
14678 return Some(pattern);
14679 }
14680 parse_literal_expr(pattern).and_then(|literal| match literal {
14681 LiteralExpr::String(value) | LiteralExpr::Ident(value) => Some(value),
14682 _ => None,
14683 })
14684}
14685
14686fn normalized_terminal_case_pattern(pattern: &str) -> Option<&str> {
14687 if is_fallback_pattern(pattern) {
14688 return None;
14689 }
14690 pattern.split_whitespace().next()
14691}
14692
14693fn is_fallback_pattern(pattern: &str) -> bool {
14694 matches!(pattern, "_" | "default")
14695}
14696
14697fn validate_union_case_pattern(
14698 rule: &RuleDecl,
14699 variants: &[TypeSyntax],
14700 literal: &LiteralExpr<'_>,
14701 span: SourceSpan,
14702 diagnostics: &mut Vec<Diagnostic>,
14703) {
14704 let allowed = variants
14705 .iter()
14706 .filter_map(|variant| match variant {
14707 TypeSyntax::LiteralString { value, .. } => Some(value.as_str()),
14708 _ => None,
14709 })
14710 .collect::<Vec<_>>();
14711 if allowed.is_empty() {
14712 return;
14713 }
14714 let LiteralExpr::String(value) = literal else {
14715 diagnostics.push(Diagnostic {
14716 related: Vec::new(),
14717 span,
14718 message: format!(
14719 "rule `{}` case pattern must be one of its literal variants",
14720 rule.name.name
14721 ),
14722 suggestion: Some(format!("use one of: {}", allowed.join(", "))),
14723 });
14724 return;
14725 };
14726 if !allowed.contains(value) {
14727 diagnostics.push(Diagnostic {
14728 related: Vec::new(),
14729 span,
14730 message: format!("rule `{}` case pattern cannot be `{value}`", rule.name.name),
14731 suggestion: Some(format!("use one of: {}", allowed.join(", "))),
14732 });
14733 }
14734}
14735
14736fn validate_agent_ref_case_pattern(
14737 rule: &RuleDecl,
14738 agents: &[Ident],
14739 literal: &LiteralExpr<'_>,
14740 span: SourceSpan,
14741 diagnostics: &mut Vec<Diagnostic>,
14742) {
14743 let allowed = agents
14744 .iter()
14745 .map(|agent| agent.name.as_str())
14746 .collect::<Vec<_>>();
14747 let (LiteralExpr::String(value) | LiteralExpr::Ident(value)) = literal else {
14748 diagnostics.push(Diagnostic {
14749 related: Vec::new(),
14750 span,
14751 message: format!("rule `{}` has non-agent case pattern", rule.name.name),
14752 suggestion: Some(format!("use one of: {}", allowed.join(", "))),
14753 });
14754 return;
14755 };
14756 if !allowed.contains(value) {
14757 diagnostics.push(Diagnostic {
14758 related: Vec::new(),
14759 span,
14760 message: format!("AgentRef has no agent `{value}`"),
14761 suggestion: Some(format!("use one of: {}", allowed.join(", "))),
14762 });
14763 }
14764}
14765
14766fn validate_binding_uses(
14767 rule: &RuleDecl,
14768 line: &str,
14769 seen_bindings: &BTreeSet<String>,
14770 scope_stack: &[(String, DependencyPredicate)],
14771 diagnostics: &mut Vec<Diagnostic>,
14772) {
14773 for root in interpolation_roots(line) {
14774 if !seen_bindings.contains(&root) {
14775 continue;
14776 }
14777 if scope_stack.iter().any(|(binding, _)| binding == &root) {
14778 continue;
14779 }
14780
14781 diagnostics.push(Diagnostic { related: Vec::new(),
14782 span: rule.body.span,
14783 message: format!(
14784 "rule `{}` uses effect output `{root}` outside a matching `after {root} ...` block",
14785 rule.name.name
14786 ),
14787 suggestion: Some(format!(
14788 "move this use into `after {root} succeeds {{ ... }}` or another matching terminal branch"
14789 )),
14790 });
14791 }
14792}
14793
14794fn after_scopes(block_stack: &[BlockFrame]) -> Vec<(String, DependencyPredicate)> {
14795 block_stack
14796 .iter()
14797 .map(|frame| match frame {
14798 BlockFrame::After { binding, predicate } => (binding.clone(), predicate.clone()),
14799 })
14800 .collect()
14801}
14802
14803pub fn runtime_fact_name_for_pattern(pattern: &str) -> Option<String> {
14808 let pattern = pattern.trim();
14809 if let Some(rest) = pattern.strip_prefix("fact ") {
14810 let name = rest.split_whitespace().next()?;
14811 return Some(name.to_owned());
14812 }
14813 if let Some(rest) = pattern.strip_prefix("message from ") {
14816 if let Some(channel) = rest.split_whitespace().next() {
14817 return Some(format!("message.{channel}"));
14818 }
14819 }
14820 let mut words = pattern.split_whitespace();
14821 let first = words.next()?;
14822 if words.next() == Some("completed") && words.next() == Some("turn") {
14823 let _ = first;
14824 return Some("agent.turn.completed".to_owned());
14825 }
14826 {
14827 let mut words = pattern.split_whitespace();
14828 let _tracker = words.next();
14829 if words.next() == Some("has")
14830 && words.next() == Some("ready")
14831 && words.next() == Some("issue")
14832 {
14833 return Some("tracker.issue.ready".to_owned());
14834 }
14835 }
14836 if first.chars().next().is_some_and(char::is_uppercase) {
14837 return Some(first.to_owned());
14838 }
14839 None
14840}
14841
14842fn binding_from_when(when: &str) -> Option<(String, String)> {
14846 let (pattern, _) = split_when_guard(when);
14847 let binding = binding_after_as(pattern)?;
14848 let first = pattern.split_whitespace().next()?;
14849 let completed_turn = {
14850 let mut words = pattern.split_whitespace();
14851 words.next();
14852 words.next() == Some("completed") && words.next() == Some("turn")
14853 };
14854 let has_ready_issue = {
14855 let mut words = pattern.split_whitespace();
14856 words.next();
14857 words.next() == Some("has")
14858 && words.next() == Some("ready")
14859 && words.next() == Some("issue")
14860 };
14861 let schema = if let Some(rest) = pattern.strip_prefix("fact ") {
14862 rest.split_whitespace().next()?.to_owned()
14863 } else if first.chars().next().is_some_and(char::is_uppercase) {
14864 first.to_owned()
14865 } else if first.contains('.') {
14866 first.to_owned()
14870 } else if completed_turn {
14871 "AgentTurn".to_owned()
14872 } else if has_ready_issue {
14873 "WorkItem".to_owned()
14874 } else if pattern.starts_with("message from ") {
14875 "Message".to_owned()
14878 } else {
14879 return None;
14880 };
14881
14882 Some((binding, schema))
14883}
14884
14885fn split_when_guard(when: &str) -> (&str, Option<&str>) {
14886 match when.split_once(" where ") {
14887 Some((pattern, guard)) => (pattern.trim(), Some(guard.trim())),
14888 None => (when.trim(), None),
14889 }
14890}
14891
14892fn effect_binding_schema(
14893 line: &str,
14894 kind: &IrEffectKind,
14895 semantic: &SemanticContext,
14896) -> Option<String> {
14897 match kind {
14898 IrEffectKind::SchemaCoerce => parse_coerce_call_name(line).and_then(|name| {
14899 semantic
14900 .coerce_outputs
14901 .get(name)
14902 .and_then(schema_name_for_path)
14903 }),
14904 IrEffectKind::AgentTell
14905 | IrEffectKind::CapabilityCall
14906 | IrEffectKind::EventEmit
14907 | IrEffectKind::WorkflowInvoke
14908 | IrEffectKind::TimerWait
14909 | IrEffectKind::ExecCommand
14910 | IrEffectKind::TrackerFile
14911 | IrEffectKind::TrackerClaim
14912 | IrEffectKind::TrackerRenew
14913 | IrEffectKind::TrackerRelease
14914 | IrEffectKind::TrackerFinish
14915 | IrEffectKind::LeaseAcquire
14916 | IrEffectKind::LeaseRenew
14917 | IrEffectKind::LedgerAppend
14918 | IrEffectKind::CounterConsume
14919 | IrEffectKind::SignalEmit
14920 | IrEffectKind::FileRead
14921 | IrEffectKind::FileWrite
14922 | IrEffectKind::FileImport
14923 | IrEffectKind::FileExport => None,
14924 }
14925}
14926
14927fn parse_coerce_call_name(line: &str) -> Option<&str> {
14928 let rest = line.strip_prefix("coerce ")?;
14929 rest.split_once('(').map(|(name, _)| name.trim())
14930}
14931
14932fn parse_coerce_call(line: &str) -> Option<(&str, Vec<&str>)> {
14933 let rest = line.strip_prefix("coerce ")?;
14934 let call = rest.split(" as ").next().unwrap_or(rest).trim();
14935 let (name, tail) = call.split_once('(')?;
14936 let (args, _) = tail.rsplit_once(')')?;
14937 Some((name.trim(), split_expression_args(args)))
14938}
14939
14940fn split_expression_args(args: &str) -> Vec<&str> {
14941 let mut values = Vec::new();
14942 let mut start = 0usize;
14943 let mut depth = 0i32;
14944 let mut in_string = false;
14945 let mut previous = '\0';
14946 for (index, ch) in args.char_indices() {
14947 if ch == '"' && previous != '\\' {
14948 in_string = !in_string;
14949 } else if !in_string {
14950 match ch {
14951 '(' | '[' | '{' => depth += 1,
14952 ')' | ']' | '}' => depth -= 1,
14953 ',' if depth == 0 => {
14954 let value = args[start..index].trim();
14955 if !value.is_empty() {
14956 values.push(value);
14957 }
14958 start = index + ch.len_utf8();
14959 }
14960 _ => {}
14961 }
14962 }
14963 previous = ch;
14964 }
14965 let value = args[start..].trim();
14966 if !value.is_empty() {
14967 values.push(value);
14968 }
14969 values
14970}
14971
14972fn effect_payload_statements(body: &str) -> Vec<String> {
14973 collect_body_statements(body, effect_payload_statement_balance)
14974}
14975
14976fn workflow_invoke_statements(body: &str) -> Vec<String> {
14977 collect_body_statements(body, workflow_invoke_statement_balance)
14978}
14979
14980#[derive(Clone, Copy, Debug, Eq, PartialEq)]
14981enum StatementBalance {
14982 None,
14983 Parens,
14984 Braces,
14985}
14986
14987fn collect_body_statements(
14988 body: &str,
14989 statement_balance: fn(&str) -> Option<StatementBalance>,
14990) -> Vec<String> {
14991 let lines = body.lines().collect::<Vec<_>>();
14992 let mut statements = Vec::new();
14993 let mut index = 0usize;
14994 let mut record_depth = 0i32;
14995 let mut multiline_string = false;
14996 while index < lines.len() {
14997 let trimmed = lines[index].trim();
14998 if trimmed.is_empty() {
14999 index += 1;
15000 continue;
15001 }
15002 if multiline_string {
15003 if trimmed.contains("\"\"\"") {
15004 multiline_string = false;
15005 }
15006 index += 1;
15007 continue;
15008 }
15009 if record_depth > 0 {
15010 record_depth += brace_delta(trimmed);
15011 index += 1;
15012 continue;
15013 }
15014 if parse_record_start(trimmed).is_some() {
15015 record_depth = brace_delta(trimmed).max(1);
15016 index += 1;
15017 continue;
15018 }
15019 if trimmed.contains("\"\"\"") {
15020 multiline_string = trimmed.matches("\"\"\"").count() % 2 == 1;
15021 index += 1;
15022 continue;
15023 }
15024 if let Some(balance) = statement_balance(trimmed) {
15025 match balance {
15026 StatementBalance::None => statements.push(trimmed.to_owned()),
15027 StatementBalance::Parens => {
15028 let (statement, next_index) =
15029 statement_until_balanced(&lines, index, trimmed, paren_delta);
15030 statements.push(statement);
15031 index = next_index + 1;
15032 continue;
15033 }
15034 StatementBalance::Braces => {
15035 let (statement, next_index) =
15036 statement_until_balanced(&lines, index, trimmed, brace_delta);
15037 statements.push(statement);
15038 index = next_index + 1;
15039 continue;
15040 }
15041 }
15042 }
15043 index += 1;
15044 }
15045 statements
15046}
15047
15048fn effect_payload_statement_balance(trimmed: &str) -> Option<StatementBalance> {
15049 if trimmed.starts_with("coerce ") {
15050 Some(StatementBalance::Parens)
15051 } else if trimmed.starts_with("claim ") {
15052 Some(StatementBalance::None)
15053 } else {
15054 None
15055 }
15056}
15057
15058fn workflow_invoke_statement_balance(trimmed: &str) -> Option<StatementBalance> {
15059 trimmed
15060 .starts_with("invoke ")
15061 .then_some(StatementBalance::Braces)
15062}
15063
15064fn invoke_statement_parts(statement: &str) -> Option<(&str, &str)> {
15065 let rest = statement.trim().strip_prefix("invoke ")?;
15066 let target = rest
15067 .split_whitespace()
15068 .next()
15069 .unwrap_or("")
15070 .trim_end_matches('{');
15071 if target.is_empty() {
15072 return None;
15073 }
15074 let open = statement.find('{')?;
15075 let mut depth = 0i32;
15076 let mut close = None;
15077 for (offset, ch) in statement[open..].char_indices() {
15078 match ch {
15079 '{' => depth += 1,
15080 '}' => {
15081 depth -= 1;
15082 if depth == 0 {
15083 close = Some(open + offset);
15084 break;
15085 }
15086 }
15087 _ => {}
15088 }
15089 }
15090 let close = close?;
15091 (close > open).then_some((target, statement[open + 1..close].trim()))
15092}
15093
15094fn statement_until_balanced(
15095 lines: &[&str],
15096 index: usize,
15097 trimmed: &str,
15098 delta: fn(&str) -> i32,
15099) -> (String, usize) {
15100 let mut statement = trimmed.to_owned();
15101 let mut depth = delta(trimmed);
15102 let mut cursor = index;
15103 while depth > 0 && cursor + 1 < lines.len() {
15104 cursor += 1;
15105 let next = lines[cursor].trim();
15106 statement.push(' ');
15107 statement.push_str(next);
15108 depth += delta(next);
15109 }
15110 (statement, cursor)
15111}
15112
15113fn paren_delta(line: &str) -> i32 {
15114 line.chars().fold(0, |depth, ch| match ch {
15115 '(' => depth + 1,
15116 ')' => depth - 1,
15117 _ => depth,
15118 })
15119}
15120
15121pub fn inline_decide_schema_name(rule: &str, binding: &str) -> String {
15129 format!("decide.{rule}.{binding}")
15130}
15131
15132fn decide_field_type_syntax(ty: &str, span: SourceSpan) -> TypeSyntax {
15136 if is_primitive_type(ty) {
15137 TypeSyntax::Primitive {
15138 name: ty.to_owned(),
15139 span,
15140 }
15141 } else {
15142 TypeSyntax::Ref {
15143 name: Ident {
15144 name: ty.to_owned(),
15145 span,
15146 },
15147 }
15148 }
15149}
15150
15151#[allow(clippy::type_complexity)]
15155fn collect_decide_effects<'a>(
15156 statements: &'a [body::BodyStmt],
15157 out: &mut Vec<(&'a str, &'a [(String, String)], SourceSpan)>,
15158) {
15159 for statement in statements {
15160 match statement {
15161 body::BodyStmt::Effect(effect) => {
15162 if let body::BodyEffectKind::Decide { result_fields } = &effect.kind {
15163 if let Some(binding) = &effect.binding {
15164 out.push((binding.as_str(), result_fields.as_slice(), effect.span));
15165 }
15166 }
15167 }
15168 body::BodyStmt::After(after) => collect_decide_effects(&after.body, out),
15169 body::BodyStmt::Case(case) => {
15170 for branch in &case.branches {
15171 collect_decide_effects(&branch.body, out);
15172 }
15173 }
15174 _ => {}
15175 }
15176 }
15177}
15178
15179fn collect_decide_payload_types(
15184 statements: &[body::BodyStmt],
15185 rule_name: &str,
15186 payloads: &mut BTreeMap<String, IrType>,
15187) {
15188 let mut decides = Vec::new();
15189 collect_decide_effects(statements, &mut decides);
15190 for (binding, _fields, _span) in decides {
15191 payloads.insert(
15192 binding.to_owned(),
15193 IrType::Ref(inline_decide_schema_name(rule_name, binding)),
15194 );
15195 }
15196}
15197
15198fn collect_prompt_payload_types(
15199 statements: &[body::BodyStmt],
15200 payloads: &mut BTreeMap<String, IrType>,
15201) {
15202 for statement in statements {
15203 match statement {
15204 body::BodyStmt::Effect(effect) => {
15205 if matches!(&effect.kind, body::BodyEffectKind::Prompt { .. }) {
15206 if let Some(binding) = &effect.binding {
15207 payloads
15208 .insert(binding.clone(), IrType::Primitive(IrPrimitiveType::String));
15209 }
15210 }
15211 }
15212 body::BodyStmt::After(after) => collect_prompt_payload_types(&after.body, payloads),
15213 body::BodyStmt::Case(case) => {
15214 for branch in &case.branches {
15215 collect_prompt_payload_types(&branch.body, payloads);
15216 }
15217 }
15218 _ => {}
15219 }
15220 }
15221}
15222
15223fn collect_inline_decide_schemas(
15229 items: &[Item],
15230 semantic: &mut SemanticContext,
15231 ir: &mut IrProgram,
15232) {
15233 for item in items {
15234 let Item::Rule(rule) = item else {
15235 continue;
15236 };
15237 let (body_ast, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
15238 let mut decides = Vec::new();
15239 collect_decide_effects(&body_ast.statements, &mut decides);
15240 for (binding, fields, span) in decides {
15241 let name = inline_decide_schema_name(&rule.name.name, binding);
15242 let mut syntax_fields: BTreeMap<String, TypeSyntax> = BTreeMap::new();
15245 let mut ir_fields = Vec::new();
15246 for (field_name, field_ty) in fields {
15247 let ty = decide_field_type_syntax(field_ty, span);
15248 ir_fields.push(IrClassField {
15249 name: field_name.clone(),
15250 ty: lower_type(ty.clone()),
15251 is_key: false,
15252 presence_condition: None,
15253 span,
15254 });
15255 syntax_fields.insert(field_name.clone(), ty);
15256 }
15257 semantic.schemas.classes.insert(name.clone(), syntax_fields);
15258 ir.schemas.push(IrSchema::Class(IrClass {
15259 name,
15260 fields: ir_fields,
15261 span,
15262 }));
15263 }
15264 }
15265}
15266
15267pub fn redact_schema_name(rule: &str, binding: &str) -> String {
15270 format!("redact.{rule}.{binding}")
15271}
15272
15273#[allow(clippy::type_complexity)]
15277fn collect_redact_effects<'a>(
15278 statements: &'a [body::BodyStmt],
15279 out: &mut Vec<(&'a str, &'a [String], &'a str, SourceSpan)>,
15280) {
15281 for statement in statements {
15282 match statement {
15283 body::BodyStmt::Redact {
15284 source,
15285 keep,
15286 binding,
15287 span,
15288 } => out.push((source.as_str(), keep.as_slice(), binding.as_str(), *span)),
15289 body::BodyStmt::After(after) => collect_redact_effects(&after.body, out),
15290 body::BodyStmt::Case(case) => {
15291 for branch in &case.branches {
15292 collect_redact_effects(&branch.body, out);
15293 }
15294 }
15295 _ => {}
15296 }
15297 }
15298}
15299
15300fn rule_binding_schemas(rule: &RuleDecl, semantic: &SemanticContext) -> BTreeMap<String, String> {
15308 let mut schemas = binding_types_for_rule(rule);
15309 let (body_ast, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
15310 let mut payloads = collect_effect_payload_types(rule, semantic, &mut Vec::new());
15311 collect_exec_payload_types(&body_ast.statements, semantic, &mut payloads);
15312 collect_decide_payload_types(&body_ast.statements, &rule.name.name, &mut payloads);
15313 collect_redact_payload_types(&body_ast.statements, &rule.name.name, &mut payloads);
15314 for line in rule.body.text.lines() {
15320 let Some(rest) = line.trim().strip_prefix("after ") else {
15321 continue;
15322 };
15323 let mut words = rest.split_whitespace();
15324 let Some(binding) = words.next() else {
15325 continue;
15326 };
15327 let Some(predicate) = words.next() else {
15328 continue;
15329 };
15330 if predicate == "times" && words.next() != Some("out") {
15331 continue;
15332 }
15333 let (Some("as"), Some(alias)) = (words.next(), words.next()) else {
15334 continue;
15335 };
15336 let alias = alias.trim_end_matches('{').trim();
15337 if alias.is_empty() {
15338 continue;
15339 }
15340 if let Some(IrType::Ref(schema)) = payloads.get(binding) {
15341 schemas.insert(alias.to_owned(), schema.clone());
15342 }
15343 }
15344 for (binding, ty) in payloads {
15345 if let IrType::Ref(schema) = ty {
15346 schemas.insert(binding, schema);
15347 }
15348 }
15349 schemas
15350}
15351
15352fn collect_redact_schemas(items: &[Item], semantic: &mut SemanticContext, ir: &mut IrProgram) {
15362 for item in items {
15363 let Item::Rule(rule) = item else {
15364 continue;
15365 };
15366 let (body_ast, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
15367 let mut redacts = Vec::new();
15368 collect_redact_effects(&body_ast.statements, &mut redacts);
15369 if redacts.is_empty() {
15370 continue;
15371 }
15372 let binding_schemas = rule_binding_schemas(rule, semantic);
15373 let mut local: BTreeMap<String, String> = BTreeMap::new();
15374 for (source, keep, binding, span) in redacts {
15375 let name = redact_schema_name(&rule.name.name, binding);
15376 let source_schema = binding_schemas
15377 .get(source)
15378 .cloned()
15379 .or_else(|| local.get(source).cloned());
15380 let projected: Vec<(String, TypeSyntax)> = source_schema
15383 .as_ref()
15384 .and_then(|schema| semantic.schemas.classes.get(schema))
15385 .map(|src_fields| {
15386 keep.iter()
15387 .filter_map(|field| {
15388 src_fields.get(field).map(|ty| (field.clone(), ty.clone()))
15389 })
15390 .collect()
15391 })
15392 .unwrap_or_default();
15393 let mut syntax_fields: BTreeMap<String, TypeSyntax> = BTreeMap::new();
15394 let mut ir_fields = Vec::new();
15395 for (field_name, ty) in &projected {
15396 syntax_fields.insert(field_name.clone(), ty.clone());
15397 ir_fields.push(IrClassField {
15398 name: field_name.clone(),
15399 ty: lower_type(ty.clone()),
15400 is_key: false,
15401 presence_condition: None,
15402 span,
15403 });
15404 }
15405 semantic.schemas.classes.insert(name.clone(), syntax_fields);
15406 ir.schemas.push(IrSchema::Class(IrClass {
15407 name: name.clone(),
15408 fields: ir_fields,
15409 span,
15410 }));
15411 local.insert(binding.to_owned(), name);
15412 }
15413 }
15414}
15415
15416fn collect_redact_payload_types(
15421 statements: &[body::BodyStmt],
15422 rule_name: &str,
15423 payloads: &mut BTreeMap<String, IrType>,
15424) {
15425 let mut redacts = Vec::new();
15426 collect_redact_effects(statements, &mut redacts);
15427 for (_source, _keep, binding, _span) in redacts {
15428 payloads.insert(
15429 binding.to_owned(),
15430 IrType::Ref(redact_schema_name(rule_name, binding)),
15431 );
15432 }
15433}
15434
15435fn validate_redactions(
15440 rule: &RuleDecl,
15441 statements: &[body::BodyStmt],
15442 semantic: &SemanticContext,
15443 binding_schemas: &BTreeMap<String, String>,
15444 diagnostics: &mut Vec<Diagnostic>,
15445) {
15446 let mut redacts = Vec::new();
15447 collect_redact_effects(statements, &mut redacts);
15448 let mut local: BTreeMap<String, String> = BTreeMap::new();
15449 for (source, keep, binding, span) in redacts {
15450 let source_schema = binding_schemas
15451 .get(source)
15452 .cloned()
15453 .or_else(|| local.get(source).cloned());
15454 local.insert(
15455 binding.to_owned(),
15456 redact_schema_name(&rule.name.name, binding),
15457 );
15458 let Some(schema) = source_schema else {
15459 diagnostics.push(Diagnostic {
15460 related: Vec::new(),
15461 span,
15462 message: format!(
15463 "rule `{}` redacts `{source}`, which has no known schema",
15464 rule.name.name
15465 ),
15466 suggestion: Some(
15467 "redact a binding with a known record type — a matched `when Class as x`, or a \
15468 coerce/decide/exec result"
15469 .to_owned(),
15470 ),
15471 });
15472 continue;
15473 };
15474 let Some(src_fields) = semantic.schemas.classes.get(&schema) else {
15475 continue;
15476 };
15477 for field in keep {
15478 if !src_fields.contains_key(field) {
15479 diagnostics.push(Diagnostic {
15480 related: Vec::new(),
15481 span,
15482 message: format!(
15483 "rule `{}` redacts `{source}` keeping unknown field `{field}` of `{schema}`",
15484 rule.name.name
15485 ),
15486 suggestion: Some(format!("keep a field declared on `{schema}`")),
15487 });
15488 }
15489 }
15490 }
15491}
15492
15493fn collect_exec_payload_types(
15499 statements: &[body::BodyStmt],
15500 semantic: &SemanticContext,
15501 payloads: &mut BTreeMap<String, IrType>,
15502) {
15503 for statement in statements {
15504 match statement {
15505 body::BodyStmt::Effect(effect) => {
15506 if let body::BodyEffectKind::Exec {
15507 parse_target: Some(parse),
15508 ..
15509 } = &effect.kind
15510 {
15511 if !parse.each {
15512 if let Some(binding) = &effect.binding {
15513 if semantic.schemas.class_exists(&parse.schema) {
15514 payloads.insert(binding.clone(), IrType::Ref(parse.schema.clone()));
15515 }
15516 }
15517 }
15518 }
15519 }
15520 body::BodyStmt::After(after) => {
15521 collect_exec_payload_types(&after.body, semantic, payloads)
15522 }
15523 body::BodyStmt::Case(case) => {
15524 for branch in &case.branches {
15525 collect_exec_payload_types(&branch.body, semantic, payloads);
15526 }
15527 }
15528 _ => {}
15529 }
15530 }
15531}
15532
15533fn push_ingest_fact_writes(statements: &[body::BodyStmt], fact_writes: &mut Vec<String>) {
15535 for statement in statements {
15536 match statement {
15537 body::BodyStmt::Effect(effect) => {
15538 match &effect.kind {
15539 body::BodyEffectKind::Exec {
15540 parse_target: Some(parse),
15541 ..
15542 } if parse.each => {
15543 fact_writes.push(format!("schema:{}", parse.schema));
15544 }
15545 body::BodyEffectKind::FileImport { schema, .. } => {
15549 fact_writes.push(format!("schema:{schema}"));
15550 }
15551 _ => {}
15552 }
15553 }
15554 body::BodyStmt::After(after) => push_ingest_fact_writes(&after.body, fact_writes),
15555 body::BodyStmt::Case(case) => {
15556 for branch in &case.branches {
15557 push_ingest_fact_writes(&branch.body, fact_writes);
15558 }
15559 }
15560 _ => {}
15561 }
15562 }
15563}
15564
15565fn validate_coordination_discipline(
15579 rule: &RuleDecl,
15580 statements: &[body::BodyStmt],
15581 diagnostics: &mut Vec<Diagnostic>,
15582) {
15583 let mut acquires = Vec::new();
15584 let mut consumes = Vec::new();
15585 let mut claims = Vec::new();
15586 collect_coordination_effects(statements, &mut acquires, &mut consumes, &mut claims);
15587
15588 let claim_bindings = collect_claim_bindings(statements);
15599 let renewable: BTreeSet<&str> = acquires
15600 .iter()
15601 .map(|(b, _, _)| b.as_str())
15602 .chain(claim_bindings.iter().map(String::as_str))
15603 .collect();
15604 for_each_body(statements, &mut |stmt| {
15605 if let body::BodyStmt::Effect(effect) = stmt {
15606 if let body::BodyEffectKind::LeaseRenew {
15607 acquire_binding, ..
15608 } = &effect.kind
15609 {
15610 if !renewable.contains(acquire_binding.as_str()) {
15611 diagnostics.push(Diagnostic {
15612 related: Vec::new(),
15613 span: effect.span,
15614 message: format!(
15615 "rule `{}` renews unbound coordination binding `{}`",
15616 rule.name.name, acquire_binding
15617 ),
15618 suggestion: Some(format!(
15619 "`renew {acquire_binding}` must name a lease acquired here (`acquire ... as {acquire_binding}`) or an issue claimed here (`claim ... as {acquire_binding}`)"
15620 )),
15621 });
15622 }
15623 }
15624 }
15625 });
15626
15627 let work_items: Vec<String> = rule
15638 .whens
15639 .iter()
15640 .filter_map(|when| when_has_ready_binding(&when.text))
15641 .collect();
15642 let releasable: BTreeSet<&str> = acquires
15643 .iter()
15644 .map(|(b, _, _)| b.as_str())
15645 .chain(claims.iter().map(|(item, _)| item.as_str()))
15646 .chain(work_items.iter().map(String::as_str))
15647 .collect();
15648 for_each_body(statements, &mut |stmt| {
15649 if let body::BodyStmt::Effect(effect) = stmt {
15650 if let body::BodyEffectKind::TrackerRelease { item } = &effect.kind {
15651 if !releasable.contains(item.as_str()) {
15652 diagnostics.push(Diagnostic {
15653 related: Vec::new(),
15654 span: effect.span,
15655 message: format!(
15656 "rule `{}` releases unbound coordination item `{}`",
15657 rule.name.name, item
15658 ),
15659 suggestion: Some(format!(
15660 "`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"
15661 )),
15662 });
15663 }
15664 }
15665 }
15666 });
15667
15668 if acquires.len() > 1 {
15669 diagnostics.push(Diagnostic { related: Vec::new(),
15670 span: acquires[1].2,
15671 message: format!(
15672 "rule `{}` acquires more than one lease in a single progression",
15673 rule.name.name
15674 ),
15675 suggestion: Some(
15676 "the hard default is at most one held lease per progression (it breaks hold-and-wait); restructure into separate rules"
15677 .to_owned(),
15678 ),
15679 });
15680 }
15681 for (binding, until_ttl, span) in &acquires {
15682 if *until_ttl {
15683 continue;
15684 }
15685 let mut predicates = BTreeSet::new();
15686 collect_after_predicates(statements, binding, &mut predicates);
15687 for required in ["held", "contended"] {
15688 if !predicates.contains(required) {
15689 diagnostics.push(Diagnostic { related: Vec::new(),
15690 span: *span,
15691 message: format!(
15692 "rule `{}` does not handle the `{required}` outcome of lease `{binding}`",
15693 rule.name.name
15694 ),
15695 suggestion: Some(format!(
15696 "coordination outcomes are exhaustive: add `after {binding} {required} {{ ... }}`"
15697 )),
15698 });
15699 }
15700 }
15701 if let Some(held_body) = find_after_body(statements, binding, body::AfterPredicate::Held) {
15702 if !releases_or_terminates(held_body, binding) {
15703 diagnostics.push(Diagnostic { related: Vec::new(),
15704 span: *span,
15705 message: format!(
15706 "rule `{}` can hold lease `{binding}` forever: the `held` branch neither releases it nor reaches a workflow terminal",
15707 rule.name.name
15708 ),
15709 suggestion: Some(format!(
15710 "add `release {binding}` on every non-terminal path, or use `acquire ... until ttl` for fire-and-forget"
15711 )),
15712 });
15713 }
15714 }
15715 }
15716 for (binding, span) in &consumes {
15717 let mut predicates = BTreeSet::new();
15718 collect_after_predicates(statements, binding, &mut predicates);
15719 for required in ["ok", "over"] {
15720 if !predicates.contains(required) {
15721 diagnostics.push(Diagnostic { related: Vec::new(),
15722 span: *span,
15723 message: format!(
15724 "rule `{}` does not handle the `{required}` outcome of counter consume `{binding}`",
15725 rule.name.name
15726 ),
15727 suggestion: Some(format!(
15728 "coordination outcomes are exhaustive: add `after {binding} {required} {{ ... }}`"
15729 )),
15730 });
15731 }
15732 }
15733 }
15734}
15735
15736fn collect_coordination_effects(
15737 statements: &[body::BodyStmt],
15738 acquires: &mut Vec<(String, bool, SourceSpan)>,
15739 consumes: &mut Vec<(String, SourceSpan)>,
15740 claims: &mut Vec<(String, SourceSpan)>,
15741) {
15742 for_each_body(statements, &mut |stmt| {
15743 if let body::BodyStmt::Effect(effect) = stmt {
15744 match &effect.kind {
15745 body::BodyEffectKind::LeaseAcquire { until_ttl, .. } => {
15746 if let Some(binding) = &effect.binding {
15747 acquires.push((binding.clone(), *until_ttl, effect.span));
15748 }
15749 }
15750 body::BodyEffectKind::CounterConsume { .. } => {
15751 if let Some(binding) = &effect.binding {
15752 consumes.push((binding.clone(), effect.span));
15753 }
15754 }
15755 body::BodyEffectKind::TrackerClaim { item, .. } => {
15759 claims.push((item.clone(), effect.span));
15760 }
15761 _ => {}
15762 }
15763 }
15764 });
15765}
15766
15767fn when_has_ready_binding(when: &str) -> Option<String> {
15773 let (pattern, _) = split_when_guard(when);
15774 let mut words = pattern.split_whitespace();
15775 let _queue = words.next()?;
15776 if words.next() == Some("has") && words.next() == Some("ready") {
15777 return binding_after_as(pattern);
15778 }
15779 None
15780}
15781
15782fn collect_after_predicates(
15783 statements: &[body::BodyStmt],
15784 binding: &str,
15785 predicates: &mut BTreeSet<&'static str>,
15786) {
15787 for_each_body(statements, &mut |stmt| {
15788 if let body::BodyStmt::After(after) = stmt {
15789 if after.binding == binding {
15790 predicates.insert(after.predicate.as_str());
15791 }
15792 }
15793 });
15794}
15795
15796fn find_after_body<'a>(
15797 statements: &'a [body::BodyStmt],
15798 binding: &str,
15799 predicate: body::AfterPredicate,
15800) -> Option<&'a [body::BodyStmt]> {
15801 for statement in statements {
15802 match statement {
15803 body::BodyStmt::After(after) => {
15804 if after.binding == binding && after.predicate == predicate {
15805 return Some(&after.body);
15806 }
15807 if let Some(found) = find_after_body(&after.body, binding, predicate) {
15808 return Some(found);
15809 }
15810 }
15811 body::BodyStmt::Case(case) => {
15812 for branch in &case.branches {
15813 if let Some(found) = find_after_body(&branch.body, binding, predicate) {
15814 return Some(found);
15815 }
15816 }
15817 }
15818 _ => {}
15819 }
15820 }
15821 None
15822}
15823
15824fn releases_or_terminates(statements: &[body::BodyStmt], binding: &str) -> bool {
15829 statements.iter().any(|statement| match statement {
15830 body::BodyStmt::Effect(effect) => matches!(
15831 &effect.kind,
15832 body::BodyEffectKind::TrackerRelease { item } if item == binding
15833 ),
15834 body::BodyStmt::Terminal(_) => true,
15835 body::BodyStmt::After(after) => releases_or_terminates(&after.body, binding),
15836 body::BodyStmt::Case(case) => {
15837 !case.branches.is_empty()
15838 && case
15839 .branches
15840 .iter()
15841 .all(|branch| releases_or_terminates(&branch.body, binding))
15842 }
15843 _ => false,
15844 })
15845}
15846
15847fn for_each_body(statements: &[body::BodyStmt], visit: &mut impl FnMut(&body::BodyStmt)) {
15848 for statement in statements {
15849 visit(statement);
15850 match statement {
15851 body::BodyStmt::After(after) => for_each_body(&after.body, visit),
15852 body::BodyStmt::Case(case) => {
15853 for branch in &case.branches {
15854 for_each_body(&branch.body, visit);
15855 }
15856 }
15857 _ => {}
15858 }
15859 }
15860}
15861
15862fn family_b_arm_allowed(
15867 scrutinee: &str,
15868 pattern: &str,
15869 binding_types: &BTreeMap<String, String>,
15870 semantic: &SemanticContext,
15871) -> BTreeSet<(String, String)> {
15872 let mut allowed = BTreeSet::new();
15873 let Some((root, disc)) = scrutinee.split_once('.') else {
15874 return allowed;
15875 };
15876 if disc.contains('.') {
15877 return allowed;
15878 }
15879 let trimmed = pattern.trim();
15880 if trimmed == "_" || trimmed == "default" {
15881 return allowed;
15882 }
15883 let literal = trimmed.trim_matches('"');
15884 if literal.is_empty() {
15885 return allowed;
15886 }
15887 let Some(schema) = binding_types.get(root) else {
15888 return allowed;
15889 };
15890 if let Some(conditions) = semantic.schemas.presence.get(schema) {
15891 for (field, (cond_disc, cond_literal)) in conditions {
15892 if cond_disc == disc && cond_literal == literal {
15893 allowed.insert((root.to_owned(), field.clone()));
15894 }
15895 }
15896 }
15897 allowed
15898}
15899
15900fn check_conditioned_reads_in_text(
15904 rule: &RuleDecl,
15905 text: &str,
15906 span: SourceSpan,
15907 semantic: &SemanticContext,
15908 binding_types: &BTreeMap<String, String>,
15909 allowed: &BTreeSet<(String, String)>,
15910 diagnostics: &mut Vec<Diagnostic>,
15911) {
15912 for (root, path) in dotted_paths(text) {
15913 let Some(first) = path.first() else {
15914 continue;
15915 };
15916 let Some(schema) = binding_types.get(&root) else {
15917 continue;
15918 };
15919 if let Some((disc, _literal)) = semantic.schemas.field_presence(schema, first) {
15920 if !allowed.contains(&(root.clone(), first.clone())) {
15921 diagnostics.push(Diagnostic {
15922 related: Vec::new(),
15923 span,
15924 message: format!(
15925 "rule `{}` reads conditional field `{root}.{first}` outside a matching `case {root}.{disc}` arm",
15926 rule.name.name
15927 ),
15928 suggestion: Some(format!(
15929 "read `{root}.{first}` inside `case {root}.{disc} {{ \"...\" => ... }}` — it is present only for a specific `{disc}`"
15930 )),
15931 });
15932 }
15933 }
15934 }
15935}
15936
15937fn check_conditioned_reads_in_fields(
15938 rule: &RuleDecl,
15939 fields: &[body::FieldAssign],
15940 semantic: &SemanticContext,
15941 binding_types: &BTreeMap<String, String>,
15942 allowed: &BTreeSet<(String, String)>,
15943 diagnostics: &mut Vec<Diagnostic>,
15944) {
15945 for field in fields {
15946 match &field.value {
15947 body::FieldValue::Expr { source, .. } => check_conditioned_reads_in_text(
15948 rule,
15949 source,
15950 field.span,
15951 semantic,
15952 binding_types,
15953 allowed,
15954 diagnostics,
15955 ),
15956 body::FieldValue::Nested { fields, .. } => check_conditioned_reads_in_fields(
15957 rule,
15958 fields,
15959 semantic,
15960 binding_types,
15961 allowed,
15962 diagnostics,
15963 ),
15964 body::FieldValue::Shorthand => {}
15965 }
15966 }
15967}
15968
15969fn validate_conditioned_field_reads(
15976 rule: &RuleDecl,
15977 statements: &[body::BodyStmt],
15978 semantic: &SemanticContext,
15979 binding_types: &BTreeMap<String, String>,
15980 allowed: &BTreeSet<(String, String)>,
15981 diagnostics: &mut Vec<Diagnostic>,
15982) {
15983 for statement in statements {
15984 match statement {
15985 body::BodyStmt::Record(record) => check_conditioned_reads_in_fields(
15986 rule,
15987 &record.fields,
15988 semantic,
15989 binding_types,
15990 allowed,
15991 diagnostics,
15992 ),
15993 body::BodyStmt::Terminal(terminal) => {
15994 check_conditioned_reads_in_fields(
15995 rule,
15996 &terminal.fields,
15997 semantic,
15998 binding_types,
15999 allowed,
16000 diagnostics,
16001 );
16002 if let Some(body::FieldValue::Expr { source, .. }) = &terminal.scalar {
16004 check_conditioned_reads_in_text(
16005 rule,
16006 source,
16007 terminal.span,
16008 semantic,
16009 binding_types,
16010 allowed,
16011 diagnostics,
16012 );
16013 }
16014 }
16015 body::BodyStmt::Done {
16016 replacement: Some(record),
16017 ..
16018 } => check_conditioned_reads_in_fields(
16019 rule,
16020 &record.fields,
16021 semantic,
16022 binding_types,
16023 allowed,
16024 diagnostics,
16025 ),
16026 body::BodyStmt::Milestone { fields, .. } => check_conditioned_reads_in_fields(
16027 rule,
16028 fields,
16029 semantic,
16030 binding_types,
16031 allowed,
16032 diagnostics,
16033 ),
16034 body::BodyStmt::Done { .. }
16035 | body::BodyStmt::Cancel { .. }
16036 | body::BodyStmt::Redact { .. }
16037 | body::BodyStmt::Effect(_) => {}
16038 body::BodyStmt::After(after) => validate_conditioned_field_reads(
16039 rule,
16040 &after.body,
16041 semantic,
16042 binding_types,
16043 allowed,
16044 diagnostics,
16045 ),
16046 body::BodyStmt::Region(region) => {
16047 validate_conditioned_field_reads(
16048 rule,
16049 ®ion.body,
16050 semantic,
16051 binding_types,
16052 allowed,
16053 diagnostics,
16054 );
16055 validate_conditioned_field_reads(
16056 rule,
16057 ®ion.lapse_body,
16058 semantic,
16059 binding_types,
16060 allowed,
16061 diagnostics,
16062 );
16063 }
16064 body::BodyStmt::Case(case) => {
16065 for arm in &case.branches {
16066 let mut arm_allowed = allowed.clone();
16067 arm_allowed.extend(family_b_arm_allowed(
16068 &case.scrutinee,
16069 &arm.pattern,
16070 binding_types,
16071 semantic,
16072 ));
16073 if let Some(guard) = &arm.guard {
16074 check_conditioned_reads_in_text(
16075 rule,
16076 guard,
16077 arm.span,
16078 semantic,
16079 binding_types,
16080 &arm_allowed,
16081 diagnostics,
16082 );
16083 }
16084 validate_conditioned_field_reads(
16085 rule,
16086 &arm.body,
16087 semantic,
16088 binding_types,
16089 &arm_allowed,
16090 diagnostics,
16091 );
16092 }
16093 }
16094 }
16095 }
16096}
16097
16098fn validate_body_effect_operands(
16099 rule: &RuleDecl,
16100 statements: &[body::BodyStmt],
16101 semantic: &SemanticContext,
16102 binding_types: &BTreeMap<String, String>,
16103 diagnostics: &mut Vec<Diagnostic>,
16104) {
16105 for statement in statements {
16106 match statement {
16107 body::BodyStmt::Effect(effect) => {
16108 match &effect.kind {
16109 body::BodyEffectKind::LeaseAcquire { resource, .. }
16110 if !semantic.leases.contains(resource) =>
16111 {
16112 diagnostics.push(Diagnostic { related: Vec::new(),
16113 span: effect.span,
16114 message: format!(
16115 "rule `{}` acquires undeclared lease `{resource}`",
16116 rule.name.name
16117 ),
16118 suggestion: Some(format!(
16119 "declare `lease {resource} {{ key <Type> slots <N> ttl <duration> }}`"
16120 )),
16121 });
16122 }
16123 body::BodyEffectKind::LedgerAppend { ledger, schema, .. } => {
16124 if !semantic.ledgers.contains(ledger) {
16125 diagnostics.push(Diagnostic { related: Vec::new(),
16126 span: effect.span,
16127 message: format!(
16128 "rule `{}` appends to undeclared ledger `{ledger}`",
16129 rule.name.name
16130 ),
16131 suggestion: Some(format!(
16132 "declare `ledger {ledger} {{ entry <Type> partition by <field> retain <duration> }}`"
16133 )),
16134 });
16135 }
16136 if !semantic.schemas.class_exists(schema) {
16137 diagnostics.push(Diagnostic {
16138 related: Vec::new(),
16139 span: effect.span,
16140 message: format!(
16141 "rule `{}` appends unknown entry class `{schema}`",
16142 rule.name.name
16143 ),
16144 suggestion: Some(format!("declare `class {schema}` first")),
16145 });
16146 }
16147 }
16148 body::BodyEffectKind::CounterConsume { counter, .. }
16149 if !semantic.counters.contains(counter) =>
16150 {
16151 diagnostics.push(Diagnostic { related: Vec::new(),
16152 span: effect.span,
16153 message: format!(
16154 "rule `{}` consumes undeclared counter `{counter}`",
16155 rule.name.name
16156 ),
16157 suggestion: Some(format!(
16158 "declare `counter {counter} {{ key <Type> cap <N> reset <period> }}`"
16159 )),
16160 });
16161 }
16162 _ => {}
16163 }
16164 if let body::BodyEffectKind::Exec {
16169 target:
16170 body::ExecTarget::Capability {
16171 name,
16172 stdin_binding,
16173 },
16174 ..
16175 } = &effect.kind
16176 {
16177 match binding_types.get(stdin_binding) {
16178 None => {
16179 diagnostics.push(Diagnostic {
16180 related: Vec::new(),
16181 span: effect.span,
16182 message: format!(
16183 "rule `{}` uses unknown binding `{stdin_binding}` in `exec {name} with {stdin_binding}` — `with` requires a typed record binding",
16184 rule.name.name
16185 ),
16186 suggestion: Some(format!(
16187 "bind a typed record first (e.g. `when <Class> as {stdin_binding}` or `coerce ... -> <Class> as {stdin_binding}`) and pass that binding to `with`"
16188 )),
16189 });
16190 }
16191 Some(schema)
16197 if schema.contains('.') && !semantic.schemas.class_exists(schema) =>
16198 {
16199 diagnostics.push(Diagnostic {
16200 related: Vec::new(),
16201 span: effect.span,
16202 message: format!(
16203 "rule `{}` passes untyped fact binding `{stdin_binding}` to `exec {name} with` — `with` requires a typed record binding",
16204 rule.name.name
16205 ),
16206 suggestion: Some(format!(
16207 "declare `signal {schema} {{ ... }}` for a typed reaction, or bind a declared class and pass that to `with`"
16208 )),
16209 });
16210 }
16211 Some(_) => {}
16212 }
16213 }
16214 if let body::BodyEffectKind::Exec {
16215 parse_target: Some(parse),
16216 ..
16217 } = &effect.kind
16218 {
16219 if !semantic.schemas.class_exists(&parse.schema) {
16220 let suggestion =
16221 match closest_name(&parse.schema, semantic.schemas.classes.keys()) {
16222 Some(candidate) => format!(
16223 "did you mean `{candidate}`? otherwise declare `class {}`",
16224 parse.schema
16225 ),
16226 None => format!(
16227 "declare `class {}` before parsing into it",
16228 parse.schema
16229 ),
16230 };
16231 diagnostics.push(Diagnostic {
16232 related: Vec::new(),
16233 span: effect.span,
16234 message: format!(
16235 "rule `{}` parses exec output into unknown schema `{}`",
16236 rule.name.name, parse.schema
16237 ),
16238 suggestion: Some(suggestion),
16239 });
16240 }
16241 }
16242 let body::BodyEffectKind::Timer {
16243 until: Some(until), ..
16244 } = &effect.kind
16245 else {
16246 continue;
16247 };
16248 if body::is_iso8601_instant(until) {
16249 continue;
16250 }
16251 let mut segments = until.split('.');
16252 let root = segments.next().unwrap_or_default();
16253 let path = segments.map(str::to_owned).collect::<Vec<_>>();
16254 let Some(schema) = binding_types.get(root) else {
16255 diagnostics.push(Diagnostic { related: Vec::new(),
16256 span: effect.span,
16257 message: format!(
16258 "rule `{}` uses unknown binding `{root}` in `timer until {until}`",
16259 rule.name.name
16260 ),
16261 suggestion: Some(
16262 "bind a fact in `when` and reference a `time` field on it, or use an ISO-8601 literal"
16263 .to_owned(),
16264 ),
16265 });
16266 continue;
16267 };
16268 if schema.contains('.') {
16271 continue;
16272 }
16273 let resolved = if path.is_empty() {
16274 Err(format!(
16275 "`{root}` is a `{schema}` record, not a `time` value"
16276 ))
16277 } else {
16278 semantic.schemas.resolve_field_path(schema, &path)
16279 };
16280 match resolved {
16281 Ok(TypeSyntax::Primitive { ref name, .. }) if name == "time" => {}
16282 Ok(_) => {
16283 diagnostics.push(Diagnostic { related: Vec::new(),
16284 span: effect.span,
16285 message: format!(
16286 "rule `{}` uses non-time operand `{until}` in `timer until`",
16287 rule.name.name
16288 ),
16289 suggestion: Some(format!(
16290 "declare the field as `time` on `{schema}` or use an ISO-8601 literal"
16291 )),
16292 });
16293 }
16294 Err(message) => {
16295 diagnostics.push(Diagnostic { related: Vec::new(),
16296 span: effect.span,
16297 message: format!(
16298 "rule `{}` has invalid `timer until` operand `{until}`: {message}",
16299 rule.name.name
16300 ),
16301 suggestion: Some(
16302 "reference a `time`-typed field on a bound fact, or use an ISO-8601 literal"
16303 .to_owned(),
16304 ),
16305 });
16306 }
16307 }
16308 }
16309 body::BodyStmt::After(after) => {
16310 validate_body_effect_operands(
16311 rule,
16312 &after.body,
16313 semantic,
16314 binding_types,
16315 diagnostics,
16316 );
16317 }
16318 body::BodyStmt::Case(case) => {
16319 for branch in &case.branches {
16320 validate_body_effect_operands(
16321 rule,
16322 &branch.body,
16323 semantic,
16324 binding_types,
16325 diagnostics,
16326 );
16327 }
16328 }
16329 _ => {}
16330 }
16331 }
16332}
16333
16334fn validate_known_field_paths(
16335 rule: &RuleDecl,
16336 line: &str,
16337 semantic: &SemanticContext,
16338 binding_types: &BTreeMap<String, String>,
16339 diagnostics: &mut Vec<Diagnostic>,
16340) {
16341 validate_known_field_paths_at_span(
16342 rule,
16343 line,
16344 rule.body.span,
16345 semantic,
16346 binding_types,
16347 diagnostics,
16348 );
16349}
16350
16351fn validate_known_field_paths_at_span(
16352 rule: &RuleDecl,
16353 line: &str,
16354 span: SourceSpan,
16355 semantic: &SemanticContext,
16356 binding_types: &BTreeMap<String, String>,
16357 diagnostics: &mut Vec<Diagnostic>,
16358) {
16359 for (root, path) in dotted_paths(line) {
16360 let Some(schema) = binding_types.get(&root) else {
16361 continue;
16362 };
16363 if !semantic.schemas.class_exists(schema) {
16364 continue;
16365 }
16366 if let Err(message) = semantic.schemas.resolve_field_path(schema, &path) {
16367 diagnostics.push(Diagnostic {
16368 related: Vec::new(),
16369 span,
16370 message: format!(
16371 "rule `{}` has invalid field path `{root}.{}`: {message}",
16372 rule.name.name,
16373 path.join(".")
16374 ),
16375 suggestion: Some(
16376 "use a field declared on the bound schema or add it to the class declaration"
16377 .to_owned(),
16378 ),
16379 });
16380 }
16381 }
16382}
16383
16384fn dotted_paths(line: &str) -> Vec<(String, Vec<String>)> {
16385 let bytes = line.as_bytes();
16386 let mut paths = Vec::new();
16387 let mut index = 0;
16388
16389 while index < bytes.len() {
16390 if !is_ident_start(bytes[index]) {
16391 index += 1;
16392 continue;
16393 }
16394
16395 let root_start = index;
16396 index += 1;
16397 while index < bytes.len() && is_ident_continue(bytes[index]) {
16398 index += 1;
16399 }
16400 let root = &line[root_start..index];
16401 let mut fields = Vec::new();
16402
16403 while bytes.get(index) == Some(&b'.')
16404 && bytes
16405 .get(index + 1)
16406 .is_some_and(|byte| is_ident_start(*byte))
16407 {
16408 index += 1;
16409 let field_start = index;
16410 index += 1;
16411 while index < bytes.len() && is_ident_continue(bytes[index]) {
16412 index += 1;
16413 }
16414 fields.push(line[field_start..index].to_owned());
16415 }
16416
16417 if !fields.is_empty() {
16418 paths.push((root.to_owned(), fields));
16419 }
16420 }
16421
16422 paths
16423}
16424
16425fn interpolation_roots(line: &str) -> Vec<String> {
16426 let mut roots = Vec::new();
16427 let mut rest = line;
16428
16429 while let Some(open) = rest.find("{{") {
16430 let after_open = &rest[open + 2..];
16431 let Some(close) = after_open.find("}}") else {
16432 break;
16433 };
16434 let expr = after_open[..close].trim();
16435 if let Some(root) = expr
16436 .split(|ch: char| !ch.is_alphanumeric() && ch != '_')
16437 .find(|part| !part.is_empty())
16438 {
16439 roots.push(root.to_owned());
16440 }
16441 rest = &after_open[close + 2..];
16442 }
16443
16444 roots
16445}
16446
16447const RESERVED_BINDING_KEYWORDS: &[&str] = &[
16450 "after", "call", "case", "coerce", "complete", "consume", "done", "emit", "fail", "invoke",
16451 "record", "tell", "when", "where",
16452];
16453
16454fn validate_binding_name(
16455 rule: &RuleDecl,
16456 binding: &str,
16457 span: SourceSpan,
16458 diagnostics: &mut Vec<Diagnostic>,
16459) {
16460 if RESERVED_BINDING_KEYWORDS.contains(&binding) {
16461 diagnostics.push(Diagnostic {
16462 related: Vec::new(),
16463 span,
16464 message: format!(
16465 "rule `{}` binds reserved keyword `{binding}`",
16466 rule.name.name
16467 ),
16468 suggestion: Some(format!(
16469 "`{binding}` is a rule body keyword; choose another binding name"
16470 )),
16471 });
16472 }
16473}
16474
16475fn closest_name<'a>(target: &str, candidates: impl Iterator<Item = &'a String>) -> Option<String> {
16476 let target_lower = target.to_lowercase();
16477 candidates
16478 .map(|candidate| {
16479 let distance = edit_distance(&target_lower, &candidate.to_lowercase());
16480 (distance, candidate)
16481 })
16482 .filter(|(distance, candidate)| {
16483 *distance <= 2 && *distance < target.len().min(candidate.len())
16484 })
16485 .min_by_key(|(distance, candidate)| (*distance, candidate.as_str().to_owned()))
16486 .map(|(_, candidate)| candidate.clone())
16487}
16488
16489fn edit_distance(a: &str, b: &str) -> usize {
16490 let a: Vec<char> = a.chars().collect();
16491 let b: Vec<char> = b.chars().collect();
16492 let mut previous: Vec<usize> = (0..=b.len()).collect();
16493 let mut current = vec![0usize; b.len() + 1];
16494 for (i, a_char) in a.iter().enumerate() {
16495 current[0] = i + 1;
16496 for (j, b_char) in b.iter().enumerate() {
16497 let substitution = previous[j] + usize::from(a_char != b_char);
16498 current[j + 1] = substitution.min(previous[j + 1] + 1).min(current[j] + 1);
16499 }
16500 std::mem::swap(&mut previous, &mut current);
16501 }
16502 previous[b.len()]
16503}
16504
16505fn fact_read_from_when(when: &str) -> String {
16506 let (pattern, _) = split_when_guard(when);
16507 let first = pattern.split_whitespace().next().unwrap_or("<empty>");
16508 if first.chars().next().is_some_and(char::is_uppercase) {
16509 format!("schema:{first}")
16510 } else {
16511 format!("pattern:{pattern}")
16512 }
16513}
16514
16515fn parse_record_start(line: &str) -> Option<(String, Option<String>)> {
16516 let rest = line.strip_prefix("record ").or_else(|| {
16517 line.strip_prefix("done ")
16518 .and_then(|rest| rest.split_once("->"))
16519 .map(|(_, record)| record.trim())
16520 .and_then(|record| record.strip_prefix("record "))
16521 })?;
16522 let before_brace = rest.split('{').next().unwrap_or(rest).trim();
16523 let mut parts = before_brace.split_whitespace();
16524 let schema = parts.next()?.to_owned();
16525 let from_binding = match (parts.next(), parts.next(), parts.next()) {
16526 (None, None, None) => None,
16527 (Some("from"), Some(binding), None) => Some(binding.to_owned()),
16528 _ => return None,
16529 };
16530 Some((schema, from_binding))
16531}
16532
16533fn validate_record_field(
16534 rule: &RuleDecl,
16535 line: &str,
16536 record_schema: &str,
16537 semantic: &SemanticContext,
16538 binding_types: &BTreeMap<String, String>,
16539 known_roots: &BTreeSet<String>,
16540 diagnostics: &mut Vec<Diagnostic>,
16541) {
16542 let Some((field, expr)) = record_field_assignment(line) else {
16543 diagnostics.push(Diagnostic {
16544 related: Vec::new(),
16545 span: rule.body.span,
16546 message: format!(
16547 "rule `{}` has malformed field assignment in `record {record_schema}`",
16548 rule.name.name
16549 ),
16550 suggestion: Some("write record fields as `field value`".to_owned()),
16551 });
16552 return;
16553 };
16554
16555 let Some(fields) = semantic.schemas.classes.get(record_schema) else {
16556 return;
16557 };
16558 let Some(field_ty) = fields.get(field) else {
16559 diagnostics.push(Diagnostic {
16560 related: Vec::new(),
16561 span: rule.body.span,
16562 message: format!("class `{record_schema}` has no field `{field}`"),
16563 suggestion: Some(format!(
16564 "add `{field}` to `class {record_schema}` or record an existing field"
16565 )),
16566 });
16567 return;
16568 };
16569
16570 if let Some((root, path)) = expression_path(expr) {
16571 if let Some(schema) = binding_types.get(&root) {
16572 if !semantic.schemas.class_exists(schema) {
16573 return;
16574 }
16575 if let Err(message) = semantic.schemas.resolve_field_path(schema, &path) {
16576 diagnostics.push(Diagnostic { related: Vec::new(),
16577 span: rule.body.span,
16578 message: format!(
16579 "rule `{}` has invalid field path `{root}.{}`: {message}",
16580 rule.name.name,
16581 path.join(".")
16582 ),
16583 suggestion: Some(
16584 "use a field declared on the bound schema or add it to the class declaration"
16585 .to_owned(),
16586 ),
16587 });
16588 }
16589 } else if let Some(root) = dangling_value_root(expr, known_roots) {
16590 diagnostics.push(Diagnostic { related: Vec::new(),
16593 span: rule.body.span,
16594 message: format!(
16595 "rule `{}` has unknown binding `{root}` in `record {record_schema}` field `{field}`",
16596 rule.name.name
16597 ),
16598 suggestion: Some(
16599 "reference a binding from a `when ... as name` clause, an effect `as` binding, or a `case` pattern"
16600 .to_owned(),
16601 ),
16602 });
16603 }
16604 }
16605
16606 validate_literal_assignment(
16607 rule,
16608 record_schema,
16609 field,
16610 field_ty,
16611 expr,
16612 semantic,
16613 diagnostics,
16614 );
16615 validate_expected_assignment(
16616 rule,
16617 record_schema,
16618 field,
16619 field_ty,
16620 expr,
16621 semantic,
16622 binding_types,
16623 diagnostics,
16624 );
16625}
16626
16627fn record_field_assignment(line: &str) -> Option<(&str, &str)> {
16628 let field_end = line.find(char::is_whitespace)?;
16629 let field = &line[..field_end];
16630 let expr = line[field_end..].trim();
16631 (!field.is_empty() && !expr.is_empty()).then_some((field, expr))
16632}
16633
16634const SPECIAL_VALUE_ROOTS: &[&str] = &["external", "ctx"];
16638
16639fn collect_all_binding_names(statements: &[body::BodyStmt], out: &mut BTreeSet<String>) {
16645 for statement in statements {
16646 match statement {
16647 body::BodyStmt::Effect(effect) => {
16648 if let Some(binding) = &effect.binding {
16649 out.insert(binding.clone());
16650 }
16651 }
16652 body::BodyStmt::Region(region) => {
16653 if let Some(view) = ®ion.lapse_binding {
16654 out.insert(view.clone());
16655 }
16656 collect_all_binding_names(®ion.body, out);
16657 collect_all_binding_names(®ion.lapse_body, out);
16658 }
16659 body::BodyStmt::After(after) => {
16660 if let Some(alias) = &after.alias {
16661 out.insert(alias.clone());
16662 }
16663 collect_all_binding_names(&after.body, out);
16664 }
16665 body::BodyStmt::Case(case) => {
16666 for branch in &case.branches {
16667 if let Some(binding) = &branch.binding {
16668 out.insert(binding.clone());
16669 }
16670 collect_all_binding_names(&branch.body, out);
16671 }
16672 }
16673 body::BodyStmt::Redact { binding, .. } => {
16675 out.insert(binding.clone());
16676 }
16677 body::BodyStmt::Record(_)
16678 | body::BodyStmt::Done { .. }
16679 | body::BodyStmt::Terminal(_)
16680 | body::BodyStmt::Milestone { .. }
16681 | body::BodyStmt::Cancel { .. } => {}
16682 }
16683 }
16684}
16685
16686fn validate_source_emit_signal_declared(
16695 source: &SourceDecl,
16696 declared_signals: &BTreeSet<String>,
16697 diagnostics: &mut Vec<Diagnostic>,
16698) {
16699 let signal = &source.emit.signal;
16700 if signal.contains('.') && !declared_signals.contains(signal) {
16701 let suggestion = match closest_name(signal, declared_signals.iter()) {
16702 Some(candidate) => {
16703 format!("did you mean `{candidate}`? otherwise declare `signal {signal} {{ ... }}`")
16704 }
16705 None => format!("declare `signal {signal} {{ ... }}` so rules can react to it"),
16706 };
16707 diagnostics.push(Diagnostic {
16708 related: Vec::new(),
16709 span: source.emit.signal_span,
16710 message: format!(
16711 "source `{}` emits undeclared signal `{}`",
16712 source.name.name, signal
16713 ),
16714 suggestion: Some(suggestion),
16715 });
16716 }
16717
16718 let observation_fields: Option<&[&str]> = match source.provider.name.as_str() {
16726 "clock" => Some(&[
16727 "scheduled_at",
16728 "observed_at",
16729 "occurrence_id",
16730 "missed_count",
16731 "schedule_name",
16732 ]),
16733 "file" if source.watch.is_some() => Some(&["path", "content_hash", "watch"]),
16737 "file" => Some(&["line", "line_index", "path"]),
16738 "http" => Some(&["item", "item_index", "url"]),
16739 _ => None,
16740 };
16741 if let (Some(fields), Some(SourceValue::Path { segments, .. })) =
16744 (observation_fields, &source.dedup)
16745 {
16746 if let [field] = segments.as_slice() {
16747 if !fields.contains(&field.name.as_str()) {
16748 diagnostics.push(Diagnostic {
16749 related: Vec::new(),
16750 span: field.span,
16751 message: format!(
16752 "source `{}` `dedup` reads `{}.{}`, but a `{}` source's observation has no field `{}`",
16753 source.name.name,
16754 source.observe_binding.name,
16755 field.name,
16756 source.provider.name,
16757 field.name
16758 ),
16759 suggestion: Some(format!(
16760 "available observation fields: {}",
16761 fields.join(", ")
16762 )),
16763 });
16764 }
16765 }
16766 }
16767 if let Some(fields) = observation_fields {
16768 let observe = &source.observe_binding.name;
16769 for emit_field in &source.emit.fields {
16770 let SourceValue::Path {
16771 binding,
16772 segments,
16773 span,
16774 } = &emit_field.value
16775 else {
16776 continue;
16777 };
16778 if &binding.name != observe {
16779 diagnostics.push(Diagnostic {
16780 related: Vec::new(),
16781 span: *span,
16782 message: format!(
16783 "source `{}` emit reads unknown binding `{}`",
16784 source.name.name, binding.name
16785 ),
16786 suggestion: Some(format!(
16787 "the source's observation binding is `{observe}` (declared by `observe as {observe}`)"
16788 )),
16789 });
16790 continue;
16791 }
16792 if let Some(obs_field) = segments.first() {
16793 if !fields.contains(&obs_field.name.as_str()) {
16794 diagnostics.push(Diagnostic {
16795 related: Vec::new(),
16796 span: obs_field.span,
16797 message: format!(
16798 "source `{}` emit reads `{}.{}`, but a `{}` source's observation has no field `{}`",
16799 source.name.name, observe, obs_field.name, source.provider.name, obs_field.name
16800 ),
16801 suggestion: Some(format!(
16802 "available observation fields: {}",
16803 fields.join(", ")
16804 )),
16805 });
16806 }
16807 }
16808 }
16809 }
16810}
16811
16812fn validate_emit_signal_declarations(
16821 rule: &RuleDecl,
16822 statements: &[body::BodyStmt],
16823 declared_signals: &BTreeSet<String>,
16824 diagnostics: &mut Vec<Diagnostic>,
16825) {
16826 for statement in statements {
16827 match statement {
16828 body::BodyStmt::Effect(effect) => {
16829 if let body::BodyEffectKind::Notify { event, .. } = &effect.kind {
16830 if !declared_signals.contains(event) {
16831 diagnostics.push(Diagnostic {
16832 related: Vec::new(),
16833 span: effect.span,
16834 message: format!(
16835 "rule `{}` emits undeclared signal `{event}`",
16836 rule.name.name
16837 ),
16838 suggestion: Some(format!(
16839 "declare `signal {event} {{ ... }}` so the emitted payload is typed and admissible, \
16840 or check the signal name"
16841 )),
16842 });
16843 }
16844 }
16845 }
16846 body::BodyStmt::After(after) => {
16847 validate_emit_signal_declarations(rule, &after.body, declared_signals, diagnostics)
16848 }
16849 body::BodyStmt::Case(case) => {
16850 for branch in &case.branches {
16851 validate_emit_signal_declarations(
16852 rule,
16853 &branch.body,
16854 declared_signals,
16855 diagnostics,
16856 );
16857 }
16858 }
16859 _ => {}
16860 }
16861 }
16862}
16863
16864fn validate_effect_field_roots(
16869 rule: &RuleDecl,
16870 statements: &[body::BodyStmt],
16871 known_roots: &BTreeSet<String>,
16872 diagnostics: &mut Vec<Diagnostic>,
16873) {
16874 for statement in statements {
16875 match statement {
16876 body::BodyStmt::Effect(effect) => match &effect.kind {
16877 body::BodyEffectKind::Notify {
16878 target_expr,
16879 event,
16880 from,
16881 fields,
16882 } => {
16883 if let Some(from) = from {
16884 check_operand_root(
16885 rule,
16886 &format!("emit `{event}` from"),
16887 from,
16888 known_roots,
16889 diagnostics,
16890 );
16891 }
16892 check_operand_root(
16893 rule,
16894 &format!("emit `{event}` target"),
16895 target_expr,
16896 known_roots,
16897 diagnostics,
16898 );
16899 check_field_value_roots(
16900 rule,
16901 &format!("emit `{event}`"),
16902 fields,
16903 known_roots,
16904 diagnostics,
16905 );
16906 }
16907 body::BodyEffectKind::TrackerFile { queue, fields } => {
16908 check_field_value_roots(
16909 rule,
16910 &format!("file into `{queue}`"),
16911 fields,
16912 known_roots,
16913 diagnostics,
16914 );
16915 }
16916 body::BodyEffectKind::TrackerFinish { item, fields } => {
16917 check_operand_root(rule, "finish item", item, known_roots, diagnostics);
16918 check_field_value_roots(rule, "finish", fields, known_roots, diagnostics);
16919 }
16920 body::BodyEffectKind::LedgerAppend { ledger, fields, .. } => {
16921 check_field_value_roots(
16922 rule,
16923 &format!("append to `{ledger}`"),
16924 fields,
16925 known_roots,
16926 diagnostics,
16927 );
16928 }
16929 body::BodyEffectKind::LeaseAcquire {
16930 resource, key_expr, ..
16931 } => {
16932 check_operand_root(
16933 rule,
16934 &format!("acquire `{resource}` key"),
16935 key_expr,
16936 known_roots,
16937 diagnostics,
16938 );
16939 }
16940 body::BodyEffectKind::CounterConsume {
16941 counter,
16942 key_expr,
16943 amount_expr,
16944 } => {
16945 check_operand_root(
16946 rule,
16947 &format!("consume `{counter}` key"),
16948 key_expr,
16949 known_roots,
16950 diagnostics,
16951 );
16952 check_operand_root(
16953 rule,
16954 &format!("consume `{counter}` amount"),
16955 amount_expr,
16956 known_roots,
16957 diagnostics,
16958 );
16959 }
16960 _ => {}
16961 },
16962 body::BodyStmt::After(after) => {
16963 validate_effect_field_roots(rule, &after.body, known_roots, diagnostics)
16964 }
16965 body::BodyStmt::Case(case) => {
16966 for branch in &case.branches {
16967 validate_effect_field_roots(rule, &branch.body, known_roots, diagnostics);
16968 }
16969 }
16970 _ => {}
16971 }
16972 }
16973}
16974
16975fn dangling_value_root(value: &str, known_roots: &BTreeSet<String>) -> Option<String> {
16983 let (root, path) = expression_path(value)?;
16984 if !path.is_empty()
16985 && !value.contains('"')
16986 && !known_roots.contains(&root)
16987 && !SPECIAL_VALUE_ROOTS.contains(&root.as_str())
16988 {
16989 Some(root)
16990 } else {
16991 None
16992 }
16993}
16994
16995fn check_operand_root(
16999 rule: &RuleDecl,
17000 context: &str,
17001 operand: &str,
17002 known_roots: &BTreeSet<String>,
17003 diagnostics: &mut Vec<Diagnostic>,
17004) {
17005 if let Some(root) = dangling_value_root(operand, known_roots) {
17006 diagnostics.push(Diagnostic { related: Vec::new(),
17007 span: rule.body.span,
17008 message: format!(
17009 "rule `{}` has unknown binding `{root}` in {context} `{operand}`",
17010 rule.name.name
17011 ),
17012 suggestion: Some(
17013 "reference a binding from a `when ... as name` clause, an effect `as` binding, or a `case` pattern"
17014 .to_owned(),
17015 ),
17016 });
17017 }
17018}
17019
17020fn check_field_value_roots(
17021 rule: &RuleDecl,
17022 context: &str,
17023 fields: &[body::FieldAssign],
17024 known_roots: &BTreeSet<String>,
17025 diagnostics: &mut Vec<Diagnostic>,
17026) {
17027 for field in fields {
17028 match &field.value {
17029 body::FieldValue::Expr { source, .. } => {
17030 if let Some(root) = dangling_value_root(source, known_roots) {
17031 diagnostics.push(Diagnostic { related: Vec::new(),
17032 span: rule.body.span,
17033 message: format!(
17034 "rule `{}` has unknown binding `{root}` in {context} field `{}`",
17035 rule.name.name, field.name
17036 ),
17037 suggestion: Some(
17038 "reference a binding from a `when ... as name` clause, an effect `as` binding, or a `case` pattern"
17039 .to_owned(),
17040 ),
17041 });
17042 }
17043 }
17044 body::FieldValue::Nested { fields, .. } => {
17045 check_field_value_roots(rule, context, fields, known_roots, diagnostics)
17046 }
17047 body::FieldValue::Shorthand => {}
17048 }
17049 }
17050}
17051
17052fn known_roots_for_rule(rule: &RuleDecl) -> BTreeSet<String> {
17055 let mut roots: BTreeSet<String> = binding_types_for_rule(rule).into_keys().collect();
17056 let (body_ast, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
17057 collect_all_binding_names(&body_ast.statements, &mut roots);
17058 roots
17059}
17060
17061fn validate_record_blocks(
17062 rule: &RuleDecl,
17063 semantic: &SemanticContext,
17064 binding_types: &BTreeMap<String, String>,
17065 known_roots: &BTreeSet<String>,
17066 diagnostics: &mut Vec<Diagnostic>,
17067) {
17068 for (schema, from_binding, body) in record_blocks(&rule.body.text) {
17069 for assignment in collect_field_assignments(&body) {
17070 let (field, value) = match assignment {
17071 RecordFieldAssignment::Value { field, value } => (field, value),
17072 RecordFieldAssignment::Shorthand { field } => {
17073 let value = from_binding
17074 .as_ref()
17075 .map(|binding| format!("{binding}.{field}"))
17076 .unwrap_or_else(|| field.clone());
17077 (field, value)
17078 }
17079 };
17080 let line = format!("{field} {value}");
17081 validate_record_field(
17082 rule,
17083 &line,
17084 &schema,
17085 semantic,
17086 binding_types,
17087 known_roots,
17088 diagnostics,
17089 );
17090 }
17091 }
17092}
17093
17094fn record_blocks(body: &str) -> Vec<(String, Option<String>, String)> {
17095 let mut blocks = Vec::new();
17096 let lines = body.lines().collect::<Vec<_>>();
17097 let mut index = 0usize;
17098 while index < lines.len() {
17099 let trimmed = lines[index].trim();
17100 let Some((schema, from_binding)) = parse_record_start(trimmed) else {
17101 index += 1;
17102 continue;
17103 };
17104 if brace_delta(trimmed) == 0 && trimmed.contains('{') {
17108 if let (Some(open), Some(close)) = (trimmed.find('{'), trimmed.rfind('}')) {
17109 if close > open {
17110 blocks.push((
17111 schema,
17112 from_binding,
17113 trimmed[open + 1..close].trim().to_owned(),
17114 ));
17115 }
17116 }
17117 index += 1;
17118 continue;
17119 }
17120 let mut depth = brace_delta(trimmed);
17121 let mut record_lines = Vec::new();
17122 index += 1;
17123 while index < lines.len() && depth > 0 {
17124 let line = lines[index];
17125 let before = depth;
17126 depth += brace_delta(line);
17127 if !(before == 1 && depth == 0 && line.trim() == "}") {
17128 record_lines.push(line.to_owned());
17129 }
17130 index += 1;
17131 }
17132 blocks.push((schema, from_binding, record_lines.join("\n")));
17133 }
17134 blocks
17135}
17136
17137fn workflow_terminal_blocks(body: &str) -> Vec<(String, String, String)> {
17138 let mut blocks = Vec::new();
17139 let lines = body.lines().collect::<Vec<_>>();
17140 let mut index = 0usize;
17141 while index < lines.len() {
17142 let trimmed = lines[index].trim();
17143 let terminal = trimmed
17144 .strip_prefix("complete ")
17145 .map(|rest| ("complete", rest))
17146 .or_else(|| trimmed.strip_prefix("fail ").map(|rest| ("fail", rest)));
17147 let Some((action, rest)) = terminal else {
17148 index += 1;
17149 continue;
17150 };
17151 let Some(name) = rest.split('{').next().and_then(|header| {
17152 let mut parts = header.split_whitespace();
17153 match (parts.next(), parts.next()) {
17154 (Some(name), None) => Some(name.to_owned()),
17155 _ => None,
17156 }
17157 }) else {
17158 index += 1;
17159 continue;
17160 };
17161 let mut depth = brace_delta(trimmed);
17162 let mut terminal_lines = Vec::new();
17163 if depth == 0 && trimmed.contains('{') {
17164 if let (Some(open), Some(close)) = (trimmed.find('{'), trimmed.rfind('}')) {
17168 if close > open {
17169 let inner = trimmed[open + 1..close].trim();
17170 if !inner.is_empty() {
17171 terminal_lines.push(inner.to_owned());
17172 }
17173 }
17174 }
17175 index += 1;
17176 } else {
17177 index += 1;
17178 while index < lines.len() && depth > 0 {
17179 let line = lines[index];
17180 let before = depth;
17181 depth += brace_delta(line);
17182 if !(before == 1 && depth == 0 && line.trim() == "}") {
17183 terminal_lines.push(line.to_owned());
17184 }
17185 index += 1;
17186 }
17187 }
17188 blocks.push((action.to_owned(), name, terminal_lines.join("\n")));
17189 }
17190 blocks
17191}
17192
17193#[derive(Clone, Debug, Eq, PartialEq)]
17194enum RecordFieldAssignment {
17195 Value { field: String, value: String },
17196 Shorthand { field: String },
17197}
17198
17199fn collect_field_assignments(body: &str) -> Vec<RecordFieldAssignment> {
17200 body::split_field_assignments(body)
17205 .into_iter()
17206 .map(|assignment| match assignment.value {
17207 Some(value) => RecordFieldAssignment::Value {
17208 field: assignment.name,
17209 value,
17210 },
17211 None => RecordFieldAssignment::Shorthand {
17212 field: assignment.name,
17213 },
17214 })
17215 .collect()
17216}
17217
17218fn expression_path(expr: &str) -> Option<(String, Vec<String>)> {
17219 let mut paths = dotted_paths(expr);
17220 if paths.len() != 1 {
17221 return None;
17222 }
17223 Some(paths.remove(0))
17224}
17225
17226fn validate_literal_assignment(
17227 rule: &RuleDecl,
17228 record_schema: &str,
17229 field: &str,
17230 field_ty: &TypeSyntax,
17231 expr: &str,
17232 semantic: &SemanticContext,
17233 diagnostics: &mut Vec<Diagnostic>,
17234) {
17235 let Some(literal) = parse_literal_expr(expr) else {
17236 return;
17237 };
17238
17239 match field_ty {
17240 TypeSyntax::Primitive { name, .. } => {
17241 validate_primitive_literal(rule, record_schema, field, name, &literal, diagnostics)
17242 }
17243 TypeSyntax::LiteralString { value, .. } => {
17244 if literal != LiteralExpr::String(value.as_str()) {
17245 diagnostics.push(Diagnostic {
17246 related: Vec::new(),
17247 span: rule.body.span,
17248 message: format!(
17249 "field `{record_schema}.{field}` expects literal string `{value}`"
17250 ),
17251 suggestion: Some(format!("record `{field} {value:?}`")),
17252 });
17253 }
17254 }
17255 TypeSyntax::Ref { name } => {
17256 validate_enum_literal(
17257 rule,
17258 record_schema,
17259 field,
17260 &name.name,
17261 &literal,
17262 semantic,
17263 diagnostics,
17264 );
17265 }
17266 TypeSyntax::Union { variants, .. } => {
17267 validate_union_literal(rule, record_schema, field, variants, &literal, diagnostics);
17268 }
17269 TypeSyntax::AgentRef { agents, .. } => {
17270 validate_agent_ref_literal(rule, record_schema, field, agents, &literal, diagnostics);
17271 }
17272 TypeSyntax::Optional { inner, .. } => {
17273 if literal != LiteralExpr::Null {
17274 validate_literal_assignment(
17275 rule,
17276 record_schema,
17277 field,
17278 inner,
17279 expr,
17280 semantic,
17281 diagnostics,
17282 );
17283 }
17284 }
17285 TypeSyntax::Array { .. } | TypeSyntax::Map { .. } => {}
17286 }
17287}
17288
17289#[allow(clippy::too_many_arguments)]
17290fn validate_expected_assignment(
17291 rule: &RuleDecl,
17292 record_schema: &str,
17293 field: &str,
17294 field_ty: &TypeSyntax,
17295 expr: &str,
17296 semantic: &SemanticContext,
17297 binding_types: &BTreeMap<String, String>,
17298 diagnostics: &mut Vec<Diagnostic>,
17299) {
17300 if !(expr.trim_start().starts_with('{') || expr.trim_start().starts_with('[')) {
17301 return;
17302 }
17303 validate_expr_source_against_type(
17304 rule,
17305 record_schema,
17306 field,
17307 field_ty,
17308 expr,
17309 semantic,
17310 &ExprScope::from_bindings(binding_types),
17311 diagnostics,
17312 );
17313}
17314
17315#[allow(clippy::too_many_arguments)]
17316fn validate_expr_source_against_type(
17317 rule: &RuleDecl,
17318 record_schema: &str,
17319 field: &str,
17320 expected_ty: &TypeSyntax,
17321 expr: &str,
17322 semantic: &SemanticContext,
17323 scope: &ExprScope,
17324 diagnostics: &mut Vec<Diagnostic>,
17325) {
17326 match expected_ty {
17327 TypeSyntax::Map { inner, .. } => {
17328 let parsed = match parse_expression(expr) {
17329 Ok(Expr::Object(fields)) => fields,
17330 Ok(_) => {
17331 diagnostics.push(Diagnostic {
17332 related: Vec::new(),
17333 span: rule.body.span,
17334 message: format!("field `{record_schema}.{field}` expects a map literal"),
17335 suggestion: Some(format!("record `{field} {{ key value }}`")),
17336 });
17337 return;
17338 }
17339 Err(message) => {
17340 diagnostics.push(Diagnostic {
17341 related: Vec::new(),
17342 span: rule.body.span,
17343 message: format!(
17344 "field `{record_schema}.{field}` expects a map literal: {message}"
17345 ),
17346 suggestion: Some(format!("record `{field} {{ key value }}`")),
17347 });
17348 return;
17349 }
17350 };
17351 for map_field in &parsed {
17352 validate_expr_against_type(
17353 rule,
17354 record_schema,
17355 field,
17356 inner,
17357 &map_field.value,
17358 semantic,
17359 scope,
17360 diagnostics,
17361 );
17362 }
17363 }
17364 TypeSyntax::Array { inner, .. } => match parse_expression(expr) {
17365 Ok(Expr::Array(items)) => {
17366 for item in items {
17367 validate_expr_against_type(
17368 rule,
17369 record_schema,
17370 field,
17371 inner,
17372 &item,
17373 semantic,
17374 scope,
17375 diagnostics,
17376 );
17377 }
17378 }
17379 Ok(expr) => validate_inferred_assignment_type(
17380 rule,
17381 record_schema,
17382 field,
17383 expected_ty,
17384 &expr,
17385 semantic,
17386 scope,
17387 diagnostics,
17388 ),
17389 Err(message) => {
17390 push_invalid_assignment_expr(rule, record_schema, field, message, diagnostics)
17391 }
17392 },
17393 TypeSyntax::Optional { inner, .. } => {
17394 if expr.trim() != "null" {
17395 validate_expr_source_against_type(
17396 rule,
17397 record_schema,
17398 field,
17399 inner,
17400 expr,
17401 semantic,
17402 scope,
17403 diagnostics,
17404 );
17405 }
17406 }
17407 TypeSyntax::Ref { name } if semantic.schemas.class_exists(&name.name) => {
17408 let parsed = match parse_expression(expr) {
17409 Ok(Expr::Object(fields)) => fields,
17410 Ok(expr) => {
17411 validate_inferred_assignment_type(
17412 rule,
17413 record_schema,
17414 field,
17415 expected_ty,
17416 &expr,
17417 semantic,
17418 scope,
17419 diagnostics,
17420 );
17421 return;
17422 }
17423 Err(message) => {
17424 push_invalid_assignment_expr(rule, record_schema, field, message, diagnostics);
17425 return;
17426 }
17427 };
17428 validate_object_literal_fields(
17429 rule,
17430 record_schema,
17431 field,
17432 &name.name,
17433 &parsed,
17434 semantic,
17435 scope,
17436 diagnostics,
17437 );
17438 }
17439 _ => match parse_expression(expr) {
17440 Ok(expr) => validate_inferred_assignment_type(
17441 rule,
17442 record_schema,
17443 field,
17444 expected_ty,
17445 &expr,
17446 semantic,
17447 scope,
17448 diagnostics,
17449 ),
17450 Err(message) => {
17451 push_invalid_assignment_expr(rule, record_schema, field, message, diagnostics)
17452 }
17453 },
17454 }
17455}
17456
17457#[allow(clippy::too_many_arguments)]
17458fn validate_expr_against_type(
17459 rule: &RuleDecl,
17460 record_schema: &str,
17461 field: &str,
17462 expected_ty: &TypeSyntax,
17463 expr: &Expr,
17464 semantic: &SemanticContext,
17465 scope: &ExprScope,
17466 diagnostics: &mut Vec<Diagnostic>,
17467) {
17468 match expr {
17469 Expr::Array(items) if matches!(expected_ty, TypeSyntax::Array { .. }) => {
17470 if let TypeSyntax::Array { inner, .. } = expected_ty {
17471 for item in items {
17472 validate_expr_against_type(
17473 rule,
17474 record_schema,
17475 field,
17476 inner,
17477 item,
17478 semantic,
17479 scope,
17480 diagnostics,
17481 );
17482 }
17483 }
17484 }
17485 Expr::Object(fields) => match expected_ty {
17486 TypeSyntax::Map { inner, .. } => {
17487 for field in fields {
17488 validate_expr_against_type(
17489 rule,
17490 record_schema,
17491 field.key.as_str(),
17492 inner,
17493 &field.value,
17494 semantic,
17495 scope,
17496 diagnostics,
17497 );
17498 }
17499 }
17500 TypeSyntax::Ref { name } if semantic.schemas.class_exists(&name.name) => {
17501 validate_object_literal_fields(
17502 rule,
17503 record_schema,
17504 field,
17505 &name.name,
17506 fields,
17507 semantic,
17508 scope,
17509 diagnostics,
17510 );
17511 }
17512 _ => validate_inferred_assignment_type(
17513 rule,
17514 record_schema,
17515 field,
17516 expected_ty,
17517 expr,
17518 semantic,
17519 scope,
17520 diagnostics,
17521 ),
17522 },
17523 _ => validate_inferred_assignment_type(
17524 rule,
17525 record_schema,
17526 field,
17527 expected_ty,
17528 expr,
17529 semantic,
17530 scope,
17531 diagnostics,
17532 ),
17533 }
17534}
17535
17536fn push_invalid_assignment_expr(
17537 rule: &RuleDecl,
17538 record_schema: &str,
17539 field: &str,
17540 message: String,
17541 diagnostics: &mut Vec<Diagnostic>,
17542) {
17543 diagnostics.push(Diagnostic {
17544 related: Vec::new(),
17545 span: rule.body.span,
17546 message: format!(
17547 "rule `{}` has invalid expression for field `{record_schema}.{field}`: {message}",
17548 rule.name.name
17549 ),
17550 suggestion: Some(
17551 "use array literals or expected-schema object literals for collection fields"
17552 .to_owned(),
17553 ),
17554 });
17555}
17556
17557#[allow(clippy::too_many_arguments)]
17558fn validate_object_literal_fields(
17559 rule: &RuleDecl,
17560 record_schema: &str,
17561 field: &str,
17562 object_schema: &str,
17563 object_fields: &[ExprObjectField],
17564 semantic: &SemanticContext,
17565 scope: &ExprScope,
17566 diagnostics: &mut Vec<Diagnostic>,
17567) {
17568 let Some(schema_fields) = semantic.schemas.classes.get(object_schema) else {
17569 return;
17570 };
17571 let mut seen = BTreeSet::new();
17572 for object_field in object_fields {
17573 if !seen.insert(object_field.key.clone()) {
17574 diagnostics.push(Diagnostic {
17575 related: Vec::new(),
17576 span: rule.body.span,
17577 message: format!(
17578 "field `{record_schema}.{field}` repeats object field `{}`",
17579 object_field.key
17580 ),
17581 suggestion: Some("remove the duplicate object field".to_owned()),
17582 });
17583 continue;
17584 }
17585 let Some(field_ty) = schema_fields.get(&object_field.key) else {
17586 diagnostics.push(Diagnostic {
17587 related: Vec::new(),
17588 span: rule.body.span,
17589 message: format!(
17590 "class `{object_schema}` has no field `{}`",
17591 object_field.key
17592 ),
17593 suggestion: Some(format!(
17594 "add `{}` to `class {object_schema}` or use an existing field",
17595 object_field.key
17596 )),
17597 });
17598 continue;
17599 };
17600 validate_expr_against_type(
17601 rule,
17602 object_schema,
17603 &object_field.key,
17604 field_ty,
17605 &object_field.value,
17606 semantic,
17607 scope,
17608 diagnostics,
17609 );
17610 }
17611 for (required, ty) in schema_fields {
17612 if seen.contains(required) || matches!(ty, TypeSyntax::Optional { .. }) {
17613 continue;
17614 }
17615 diagnostics.push(Diagnostic { related: Vec::new(),
17616 span: rule.body.span,
17617 message: format!(
17618 "field `{record_schema}.{field}` is missing required object field `{object_schema}.{required}`"
17619 ),
17620 suggestion: Some(format!("add `{required}` to the `{field}` object literal")),
17621 });
17622 }
17623}
17624
17625#[allow(clippy::too_many_arguments)]
17626fn validate_inferred_assignment_type(
17627 rule: &RuleDecl,
17628 record_schema: &str,
17629 field: &str,
17630 expected_ty: &TypeSyntax,
17631 expr: &Expr,
17632 semantic: &SemanticContext,
17633 scope: &ExprScope,
17634 diagnostics: &mut Vec<Diagnostic>,
17635) {
17636 let literal = expr_literal_as_literal_expr(expr);
17637 if let Some(literal) = literal {
17638 validate_literal_against_type(
17639 rule,
17640 record_schema,
17641 field,
17642 expected_ty,
17643 &literal,
17644 semantic,
17645 diagnostics,
17646 );
17647 return;
17648 }
17649
17650 let context = ExprValidationContext::rule(rule);
17651 let mut local_diagnostics = Vec::new();
17652 let actual_ty = infer_expr_type(expr, semantic, scope, &context, &mut local_diagnostics);
17653 diagnostics.extend(local_diagnostics);
17654 let expected_expr_ty = expr_type_from_type_syntax(expected_ty, semantic);
17655 if !types_comparable(&actual_ty, &expected_expr_ty) {
17656 diagnostics.push(Diagnostic {
17657 related: Vec::new(),
17658 span: rule.body.span,
17659 message: format!(
17660 "field `{record_schema}.{field}` receives incompatible expression type"
17661 ),
17662 suggestion: Some(format!(
17663 "record a value compatible with `{}`",
17664 expected_ty.to_source()
17665 )),
17666 });
17667 }
17668}
17669
17670fn validate_literal_against_type(
17671 rule: &RuleDecl,
17672 record_schema: &str,
17673 field: &str,
17674 field_ty: &TypeSyntax,
17675 literal: &LiteralExpr<'_>,
17676 semantic: &SemanticContext,
17677 diagnostics: &mut Vec<Diagnostic>,
17678) {
17679 match field_ty {
17680 TypeSyntax::Primitive { name, .. } => {
17681 validate_primitive_literal(rule, record_schema, field, name, literal, diagnostics)
17682 }
17683 TypeSyntax::LiteralString { value, .. } => {
17684 if literal != &LiteralExpr::String(value.as_str()) {
17685 diagnostics.push(Diagnostic {
17686 related: Vec::new(),
17687 span: rule.body.span,
17688 message: format!(
17689 "field `{record_schema}.{field}` expects literal string `{value}`"
17690 ),
17691 suggestion: Some(format!("record `{field} {value:?}`")),
17692 });
17693 }
17694 }
17695 TypeSyntax::Ref { name } => {
17696 validate_enum_literal(
17697 rule,
17698 record_schema,
17699 field,
17700 &name.name,
17701 literal,
17702 semantic,
17703 diagnostics,
17704 );
17705 }
17706 TypeSyntax::Union { variants, .. } => {
17707 validate_union_literal(rule, record_schema, field, variants, literal, diagnostics);
17708 }
17709 TypeSyntax::AgentRef { agents, .. } => {
17710 validate_agent_ref_literal(rule, record_schema, field, agents, literal, diagnostics);
17711 }
17712 TypeSyntax::Optional { inner, .. } => {
17713 if literal != &LiteralExpr::Null {
17714 validate_literal_against_type(
17715 rule,
17716 record_schema,
17717 field,
17718 inner,
17719 literal,
17720 semantic,
17721 diagnostics,
17722 );
17723 }
17724 }
17725 TypeSyntax::Array { .. } | TypeSyntax::Map { .. } => {}
17726 }
17727}
17728
17729fn expr_literal_as_literal_expr(expr: &Expr) -> Option<LiteralExpr<'_>> {
17730 match expr {
17731 Expr::Literal(ExprLiteral::String(value)) => Some(LiteralExpr::String(value)),
17732 Expr::Literal(ExprLiteral::Number(value)) => Some(LiteralExpr::Number(value)),
17733 Expr::Literal(ExprLiteral::Bool(_)) => Some(LiteralExpr::Bool),
17734 Expr::Literal(ExprLiteral::Null) => Some(LiteralExpr::Null),
17735 Expr::Literal(ExprLiteral::Ident(value)) => Some(LiteralExpr::Ident(value)),
17736 _ => None,
17737 }
17738}
17739
17740fn validate_agent_ref_literal(
17741 rule: &RuleDecl,
17742 record_schema: &str,
17743 field: &str,
17744 agents: &[Ident],
17745 literal: &LiteralExpr<'_>,
17746 diagnostics: &mut Vec<Diagnostic>,
17747) {
17748 let allowed = agents
17749 .iter()
17750 .map(|agent| agent.name.as_str())
17751 .collect::<Vec<_>>();
17752 if let LiteralExpr::String(value) = literal {
17753 diagnostics.push(Diagnostic {
17754 related: Vec::new(),
17755 span: rule.body.span,
17756 message: format!(
17757 "field `{record_schema}.{field}` expects an AgentRef value, not string `{value}`"
17758 ),
17759 suggestion: Some(format!(
17760 "use an unquoted declared agent name: {}",
17761 allowed.join(", ")
17762 )),
17763 });
17764 return;
17765 }
17766 let LiteralExpr::Ident(value) = literal else {
17767 diagnostics.push(Diagnostic {
17768 related: Vec::new(),
17769 span: rule.body.span,
17770 message: format!("field `{record_schema}.{field}` expects an AgentRef value"),
17771 suggestion: Some(format!("use one of: {}", allowed.join(", "))),
17772 });
17773 return;
17774 };
17775 if !allowed.contains(value) {
17776 diagnostics.push(Diagnostic {
17777 related: Vec::new(),
17778 span: rule.body.span,
17779 message: format!("field `{record_schema}.{field}` cannot reference agent `{value}`"),
17780 suggestion: Some(format!("use one of: {}", allowed.join(", "))),
17781 });
17782 }
17783}
17784
17785fn parse_literal_expr(expr: &str) -> Option<LiteralExpr<'_>> {
17786 let expr = expr.trim().trim_end_matches(',');
17787 if let Some(value) = expr
17788 .strip_prefix('"')
17789 .and_then(|rest| rest.strip_suffix('"'))
17790 {
17791 return Some(LiteralExpr::String(value));
17792 }
17793 if expr.chars().all(|ch| ch.is_ascii_digit() || ch == '.')
17794 && expr.chars().any(|ch| ch.is_ascii_digit())
17795 {
17796 return Some(LiteralExpr::Number(expr));
17797 }
17798 match expr {
17799 "true" => Some(LiteralExpr::Bool),
17800 "false" => Some(LiteralExpr::Bool),
17801 "null" => Some(LiteralExpr::Null),
17802 value if value.chars().all(|ch| ch.is_alphanumeric() || ch == '_') => {
17803 Some(LiteralExpr::Ident(value))
17804 }
17805 _ => None,
17806 }
17807}
17808
17809struct ExprParser<'a> {
17810 source: &'a str,
17811 tokens: Vec<ExprToken>,
17812 pos: usize,
17813 depth: usize,
17814}
17815
17816const MAX_EXPR_DEPTH: usize = 256;
17822
17823#[derive(Clone, Debug, Eq, PartialEq)]
17824struct ExprToken {
17825 kind: ExprTokenKind,
17826}
17827
17828#[derive(Clone, Debug, Eq, PartialEq)]
17829enum ExprTokenKind {
17830 Ident(String),
17831 String(String),
17832 Number(String),
17833 Symbol(char),
17834 Op(&'static str),
17835}
17836
17837impl<'a> ExprParser<'a> {
17838 fn new(source: &'a str) -> Self {
17839 Self {
17840 source,
17841 tokens: lex_expr(source),
17842 pos: 0,
17843 depth: 0,
17844 }
17845 }
17846
17847 fn parse(mut self) -> Result<Expr, String> {
17848 let expr = self.parse_or()?;
17849 if self.peek().is_some() {
17850 return Err(format!(
17851 "unexpected token in expression `{}`",
17852 self.source.trim()
17853 ));
17854 }
17855 Ok(expr)
17856 }
17857
17858 fn parse_or(&mut self) -> Result<Expr, String> {
17859 let mut expr = self.parse_and()?;
17860 while self.consume_op("||") || self.consume_ident("or") {
17861 let right = self.parse_and()?;
17862 expr = Expr::Binary {
17863 op: BinaryOp::Or,
17864 left: Box::new(expr),
17865 right: Box::new(right),
17866 };
17867 }
17868 Ok(expr)
17869 }
17870
17871 fn parse_and(&mut self) -> Result<Expr, String> {
17872 let mut expr = self.parse_comparison()?;
17873 while self.consume_op("&&") || self.consume_ident("and") {
17874 let right = self.parse_comparison()?;
17875 expr = Expr::Binary {
17876 op: BinaryOp::And,
17877 left: Box::new(expr),
17878 right: Box::new(right),
17879 };
17880 }
17881 Ok(expr)
17882 }
17883
17884 fn parse_comparison(&mut self) -> Result<Expr, String> {
17885 let mut expr = self.parse_additive()?;
17886 loop {
17887 let op = if self.consume_op("==") {
17888 Some(BinaryOp::Eq)
17889 } else if self.consume_op("!=") {
17890 Some(BinaryOp::Ne)
17891 } else if self.consume_op("<=") {
17892 Some(BinaryOp::Le)
17893 } else if self.consume_op(">=") {
17894 Some(BinaryOp::Ge)
17895 } else if self.consume_symbol('<') {
17896 Some(BinaryOp::Lt)
17897 } else if self.consume_symbol('>') {
17898 Some(BinaryOp::Gt)
17899 } else if self.consume_ident("not") {
17900 if !self.consume_ident("in") {
17901 return Err("expected `in` after `not`".to_owned());
17902 }
17903 Some(BinaryOp::NotIn)
17904 } else if self.consume_ident("in") {
17905 Some(BinaryOp::In)
17906 } else {
17907 None
17908 };
17909 let Some(op) = op else {
17910 return Ok(expr);
17911 };
17912 let right = self.parse_additive()?;
17913 expr = Expr::Binary {
17914 op,
17915 left: Box::new(expr),
17916 right: Box::new(right),
17917 };
17918 }
17919 }
17920
17921 fn parse_additive(&mut self) -> Result<Expr, String> {
17922 let mut expr = self.parse_multiplicative()?;
17923 loop {
17924 let op = if self.consume_symbol('+') {
17925 Some(BinaryOp::Add)
17926 } else if self.consume_symbol('-') {
17927 Some(BinaryOp::Sub)
17928 } else {
17929 None
17930 };
17931 let Some(op) = op else {
17932 return Ok(expr);
17933 };
17934 let right = self.parse_multiplicative()?;
17935 expr = Expr::Binary {
17936 op,
17937 left: Box::new(expr),
17938 right: Box::new(right),
17939 };
17940 }
17941 }
17942
17943 fn parse_multiplicative(&mut self) -> Result<Expr, String> {
17944 let mut expr = self.parse_unary()?;
17945 loop {
17946 let op = if self.consume_symbol('*') {
17947 Some(BinaryOp::Mul)
17948 } else if self.consume_symbol('/') {
17949 Some(BinaryOp::Div)
17950 } else {
17951 None
17952 };
17953 let Some(op) = op else {
17954 return Ok(expr);
17955 };
17956 let right = self.parse_unary()?;
17957 expr = Expr::Binary {
17958 op,
17959 left: Box::new(expr),
17960 right: Box::new(right),
17961 };
17962 }
17963 }
17964
17965 fn parse_unary(&mut self) -> Result<Expr, String> {
17966 self.depth += 1;
17969 if self.depth > MAX_EXPR_DEPTH {
17970 self.depth -= 1;
17971 return Err(format!(
17972 "expression in `{}` is nested too deeply (limit {MAX_EXPR_DEPTH})",
17973 self.source.trim()
17974 ));
17975 }
17976 let result = self.parse_unary_inner();
17977 self.depth -= 1;
17978 result
17979 }
17980
17981 fn parse_unary_inner(&mut self) -> Result<Expr, String> {
17982 if self.consume_symbol('!') {
17983 return Ok(Expr::Unary {
17984 op: UnaryOp::Not,
17985 expr: Box::new(self.parse_unary()?),
17986 });
17987 }
17988 if self.consume_ident("not") {
17992 return Ok(Expr::Unary {
17993 op: UnaryOp::Not,
17994 expr: Box::new(self.parse_comparison()?),
17995 });
17996 }
17997 self.parse_postfix()
17998 }
17999
18000 fn parse_postfix(&mut self) -> Result<Expr, String> {
18001 let mut expr = self.parse_primary()?;
18002 loop {
18003 if self.consume_symbol('[') {
18004 let key = self.parse_or()?;
18005 self.expect_symbol(']')?;
18006 expr = Expr::Index {
18007 target: Box::new(expr),
18008 key: Box::new(key),
18009 };
18010 continue;
18011 }
18012 return Ok(expr);
18013 }
18014 }
18015
18016 fn parse_primary(&mut self) -> Result<Expr, String> {
18017 if self.consume_symbol('(') {
18018 let expr = self.parse_or()?;
18019 self.expect_symbol(')')?;
18020 return Ok(expr);
18021 }
18022 if self.consume_symbol('[') {
18023 let mut items = Vec::new();
18024 if self.consume_symbol(']') {
18025 return Ok(Expr::Array(items));
18026 }
18027 loop {
18028 items.push(self.parse_or()?);
18029 if self.consume_symbol(']') {
18030 break;
18031 }
18032 self.expect_symbol(',')?;
18033 }
18034 return Ok(Expr::Array(items));
18035 }
18036 if self.consume_symbol('{') {
18037 let mut fields = Vec::new();
18038 if self.consume_symbol('}') {
18039 return Ok(Expr::Object(fields));
18040 }
18041 loop {
18042 let key = match self.advance().map(|token| token.kind.clone()) {
18043 Some(ExprTokenKind::Ident(value) | ExprTokenKind::String(value)) => value,
18044 _ => return Err("expected object field name".to_owned()),
18045 };
18046 let value = self.parse_or()?;
18047 fields.push(ExprObjectField { key, value });
18048 if self.consume_symbol('}') {
18049 break;
18050 }
18051 let _ = self.consume_symbol(',');
18052 }
18053 return Ok(Expr::Object(fields));
18054 }
18055 match self.advance().map(|token| token.kind.clone()) {
18056 Some(ExprTokenKind::String(value)) => Ok(Expr::Literal(ExprLiteral::String(value))),
18057 Some(ExprTokenKind::Number(value)) => Ok(Expr::Literal(ExprLiteral::Number(value))),
18058 Some(ExprTokenKind::Ident(value)) if value == "true" => {
18059 Ok(Expr::Literal(ExprLiteral::Bool(true)))
18060 }
18061 Some(ExprTokenKind::Ident(value)) if value == "false" => {
18062 Ok(Expr::Literal(ExprLiteral::Bool(false)))
18063 }
18064 Some(ExprTokenKind::Ident(value)) if value == "null" => {
18065 Ok(Expr::Literal(ExprLiteral::Null))
18066 }
18067 Some(ExprTokenKind::Ident(value)) if value == "exists" && !self.at_symbol('(') => {
18068 let arg = match self.parse_postfix()? {
18069 Expr::Literal(ExprLiteral::Ident(path)) => Expr::Path(vec![path]),
18070 expr => expr,
18071 };
18072 Ok(Expr::Call {
18073 name: value,
18074 args: vec![arg],
18075 })
18076 }
18077 Some(ExprTokenKind::Ident(value))
18078 if matches!(value.as_str(), "count" | "exists" | "empty")
18079 && self.at_symbol('(') =>
18080 {
18081 self.expect_symbol('(')?;
18082 if let Some(query) = self.try_parse_query()? {
18083 self.expect_symbol(')')?;
18084 Ok(Expr::Call {
18085 name: value,
18086 args: vec![query],
18087 })
18088 } else {
18089 let mut args = Vec::new();
18090 if self.consume_symbol(')') {
18091 return Ok(Expr::Call { name: value, args });
18092 }
18093 loop {
18094 args.push(self.parse_or()?);
18095 if self.consume_symbol(')') {
18096 break;
18097 }
18098 self.expect_symbol(',')?;
18099 }
18100 Ok(Expr::Call { name: value, args })
18101 }
18102 }
18103 Some(ExprTokenKind::Ident(value)) => {
18104 let mut path = vec![value];
18105 while self.consume_symbol('.') {
18106 let Some(ExprTokenKind::Ident(field)) =
18107 self.advance().map(|token| token.kind.clone())
18108 else {
18109 return Err("expected field name after `.`".to_owned());
18110 };
18111 path.push(field);
18112 }
18113 if path.len() == 1 {
18114 Ok(Expr::Literal(ExprLiteral::Ident(path.remove(0))))
18115 } else {
18116 Ok(Expr::Path(path))
18117 }
18118 }
18119 _ => Err(format!("expected expression in `{}`", self.source.trim())),
18120 }
18121 }
18122
18123 fn try_parse_query(&mut self) -> Result<Option<Expr>, String> {
18124 let checkpoint = self.pos;
18125 let kind = if self.consume_ident("effect") {
18126 QueryKind::Effect
18127 } else if matches!(
18128 self.peek().map(|token| &token.kind),
18129 Some(ExprTokenKind::Ident(value)) if value.chars().next().is_some_and(char::is_uppercase)
18130 ) {
18131 QueryKind::Fact
18132 } else {
18133 return Ok(None);
18134 };
18135 let mut head = Vec::new();
18136 while let Some(token) = self.peek() {
18137 if self.at_symbol(')') || self.at_ident("where") {
18138 break;
18139 }
18140 head.push(self.token_text(token));
18141 self.pos += 1;
18142 }
18143 if head.is_empty() {
18144 self.pos = checkpoint;
18145 return Ok(None);
18146 }
18147 let guard = if self.consume_ident("where") {
18148 Some(Box::new(self.parse_or()?))
18149 } else {
18150 None
18151 };
18152 Ok(Some(Expr::Query {
18153 kind,
18154 head: join_query_head(&head),
18155 guard,
18156 }))
18157 }
18158
18159 fn token_text(&self, token: &ExprToken) -> String {
18160 match &token.kind {
18161 ExprTokenKind::Ident(value) | ExprTokenKind::Number(value) => value.clone(),
18162 ExprTokenKind::String(value) => format!("{value:?}"),
18163 ExprTokenKind::Symbol(value) => value.to_string(),
18164 ExprTokenKind::Op(value) => value.to_string(),
18165 }
18166 }
18167
18168 fn peek(&self) -> Option<&ExprToken> {
18169 self.tokens.get(self.pos)
18170 }
18171
18172 fn advance(&mut self) -> Option<&ExprToken> {
18173 let token = self.tokens.get(self.pos)?;
18174 self.pos += 1;
18175 Some(token)
18176 }
18177
18178 fn at_symbol(&self, symbol: char) -> bool {
18179 matches!(
18180 self.peek().map(|token| &token.kind),
18181 Some(ExprTokenKind::Symbol(value)) if *value == symbol
18182 )
18183 }
18184
18185 fn consume_symbol(&mut self, symbol: char) -> bool {
18186 if self.at_symbol(symbol) {
18187 self.pos += 1;
18188 true
18189 } else {
18190 false
18191 }
18192 }
18193
18194 fn expect_symbol(&mut self, symbol: char) -> Result<(), String> {
18195 if self.consume_symbol(symbol) {
18196 Ok(())
18197 } else {
18198 Err(format!("expected `{symbol}`"))
18199 }
18200 }
18201
18202 fn at_ident(&self, ident: &str) -> bool {
18203 matches!(
18204 self.peek().map(|token| &token.kind),
18205 Some(ExprTokenKind::Ident(value)) if value == ident
18206 )
18207 }
18208
18209 fn consume_ident(&mut self, ident: &str) -> bool {
18210 if self.at_ident(ident) {
18211 self.pos += 1;
18212 true
18213 } else {
18214 false
18215 }
18216 }
18217
18218 fn consume_op(&mut self, op: &'static str) -> bool {
18219 if matches!(
18220 self.peek().map(|token| &token.kind),
18221 Some(ExprTokenKind::Op(value)) if *value == op
18222 ) {
18223 self.pos += 1;
18224 true
18225 } else {
18226 false
18227 }
18228 }
18229}
18230
18231fn join_query_head(tokens: &[String]) -> String {
18232 let mut head = String::new();
18233 for token in tokens {
18234 if token == "." {
18235 head.push('.');
18236 } else if head.ends_with('.') || head.is_empty() {
18237 head.push_str(token);
18238 } else {
18239 head.push(' ');
18240 head.push_str(token);
18241 }
18242 }
18243 head
18244}
18245
18246fn lex_expr(source: &str) -> Vec<ExprToken> {
18247 let bytes = source.as_bytes();
18248 let mut tokens = Vec::new();
18249 let mut index = 0usize;
18250 while index < bytes.len() {
18251 let byte = bytes[index];
18252 if byte.is_ascii_whitespace() {
18253 index += 1;
18254 continue;
18255 }
18256 if is_ident_start(byte) {
18257 let start = index;
18258 index += 1;
18259 while index < bytes.len() && is_ident_continue(bytes[index]) {
18260 index += 1;
18261 }
18262 tokens.push(ExprToken {
18263 kind: ExprTokenKind::Ident(source[start..index].to_owned()),
18264 });
18265 continue;
18266 }
18267 if byte.is_ascii_digit() {
18268 let start = index;
18269 index += 1;
18270 while index < bytes.len() && (bytes[index].is_ascii_digit() || bytes[index] == b'.') {
18271 index += 1;
18272 }
18273 tokens.push(ExprToken {
18274 kind: ExprTokenKind::Number(source[start..index].to_owned()),
18275 });
18276 continue;
18277 }
18278 if byte == b'"' {
18279 let start = index + 1;
18280 index += 1;
18281 while index < bytes.len() && bytes[index] != b'"' {
18282 index += 1;
18283 }
18284 let value = source[start..index.min(bytes.len())].to_owned();
18285 index = (index + 1).min(bytes.len());
18286 tokens.push(ExprToken {
18287 kind: ExprTokenKind::String(value),
18288 });
18289 continue;
18290 }
18291 let rest = &source[index..];
18292 if rest.starts_with("&&") {
18293 tokens.push(ExprToken {
18294 kind: ExprTokenKind::Op("&&"),
18295 });
18296 index += 2;
18297 } else if rest.starts_with("||") {
18298 tokens.push(ExprToken {
18299 kind: ExprTokenKind::Op("||"),
18300 });
18301 index += 2;
18302 } else if rest.starts_with("==") {
18303 tokens.push(ExprToken {
18304 kind: ExprTokenKind::Op("=="),
18305 });
18306 index += 2;
18307 } else if rest.starts_with("!=") {
18308 tokens.push(ExprToken {
18309 kind: ExprTokenKind::Op("!="),
18310 });
18311 index += 2;
18312 } else if rest.starts_with("<=") {
18313 tokens.push(ExprToken {
18314 kind: ExprTokenKind::Op("<="),
18315 });
18316 index += 2;
18317 } else if rest.starts_with(">=") {
18318 tokens.push(ExprToken {
18319 kind: ExprTokenKind::Op(">="),
18320 });
18321 index += 2;
18322 } else {
18323 tokens.push(ExprToken {
18324 kind: ExprTokenKind::Symbol(byte as char),
18325 });
18326 index += 1;
18327 }
18328 }
18329 tokens
18330}
18331
18332fn validate_primitive_literal(
18333 rule: &RuleDecl,
18334 record_schema: &str,
18335 field: &str,
18336 primitive: &str,
18337 literal: &LiteralExpr<'_>,
18338 diagnostics: &mut Vec<Diagnostic>,
18339) {
18340 let valid = matches!(
18341 (primitive, literal),
18342 ("string", LiteralExpr::String(_))
18343 | ("string", LiteralExpr::Ident(_))
18344 | ("int", LiteralExpr::Number(_))
18345 | ("float", LiteralExpr::Number(_))
18346 | ("bool", LiteralExpr::Bool)
18347 | ("null", LiteralExpr::Null)
18348 | ("duration", LiteralExpr::String(_))
18349 | ("time", LiteralExpr::String(_))
18350 );
18351 if !valid {
18352 diagnostics.push(Diagnostic {
18353 related: Vec::new(),
18354 span: rule.body.span,
18355 message: format!("field `{record_schema}.{field}` expects `{primitive}`"),
18356 suggestion: Some(format!("record a value compatible with `{primitive}`")),
18357 });
18358 return;
18359 }
18360 match (primitive, literal) {
18361 ("duration", LiteralExpr::String(value)) if parse_duration_seconds(value).is_none() => {
18362 diagnostics.push(Diagnostic {
18363 related: Vec::new(),
18364 span: rule.body.span,
18365 message: format!("field `{record_schema}.{field}` has invalid duration literal"),
18366 suggestion: Some("use an ISO-8601 duration such as `\"PT30M\"`".to_owned()),
18367 });
18368 }
18369 ("time", LiteralExpr::String(value)) if parse_time_epoch_seconds(value).is_none() => {
18370 diagnostics.push(Diagnostic {
18371 related: Vec::new(),
18372 span: rule.body.span,
18373 message: format!("field `{record_schema}.{field}` has invalid time literal"),
18374 suggestion: Some(
18375 "use an RFC3339 timestamp such as `\"2026-05-29T10:00:00Z\"`".to_owned(),
18376 ),
18377 });
18378 }
18379 _ => {}
18380 }
18381}
18382
18383fn validate_enum_literal(
18384 rule: &RuleDecl,
18385 record_schema: &str,
18386 field: &str,
18387 schema: &str,
18388 literal: &LiteralExpr<'_>,
18389 semantic: &SemanticContext,
18390 diagnostics: &mut Vec<Diagnostic>,
18391) {
18392 let Some(variants) = semantic.schemas.enums.get(schema) else {
18393 return;
18394 };
18395 let LiteralExpr::Ident(variant) = literal else {
18396 diagnostics.push(Diagnostic {
18397 related: Vec::new(),
18398 span: rule.body.span,
18399 message: format!("field `{record_schema}.{field}` expects enum `{schema}`"),
18400 suggestion: Some(format!(
18401 "use one of: {}",
18402 variants.iter().cloned().collect::<Vec<_>>().join(", ")
18403 )),
18404 });
18405 return;
18406 };
18407 if !variants.contains(*variant) {
18408 diagnostics.push(Diagnostic {
18409 related: Vec::new(),
18410 span: rule.body.span,
18411 message: format!("enum `{schema}` has no variant `{variant}`"),
18412 suggestion: Some(format!(
18413 "use one of: {}",
18414 variants.iter().cloned().collect::<Vec<_>>().join(", ")
18415 )),
18416 });
18417 }
18418}
18419
18420fn validate_union_literal(
18421 rule: &RuleDecl,
18422 record_schema: &str,
18423 field: &str,
18424 variants: &[TypeSyntax],
18425 literal: &LiteralExpr<'_>,
18426 diagnostics: &mut Vec<Diagnostic>,
18427) {
18428 let allowed = variants
18429 .iter()
18430 .filter_map(|variant| match variant {
18431 TypeSyntax::LiteralString { value, .. } => Some(value.as_str()),
18432 _ => None,
18433 })
18434 .collect::<Vec<_>>();
18435 if allowed.is_empty() {
18436 return;
18437 }
18438 let LiteralExpr::String(value) = literal else {
18439 diagnostics.push(Diagnostic {
18440 related: Vec::new(),
18441 span: rule.body.span,
18442 message: format!("field `{record_schema}.{field}` expects one of its literal variants"),
18443 suggestion: Some(format!("use one of: {}", allowed.join(", "))),
18444 });
18445 return;
18446 };
18447 if !allowed.contains(value) {
18448 diagnostics.push(Diagnostic {
18449 related: Vec::new(),
18450 span: rule.body.span,
18451 message: format!("field `{record_schema}.{field}` cannot be `{value}`"),
18452 suggestion: Some(format!("use one of: {}", allowed.join(", "))),
18453 });
18454 }
18455}
18456
18457fn parse_effect_line(line: &str) -> Option<(IrEffectKind, Option<String>)> {
18458 let kind = if line.starts_with("tell ") {
18459 IrEffectKind::AgentTell
18460 } else if line.starts_with("coerce ") || line.starts_with("prompt ") {
18461 IrEffectKind::SchemaCoerce
18462 } else if line.starts_with("claim ") {
18463 IrEffectKind::TrackerClaim
18464 } else if line.starts_with("call ")
18465 || line.starts_with("recall ")
18466 || line.starts_with("learn ")
18467 || line.starts_with("curate ")
18468 {
18469 IrEffectKind::CapabilityCall
18470 } else if line.starts_with("emit ") {
18471 IrEffectKind::EventEmit
18472 } else if line.starts_with("invoke ") {
18473 IrEffectKind::WorkflowInvoke
18474 } else if line.starts_with("read ") {
18475 IrEffectKind::FileRead
18476 } else if line.starts_with("write ") {
18477 IrEffectKind::FileWrite
18478 } else if line.starts_with("import ") {
18479 IrEffectKind::FileImport
18480 } else if line.starts_with("export ") {
18481 IrEffectKind::FileExport
18482 } else if line.starts_with("acquire ") {
18483 IrEffectKind::LeaseAcquire
18484 } else if line.starts_with("renew ") {
18485 IrEffectKind::LeaseRenew
18486 } else if line.starts_with("append ") {
18487 IrEffectKind::LedgerAppend
18488 } else if line.starts_with("consume ") && line.contains(" for ") {
18489 IrEffectKind::CounterConsume
18493 } else {
18494 return None;
18495 };
18496
18497 Some((kind, binding_after_as(line)))
18498}
18499
18500fn parse_consume_line(line: &str) -> Option<String> {
18501 let binding = line
18505 .trim()
18506 .trim_end_matches(';')
18507 .strip_prefix("done ")?
18508 .split("->")
18509 .next()
18510 .unwrap_or_default()
18511 .trim();
18512 let mut chars = binding.chars();
18513 let first = chars.next()?;
18514 if !(first.is_ascii_alphabetic() || first == '_') {
18515 return None;
18516 }
18517 chars
18518 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
18519 .then(|| binding.to_owned())
18520}
18521
18522fn binding_after_multiline_string_end(line: &str) -> Option<String> {
18523 line.strip_prefix("\"\"\"")
18524 .and_then(|rest| rest.trim().strip_prefix("as "))
18525 .and_then(|rest| rest.split_whitespace().next())
18526 .map(|binding| binding.trim_matches(|ch: char| !ch.is_alphanumeric() && ch != '_'))
18527 .filter(|binding| !binding.is_empty())
18528 .map(str::to_owned)
18529}
18530
18531fn validate_rule_prompt_content_type_annotation(
18532 rule: &RuleDecl,
18533 line: &str,
18534 diagnostics: &mut Vec<Diagnostic>,
18535) {
18536 if !(line.starts_with("tell ") || line.starts_with("coerce ")) {
18537 return;
18538 }
18539 let Some(annotation) = malformed_prompt_content_type_annotation(line) else {
18540 return;
18541 };
18542 diagnostics.push(Diagnostic {
18543 related: Vec::new(),
18544 span: rule.body.span,
18545 message: format!(
18546 "rule `{}` has malformed multiline prompt content type `{annotation}`",
18547 rule.name.name
18548 ),
18549 suggestion: Some(
18550 "write a supported token such as `\"\"\"markdown` or put prompt text on the next line"
18551 .to_owned(),
18552 ),
18553 });
18554}
18555
18556fn validate_coerce_prompt_content_type_annotations(
18557 coerce: &CoerceDecl,
18558 diagnostics: &mut Vec<Diagnostic>,
18559) {
18560 for line in coerce.body.text.lines().map(str::trim) {
18561 if !line.starts_with("prompt ") {
18562 continue;
18563 }
18564 let Some(annotation) = malformed_prompt_content_type_annotation(line) else {
18565 continue;
18566 };
18567 diagnostics.push(Diagnostic { related: Vec::new(),
18568 span: coerce.body.span,
18569 message: format!(
18570 "coerce `{}` has malformed multiline prompt content type `{annotation}`",
18571 coerce.name.name
18572 ),
18573 suggestion: Some(
18574 "write a supported token such as `\"\"\"markdown` or put prompt text on the next line"
18575 .to_owned(),
18576 ),
18577 });
18578 }
18579}
18580
18581fn malformed_prompt_content_type_annotation(line: &str) -> Option<String> {
18582 let (_, tail) = line.split_once("\"\"\"")?;
18583 let candidate = tail.trim();
18584 if candidate.is_empty() || candidate.contains("\"\"\"") {
18585 return None;
18586 }
18587 let mut parts = candidate.split_whitespace();
18588 let first = parts.next()?;
18589 let has_extra_text = parts.next().is_some();
18590 let first_is_supported = is_supported_prompt_content_type(first);
18591 let first_is_annotation_shaped = first_is_supported || first.contains('/');
18592 if has_extra_text && first_is_annotation_shaped {
18593 return Some(candidate.to_owned());
18594 }
18595 if first.contains('/') && !first_is_supported {
18596 return Some(first.to_owned());
18597 }
18598 None
18599}
18600
18601fn is_supported_prompt_content_type(candidate: &str) -> bool {
18602 if !is_prompt_content_type_token(candidate) {
18603 return false;
18604 }
18605 let normalized = candidate.to_ascii_lowercase();
18606 normalized.contains('/')
18607 || matches!(
18608 normalized.as_str(),
18609 "markdown" | "json" | "text" | "plain" | "html" | "xml" | "yaml" | "yml"
18610 )
18611}
18612
18613fn is_prompt_content_type_token(candidate: &str) -> bool {
18614 let mut chars = candidate.chars();
18615 let Some(first) = chars.next() else {
18616 return false;
18617 };
18618 first.is_ascii_alphanumeric()
18619 && chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '+' | '-' | '_'))
18620}
18621
18622fn binding_after_as(line: &str) -> Option<String> {
18623 let mut tokens = line.split_whitespace();
18624 while let Some(token) = tokens.next() {
18625 if token == "as" {
18626 return tokens
18627 .next()
18628 .map(|binding| binding.trim_matches(|ch: char| !ch.is_alphanumeric() && ch != '_'))
18629 .filter(|binding| !binding.is_empty())
18630 .map(str::to_owned);
18631 }
18632 }
18633 None
18634}
18635
18636fn parse_after_line(line: &str) -> Option<(String, DependencyPredicate)> {
18637 let rest = line.strip_prefix("after ")?;
18638 if rest.contains("=>") {
18639 return None;
18640 }
18641 let before_body = rest.split('{').next().unwrap_or(rest).trim();
18642 let mut parts = before_body.split_whitespace();
18643 let binding = parts.next()?.to_owned();
18644 let predicate = match parts.next()? {
18645 "succeeds" => DependencyPredicate::Succeeds,
18646 "fails" => DependencyPredicate::Fails,
18647 "cancelled" => DependencyPredicate::Cancelled,
18650 "times" => {
18651 if parts.next()? != "out" {
18652 return None;
18653 }
18654 DependencyPredicate::TimedOut
18655 }
18656 "completes" | "held" | "contended" | "ok" | "over" => DependencyPredicate::Completes,
18659 "reaches" => {
18664 let rest = before_body.trim().strip_prefix(&binding)?.trim_start();
18665 let after_kw = rest.strip_prefix("reaches")?.trim_start();
18666 let quoted = after_kw.strip_prefix('"')?;
18667 let close = quoted.find('"')?;
18668 let tail = "ed[close + 1..];
18669 let mut tail_parts = tail.split_whitespace();
18670 match (tail_parts.next(), tail_parts.next(), tail_parts.next()) {
18671 (None, None, None) => {}
18672 (Some("as"), Some(alias), None) if is_identifier(alias) => {}
18673 _ => return None,
18674 }
18675 return Some((binding, DependencyPredicate::Completes));
18676 }
18677 _ => return None,
18678 };
18679 match (parts.next(), parts.next(), parts.next()) {
18680 (None, None, None) => {}
18681 (Some("as"), Some(alias), None) if is_identifier(alias) => {}
18682 _ => return None,
18683 }
18684 Some((binding, predicate))
18685}
18686
18687pub(crate) fn is_identifier(value: &str) -> bool {
18688 let mut chars = value.chars();
18689 let Some(first) = chars.next() else {
18690 return false;
18691 };
18692 (first.is_ascii_alphabetic() || first == '_')
18693 && chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
18694}
18695
18696fn lower_type(ty: TypeSyntax) -> IrType {
18697 match ty {
18698 TypeSyntax::Primitive { name, .. } => IrType::Primitive(lower_primitive_type(&name)),
18699 TypeSyntax::LiteralString { value, .. } => IrType::LiteralString(value),
18700 TypeSyntax::Ref { name } => IrType::Ref(name.name),
18701 TypeSyntax::AgentRef { agents, .. } => {
18702 IrType::AgentRef(agents.into_iter().map(|agent| agent.name).collect())
18703 }
18704 TypeSyntax::Optional { inner, .. } => IrType::Optional(Box::new(lower_type(*inner))),
18705 TypeSyntax::Array { inner, .. } => IrType::Array(Box::new(lower_type(*inner))),
18706 TypeSyntax::Map { inner, .. } => IrType::Map(Box::new(lower_type(*inner))),
18707 TypeSyntax::Union { variants, .. } => {
18708 IrType::Union(variants.into_iter().map(lower_type).collect())
18709 }
18710 }
18711}
18712
18713fn lower_primitive_type(name: &str) -> IrPrimitiveType {
18714 match name {
18715 "string" => IrPrimitiveType::String,
18716 "int" => IrPrimitiveType::Int,
18717 "float" => IrPrimitiveType::Float,
18718 "bool" => IrPrimitiveType::Bool,
18719 "null" => IrPrimitiveType::Null,
18720 "duration" => IrPrimitiveType::Duration,
18721 "time" => IrPrimitiveType::Time,
18722 "image" => IrPrimitiveType::Image,
18723 "audio" => IrPrimitiveType::Audio,
18724 "pdf" => IrPrimitiveType::Pdf,
18725 "video" => IrPrimitiveType::Video,
18726 _ => IrPrimitiveType::String,
18727 }
18728}
18729
18730fn push_line(snapshot: &mut String, line: impl AsRef<str>) {
18731 snapshot.push_str(line.as_ref());
18732 snapshot.push('\n');
18733}
18734
18735fn stable_hash(value: &str) -> String {
18736 use sha2::Digest;
18741 let digest = sha2::Sha256::digest(value.as_bytes());
18742 let mut hex = String::with_capacity(32);
18743 for byte in &digest[..16] {
18744 hex.push_str(&format!("{byte:02x}"));
18745 }
18746 hex
18747}
18748
18749pub fn parse_duration_seconds(value: &str) -> Option<f64> {
18750 let value = value.strip_prefix('P')?;
18751 let mut rest = value;
18752 let mut seconds = 0.0;
18753 let mut consumed = false;
18754 let mut in_time = false;
18755
18756 while !rest.is_empty() {
18757 if let Some(next) = rest.strip_prefix('T') {
18758 if in_time {
18759 return None;
18760 }
18761 in_time = true;
18762 rest = next;
18763 continue;
18764 }
18765
18766 let number_len = rest
18767 .char_indices()
18768 .take_while(|(_, ch)| ch.is_ascii_digit() || *ch == '.')
18769 .map(|(index, ch)| index + ch.len_utf8())
18770 .last()?;
18771 let number = rest[..number_len].parse::<f64>().ok()?;
18772 if !number.is_finite() {
18773 return None;
18774 }
18775 let unit = rest[number_len..].chars().next()?;
18776 rest = &rest[number_len + unit.len_utf8()..];
18777 let multiplier = match (in_time, unit) {
18778 (false, 'D') => 86_400.0,
18779 (true, 'H') => 3_600.0,
18780 (true, 'M') => 60.0,
18781 (true, 'S') => 1.0,
18782 _ => return None,
18783 };
18784 seconds += number * multiplier;
18785 consumed = true;
18786 }
18787
18788 consumed.then_some(seconds)
18789}
18790
18791pub fn parse_time_epoch_seconds(value: &str) -> Option<f64> {
18792 if value.len() < 20 {
18793 return None;
18794 }
18795 let year = parse_fixed_i32(value, 0, 4)?;
18796 require_byte(value, 4, b'-')?;
18797 let month = parse_fixed_u32(value, 5, 2)?;
18798 require_byte(value, 7, b'-')?;
18799 let day = parse_fixed_u32(value, 8, 2)?;
18800 require_byte(value, 10, b'T')?;
18801 let hour = parse_fixed_u32(value, 11, 2)?;
18802 require_byte(value, 13, b':')?;
18803 let minute = parse_fixed_u32(value, 14, 2)?;
18804 require_byte(value, 16, b':')?;
18805 let second = parse_fixed_u32(value, 17, 2)?;
18806 let mut offset_start = 19;
18807 let mut fractional_second = 0.0;
18808 if value.as_bytes().get(offset_start).copied() == Some(b'.') {
18809 let fraction_start = offset_start + 1;
18810 let fraction_len = value[fraction_start..]
18811 .char_indices()
18812 .take_while(|(_, ch)| ch.is_ascii_digit())
18813 .map(|(index, ch)| index + ch.len_utf8())
18814 .last()?;
18815 let fraction = &value[fraction_start..fraction_start + fraction_len];
18816 let scale = 10_f64.powi(i32::try_from(fraction.len()).ok()?);
18817 fractional_second = fraction.parse::<f64>().ok()? / scale;
18818 offset_start = fraction_start + fraction_len;
18819 }
18820 if !(1..=12).contains(&month)
18821 || !(1..=days_in_month(year, month)).contains(&day)
18822 || hour > 23
18823 || minute > 59
18824 || second > 60
18825 {
18826 return None;
18827 }
18828
18829 let offset_seconds = match value.as_bytes().get(offset_start).copied()? {
18830 b'Z' if value.len() == offset_start + 1 => 0,
18831 b'+' | b'-' if value.len() == offset_start + 6 => {
18832 let sign = if value.as_bytes()[offset_start] == b'+' {
18833 1
18834 } else {
18835 -1
18836 };
18837 let offset_hour = parse_fixed_i32(value, offset_start + 1, 2)?;
18838 require_byte(value, offset_start + 3, b':')?;
18839 let offset_minute = parse_fixed_i32(value, offset_start + 4, 2)?;
18840 if offset_hour > 23 || offset_minute > 59 {
18841 return None;
18842 }
18843 sign * (offset_hour * 3_600 + offset_minute * 60)
18844 }
18845 _ => return None,
18846 };
18847
18848 let days = days_from_civil(year, month, day);
18849 let local_seconds = days * 86_400 + i64::from(hour * 3_600 + minute * 60 + second.min(59));
18850 Some((local_seconds - i64::from(offset_seconds)) as f64 + fractional_second)
18851}
18852
18853fn parse_fixed_i32(value: &str, start: usize, len: usize) -> Option<i32> {
18854 value.get(start..start + len)?.parse::<i32>().ok()
18855}
18856
18857fn parse_fixed_u32(value: &str, start: usize, len: usize) -> Option<u32> {
18858 value.get(start..start + len)?.parse::<u32>().ok()
18859}
18860
18861fn require_byte(value: &str, index: usize, expected: u8) -> Option<()> {
18862 (value.as_bytes().get(index).copied()? == expected).then_some(())
18863}
18864
18865fn days_in_month(year: i32, month: u32) -> u32 {
18866 match month {
18867 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
18868 4 | 6 | 9 | 11 => 30,
18869 2 if is_leap_year(year) => 29,
18870 2 => 28,
18871 _ => 0,
18872 }
18873}
18874
18875fn is_leap_year(year: i32) -> bool {
18876 (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
18877}
18878
18879fn days_from_civil(year: i32, month: u32, day: u32) -> i64 {
18880 let year = year - i32::from(month <= 2);
18881 let era = if year >= 0 { year } else { year - 399 } / 400;
18882 let year_of_era = year - era * 400;
18883 let month = month as i32;
18884 let day = day as i32;
18885 let day_of_year = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day - 1;
18886 let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
18887 i64::from(era * 146_097 + day_of_era - 719_468)
18888}
18889
18890fn format_syntax(program: Program) -> String {
18891 let mut formatted = String::new();
18892 if let Some(workflow) = program.workflow {
18893 format_tags(&program.workflow_tags, &mut formatted);
18894 format_description(program.workflow_description.as_ref(), &mut formatted);
18895 push_line(&mut formatted, format!("workflow {}", workflow.name));
18896 formatted.push('\n');
18897 }
18898
18899 let mut top_level_items = Vec::new();
18900 top_level_items.extend(program.patterns.into_iter().map(Item::Pattern));
18901 top_level_items.extend(program.items);
18902 format_items(top_level_items, &mut formatted);
18903
18904 if !formatted.is_empty() && !program.workflows.is_empty() {
18905 formatted.push('\n');
18906 }
18907 let workflow_count = program.workflows.len();
18908 for (index, workflow) in program.workflows.into_iter().enumerate() {
18909 format_workflow(workflow, &mut formatted);
18910 if index + 1 < workflow_count {
18911 formatted.push('\n');
18912 }
18913 }
18914
18915 formatted
18916}
18917
18918fn format_items(items: Vec<Item>, formatted: &mut String) {
18919 let item_count = items.len();
18920 for (index, item) in items.into_iter().enumerate() {
18921 format_item(item, formatted);
18922 if index + 1 < item_count {
18923 formatted.push('\n');
18924 }
18925 }
18926}
18927
18928fn format_item(item: Item, formatted: &mut String) {
18929 match item {
18930 Item::Include(include) => {
18931 push_line(formatted, format!("include {:?}", include.path.value));
18932 }
18933 Item::Use(use_decl) => {
18934 push_line(formatted, format!("use {}", use_decl.name.value));
18935 }
18936 Item::Tracker(queue) => {
18937 push_line(formatted, format!("tracker {} {{", queue.name.name));
18938 push_line(formatted, format!(" provider {}", queue.provider.name));
18939 push_line(formatted, "}");
18940 }
18941 Item::Mark(mark) => {
18942 push_line(
18943 formatted,
18944 format!("mark {:?} after {}", mark.name.value, mark.site),
18945 );
18946 }
18947 Item::Gauge(gauge) => {
18948 let mut header = format!("gauge {}", gauge.name.name);
18949 if let Some(site) = &gauge.site {
18950 header.push_str(&format!(" on {site}"));
18951 }
18952 header.push_str(" {");
18953 push_line(formatted, header);
18954 let judge = match &gauge.judge {
18955 GaugeJudge::Coerce(target, args) if args.is_empty() => {
18956 format!("coerce {}", target.name)
18957 }
18958 GaugeJudge::Coerce(target, args) => {
18959 format!("coerce {}({})", target.name, args.join(", "))
18960 }
18961 GaugeJudge::Prompt(template) => format!("prompt {:?}", template.value),
18962 GaugeJudge::Exec(command) => format!("exec {:?}", command.value),
18963 GaugeJudge::Labels(source) => format!("labels {:?}", source.value),
18964 };
18965 push_line(formatted, format!(" judge via {judge}"));
18966 if let Some(bar) = &gauge.expect {
18967 let subject = match &bar.subject {
18968 GaugeBarSubject::Chance { field } => format!("P({})", field.name),
18969 GaugeBarSubject::Stat { stat } => stat.name.clone(),
18970 };
18971 let direction = if bar.at_least { "at least" } else { "at most" };
18972 push_line(
18973 formatted,
18974 format!(" expect {subject} {direction} {}", bar.threshold),
18975 );
18976 }
18977 if !gauge.inputs.is_empty() {
18978 let names = gauge
18979 .inputs
18980 .iter()
18981 .map(|input| input.name.as_str())
18982 .collect::<Vec<_>>()
18983 .join(", ");
18984 push_line(formatted, format!(" inputs {names}"));
18985 }
18986 push_line(formatted, "}");
18987 }
18988 Item::Campaign(campaign) => {
18989 push_line(formatted, format!("campaign {} {{", campaign.name.name));
18990 if !campaign.ascend.is_empty() {
18991 let names = campaign
18992 .ascend
18993 .iter()
18994 .map(|gauge| gauge.name.as_str())
18995 .collect::<Vec<_>>()
18996 .join(", ");
18997 push_line(formatted, format!(" ascend {names}"));
18998 }
18999 for reach in &campaign.reach {
19000 let direction = if reach.at_least {
19001 "at least"
19002 } else {
19003 "at most"
19004 };
19005 let unit = reach.unit.as_deref().unwrap_or("");
19006 push_line(
19007 formatted,
19008 format!(
19009 " reach {} {direction} {}{unit}",
19010 reach.gauge.name, reach.threshold
19011 ),
19012 );
19013 }
19014 for guard in &campaign.guard {
19015 push_line(
19016 formatted,
19017 format!(
19018 " guard {} within {} percent",
19019 guard.gauge.name, guard.band_percent
19020 ),
19021 );
19022 }
19023 if !campaign.sacrifice.is_empty() {
19024 let names = campaign
19025 .sacrifice
19026 .iter()
19027 .map(|gauge| gauge.name.as_str())
19028 .collect::<Vec<_>>()
19029 .join(", ");
19030 push_line(formatted, format!(" sacrifice {names}"));
19031 }
19032 if campaign.proposer_redacted {
19033 push_line(formatted, " proposer redacted");
19034 }
19035 push_line(formatted, "}");
19036 }
19037 Item::Channel(channel) => {
19038 push_line(formatted, format!("channel {} {{", channel.name.name));
19039 push_line(formatted, format!(" provider {}", channel.provider.name));
19040 if let Some(workspace) = &channel.workspace {
19041 push_line(formatted, format!(" workspace {}", workspace.name));
19042 }
19043 if let Some(destination) = &channel.destination {
19044 push_line(formatted, format!(" destination {:?}", destination.value));
19045 }
19046 push_line(formatted, "}");
19047 }
19048 Item::FileStore(file_store) => {
19049 push_line(formatted, format!("file store {} {{", file_store.name.name));
19050 push_line(formatted, format!(" root {:?}", file_store.root));
19051 let format_globs = |formatted: &mut String, direction: &str, globs: &[String]| {
19052 if !globs.is_empty() {
19053 let rendered = globs
19054 .iter()
19055 .map(|glob| format!("{glob:?}"))
19056 .collect::<Vec<_>>()
19057 .join(", ");
19058 push_line(formatted, format!(" allow {direction} [{rendered}]"));
19059 }
19060 };
19061 format_globs(formatted, "read", &file_store.read_globs);
19062 format_globs(formatted, "write", &file_store.write_globs);
19063 if let Some(provider) = &file_store.provider {
19064 push_line(formatted, format!(" provider {}", provider.name));
19065 }
19066 push_line(formatted, "}");
19067 }
19068 Item::MemoryPool(pool) => {
19069 push_line(formatted, format!("memory pool {} {{", pool.name.name));
19070 if let Some(limit) = pool.context_limit {
19071 push_line(formatted, format!(" context limit {limit}"));
19072 }
19073 push_line(formatted, "}");
19074 }
19075 Item::Action(action) => {
19076 let params = action
19077 .params
19078 .iter()
19079 .map(|param| format!("{} {}", param.name.name, param.ty.to_source()))
19080 .collect::<Vec<_>>()
19081 .join(", ");
19082 push_line(
19083 formatted,
19084 format!("action {}({params}) {{", action.name.name),
19085 );
19086 for line in action.body.text.lines() {
19087 if line.trim().is_empty() {
19088 push_line(formatted, "");
19089 } else {
19090 push_line(formatted, line.trim_end());
19091 }
19092 }
19093 push_line(formatted, "}");
19094 }
19095 Item::Pattern(pattern) => format_pattern(pattern, formatted),
19096 Item::Apply(apply) => format_apply(apply, formatted),
19097 Item::WorkflowContract(contract) => {
19098 push_line(
19099 formatted,
19100 format!(
19101 "{} {} {}",
19102 contract.kind.as_str(),
19103 contract.name.name,
19104 contract.ty.to_source()
19105 ),
19106 );
19107 }
19108 Item::Harness(harness) => format_harness(harness, formatted),
19109 Item::Agent(agent) => format_agent(agent, formatted),
19110 Item::Enum(enum_decl) => format_enum(enum_decl, formatted),
19111 Item::Event(event) => format_event(event, formatted),
19112 Item::Source(source) => format_source(*source, formatted),
19113 Item::Test(test) => format_test(test, formatted),
19114 Item::Lease(lease) => {
19115 push_line(formatted, format!("lease {} {{", lease.name.name));
19116 if lease.shared {
19117 push_line(formatted, " shared");
19118 }
19119 push_line(formatted, format!(" key {}", lease.key_type.name));
19120 push_line(formatted, format!(" slots {}", lease.slots));
19121 push_line(formatted, format!(" ttl {}s", lease.ttl_seconds));
19122 push_line(formatted, "}");
19123 }
19124 Item::Ledger(ledger) => {
19125 push_line(formatted, format!("ledger {} {{", ledger.name.name));
19126 if ledger.shared {
19127 push_line(formatted, " shared");
19128 }
19129 push_line(formatted, format!(" entry {}", ledger.entry_schema.name));
19130 push_line(
19131 formatted,
19132 format!(" partition by {}", ledger.partition_field.name),
19133 );
19134 push_line(formatted, format!(" retain {}s", ledger.retain_seconds));
19135 push_line(formatted, "}");
19136 }
19137 Item::Counter(counter) => {
19138 push_line(formatted, format!("counter {} {{", counter.name.name));
19139 if counter.shared {
19140 push_line(formatted, " shared");
19141 }
19142 push_line(formatted, format!(" key {}", counter.key_type.name));
19143 push_line(formatted, format!(" cap {}", counter.cap));
19144 push_line(formatted, format!(" reset {}", counter.reset));
19145 push_line(formatted, "}");
19146 }
19147 Item::Class(class_decl) => format_class(class_decl, formatted),
19148 Item::Table(table) => format_table(table, formatted),
19149 Item::Coerce(coerce) => format_coerce(coerce, formatted),
19150 Item::Assert(assertion) => {
19151 format_tags(&assertion.tags, formatted);
19152 format_description(assertion.description.as_ref(), formatted);
19153 push_line(formatted, format!("assert {}", assertion.expr));
19154 }
19155 Item::Rule(rule) => format_rule(rule, formatted),
19156 }
19157}
19158
19159fn format_tags(tags: &[TagDecl], formatted: &mut String) {
19160 for tag in tags {
19161 push_line(formatted, format!("@{}", tag.name));
19162 }
19163}
19164
19165fn format_description(description: Option<&StringLiteral>, formatted: &mut String) {
19166 if let Some(description) = description {
19167 push_line(formatted, format!("description {:?}", description.value));
19168 }
19169}
19170
19171fn format_pattern(pattern: PatternDecl, formatted: &mut String) {
19172 let params = if pattern.type_params.is_empty() {
19173 String::new()
19174 } else {
19175 format!(
19176 "<{}>",
19177 pattern
19178 .type_params
19179 .iter()
19180 .map(|param| param.name.as_str())
19181 .collect::<Vec<_>>()
19182 .join(", ")
19183 )
19184 };
19185 push_line(
19186 formatted,
19187 format!("pattern {}{} {{", pattern.name.name, params),
19188 );
19189 let mut inner = String::new();
19190 format_items(pattern.items, &mut inner);
19191 for line in inner.lines() {
19192 if line.is_empty() {
19193 formatted.push('\n');
19194 } else {
19195 push_line(formatted, format!(" {line}"));
19196 }
19197 }
19198 push_line(formatted, "}");
19199}
19200
19201fn format_apply(apply: ApplyDecl, formatted: &mut String) {
19202 let args = if apply.type_args.is_empty() {
19203 String::new()
19204 } else {
19205 format!(
19206 "<{}>",
19207 apply
19208 .type_args
19209 .iter()
19210 .map(TypeSyntax::to_source)
19211 .collect::<Vec<_>>()
19212 .join(", ")
19213 )
19214 };
19215 push_line(
19216 formatted,
19217 format!(
19218 "apply {}{} as {} {{",
19219 apply.pattern.name, args, apply.alias.name
19220 ),
19221 );
19222 format_block_body(&apply.body.text, formatted);
19223 push_line(formatted, "}");
19224}
19225
19226fn format_workflow(workflow: WorkflowDecl, formatted: &mut String) {
19227 format_tags(&workflow.tags, formatted);
19228 format_description(workflow.description.as_ref(), formatted);
19229 push_line(formatted, format!("workflow {} {{", workflow.name.name));
19230 let mut inner = String::new();
19231 format_items(workflow.items, &mut inner);
19232 for line in inner.lines() {
19233 if line.is_empty() {
19234 formatted.push('\n');
19235 } else {
19236 push_line(formatted, format!(" {line}"));
19237 }
19238 }
19239 push_line(formatted, "}");
19240}
19241
19242fn format_harness(harness: HarnessDecl, formatted: &mut String) {
19243 push_line(
19244 formatted,
19245 format!("harness {}: {}", harness.name.name, harness.kind.name),
19246 );
19247}
19248
19249fn format_agent(agent: AgentDecl, formatted: &mut String) {
19250 let harness = agent
19251 .harness
19252 .as_ref()
19253 .map(|harness| format!(" using {}", harness.name))
19254 .or_else(|| {
19255 agent
19256 .delegated_to
19257 .as_ref()
19258 .map(|delegate| format!(" delegated to {}", delegate.name))
19259 })
19260 .unwrap_or_default();
19261 push_line(
19262 formatted,
19263 format!("agent {}{} {{", agent.name.name, harness),
19264 );
19265 for field in agent.fields {
19266 match field {
19267 AgentField::Provider(provider) => {
19268 push_line(formatted, format!(" provider {}", provider.name));
19269 }
19270 AgentField::Profile(profile) => {
19271 push_line(formatted, format!(" profile {:?}", profile.value));
19272 }
19273 AgentField::Capacity(capacity, _) => {
19274 push_line(formatted, format!(" capacity {capacity}"));
19275 }
19276 AgentField::Skills(skills, _) => {
19277 let skills = skills
19278 .into_iter()
19279 .map(|skill| format!("{:?}", skill.value))
19280 .collect::<Vec<_>>()
19281 .join(", ");
19282 push_line(formatted, format!(" skills [{skills}]"));
19283 }
19284 AgentField::Capabilities(capabilities, _) => {
19285 let capabilities = capabilities
19286 .into_iter()
19287 .map(|capability| format!("{:?}", capability.value))
19288 .collect::<Vec<_>>()
19289 .join(", ");
19290 push_line(formatted, format!(" capabilities [{capabilities}]"));
19291 }
19292 AgentField::Requires(classes, _) => {
19293 let classes = classes
19294 .into_iter()
19295 .map(|class| class.name)
19296 .collect::<Vec<_>>()
19297 .join(", ");
19298 push_line(formatted, format!(" requires [{classes}]"));
19299 }
19300 AgentField::Tools(tools, _) => {
19301 let tools = tools
19302 .into_iter()
19303 .map(|tool| tool.name)
19304 .collect::<Vec<_>>()
19305 .join(", ");
19306 push_line(formatted, format!(" tools [{tools}]"));
19307 }
19308 AgentField::Compaction(strategy) => {
19309 push_line(formatted, format!(" compaction {}", strategy.name));
19310 }
19311 AgentField::Thread(mode) => {
19312 push_line(formatted, format!(" thread {}", mode.name));
19313 }
19314 AgentField::Settings(sources) => {
19315 push_line(formatted, format!(" settings {}", sources.name));
19316 }
19317 AgentField::Unknown { name, .. } => {
19318 push_line(formatted, format!(" {}", name.name));
19319 }
19320 }
19321 }
19322 push_line(formatted, "}");
19323}
19324
19325fn format_enum(enum_decl: EnumDecl, formatted: &mut String) {
19326 push_line(formatted, format!("enum {} {{", enum_decl.name.name));
19327 for variant in enum_decl.variants {
19328 if variant.fields.is_empty() {
19329 push_line(formatted, format!(" {}", variant.name.name));
19330 continue;
19331 }
19332 push_line(formatted, format!(" {} {{", variant.name.name));
19333 for field in variant.fields {
19334 push_line(
19335 formatted,
19336 format!(" {} {}", field.name.name, field.ty.to_source()),
19337 );
19338 }
19339 push_line(formatted, " }");
19340 }
19341 push_line(formatted, "}");
19342}
19343
19344fn format_time_of_day(time: TimeOfDay) -> String {
19345 format!("{:02}:{:02}", time.hour, time.minute)
19346}
19347
19348fn format_weekday(day: Weekday) -> &'static str {
19349 match day {
19350 Weekday::Monday => "monday",
19351 Weekday::Tuesday => "tuesday",
19352 Weekday::Wednesday => "wednesday",
19353 Weekday::Thursday => "thursday",
19354 Weekday::Friday => "friday",
19355 Weekday::Saturday => "saturday",
19356 Weekday::Sunday => "sunday",
19357 }
19358}
19359
19360fn format_recurrence(recurrence: &Recurrence) -> String {
19361 match recurrence {
19362 Recurrence::At { time, .. } => format!("at {}", format_time_of_day(*time)),
19363 Recurrence::EveryDuration { source, .. } => format!("every {source}"),
19364 Recurrence::EveryCalendar { pattern, time, .. } => {
19365 let pattern = match pattern {
19366 CalendarPattern::Day => "day".to_owned(),
19367 CalendarPattern::Weekday => "weekday".to_owned(),
19368 CalendarPattern::Weekly(day) => format_weekday(*day).to_owned(),
19369 };
19370 format!("every {pattern} at {}", format_time_of_day(*time))
19371 }
19372 }
19373}
19374
19375fn format_source_value(value: &SourceValue) -> String {
19376 match value {
19377 SourceValue::Path {
19378 binding, segments, ..
19379 } => {
19380 let mut text = binding.name.clone();
19381 for segment in segments {
19382 text.push('.');
19383 text.push_str(&segment.name);
19384 }
19385 text
19386 }
19387 SourceValue::String(literal) => format!("{:?}", literal.value),
19388 SourceValue::Number(number, _) => number.clone(),
19389 }
19390}
19391
19392fn format_test_fields(fields: &[TestField], formatted: &mut String) {
19393 for field in fields {
19394 push_line(
19395 formatted,
19396 format!(" {} {}", field.name.name, field.value),
19397 );
19398 }
19399}
19400
19401fn format_test(test: TestDecl, formatted: &mut String) {
19402 push_line(formatted, format!("test {:?} {{", test.name.value));
19403 if let Some(workflow) = &test.workflow {
19404 push_line(formatted, format!(" workflow {}", workflow.name));
19405 }
19406 for clause in &test.clauses {
19407 match clause {
19408 TestClause::Given(given) => match given {
19409 GivenClause::Input { fields, .. } => {
19410 push_line(formatted, " given input {");
19411 format_test_fields(fields, formatted);
19412 push_line(formatted, " }");
19413 }
19414 GivenClause::Fact { ty, fields, .. } => {
19415 push_line(formatted, format!(" given fact {} {{", ty.name));
19416 format_test_fields(fields, formatted);
19417 push_line(formatted, " }");
19418 }
19419 GivenClause::Signal { name, fields, .. } => {
19420 push_line(formatted, format!(" given signal {name} {{"));
19421 format_test_fields(fields, formatted);
19422 push_line(formatted, " }");
19423 }
19424 GivenClause::Clock { at, .. } => {
19425 push_line(formatted, format!(" given clock at {:?}", at.value));
19426 }
19427 GivenClause::Tracker {
19428 tracker, fields, ..
19429 } => {
19430 push_line(formatted, format!(" given tracker {tracker} issue {{"));
19431 format_test_fields(fields, formatted);
19432 push_line(formatted, " }");
19433 }
19434 GivenClause::File {
19435 store,
19436 path,
19437 content,
19438 ..
19439 } => {
19440 push_line(
19441 formatted,
19442 format!(
19443 " given file {store} at {:?} {:?}",
19444 path.value, content.value
19445 ),
19446 );
19447 }
19448 },
19449 TestClause::Stub(stub) => {
19450 let surface = stub.surface.join(" ");
19451 match &stub.payload {
19452 Some(StubPayload::Message(message)) => push_line(
19453 formatted,
19454 format!(" stub {surface} {} {:?}", stub.outcome, message.value),
19455 ),
19456 Some(StubPayload::Record(fields)) => {
19457 push_line(formatted, format!(" stub {surface} {} {{", stub.outcome));
19458 format_test_fields(fields, formatted);
19459 push_line(formatted, " }");
19460 }
19461 None => push_line(formatted, format!(" stub {surface} {}", stub.outcome)),
19462 }
19463 }
19464 TestClause::Run(run) => {
19465 let text = match &run.kind {
19466 RunKind::UntilIdle => "run until idle".to_owned(),
19467 RunKind::UntilWorkflowCompleted => "run until workflow completed".to_owned(),
19468 RunKind::UntilWorkflowFailed => "run until workflow failed".to_owned(),
19469 RunKind::ForSteps(steps) => format!("run for {steps} steps"),
19470 };
19471 push_line(formatted, format!(" {text}"));
19472 }
19473 TestClause::Expect(expect) => {
19474 push_line(
19475 formatted,
19476 format!(" {}", format_expect_target(&expect.target)),
19477 );
19478 }
19479 }
19480 }
19481 push_line(formatted, "}");
19482}
19483
19484fn format_expect_target(target: &ExpectTarget) -> String {
19485 match target {
19486 ExpectTarget::WorkflowCompleted => "expect workflow completed".to_owned(),
19487 ExpectTarget::WorkflowFailed { failure: None } => "expect workflow failed".to_owned(),
19488 ExpectTarget::WorkflowFailed {
19489 failure: Some(failure),
19490 } => format!("expect workflow failed with {}", failure.name),
19491 ExpectTarget::Rule { name, status } => {
19492 let status = match status {
19493 RuleStatus::Fired => "fired".to_owned(),
19494 RuleStatus::FiredTimes(count) => format!("fired {count} times"),
19495 RuleStatus::DidNotFire => "did not fire".to_owned(),
19496 };
19497 format!("expect rule {} {status}", name.name)
19498 }
19499 ExpectTarget::Effect { name, status } => {
19500 let status = match status {
19501 EffectStatus::Requested => "requested",
19502 EffectStatus::Completed => "completed",
19503 EffectStatus::Failed => "failed",
19504 };
19505 format!("expect effect {name} {status}")
19506 }
19507 ExpectTarget::Diagnostic { code } => format!("expect diagnostic {code}"),
19508 ExpectTarget::NoEffect { name } => format!("expect no {name}"),
19509 ExpectTarget::Projection(query) => format!("expect {}", format_proj_query(query)),
19510 }
19511}
19512
19513fn format_proj_query(query: &ProjQuery) -> String {
19514 match &query.kind {
19515 ProjQueryKind::Exists => format!("{} exists", query.noun),
19516 ProjQueryKind::Count { predicate, count } => {
19517 format!("{} count where {predicate} is {count}", query.noun)
19518 }
19519 ProjQueryKind::Where { predicate } => {
19520 format!("{} where {predicate}", query.noun)
19521 }
19522 }
19523}
19524
19525fn format_source(source: SourceDecl, formatted: &mut String) {
19526 push_line(
19527 formatted,
19528 format!("source {} as {} {{", source.provider.name, source.name.name),
19529 );
19530 if let Some(clock) = &source.clock {
19531 push_line(
19532 formatted,
19533 format!(" {}", format_recurrence(&clock.recurrence)),
19534 );
19535 if let Some(timezone) = &clock.timezone {
19536 push_line(formatted, format!(" timezone {:?}", timezone.value));
19537 }
19538 match clock.missed {
19539 Some(MissedPolicy::Skip) => push_line(formatted, " missed skip"),
19540 Some(MissedPolicy::Coalesce) => push_line(formatted, " missed coalesce"),
19541 Some(MissedPolicy::CatchUp { limit }) => {
19542 push_line(formatted, format!(" missed catch_up limit {limit}"))
19543 }
19544 None => {}
19545 }
19546 }
19547 if let Some(path) = &source.path {
19548 push_line(formatted, format!(" path {:?}", path.value));
19549 }
19550 if let Some(watch) = &source.watch {
19551 push_line(formatted, format!(" watch {:?}", watch.value));
19552 }
19553 if let Some(url) = &source.url {
19554 push_line(formatted, format!(" url {:?}", url.value));
19555 }
19556 if let Some(dedup) = &source.dedup {
19557 push_line(formatted, format!(" dedup {}", format_source_value(dedup)));
19558 }
19559 push_line(
19560 formatted,
19561 format!(" observe as {}", source.observe_binding.name),
19562 );
19563 let from = source
19564 .emit
19565 .from
19566 .as_ref()
19567 .map(|ident| format!(" from {}", ident.name))
19568 .unwrap_or_default();
19569 if source.emit.fields.is_empty() && source.emit.from.is_some() {
19570 push_line(formatted, format!(" emit {}{from}", source.emit.signal));
19571 } else {
19572 push_line(formatted, format!(" emit {}{from} {{", source.emit.signal));
19573 for field in &source.emit.fields {
19574 push_line(
19575 formatted,
19576 format!(
19577 " {} {}",
19578 field.name.name,
19579 format_source_value(&field.value)
19580 ),
19581 );
19582 }
19583 push_line(formatted, " }");
19584 }
19585 push_line(formatted, "}");
19586}
19587
19588fn format_event(event: EventDecl, formatted: &mut String) {
19589 push_line(formatted, format!("signal {} {{", event.name));
19590 for field in event.fields {
19591 push_line(
19592 formatted,
19593 format!(" {} {}", field.name.name, field.ty.to_source()),
19594 );
19595 }
19596 push_line(formatted, "}");
19597}
19598
19599fn format_class(class_decl: ClassDecl, formatted: &mut String) {
19600 push_line(formatted, format!("class {} {{", class_decl.name.name));
19601 for field in class_decl.fields {
19602 let key = if field.is_key { " @key" } else { "" };
19603 push_line(
19604 formatted,
19605 format!(" {} {}{key}", field.name.name, field.ty.to_source()),
19606 );
19607 }
19608 push_line(formatted, "}");
19609}
19610
19611fn format_table(table: TableDecl, formatted: &mut String) {
19612 format_tags(&table.tags, formatted);
19613 format_description(table.description.as_ref(), formatted);
19614 push_line(
19615 formatted,
19616 format!("table {} as {} [", table.name.name, table.schema.name),
19617 );
19618 for row in table.rows {
19619 push_line(formatted, " {");
19620 for line in row.body.text.lines() {
19621 if line.trim().is_empty() {
19622 formatted.push('\n');
19623 } else {
19624 push_line(formatted, format!(" {}", line.trim()));
19629 }
19630 }
19631 push_line(formatted, " }");
19632 }
19633 push_line(formatted, "]");
19634}
19635
19636fn format_coerce(coerce: CoerceDecl, formatted: &mut String) {
19637 let params = coerce
19638 .params
19639 .into_iter()
19640 .map(|param| format!("{} {}", param.name.name, param.ty.to_source()))
19641 .collect::<Vec<_>>()
19642 .join(", ");
19643 push_line(
19644 formatted,
19645 format!(
19646 "coerce {}({}) -> {} {{",
19647 coerce.name.name,
19648 params,
19649 coerce.output.to_source()
19650 ),
19651 );
19652 format_block_body(&coerce.body.text, formatted);
19653 push_line(formatted, "}");
19654}
19655
19656fn format_rule(rule: RuleDecl, formatted: &mut String) {
19657 format_tags(&rule.tags, formatted);
19658 format_description(rule.description.as_ref(), formatted);
19659 push_line(formatted, format!("rule {}", rule.name.name));
19660 for when in rule.whens {
19661 push_line(formatted, format!(" when {}", when.text));
19662 }
19663 push_line(formatted, "=> {");
19664 format_block_body(&rule.body.text, formatted);
19665 push_line(formatted, "}");
19666}
19667
19668fn push_block_body(body: &str, formatted: &mut String) {
19673 if body.is_empty() {
19674 return;
19675 }
19676 for line in body.lines() {
19677 if line.trim().is_empty() {
19678 formatted.push('\n');
19679 } else {
19680 push_line(formatted, format!(" {}", line.trim_end()));
19681 }
19682 }
19683}
19684
19685fn format_block_body(body: &str, formatted: &mut String) {
19695 if body.trim().is_empty() {
19696 return;
19697 }
19698 let lines: Vec<&str> = body.lines().collect();
19699 let mut index = 0;
19700 let mut depth: i32 = 1;
19701 while index < lines.len() {
19702 let trimmed = lines[index].trim();
19703 if trimmed.is_empty() {
19704 formatted.push('\n');
19705 index += 1;
19706 continue;
19707 }
19708 let opens_with_closer = trimmed
19709 .chars()
19710 .next()
19711 .is_some_and(|ch| matches!(ch, '}' | ']' | ')'));
19712 let line_depth = if opens_with_closer {
19713 (depth - 1).max(0)
19714 } else {
19715 depth
19716 };
19717 let prefix = " ".repeat(line_depth as usize);
19718 let (delta, opens_triple) = scan_braces(trimmed);
19719 push_line(formatted, format!("{prefix}{trimmed}"));
19720 if opens_triple {
19721 let mut end = index + 1;
19723 while end < lines.len() && lines[end].matches("\"\"\"").count().is_multiple_of(2) {
19724 end += 1;
19725 }
19726 let content = &lines[index + 1..end];
19727 let common = content
19728 .iter()
19729 .filter(|line| !line.trim().is_empty())
19730 .map(|line| line.len() - line.trim_start().len())
19731 .min()
19732 .unwrap_or(0);
19733 for line in content {
19734 if line.trim().is_empty() {
19735 formatted.push('\n');
19736 } else {
19737 push_line(formatted, format!("{prefix}{}", &line[common..]));
19738 }
19739 }
19740 if end < lines.len() {
19741 push_line(formatted, format!("{prefix}{}", lines[end].trim()));
19743 }
19744 index = end + 1;
19745 } else {
19746 index += 1;
19747 }
19748 depth = (depth + delta).max(0);
19749 }
19750}
19751
19752fn scan_braces(line: &str) -> (i32, bool) {
19758 let bytes = line.as_bytes();
19759 let mut index = 0;
19760 let mut delta = 0i32;
19761 let mut in_string = false;
19762 while index < bytes.len() {
19763 if in_string {
19764 match bytes[index] {
19765 b'\\' => index += 1,
19766 b'"' => in_string = false,
19767 _ => {}
19768 }
19769 index += 1;
19770 continue;
19771 }
19772 if line[index..].starts_with("\"\"\"") {
19773 match line[index + 3..].find("\"\"\"") {
19774 Some(offset) => index += 3 + offset + 3,
19775 None => return (delta, true),
19776 }
19777 continue;
19778 }
19779 match bytes[index] {
19780 b'"' => in_string = true,
19781 b'{' | b'[' | b'(' => delta += 1,
19782 b'}' | b']' | b')' => delta -= 1,
19783 _ => {}
19784 }
19785 index += 1;
19786 }
19787 (delta, false)
19788}
19789
19790impl TypeSyntax {
19791 fn to_source(&self) -> String {
19792 match self {
19793 Self::Primitive { name, .. } => name.clone(),
19794 Self::LiteralString { value, .. } => format!("{value:?}"),
19795 Self::Ref { name } => name.name.clone(),
19796 Self::AgentRef { agents, .. } => {
19797 let agents = agents
19798 .iter()
19799 .map(|agent| agent.name.as_str())
19800 .collect::<Vec<_>>()
19801 .join(" | ");
19802 format!("AgentRef<{agents}>")
19803 }
19804 Self::Optional { inner, .. } => format!("{}?", inner.to_source()),
19805 Self::Array { inner, .. } => format!("{}[]", inner.to_source()),
19806 Self::Map { inner, .. } => format!("map<{}>", inner.to_source()),
19807 Self::Union { variants, .. } => variants
19808 .iter()
19809 .map(Self::to_source)
19810 .collect::<Vec<_>>()
19811 .join(" | "),
19812 }
19813 }
19814}
19815
19816pub fn parser_stage() -> &'static str {
19818 whipplescript_core::IMPLEMENTATION_STAGE
19819}
19820
19821#[derive(Clone, Debug, Eq, PartialEq)]
19822struct Lexed {
19823 tokens: Vec<Token>,
19824 diagnostics: Vec<Diagnostic>,
19825 comments: Vec<Comment>,
19826}
19827
19828#[derive(Clone, Debug, Eq, PartialEq)]
19829struct Token {
19830 kind: TokenKind,
19831 span: SourceSpan,
19832}
19833
19834#[derive(Clone, Debug, Eq, PartialEq)]
19835enum TokenKind {
19836 Ident(String),
19837 String(String),
19838 Number(String),
19839 Arrow,
19840 ThinArrow,
19841 Symbol(char),
19842}
19843
19844impl TokenKind {
19845 fn label(&self) -> String {
19846 match self {
19847 Self::Ident(value) => format!("identifier `{value}`"),
19848 Self::String(_) => "string literal".to_owned(),
19849 Self::Number(_) => "number literal".to_owned(),
19850 Self::Arrow => "`=>`".to_owned(),
19851 Self::ThinArrow => "`->`".to_owned(),
19852 Self::Symbol(value) => format!("`{value}`"),
19853 }
19854 }
19855}
19856
19857fn lex(source: &str) -> Lexed {
19858 let bytes = source.as_bytes();
19859 let mut tokens = Vec::new();
19860 let mut diagnostics = Vec::new();
19861 let mut comments = Vec::new();
19862 let mut index = 0;
19863
19864 while index < bytes.len() {
19865 let byte = bytes[index];
19866 if byte.is_ascii_whitespace() {
19867 index += 1;
19868 continue;
19869 }
19870
19871 if byte == b'#' {
19872 let end = skip_line(bytes, index + 1);
19873 comments.push(Comment {
19874 marker: CommentMarker::Hash,
19875 text: source[index + 1..end].trim().to_owned(),
19876 span: SourceSpan { start: index, end },
19877 });
19878 index = end;
19879 continue;
19880 }
19881
19882 if byte == b'/' && bytes.get(index + 1) == Some(&b'/') {
19883 let end = skip_line(bytes, index + 2);
19884 comments.push(Comment {
19885 marker: CommentMarker::Slash,
19886 text: source[index + 2..end].trim().to_owned(),
19887 span: SourceSpan { start: index, end },
19888 });
19889 index = end;
19890 continue;
19891 }
19892
19893 if is_ident_start(byte) {
19894 let start = index;
19895 index += 1;
19896 while index < bytes.len() && is_ident_continue(bytes[index]) {
19897 index += 1;
19898 }
19899 tokens.push(Token {
19900 kind: TokenKind::Ident(source[start..index].to_owned()),
19901 span: SourceSpan { start, end: index },
19902 });
19903 continue;
19904 }
19905
19906 if byte.is_ascii_digit() {
19907 let start = index;
19908 index += 1;
19909 while index < bytes.len() && bytes[index].is_ascii_digit() {
19910 index += 1;
19911 }
19912 tokens.push(Token {
19913 kind: TokenKind::Number(source[start..index].to_owned()),
19914 span: SourceSpan { start, end: index },
19915 });
19916 continue;
19917 }
19918
19919 if byte == b'"' {
19920 let (token, next, diagnostic) = lex_string(source, index);
19921 tokens.push(token);
19922 if let Some(diagnostic) = diagnostic {
19923 diagnostics.push(diagnostic);
19924 }
19925 index = next;
19926 continue;
19927 }
19928
19929 if byte == b'=' && bytes.get(index + 1) == Some(&b'>') {
19930 tokens.push(Token {
19931 kind: TokenKind::Arrow,
19932 span: SourceSpan {
19933 start: index,
19934 end: index + 2,
19935 },
19936 });
19937 index += 2;
19938 continue;
19939 }
19940
19941 if byte == b'=' && bytes.get(index + 1) == Some(&b'=') {
19942 index += 2;
19943 continue;
19944 }
19945
19946 if byte == b'!' && bytes.get(index + 1) == Some(&b'=') {
19947 index += 2;
19948 continue;
19949 }
19950
19951 if matches!(byte, b'<' | b'>') && bytes.get(index + 1) == Some(&b'=') {
19952 index += 2;
19953 continue;
19954 }
19955
19956 if matches!(byte, b'&' | b'|') && bytes.get(index + 1) == Some(&byte) {
19957 index += 2;
19958 continue;
19959 }
19960
19961 if byte == b'-' && bytes.get(index + 1) == Some(&b'>') {
19962 tokens.push(Token {
19963 kind: TokenKind::ThinArrow,
19964 span: SourceSpan {
19965 start: index,
19966 end: index + 2,
19967 },
19968 });
19969 index += 2;
19970 continue;
19971 }
19972
19973 if matches!(byte, b'*' | b'/' | b'-') {
19977 index += 1;
19978 continue;
19979 }
19980
19981 if b"{}[]()<>,?|.+!:@".contains(&byte) {
19982 tokens.push(Token {
19983 kind: TokenKind::Symbol(byte as char),
19984 span: SourceSpan {
19985 start: index,
19986 end: index + 1,
19987 },
19988 });
19989 index += 1;
19990 continue;
19991 }
19992
19993 diagnostics.push(Diagnostic {
19994 related: Vec::new(),
19995 span: SourceSpan {
19996 start: index,
19997 end: index + 1,
19998 },
19999 message: format!("unexpected character `{}`", byte as char),
20000 suggestion: None,
20001 });
20002 index += 1;
20003 }
20004
20005 Lexed {
20006 tokens,
20007 diagnostics,
20008 comments,
20009 }
20010}
20011
20012pub fn lex_comments(source: &str) -> Vec<Comment> {
20016 lex(source).comments
20017}
20018
20019pub fn string_and_comment_spans(source: &str) -> Vec<SourceSpan> {
20024 let lexed = lex(source);
20025 let mut spans: Vec<SourceSpan> = lexed
20026 .tokens
20027 .iter()
20028 .filter(|token| matches!(token.kind, TokenKind::String(_)))
20029 .map(|token| token.span)
20030 .collect();
20031 spans.extend(lexed.comments.iter().map(|comment| comment.span));
20032 spans
20033}
20034
20035fn skip_line(bytes: &[u8], mut index: usize) -> usize {
20036 while index < bytes.len() && bytes[index] != b'\n' {
20037 index += 1;
20038 }
20039 index
20040}
20041
20042fn is_ident_start(byte: u8) -> bool {
20043 byte.is_ascii_alphabetic() || byte == b'_'
20044}
20045
20046fn is_ident_continue(byte: u8) -> bool {
20047 is_ident_start(byte) || byte.is_ascii_digit() || byte == b'-'
20048}
20049
20050fn lex_string(source: &str, start: usize) -> (Token, usize, Option<Diagnostic>) {
20051 let bytes = source.as_bytes();
20052 let triple = bytes.get(start..start + 3) == Some(b"\"\"\"");
20053 let content_start = if triple { start + 3 } else { start + 1 };
20054 let mut index = content_start;
20055
20056 while index < bytes.len() {
20057 if triple && bytes.get(index..index + 3) == Some(b"\"\"\"") {
20058 let end = index + 3;
20059 return (
20060 Token {
20061 kind: TokenKind::String(source[content_start..index].to_owned()),
20062 span: SourceSpan { start, end },
20063 },
20064 end,
20065 None,
20066 );
20067 }
20068
20069 if !triple && bytes[index] == b'"' {
20070 let end = index + 1;
20071 return (
20072 Token {
20073 kind: TokenKind::String(source[content_start..index].to_owned()),
20074 span: SourceSpan { start, end },
20075 },
20076 end,
20077 None,
20078 );
20079 }
20080
20081 if !triple && bytes[index] == b'\\' && index + 1 < bytes.len() {
20082 index += 2;
20083 } else {
20084 index += 1;
20085 }
20086 }
20087
20088 (
20089 Token {
20090 kind: TokenKind::String(source[content_start..].to_owned()),
20091 span: SourceSpan {
20092 start,
20093 end: source.len(),
20094 },
20095 },
20096 source.len(),
20097 Some(Diagnostic {
20098 related: Vec::new(),
20099 span: SourceSpan {
20100 start,
20101 end: source.len(),
20102 },
20103 message: "unterminated string literal".to_owned(),
20104 suggestion: Some("close the string literal".to_owned()),
20105 }),
20106 )
20107}
20108
20109#[derive(Clone, Copy, Debug)]
20114#[allow(dead_code)]
20115enum ClauseKind {
20116 Identifier,
20117 Expression,
20118 Duration,
20119 Glob,
20120 Schema,
20121 Scalar,
20122 Flag,
20123}
20124
20125#[derive(Clone, Copy, Debug)]
20131#[allow(dead_code)]
20132enum DeclAstKind {
20133 Tracker,
20134 Channel,
20135 Counter,
20136 Lease,
20137 Ledger,
20138 MemoryPool,
20139 FileStore,
20140}
20141
20142#[derive(Clone, Copy, Debug)]
20157struct ClauseSpec {
20158 name: &'static str,
20159 words: &'static [&'static str],
20160 connective: Option<&'static str>,
20161 kind: ClauseKind,
20162 list: bool,
20163 unknown_hint: &'static str,
20164}
20165
20166#[derive(Clone, Copy, Debug)]
20171#[allow(dead_code)]
20172struct DeclarationBlockSpec {
20173 keyword: &'static str,
20174 keyword_words: &'static [&'static str],
20175 ast_kind: DeclAstKind,
20176 clauses: &'static [ClauseSpec],
20177}
20178
20179include!(concat!(env!("OUT_DIR"), "/declaration_block_grammar.rs"));
20186
20187#[derive(Clone, Debug)]
20195enum ClauseValue {
20196 Ident(Ident),
20197 Duration(u64),
20198 Number(u32),
20199 Str(StringLiteral),
20200 Globs(Vec<String>),
20201 Flag,
20202 Missing,
20203}
20204
20205struct ClauseBag {
20212 records: Vec<(&'static str, SourceSpan, ClauseValue)>,
20213}
20214
20215impl ClauseBag {
20216 fn new() -> Self {
20217 ClauseBag {
20218 records: Vec::new(),
20219 }
20220 }
20221
20222 fn record(&mut self, name: &'static str, first_word_span: SourceSpan, value: ClauseValue) {
20223 self.records.push((name, first_word_span, value));
20224 }
20225
20226 fn get(&self, name: &str) -> Option<&(&'static str, SourceSpan, ClauseValue)> {
20227 self.records
20228 .iter()
20229 .rev()
20230 .find(|(clause, _, _)| *clause == name)
20231 }
20232
20233 fn ident(&self, name: &str) -> Option<Ident> {
20234 match self.get(name) {
20235 Some((_, _, ClauseValue::Ident(ident))) => Some(ident.clone()),
20236 _ => None,
20237 }
20238 }
20239
20240 fn duration(&self, name: &str) -> Option<u64> {
20241 match self.get(name) {
20242 Some((_, _, ClauseValue::Duration(seconds))) => Some(*seconds),
20243 _ => None,
20244 }
20245 }
20246
20247 fn number(&self, name: &str) -> Option<u32> {
20248 match self.get(name) {
20249 Some((_, _, ClauseValue::Number(value))) => Some(*value),
20250 _ => None,
20251 }
20252 }
20253
20254 fn text(&self, name: &str) -> Option<String> {
20255 self.text_literal(name).map(|literal| literal.value)
20256 }
20257
20258 fn text_literal(&self, name: &str) -> Option<StringLiteral> {
20259 match self.get(name) {
20260 Some((_, _, ClauseValue::Str(literal))) => Some(literal.clone()),
20261 _ => None,
20262 }
20263 }
20264
20265 fn globs(&self, name: &str) -> Vec<String> {
20266 match self.get(name) {
20267 Some((_, _, ClauseValue::Globs(values))) => values.clone(),
20268 _ => Vec::new(),
20269 }
20270 }
20271
20272 fn flag(&self, name: &str) -> bool {
20273 matches!(self.get(name), Some((_, _, ClauseValue::Flag)))
20274 }
20275
20276 fn span(&self, name: &str) -> Option<SourceSpan> {
20277 self.get(name).map(|(_, span, _)| *span)
20278 }
20279}
20280
20281struct Parser<'a> {
20282 source: &'a str,
20283 tokens: Vec<Token>,
20284 pos: usize,
20285 diagnostics: Vec<Diagnostic>,
20286 pending_contract_classes: Vec<ClassDecl>,
20289}
20290
20291struct ParsedWorkflow {
20292 decl: WorkflowDecl,
20293 explicit_body: bool,
20294}
20295
20296impl Parser<'_> {
20297 fn parse_program(&mut self) -> Program {
20298 let mut workflow = None;
20299 let mut workflow_tags = Vec::new();
20300 let mut workflow_description = None;
20301 let mut explicit_workflow_body = false;
20302 let mut workflows = Vec::new();
20303 let mut patterns = Vec::new();
20304 let mut items = Vec::new();
20305 let mut pending_tags = Vec::new();
20306 let mut pending_description = None;
20307
20308 while !self.is_at_end() {
20309 if self.at_symbol('@') {
20310 if let Some(tag) = self.parse_tag() {
20311 pending_tags.push(tag);
20312 }
20313 } else if self.at_ident("description") {
20314 self.parse_pending_description(&mut pending_description);
20315 } else if self.at_ident("workflow") {
20316 if let Some(parsed_workflow) = self.parse_workflow(
20317 std::mem::take(&mut pending_tags),
20318 pending_description.take(),
20319 ) {
20320 if parsed_workflow.explicit_body {
20321 workflows.push(parsed_workflow.decl);
20322 } else {
20323 if workflow.is_some() {
20324 self.diagnostics.push(Diagnostic { related: Vec::new(),
20325 span: parsed_workflow.decl.name.span,
20326 message: "multiple implicit workflow headers are not supported"
20327 .to_owned(),
20328 suggestion: Some(
20329 "use explicit `workflow Name { ... }` declarations with `--root`"
20330 .to_owned(),
20331 ),
20332 });
20333 }
20334 workflow_tags = parsed_workflow.decl.tags;
20335 workflow_description = parsed_workflow.decl.description;
20336 items.extend(parsed_workflow.decl.items);
20340 workflow = Some(parsed_workflow.decl.name);
20341 explicit_workflow_body = false;
20342 }
20343 }
20344 } else if self.at_ident("pattern") {
20345 self.reject_pending_tags(&mut pending_tags, "pattern");
20346 self.reject_pending_description(&mut pending_description, "pattern");
20347 if let Some(pattern) = self.parse_pattern() {
20348 patterns.push(pattern);
20349 }
20350 } else if let Some(item) =
20351 self.parse_declaration_item(&mut pending_tags, &mut pending_description)
20352 {
20353 items.push(item);
20354 } else if self.reject_gherkin_misuse() {
20355 continue;
20356 } else {
20357 if self.is_at_end() {
20358 break;
20359 }
20360 self.unexpected("top-level declaration");
20361 if !self.is_at_end() {
20362 self.advance();
20363 }
20364 }
20365 }
20366
20367 items.extend(
20370 std::mem::take(&mut self.pending_contract_classes)
20371 .into_iter()
20372 .map(Item::Class),
20373 );
20374
20375 Program {
20376 workflow,
20377 workflow_tags,
20378 workflow_description,
20379 explicit_workflow_body,
20380 workflows,
20381 patterns,
20382 items,
20383 }
20384 }
20385
20386 fn parse_workflow(
20387 &mut self,
20388 tags: Vec<TagDecl>,
20389 description: Option<StringLiteral>,
20390 ) -> Option<ParsedWorkflow> {
20391 let start = self.expect_keyword("workflow")?.span.start;
20392 let name = self.expect_ident("workflow name")?;
20393 let mut explicit_body = false;
20394 let mut items = Vec::new();
20395 let mut end = name.span.end;
20396 if self.at_symbol('(') {
20402 if let Some((contracts, signature_end)) = self.parse_compact_contract_signature() {
20403 end = signature_end;
20404 items.extend(contracts.into_iter().map(Item::WorkflowContract));
20405 }
20406 }
20407 if self.at_symbol('{') {
20408 explicit_body = true;
20409 self.expect_symbol('{')?;
20410 let mut pending_tags = Vec::new();
20411 let mut pending_description = None;
20412 while !self.is_at_end() && !self.at_symbol('}') {
20413 if self.at_symbol('@') {
20414 if let Some(tag) = self.parse_tag() {
20415 pending_tags.push(tag);
20416 }
20417 continue;
20418 }
20419 if self.at_ident("description") {
20420 self.parse_pending_description(&mut pending_description);
20421 continue;
20422 }
20423 if self.at_ident("workflow") || self.at_ident("pattern") {
20424 self.reject_pending_tags(&mut pending_tags, "workflow body declaration");
20425 self.reject_pending_description(
20426 &mut pending_description,
20427 "workflow body declaration",
20428 );
20429 self.unexpected("workflow body declaration");
20430 self.advance();
20431 continue;
20432 }
20433 if let Some(item) =
20434 self.parse_declaration_item(&mut pending_tags, &mut pending_description)
20435 {
20436 items.push(item);
20437 } else if self.reject_gherkin_misuse() {
20438 continue;
20439 } else {
20440 if self.is_at_end() {
20441 break;
20442 }
20443 self.reject_pending_tags(&mut pending_tags, "workflow body declaration");
20444 self.reject_pending_description(
20445 &mut pending_description,
20446 "workflow body declaration",
20447 );
20448 self.unexpected("workflow body declaration");
20449 if !self.is_at_end() {
20450 self.advance();
20451 }
20452 }
20453 }
20454 if let Some(close) = self.expect_symbol('}') {
20455 end = close.span.end;
20456 }
20457 }
20458 items.extend(
20463 std::mem::take(&mut self.pending_contract_classes)
20464 .into_iter()
20465 .map(Item::Class),
20466 );
20467 Some(ParsedWorkflow {
20468 decl: WorkflowDecl {
20469 name,
20470 tags,
20471 description,
20472 items,
20473 span: SourceSpan { start, end },
20474 },
20475 explicit_body,
20476 })
20477 }
20478
20479 fn parse_compact_contract_signature(&mut self) -> Option<(Vec<WorkflowContractDecl>, usize)> {
20485 self.expect_symbol('(')?;
20486 let mut contracts = Vec::new();
20487 while !self.is_at_end() && !self.at_symbol(')') {
20488 let name = self.expect_ident("workflow input name")?;
20489 self.expect_symbol(':')?;
20490 let ty = self.parse_type()?;
20491 let span = name.span.join(ty.span());
20492 contracts.push(WorkflowContractDecl {
20493 kind: WorkflowContractKind::Input,
20494 name,
20495 ty,
20496 span,
20497 });
20498 if self.at_symbol(',') {
20499 self.advance();
20500 } else if !self.at_symbol(')') {
20501 self.unexpected("`,` or `)`");
20502 while !self.is_at_end() && !self.at_symbol(')') && !self.at_symbol(',') {
20503 self.advance();
20504 }
20505 }
20506 }
20507 self.expect_symbol(')')?;
20508 self.expect_thin_arrow()?;
20509 let output_ty = self.parse_type()?;
20510 let output_span = output_ty.span();
20511 let mut end = output_span.end;
20512 contracts.push(WorkflowContractDecl {
20513 kind: WorkflowContractKind::Output,
20514 name: Ident {
20515 name: "result".to_owned(),
20516 span: output_span,
20517 },
20518 ty: output_ty,
20519 span: output_span,
20520 });
20521 if self.at_symbol('!') {
20522 self.advance();
20523 let failure_ty = self.parse_type()?;
20524 let failure_span = failure_ty.span();
20525 end = failure_span.end;
20526 contracts.push(WorkflowContractDecl {
20527 kind: WorkflowContractKind::Failure,
20528 name: Ident {
20529 name: "error".to_owned(),
20530 span: failure_span,
20531 },
20532 ty: failure_ty,
20533 span: failure_span,
20534 });
20535 }
20536 Some((contracts, end))
20537 }
20538
20539 fn parse_tag(&mut self) -> Option<TagDecl> {
20540 let at = self.expect_symbol('@')?;
20541 let name_start = at.span.end;
20542 let mut name_end = name_start;
20543 for (offset, ch) in self.source[name_start..].char_indices() {
20544 if ch.is_whitespace() {
20545 break;
20546 }
20547 name_end = name_start + offset + ch.len_utf8();
20548 }
20549 let name = self.source[name_start..name_end].to_owned();
20550 while !self.is_at_end() && self.peek().is_some_and(|token| token.span.start < name_end) {
20551 self.advance();
20552 }
20553 let span = SourceSpan {
20554 start: at.span.start,
20555 end: name_end,
20556 };
20557 if name.is_empty() {
20558 self.diagnostics.push(Diagnostic {
20559 related: Vec::new(),
20560 span,
20561 message: "tag is missing a name".to_owned(),
20562 suggestion: Some("write a tag such as `@fixture`".to_owned()),
20563 });
20564 return None;
20565 }
20566 if !name
20567 .chars()
20568 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | ':' | '.'))
20569 {
20570 self.diagnostics.push(Diagnostic {
20571 related: Vec::new(),
20572 span,
20573 message: format!("tag `@{name}` contains unsupported characters"),
20574 suggestion: Some(
20575 "use letters, digits, `_`, `-`, `.`, or `:` in tag names".to_owned(),
20576 ),
20577 });
20578 return None;
20579 }
20580 Some(TagDecl { name, span })
20581 }
20582
20583 fn reject_pending_tags(&mut self, pending_tags: &mut Vec<TagDecl>, target: &str) {
20584 for tag in pending_tags.drain(..) {
20585 self.diagnostics.push(Diagnostic {
20586 related: Vec::new(),
20587 span: tag.span,
20588 message: format!("tag `@{}` cannot be attached to {target}", tag.name),
20589 suggestion: Some(
20590 "place tags on workflows, matrices, assertions, or rules".to_owned(),
20591 ),
20592 });
20593 }
20594 }
20595
20596 fn parse_pending_description(&mut self, pending_description: &mut Option<StringLiteral>) {
20597 let Some(description) = self.parse_description() else {
20598 return;
20599 };
20600 if let Some(previous) = pending_description.replace(description) {
20601 self.diagnostics.push(Diagnostic { related: Vec::new(),
20602 span: previous.span,
20603 message: "description is not attached to a declaration".to_owned(),
20604 suggestion: Some(
20605 "place only one `description \"...\"` immediately before the target declaration"
20606 .to_owned(),
20607 ),
20608 });
20609 }
20610 }
20611
20612 fn parse_description(&mut self) -> Option<StringLiteral> {
20613 let description = self.expect_keyword("description")?;
20614 let Some(value) = self.expect_string("description string") else {
20615 return Some(StringLiteral {
20616 value: String::new(),
20617 span: description.span,
20618 });
20619 };
20620 Some(value)
20621 }
20622
20623 fn reject_pending_description(
20624 &mut self,
20625 pending_description: &mut Option<StringLiteral>,
20626 target: &str,
20627 ) {
20628 if let Some(description) = pending_description.take() {
20629 self.diagnostics.push(Diagnostic {
20630 related: Vec::new(),
20631 span: description.span,
20632 message: format!("description cannot be attached to {target}"),
20633 suggestion: Some(
20634 "place descriptions on workflows, matrices, assertions, or rules".to_owned(),
20635 ),
20636 });
20637 }
20638 }
20639
20640 fn reject_gherkin_misuse(&mut self) -> bool {
20641 let Some(token) = self.peek() else {
20642 return false;
20643 };
20644 let TokenKind::Ident(keyword) = &token.kind else {
20645 return false;
20646 };
20647 if !is_gherkin_keyword(keyword) {
20648 return false;
20649 }
20650 let span = token.span;
20651 self.diagnostics.push(Diagnostic { related: Vec::new(),
20652 span,
20653 message: format!(
20654 "Gherkin keyword `{keyword}` is not WhippleScript workflow syntax"
20655 ),
20656 suggestion: Some(
20657 "use `workflow`, `table`, `rule ... when ... => { ... }`, and `assert` instead of free-text Given/When/Then steps"
20658 .to_owned(),
20659 ),
20660 });
20661 self.advance_to_line_end(span.start);
20662 true
20663 }
20664
20665 fn advance_to_line_end(&mut self, line_start: usize) {
20666 let line_end = self.source[line_start..]
20667 .find('\n')
20668 .map(|offset| line_start + offset)
20669 .unwrap_or(self.source.len());
20670 while self.peek().is_some_and(|token| token.span.start < line_end) {
20671 self.advance();
20672 }
20673 }
20674
20675 fn parse_declaration_item(
20676 &mut self,
20677 pending_tags: &mut Vec<TagDecl>,
20678 pending_description: &mut Option<StringLiteral>,
20679 ) -> Option<Item> {
20680 if let Some(spec) = self.declaration_block_spec_at() {
20687 self.reject_pending_tags(pending_tags, spec.keyword);
20688 self.reject_pending_description(pending_description, spec.keyword);
20689 return self.parse_declaration_block(spec);
20690 }
20691 if self.at_ident("include") {
20692 self.reject_pending_tags(pending_tags, "include");
20693 self.reject_pending_description(pending_description, "include");
20694 self.parse_include().map(Item::Include)
20695 } else if self.at_ident("use") {
20696 self.reject_pending_tags(pending_tags, "use");
20697 self.reject_pending_description(pending_description, "use");
20698 self.parse_use().map(Item::Use)
20699 } else if self.at_ident("pattern") {
20700 self.reject_pending_tags(pending_tags, "pattern");
20701 self.reject_pending_description(pending_description, "pattern");
20702 self.parse_pattern().map(Item::Pattern)
20703 } else if self.at_ident("apply") {
20704 self.reject_pending_tags(pending_tags, "apply");
20705 self.reject_pending_description(pending_description, "apply");
20706 self.parse_apply().map(Item::Apply)
20707 } else if self.at_ident("input") || self.at_ident("output") || self.at_ident("failure") {
20708 self.reject_pending_tags(pending_tags, "workflow contract");
20709 self.reject_pending_description(pending_description, "workflow contract");
20710 self.parse_workflow_contract().map(Item::WorkflowContract)
20711 } else if self.at_ident("flow") {
20712 let span = self
20716 .peek()
20717 .map(|token| token.span)
20718 .unwrap_or(SourceSpan { start: 0, end: 0 });
20719 self.diagnostics.push(Diagnostic {
20720 related: Vec::new(),
20721 span,
20722 message: "the `flow` declaration was removed".to_owned(),
20723 suggestion: Some(
20724 "write a `rule` and chain sequential steps with `then <binding> <- <effect>`"
20725 .to_owned(),
20726 ),
20727 });
20728 let mut depth = 0usize;
20732 while !self.is_at_end() {
20733 let token = self.advance();
20734 match &token.kind {
20735 TokenKind::Symbol('{') => depth += 1,
20736 TokenKind::Symbol('}') => {
20737 depth = depth.saturating_sub(1);
20738 if depth == 0 {
20739 break;
20740 }
20741 }
20742 _ => {}
20743 }
20744 }
20745 None
20746 } else if self.at_ident("action") {
20747 self.reject_pending_tags(pending_tags, "action");
20748 self.reject_pending_description(pending_description, "action");
20749 self.parse_action().map(Item::Action)
20750 } else if self.at_ident("harness") {
20751 self.reject_pending_tags(pending_tags, "harness");
20752 self.reject_pending_description(pending_description, "harness");
20753 self.parse_harness().map(Item::Harness)
20754 } else if self.at_ident("agent") {
20755 self.reject_pending_tags(pending_tags, "agent");
20756 self.reject_pending_description(pending_description, "agent");
20757 self.parse_agent().map(Item::Agent)
20758 } else if self.at_ident("enum") {
20759 self.reject_pending_tags(pending_tags, "enum");
20760 self.reject_pending_description(pending_description, "enum");
20761 self.parse_enum().map(Item::Enum)
20762 } else if self.at_ident("signal") {
20763 self.reject_pending_tags(pending_tags, "signal");
20764 self.reject_pending_description(pending_description, "signal");
20765 self.parse_event().map(Item::Event)
20766 } else if self.at_ident("gauge") {
20767 self.reject_pending_tags(pending_tags, "gauge");
20768 self.reject_pending_description(pending_description, "gauge");
20769 self.parse_gauge().map(Item::Gauge)
20770 } else if self.at_ident("campaign") {
20771 self.reject_pending_tags(pending_tags, "campaign");
20772 self.reject_pending_description(pending_description, "campaign");
20773 self.parse_campaign().map(Item::Campaign)
20774 } else if self.at_ident("mark") {
20775 self.reject_pending_tags(pending_tags, "mark");
20776 self.reject_pending_description(pending_description, "mark");
20777 self.parse_mark().map(Item::Mark)
20778 } else if self.at_ident("source") {
20779 self.reject_pending_tags(pending_tags, "source");
20780 self.reject_pending_description(pending_description, "source");
20781 self.parse_source()
20782 .map(|source| Item::Source(Box::new(source)))
20783 } else if self.at_ident("test") {
20784 self.reject_pending_tags(pending_tags, "test");
20785 self.reject_pending_description(pending_description, "test");
20786 self.parse_test().map(Item::Test)
20787 } else if self.at_ident("class") {
20788 self.reject_pending_tags(pending_tags, "class");
20789 self.reject_pending_description(pending_description, "class");
20790 self.parse_class().map(Item::Class)
20791 } else if self.at_ident("table") {
20792 self.parse_table(std::mem::take(pending_tags), pending_description.take())
20793 .map(Item::Table)
20794 } else if self.at_ident("coerce") {
20795 self.reject_pending_tags(pending_tags, "coerce");
20796 self.reject_pending_description(pending_description, "coerce");
20797 self.parse_coerce().map(Item::Coerce)
20798 } else if self.at_ident("assert") {
20799 self.parse_assert(std::mem::take(pending_tags), pending_description.take())
20800 .map(Item::Assert)
20801 } else if self.at_ident("rule") {
20802 self.parse_rule(std::mem::take(pending_tags), pending_description.take())
20803 .map(Item::Rule)
20804 } else {
20805 None
20806 }
20807 }
20808
20809 fn declaration_block_spec_at(&self) -> Option<&'static DeclarationBlockSpec> {
20815 let head = match self.peek().map(|token| &token.kind) {
20816 Some(TokenKind::Ident(value)) => value.as_str(),
20817 _ => return None,
20818 };
20819 DECLARATION_BLOCK_GRAMMAR
20820 .iter()
20821 .find(|spec| spec.keyword_words.first() == Some(&head))
20822 }
20823
20824 fn parse_declaration_block(&mut self, spec: &'static DeclarationBlockSpec) -> Option<Item> {
20832 let head = *spec.keyword_words.first()?;
20833 let start = self.expect_keyword(head)?.span.start;
20834 for tail in &spec.keyword_words[1..] {
20835 if !self.consume_ident(tail) {
20836 self.expected(format!("`{tail}` after `{head}`"));
20837 return None;
20838 }
20839 }
20840 let name = self.expect_ident(&format!("{} name", spec.keyword))?;
20841 if !self.at_symbol('{') {
20847 let span = SourceSpan {
20848 start,
20849 end: name.span.end,
20850 };
20851 let bag = ClauseBag::new();
20852 return self.item_from_decl_ast(spec.ast_kind, &bag, name, span);
20853 }
20854 self.expect_symbol('{')?;
20855 let field_label = format!("{} field", spec.keyword);
20856 let mut bag = ClauseBag::new();
20857 while !self.is_at_end() && !self.at_symbol('}') {
20858 let Some(field) = self.expect_ident(&field_label) else {
20859 self.synchronize_to_block_item();
20860 continue;
20861 };
20862 let first_word_span = field.span;
20866 let mut words = vec![field.name];
20867 let matched = loop {
20868 let depth = words.len();
20869 if let Some(clause) = spec.clauses.iter().find(|clause| {
20870 clause.words.len() == depth
20871 && clause
20872 .words
20873 .iter()
20874 .zip(&words)
20875 .all(|(a, b)| *a == b.as_str())
20876 }) {
20877 break Some(clause);
20878 }
20879 let extendable = spec.clauses.iter().any(|clause| {
20880 clause.words.len() > depth
20881 && clause
20882 .words
20883 .iter()
20884 .zip(&words)
20885 .all(|(a, b)| *a == b.as_str())
20886 });
20887 if !extendable {
20888 break None;
20889 }
20890 let Some(next) = self.expect_ident("clause name") else {
20891 break None;
20892 };
20893 words.push(next.name);
20894 };
20895 let Some(clause) = matched else {
20896 let hint = spec.clauses.first().map(|clause| clause.unknown_hint);
20897 self.diagnostics.push(Diagnostic {
20898 related: Vec::new(),
20899 span: first_word_span,
20900 message: format!("unknown {} field `{}`", spec.keyword, words.join(" ")),
20901 suggestion: hint.map(str::to_owned),
20902 });
20903 self.synchronize_to_block_item();
20904 continue;
20905 };
20906 if let Some(connective) = clause.connective {
20909 if !self.consume_ident(connective) {
20910 self.diagnostics.push(Diagnostic {
20911 related: Vec::new(),
20912 span: first_word_span,
20913 message: format!("expected `{connective}` after `{}`", clause.name),
20914 suggestion: Some(format!("write `{} {connective} <field>`", clause.name)),
20915 });
20916 self.synchronize_to_block_item();
20917 continue;
20918 }
20919 }
20920 let value = self.parse_clause_value(clause);
20921 bag.record(clause.name, first_word_span, value);
20922 }
20923 let close = self.expect_symbol('}')?;
20924 let span = SourceSpan {
20925 start,
20926 end: close.span.end,
20927 };
20928 self.item_from_decl_ast(spec.ast_kind, &bag, name, span)
20929 }
20930
20931 fn parse_clause_value(&mut self, clause: &ClauseSpec) -> ClauseValue {
20935 match clause.kind {
20936 ClauseKind::Identifier | ClauseKind::Schema => self
20937 .expect_ident(&format!("{} value", clause.name))
20938 .map_or(ClauseValue::Missing, ClauseValue::Ident),
20939 ClauseKind::Duration => self
20940 .parse_decl_duration_seconds(&format!("{} duration", clause.name))
20941 .map_or(ClauseValue::Missing, ClauseValue::Duration),
20942 ClauseKind::Scalar => match self.peek().map(|token| &token.kind) {
20943 Some(TokenKind::String(_)) => self
20944 .expect_string(&format!("{} value", clause.name))
20945 .map_or(ClauseValue::Missing, ClauseValue::Str),
20946 _ => self
20947 .expect_u32(&format!("{} value", clause.name))
20948 .map_or(ClauseValue::Missing, |(value, _)| {
20949 ClauseValue::Number(value)
20950 }),
20951 },
20952 ClauseKind::Glob if clause.list => {
20953 let globs = self
20957 .parse_string_list()
20958 .map(|(literals, _)| literals.into_iter().map(|l| l.value).collect())
20959 .unwrap_or_default();
20960 ClauseValue::Globs(globs)
20961 }
20962 ClauseKind::Glob => self
20963 .expect_string(&format!("{} value", clause.name))
20964 .map_or(ClauseValue::Missing, ClauseValue::Str),
20965 ClauseKind::Flag => ClauseValue::Flag,
20966 ClauseKind::Expression => ClauseValue::Missing,
20968 }
20969 }
20970
20971 fn item_from_decl_ast(
20977 &mut self,
20978 ast_kind: DeclAstKind,
20979 bag: &ClauseBag,
20980 name: Ident,
20981 span: SourceSpan,
20982 ) -> Option<Item> {
20983 match ast_kind {
20984 DeclAstKind::Tracker => {
20985 let provider = bag.ident("provider").unwrap_or_else(|| Ident {
20988 name: "builtin".to_owned(),
20989 span,
20990 });
20991 Some(Item::Tracker(TrackerDecl {
20992 name,
20993 provider,
20994 span,
20995 }))
20996 }
20997 DeclAstKind::Channel => {
20998 let provider = bag.ident("provider").unwrap_or_else(|| Ident {
21000 name: "local".to_owned(),
21001 span,
21002 });
21003 Some(Item::Channel(ChannelDecl {
21004 name,
21005 provider,
21006 workspace: bag.ident("workspace"),
21007 destination: bag.text_literal("destination"),
21008 span,
21009 }))
21010 }
21011 DeclAstKind::Counter => {
21012 let key_type = bag.ident("key");
21013 let cap = bag.number("cap").map(i64::from);
21014 let reset = bag.ident("reset").map(|period| {
21017 if !matches!(
21018 period.name.as_str(),
21019 "hourly" | "daily" | "weekly" | "monthly"
21020 ) {
21021 self.diagnostics.push(Diagnostic {
21022 related: Vec::new(),
21023 span: period.span,
21024 message: format!("unknown reset period `{}`", period.name),
21025 suggestion: Some(
21026 "use `hourly`, `daily`, `weekly`, or `monthly`".to_owned(),
21027 ),
21028 });
21029 }
21030 period.name
21031 });
21032 let shared = bag.flag("shared");
21033 let (Some(key_type), Some(cap), Some(reset)) = (key_type, cap, reset) else {
21034 self.diagnostics.push(Diagnostic {
21035 related: Vec::new(),
21036 span,
21037 message: format!(
21038 "counter `{}` must declare `key`, `cap`, and `reset`",
21039 name.name
21040 ),
21041 suggestion: Some(
21042 "every counter is bounded: declare all three fields".to_owned(),
21043 ),
21044 });
21045 return None;
21046 };
21047 Some(Item::Counter(CounterDecl {
21048 name,
21049 key_type,
21050 cap,
21051 reset,
21052 timezone: bag.text("timezone"),
21053 shared,
21054 span,
21055 }))
21056 }
21057 DeclAstKind::Lease => {
21058 let key_type = bag.ident("key");
21059 let slots = bag.number("slots").unwrap_or(1);
21060 let ttl_seconds = bag.duration("ttl");
21061 let shared = bag.flag("shared");
21062 let (Some(key_type), Some(ttl_seconds)) = (key_type, ttl_seconds) else {
21063 self.diagnostics.push(Diagnostic {
21064 related: Vec::new(),
21065 span,
21066 message: format!(
21067 "lease `{}` must declare a `key` type and a `ttl` backstop",
21068 name.name
21069 ),
21070 suggestion: Some(
21071 "every lease is bounded: declare `key <Type>` and `ttl <duration>`"
21072 .to_owned(),
21073 ),
21074 });
21075 return None;
21076 };
21077 Some(Item::Lease(LeaseDecl {
21078 name,
21079 key_type,
21080 slots,
21081 ttl_seconds,
21082 shared,
21083 span,
21084 }))
21085 }
21086 DeclAstKind::Ledger => {
21087 let entry_schema = bag.ident("entry");
21088 let partition_field = bag.ident("partition");
21089 let retain_seconds = bag.duration("retain");
21090 let shared = bag.flag("shared");
21091 let (Some(entry_schema), Some(partition_field), Some(retain_seconds)) =
21092 (entry_schema, partition_field, retain_seconds)
21093 else {
21094 self.diagnostics.push(Diagnostic {
21095 related: Vec::new(),
21096 span,
21097 message: format!(
21098 "ledger `{}` must declare `entry`, `partition by`, and `retain`",
21099 name.name
21100 ),
21101 suggestion: Some(
21102 "every ledger is bounded and partitioned: declare all three fields"
21103 .to_owned(),
21104 ),
21105 });
21106 return None;
21107 };
21108 Some(Item::Ledger(LedgerDecl {
21109 name,
21110 entry_schema,
21111 partition_field,
21112 retain_seconds,
21113 shared,
21114 span,
21115 }))
21116 }
21117 DeclAstKind::FileStore => {
21118 let root_span = bag.span("root");
21119 let read_span = bag.span("allow read");
21120 let write_span = bag.span("allow write");
21121 let provider_span = bag.span("provider");
21122 let read_globs = bag.globs("allow read");
21123 let write_globs = bag.globs("allow write");
21124 let provider = bag.ident("provider");
21125 let Some(root) = bag.text("root") else {
21126 self.diagnostics.push(Diagnostic {
21127 related: Vec::new(),
21128 span,
21129 message: format!("file store `{}` is missing a root", name.name),
21130 suggestion: Some(
21131 "add `root \"<dir>\"` inside the file store block".to_owned(),
21132 ),
21133 });
21134 return None;
21135 };
21136 Some(Item::FileStore(FileStoreDecl {
21137 name,
21138 root,
21139 read_globs,
21140 write_globs,
21141 provider,
21142 root_span,
21143 read_span,
21144 write_span,
21145 provider_span,
21146 span,
21147 }))
21148 }
21149 DeclAstKind::MemoryPool => {
21150 let context_limit = bag.number("context limit").map(u64::from);
21151 let context_limit_span = context_limit.and(bag.span("context limit"));
21154 Some(Item::MemoryPool(MemoryPoolDecl {
21155 name,
21156 context_limit,
21157 context_limit_span,
21158 span,
21159 }))
21160 }
21161 }
21162 }
21163
21164 fn parse_pattern(&mut self) -> Option<PatternDecl> {
21165 let start = self.expect_keyword("pattern")?.span.start;
21166 let name = self.expect_ident("pattern name")?;
21167 let type_params = self.parse_type_param_list().unwrap_or_default();
21168 let open = self.expect_symbol('{')?;
21169 let mut items = Vec::new();
21170 let mut pending_tags = Vec::new();
21171 let mut pending_description = None;
21172 while !self.is_at_end() && !self.at_symbol('}') {
21173 if self.at_symbol('@') {
21174 if let Some(tag) = self.parse_tag() {
21175 pending_tags.push(tag);
21176 }
21177 continue;
21178 }
21179 if self.at_ident("description") {
21180 self.parse_pending_description(&mut pending_description);
21181 continue;
21182 }
21183 if self.at_ident("workflow") || self.at_ident("pattern") {
21184 self.reject_pending_tags(&mut pending_tags, "pattern body declaration");
21185 self.reject_pending_description(
21186 &mut pending_description,
21187 "pattern body declaration",
21188 );
21189 self.unexpected("pattern body declaration");
21190 self.advance();
21191 continue;
21192 }
21193 if let Some(item) =
21194 self.parse_declaration_item(&mut pending_tags, &mut pending_description)
21195 {
21196 items.push(item);
21197 } else if self.reject_gherkin_misuse() {
21198 continue;
21199 } else {
21200 if self.is_at_end() {
21201 break;
21202 }
21203 self.reject_pending_tags(&mut pending_tags, "pattern body declaration");
21204 self.reject_pending_description(
21205 &mut pending_description,
21206 "pattern body declaration",
21207 );
21208 self.unexpected("pattern body declaration");
21209 self.advance();
21210 }
21211 }
21212 let end = self
21213 .expect_symbol('}')
21214 .map(|token| token.span.end)
21215 .unwrap_or(open.span.end);
21216 Some(PatternDecl {
21217 name,
21218 type_params,
21219 items,
21220 span: SourceSpan { start, end },
21221 })
21222 }
21223
21224 fn parse_type_param_list(&mut self) -> Option<Vec<Ident>> {
21225 if !self.at_symbol('<') {
21226 return Some(Vec::new());
21227 }
21228 self.expect_symbol('<')?;
21229 let mut params = Vec::new();
21230 while !self.is_at_end() && !self.at_symbol('>') {
21231 params.push(self.expect_ident("type parameter")?);
21232 if self.at_symbol(',') {
21233 self.advance();
21234 } else if !self.at_symbol('>') {
21235 self.unexpected("`,` or `>`");
21236 while !self.is_at_end() && !self.at_symbol('>') && !self.at_symbol(',') {
21237 self.advance();
21238 }
21239 }
21240 }
21241 self.expect_symbol('>')?;
21242 Some(params)
21243 }
21244
21245 fn parse_type_arg_list(&mut self) -> Option<Vec<TypeSyntax>> {
21246 if !self.at_symbol('<') {
21247 return Some(Vec::new());
21248 }
21249 self.expect_symbol('<')?;
21250 let mut args = Vec::new();
21251 while !self.is_at_end() && !self.at_symbol('>') {
21252 args.push(self.parse_type()?);
21253 if self.at_symbol(',') {
21254 self.advance();
21255 } else if !self.at_symbol('>') {
21256 self.unexpected("`,` or `>`");
21257 while !self.is_at_end() && !self.at_symbol('>') && !self.at_symbol(',') {
21258 self.advance();
21259 }
21260 }
21261 }
21262 self.expect_symbol('>')?;
21263 Some(args)
21264 }
21265
21266 fn parse_apply(&mut self) -> Option<ApplyDecl> {
21267 let start = self.expect_keyword("apply")?.span.start;
21268 let pattern = self.expect_ident("pattern name")?;
21269 let type_args = self.parse_type_arg_list().unwrap_or_default();
21270 self.expect_keyword("as")?;
21271 let alias = self.expect_ident("pattern application alias")?;
21272 let body = self.parse_block_source()?;
21273 let span = SourceSpan {
21274 start,
21275 end: body.span.end,
21276 };
21277 Some(ApplyDecl {
21278 pattern,
21279 type_args,
21280 alias,
21281 body,
21282 span,
21283 })
21284 }
21285
21286 fn parse_include(&mut self) -> Option<IncludeDecl> {
21287 self.expect_keyword("include")?;
21288 Some(IncludeDecl {
21289 path: self.expect_string("include path")?,
21290 })
21291 }
21292
21293 fn parse_workflow_contract(&mut self) -> Option<WorkflowContractDecl> {
21294 let keyword = self.advance().clone();
21295 let kind = match &keyword.kind {
21296 TokenKind::Ident(value) if value == "input" => WorkflowContractKind::Input,
21297 TokenKind::Ident(value) if value == "output" => WorkflowContractKind::Output,
21298 TokenKind::Ident(value) if value == "failure" => WorkflowContractKind::Failure,
21299 _ => return None,
21300 };
21301 let name = self.expect_ident("workflow contract name")?;
21302 if self.at_symbol('{') {
21308 self.advance();
21309 let mut fields = Vec::new();
21310 while !self.is_at_end() && !self.at_symbol('}') {
21311 let Some(field_name) = self.expect_ident("contract payload field name") else {
21312 self.synchronize_to_block_item();
21313 continue;
21314 };
21315 let Some(field_ty) = self.parse_type() else {
21316 self.synchronize_to_block_item();
21317 continue;
21318 };
21319 let field_span = field_name.span.join(field_ty.span());
21320 fields.push(ClassField {
21321 name: field_name,
21322 ty: field_ty,
21323 is_key: false,
21324 presence_condition: None,
21325 span: field_span,
21326 });
21327 }
21328 let close_span = self.peek().map(|token| token.span);
21329 self.expect_symbol('}')?;
21330 let contract_keyword = match kind {
21331 WorkflowContractKind::Input => "input",
21332 WorkflowContractKind::Output => "output",
21333 WorkflowContractKind::Failure => "failure",
21334 };
21335 let class_name = format!("{contract_keyword}.{}", name.name);
21336 let end = close_span.map(|span| span.end).unwrap_or(name.span.end);
21337 let span = SourceSpan {
21338 start: keyword.span.start,
21339 end,
21340 };
21341 self.pending_contract_classes.push(ClassDecl {
21342 name: Ident {
21343 name: class_name.clone(),
21344 span,
21345 },
21346 fields,
21347 span,
21348 });
21349 return Some(WorkflowContractDecl {
21350 kind,
21351 name,
21352 ty: TypeSyntax::Ref {
21353 name: Ident {
21354 name: class_name,
21355 span,
21356 },
21357 },
21358 span,
21359 });
21360 }
21361 let ty = self.parse_type()?;
21362 let span = keyword.span.join(ty.span());
21363 Some(WorkflowContractDecl {
21364 kind,
21365 name,
21366 ty,
21367 span,
21368 })
21369 }
21370
21371 fn parse_use(&mut self) -> Option<UseDecl> {
21372 self.expect_keyword("use")?;
21373 if self.at_ident("plugin") || self.at_ident("skill") {
21374 let removed_kind = self.advance().clone();
21375 let removed_label = match &removed_kind.kind {
21376 TokenKind::Ident(value) => value.as_str(),
21377 _ => "",
21378 };
21379 self.diagnostics.push(Diagnostic { related: Vec::new(),
21380 span: removed_kind.span,
21381 message: format!("`use {removed_label}` is no longer supported"),
21382 suggestion: Some(
21383 "write `use std.memory` for package libraries; attach skills with `agent { skills [...] }`"
21384 .to_owned(),
21385 ),
21386 });
21387 }
21388 Some(UseDecl {
21389 name: self.expect_use_name("package library name")?,
21390 })
21391 }
21392
21393 fn parse_decl_duration_seconds(&mut self, label: &str) -> Option<u64> {
21396 let (value, span) = self.expect_u32(label)?;
21397 let unit = self.expect_ident(label)?;
21398 match body::parse_short_duration_seconds(&format!("{value}{}", unit.name)) {
21399 Some(seconds) if seconds > 0 => Some(seconds),
21400 _ => {
21401 self.diagnostics.push(Diagnostic {
21402 related: Vec::new(),
21403 span: span.join(unit.span),
21404 message: format!("invalid duration `{value}{}`", unit.name),
21405 suggestion: Some("use `<n><unit>` with unit s, m, h, or d".to_owned()),
21406 });
21407 None
21408 }
21409 }
21410 }
21411
21412 fn parse_harness(&mut self) -> Option<HarnessDecl> {
21413 let start = self.expect_keyword("harness")?.span.start;
21414 let name = self.expect_ident("harness name")?;
21415 self.expect_symbol(':')?;
21416 let kind = self.expect_ident("harness kind")?;
21417 let span = SourceSpan {
21418 start,
21419 end: kind.span.end,
21420 };
21421 Some(HarnessDecl { name, kind, span })
21422 }
21423
21424 fn parse_agent(&mut self) -> Option<AgentDecl> {
21425 let start = self.expect_keyword("agent")?.span.start;
21426 let name = self.expect_ident("agent name")?;
21427 let harness = if self.at_ident("using") {
21428 self.advance();
21429 Some(self.expect_ident("harness name")?)
21430 } else {
21431 None
21432 };
21433 let delegated_to = if harness.is_none() && self.at_ident("delegated") {
21434 self.advance();
21435 self.expect_keyword("to")?;
21436 Some(self.expect_ident("delegate provider")?)
21437 } else {
21438 None
21439 };
21440 if !self.at_symbol('{') {
21443 let end = delegated_to
21444 .as_ref()
21445 .map(|ident| ident.span.end)
21446 .or_else(|| harness.as_ref().map(|ident| ident.span.end))
21447 .unwrap_or(name.span.end);
21448 return Some(AgentDecl {
21449 name,
21450 harness,
21451 delegated_to,
21452 fields: Vec::new(),
21453 span: SourceSpan { start, end },
21454 });
21455 }
21456 let open = self.expect_symbol('{')?;
21457 let mut fields = Vec::new();
21458
21459 while !self.is_at_end() && !self.at_symbol('}') {
21460 let Some(field_name) = self.expect_ident("agent field") else {
21461 self.synchronize_to_block_item();
21462 continue;
21463 };
21464
21465 match field_name.name.as_str() {
21466 "provider" => {
21467 if let Some(provider) = self.expect_ident("provider name") {
21468 fields.push(AgentField::Provider(provider));
21469 } else {
21470 self.synchronize_to_block_item();
21471 }
21472 }
21473 "profile" => {
21474 if let Some(value) = self.expect_string("profile string") {
21475 fields.push(AgentField::Profile(value));
21476 } else {
21477 self.synchronize_to_block_item();
21478 }
21479 }
21480 "capacity" => {
21481 if let Some((value, span)) = self.expect_u32("capacity value") {
21482 fields.push(AgentField::Capacity(value, span));
21483 } else {
21484 self.synchronize_to_block_item();
21485 }
21486 }
21487 "skills" => {
21488 if let Some((skills, span)) = self.parse_string_list() {
21489 fields.push(AgentField::Skills(skills, span));
21490 } else {
21491 self.synchronize_to_block_item();
21492 }
21493 }
21494 "capabilities" => {
21495 if let Some((capabilities, span)) = self.parse_string_list() {
21496 fields.push(AgentField::Capabilities(capabilities, span));
21497 } else {
21498 self.synchronize_to_block_item();
21499 }
21500 }
21501 "requires" => {
21502 if let Some((classes, span)) = self.parse_feature_class_list() {
21503 fields.push(AgentField::Requires(classes, span));
21504 } else {
21505 self.synchronize_to_block_item();
21506 }
21507 }
21508 "tools" => {
21509 if let Some((tools, span)) = self.parse_ident_list() {
21510 fields.push(AgentField::Tools(tools, span));
21511 } else {
21512 self.synchronize_to_block_item();
21513 }
21514 }
21515 "compaction" => {
21516 if let Some(strategy) = self.expect_ident("compaction strategy") {
21517 fields.push(AgentField::Compaction(strategy));
21518 } else {
21519 self.synchronize_to_block_item();
21520 }
21521 }
21522 "thread" => {
21523 if let Some(mode) = self.expect_ident("thread mode") {
21524 fields.push(AgentField::Thread(mode));
21525 } else {
21526 self.synchronize_to_block_item();
21527 }
21528 }
21529 "settings" => {
21530 if let Some(sources) = self.expect_ident("settings source") {
21531 fields.push(AgentField::Settings(sources));
21532 } else {
21533 self.synchronize_to_block_item();
21534 }
21535 }
21536 _ => {
21537 let span = field_name.span;
21538 fields.push(AgentField::Unknown {
21539 name: field_name,
21540 span,
21541 });
21542 self.synchronize_to_block_item();
21543 }
21544 }
21545 }
21546
21547 let end = self
21548 .expect_symbol('}')
21549 .map(|token| token.span.end)
21550 .unwrap_or(open.span.end);
21551
21552 Some(AgentDecl {
21553 name,
21554 harness,
21555 delegated_to,
21556 fields,
21557 span: SourceSpan { start, end },
21558 })
21559 }
21560
21561 fn parse_enum(&mut self) -> Option<EnumDecl> {
21562 let start = self.expect_keyword("enum")?.span.start;
21563 let name = self.expect_ident("enum name")?;
21564 let open = self.expect_symbol('{')?;
21565 let mut variants = Vec::new();
21566
21567 let mut previous_variant_end: Option<usize> = None;
21568 while !self.is_at_end() && !self.at_symbol('}') {
21569 let Some(variant) = self.expect_ident("enum variant") else {
21570 self.synchronize_to_block_item();
21571 continue;
21572 };
21573 if let Some(previous_end) = previous_variant_end {
21578 let line_of = |offset: usize| {
21579 self.source[..offset.min(self.source.len())]
21580 .bytes()
21581 .filter(|byte| *byte == b'\n')
21582 .count()
21583 };
21584 if line_of(previous_end) == line_of(variant.span.start) {
21585 self.diagnostics.push(Diagnostic {
21586 related: Vec::new(),
21587 span: variant.span,
21588 message: format!(
21589 "enum `{}` declares variant `{}` on the same line as the previous variant",
21590 name.name, variant.name
21591 ),
21592 suggestion: Some("write one enum variant per line".to_owned()),
21593 });
21594 }
21595 }
21596 let mut fields = Vec::new();
21599 let mut end = variant.span.end;
21600 if self.at_symbol('{') {
21601 self.expect_symbol('{');
21602 while !self.is_at_end() && !self.at_symbol('}') {
21603 let Some(field_name) = self.expect_ident("variant field name") else {
21604 self.synchronize_to_block_item();
21605 continue;
21606 };
21607 let Some(ty) = self.parse_type() else {
21608 self.synchronize_to_block_item();
21609 continue;
21610 };
21611 fields.push(ClassField {
21612 span: field_name.span.join(ty.span()),
21613 name: field_name,
21614 ty,
21615 is_key: false,
21616 presence_condition: None,
21617 });
21618 }
21619 if let Some(close) = self.expect_symbol('}') {
21620 end = close.span.end;
21621 }
21622 }
21623 let span = SourceSpan {
21624 start: variant.span.start,
21625 end,
21626 };
21627 previous_variant_end = Some(end);
21628 variants.push(EnumVariantDecl {
21629 name: variant,
21630 fields,
21631 span,
21632 });
21633 }
21634
21635 let end = self
21636 .expect_symbol('}')
21637 .map(|token| token.span.end)
21638 .unwrap_or(open.span.end);
21639
21640 Some(EnumDecl {
21641 name,
21642 variants,
21643 span: SourceSpan { start, end },
21644 })
21645 }
21646
21647 fn parse_event(&mut self) -> Option<EventDecl> {
21648 let start = self.expect_keyword("signal")?.span.start;
21649 let first = self.expect_ident("signal name")?;
21652 let mut name = first.name.clone();
21653 let mut name_span = first.span;
21654 while self.at_symbol('.') {
21655 self.expect_symbol('.');
21656 let segment = self.expect_ident("signal name segment")?;
21657 name.push('.');
21658 name.push_str(&segment.name);
21659 name_span = name_span.join(segment.span);
21660 }
21661 if !name.contains('.')
21662 || name
21663 .split('.')
21664 .any(|segment| segment.chars().next().is_some_and(char::is_uppercase))
21665 {
21666 self.diagnostics.push(Diagnostic {
21667 related: Vec::new(),
21668 span: name_span,
21669 message: format!("signal name `{name}` must be dotted lowercase"),
21670 suggestion: Some(
21671 "use a dotted lowercase name such as `deploy.finished`".to_owned(),
21672 ),
21673 });
21674 }
21675 let open = self.expect_symbol('{')?;
21676 let mut fields = Vec::new();
21677 while !self.is_at_end() && !self.at_symbol('}') {
21678 let Some(field_name) = self.expect_ident("signal field name") else {
21679 self.synchronize_to_block_item();
21680 continue;
21681 };
21682 let Some(ty) = self.parse_type() else {
21683 self.synchronize_to_block_item();
21684 continue;
21685 };
21686 let presence_condition = self.parse_field_presence_condition();
21687 fields.push(ClassField {
21688 span: field_name.span.join(ty.span()),
21689 name: field_name,
21690 ty,
21691 is_key: false,
21692 presence_condition,
21693 });
21694 }
21695 let end = self
21696 .expect_symbol('}')
21697 .map(|token| token.span.end)
21698 .unwrap_or(open.span.end);
21699 Some(EventDecl {
21700 name,
21701 name_span,
21702 fields,
21703 span: SourceSpan { start, end },
21704 })
21705 }
21706
21707 fn parse_dotted_name_spanned(&mut self, label: &str) -> Option<(String, SourceSpan)> {
21709 let first = self.expect_ident(label)?;
21710 let mut name = first.name.clone();
21711 let mut span = first.span;
21712 while self.at_symbol('.') {
21713 self.expect_symbol('.');
21714 let segment = self.expect_ident(label)?;
21715 name.push('.');
21716 name.push_str(&segment.name);
21717 span = span.join(segment.span);
21718 }
21719 Some((name, span))
21720 }
21721
21722 fn parse_gauge_ref(&mut self, label: &str) -> Option<GaugeRef> {
21723 let (name, span) = self.parse_dotted_name_spanned(label)?;
21724 Some(GaugeRef { name, span })
21725 }
21726
21727 fn parse_gauge_ref_list(&mut self, label: &str, into: &mut Vec<GaugeRef>) -> Option<()> {
21729 into.push(self.parse_gauge_ref(label)?);
21730 while self.at_symbol(',') {
21731 self.expect_symbol(',');
21732 into.push(self.parse_gauge_ref(label)?);
21733 }
21734 Some(())
21735 }
21736
21737 fn parse_decl_number_text(&mut self, label: &str) -> Option<(String, SourceSpan)> {
21741 let token = self.peek()?;
21742 let TokenKind::Number(whole) = token.kind.clone() else {
21743 self.expected(label);
21744 return None;
21745 };
21746 let mut span = token.span;
21747 let mut text = whole;
21748 self.advance();
21749 if self.at_symbol('.') {
21750 self.expect_symbol('.');
21751 let token = self.peek()?;
21752 let TokenKind::Number(fraction) = token.kind.clone() else {
21753 self.expected(format!("{label} fraction digits"));
21754 return None;
21755 };
21756 text.push('.');
21757 text.push_str(&fraction);
21758 span = span.join(token.span);
21759 self.advance();
21760 }
21761 Some((text, span))
21762 }
21763
21764 fn parse_bar_direction(&mut self, label: &str) -> Option<bool> {
21770 if self.consume_ident("at") {
21771 if self.consume_ident("least") {
21772 return Some(true);
21773 }
21774 if self.consume_ident("most") {
21775 return Some(false);
21776 }
21777 self.expected(format!("`least` or `most` after `at` in {label}"));
21778 return None;
21779 }
21780 let gap_start = self.last_span_end();
21781 let gap_end = self
21782 .peek()
21783 .map(|token| token.span.start)
21784 .unwrap_or(gap_start);
21785 let gap = &self.source[gap_start..gap_end.max(gap_start)];
21786 let suggestion = if gap.contains(">=") {
21787 Some("write `at least` (the declaration grammar uses words, not `>=`)".to_owned())
21788 } else if gap.contains("<=") {
21789 Some("write `at most` (the declaration grammar uses words, not `<=`)".to_owned())
21790 } else {
21791 Some("write `at least <n>` or `at most <n>`".to_owned())
21792 };
21793 self.diagnostics.push(Diagnostic {
21794 related: Vec::new(),
21795 span: SourceSpan {
21796 start: gap_start,
21797 end: gap_end.max(gap_start),
21798 },
21799 message: format!("expected `at least` or `at most` in {label}"),
21800 suggestion,
21801 });
21802 None
21803 }
21804
21805 fn parse_mark(&mut self) -> Option<MarkDecl> {
21807 let start = self.expect_keyword("mark")?.span.start;
21808 let name = self.expect_string("mark name")?;
21809 if !self.consume_ident("after") {
21810 self.expected("`after` and a committing site in the mark declaration");
21811 return None;
21812 }
21813 let (site, site_span) = self.parse_dotted_name_spanned("mark site")?;
21814 Some(MarkDecl {
21815 name,
21816 site,
21817 site_span,
21818 span: SourceSpan {
21819 start,
21820 end: site_span.end,
21821 },
21822 })
21823 }
21824
21825 fn parse_gauge(&mut self) -> Option<GaugeDecl> {
21828 let start = self.expect_keyword("gauge")?.span.start;
21829 let name = self.expect_ident("gauge name")?;
21830 let (site, site_span) = if self.consume_ident("on") {
21831 let (site, span) = self.parse_dotted_name_spanned("gauge site")?;
21832 (Some(site), Some(span))
21833 } else {
21834 (None, None)
21835 };
21836 let open = self.expect_symbol('{')?;
21837 let mut judge: Option<GaugeJudge> = None;
21838 let mut expect: Option<GaugeBar> = None;
21839 let mut inputs: Vec<GaugeRef> = Vec::new();
21840 while !self.is_at_end() && !self.at_symbol('}') {
21841 if self.at_ident("judge") {
21842 let keyword = self.advance().clone();
21843 if !self.consume_ident("via") {
21844 self.expected("`via` after `judge`");
21845 self.synchronize_to_block_item();
21846 continue;
21847 }
21848 let form = if self.consume_ident("coerce") {
21849 self.expect_ident("coerce judge name").and_then(|name| {
21850 let mut args = Vec::new();
21851 if self.at_symbol('(') {
21852 self.expect_symbol('(');
21853 loop {
21854 let (path, _) = self.parse_dotted_name_spanned("judge argument")?;
21855 args.push(path);
21856 if !self.at_symbol(',') {
21857 break;
21858 }
21859 self.expect_symbol(',')?;
21860 }
21861 self.expect_symbol(')')?;
21862 }
21863 Some(GaugeJudge::Coerce(name, args))
21864 })
21865 } else if self.consume_ident("prompt") {
21866 self.expect_string("prompt judge template")
21867 .map(GaugeJudge::Prompt)
21868 } else if self.consume_ident("exec") {
21869 self.expect_string("exec judge command")
21870 .map(GaugeJudge::Exec)
21871 } else if self.consume_ident("labels") {
21872 self.expect_string("labels source").map(GaugeJudge::Labels)
21873 } else {
21874 self.diagnostics.push(Diagnostic {
21875 related: Vec::new(),
21876 span: keyword.span,
21877 message: "unknown judge form".to_owned(),
21878 suggestion: Some(
21879 "judge forms are `coerce <Name>`, `prompt \"<template>\"`, \
21880 `exec \"<command>\"`, and `labels \"<source>\"`"
21881 .to_owned(),
21882 ),
21883 });
21884 None
21885 };
21886 let Some(form) = form else {
21887 self.synchronize_to_block_item();
21888 continue;
21889 };
21890 if judge.is_some() {
21891 self.diagnostics.push(Diagnostic {
21892 related: Vec::new(),
21893 span: keyword.span,
21894 message: "gauge declares more than one judge".to_owned(),
21895 suggestion: Some("a gauge has exactly one judge".to_owned()),
21896 });
21897 } else {
21898 judge = Some(form);
21899 }
21900 } else if self.at_ident("expect") {
21901 let keyword = self.advance().clone();
21902 let subject_ident = match self.expect_ident("bar subject") {
21903 Some(ident) => ident,
21904 None => {
21905 self.synchronize_to_block_item();
21906 continue;
21907 }
21908 };
21909 let subject = if subject_ident.name == "P" && self.at_symbol('(') {
21910 self.expect_symbol('(');
21911 let Some(field) = self.expect_ident("chance bar field") else {
21912 self.synchronize_to_block_item();
21913 continue;
21914 };
21915 if self.expect_symbol(')').is_none() {
21916 self.synchronize_to_block_item();
21917 continue;
21918 }
21919 GaugeBarSubject::Chance { field }
21920 } else {
21921 let stat = &subject_ident.name;
21922 let is_quantile = stat.len() > 1
21923 && stat.starts_with('p')
21924 && stat[1..].chars().all(|ch| ch.is_ascii_digit());
21925 if stat != "mean" && !is_quantile {
21926 self.diagnostics.push(Diagnostic {
21927 related: Vec::new(),
21928 span: subject_ident.span,
21929 message: format!("unknown bar statistic `{stat}`"),
21930 suggestion: Some(
21931 "bars are chance-shaped (`P(<field>)`) or stat-shaped \
21932 (`mean`, `p10`, `p90`, ...)"
21933 .to_owned(),
21934 ),
21935 });
21936 }
21937 GaugeBarSubject::Stat {
21938 stat: subject_ident,
21939 }
21940 };
21941 let Some(at_least) = self.parse_bar_direction("the gauge bar") else {
21942 self.synchronize_to_block_item();
21943 continue;
21944 };
21945 let Some((threshold, threshold_span)) =
21946 self.parse_decl_number_text("bar threshold")
21947 else {
21948 self.synchronize_to_block_item();
21949 continue;
21950 };
21951 if expect.is_some() {
21952 self.diagnostics.push(Diagnostic {
21953 related: Vec::new(),
21954 span: keyword.span,
21955 message: "gauge declares more than one bar".to_owned(),
21956 suggestion: Some("a gauge has at most one `expect` bar".to_owned()),
21957 });
21958 } else {
21959 expect = Some(GaugeBar {
21960 subject,
21961 at_least,
21962 threshold,
21963 span: keyword.span.join(threshold_span),
21964 });
21965 }
21966 } else if self.at_ident("inputs") {
21967 self.advance();
21968 if self
21969 .parse_gauge_ref_list("input gauge name", &mut inputs)
21970 .is_none()
21971 {
21972 self.synchronize_to_block_item();
21973 }
21974 } else {
21975 let span = self.peek().map(|token| token.span).unwrap_or(open.span);
21976 self.diagnostics.push(Diagnostic {
21977 related: Vec::new(),
21978 span,
21979 message: "unknown gauge clause".to_owned(),
21980 suggestion: Some(
21981 "gauge clauses are `judge via`, `expect`, and `inputs`".to_owned(),
21982 ),
21983 });
21984 self.synchronize_to_block_item();
21985 }
21986 }
21987 let end = self
21988 .expect_symbol('}')
21989 .map(|token| token.span.end)
21990 .unwrap_or(open.span.end);
21991 let Some(judge) = judge else {
21992 self.diagnostics.push(Diagnostic {
21993 related: Vec::new(),
21994 span: name.span,
21995 message: format!("gauge `{}` declares no judge", name.name),
21996 suggestion: Some(
21997 "add `judge via coerce <Name>`, `judge via prompt \"<template>\"`, \
21998 `judge via exec \"<command>\"`, or `judge via labels \"<source>\"`"
21999 .to_owned(),
22000 ),
22001 });
22002 return None;
22003 };
22004 Some(GaugeDecl {
22005 name,
22006 site,
22007 site_span,
22008 judge,
22009 expect,
22010 inputs,
22011 span: SourceSpan { start, end },
22012 })
22013 }
22014
22015 fn parse_campaign(&mut self) -> Option<CampaignDecl> {
22017 let start = self.expect_keyword("campaign")?.span.start;
22018 let name = self.expect_ident("campaign name")?;
22019 let open = self.expect_symbol('{')?;
22020 let mut ascend: Vec<GaugeRef> = Vec::new();
22021 let mut reach: Vec<CampaignReach> = Vec::new();
22022 let mut guard: Vec<CampaignGuard> = Vec::new();
22023 let mut sacrifice: Vec<GaugeRef> = Vec::new();
22024 let mut proposer_redacted = false;
22025 while !self.is_at_end() && !self.at_symbol('}') {
22026 if self.at_ident("ascend") {
22027 self.advance();
22028 if self
22029 .parse_gauge_ref_list("ascend gauge name", &mut ascend)
22030 .is_none()
22031 {
22032 self.synchronize_to_block_item();
22033 }
22034 } else if self.at_ident("reach") {
22035 let keyword = self.advance().clone();
22036 let Some(gauge) = self.parse_gauge_ref("reach gauge name") else {
22037 self.synchronize_to_block_item();
22038 continue;
22039 };
22040 let Some(at_least) = self.parse_bar_direction("the reach target") else {
22041 self.synchronize_to_block_item();
22042 continue;
22043 };
22044 let Some((threshold, threshold_span)) =
22045 self.parse_decl_number_text("reach threshold")
22046 else {
22047 self.synchronize_to_block_item();
22048 continue;
22049 };
22050 let unit = if self
22053 .peek()
22054 .map(|token| {
22055 matches!(&token.kind, TokenKind::Ident(name)
22056 if matches!(name.as_str(), "ms" | "s" | "m" | "h" | "d"))
22057 })
22058 .unwrap_or(false)
22059 {
22060 self.expect_ident("unit").map(|ident| ident.name)
22061 } else {
22062 None
22063 };
22064 reach.push(CampaignReach {
22065 gauge,
22066 at_least,
22067 threshold,
22068 unit,
22069 span: keyword.span.join(threshold_span),
22070 });
22071 } else if self.at_ident("guard") {
22072 let keyword = self.advance().clone();
22073 let Some(gauge) = self.parse_gauge_ref("guard gauge name") else {
22074 self.synchronize_to_block_item();
22075 continue;
22076 };
22077 if !self.consume_ident("within") {
22078 self.expected("`within` after the guarded gauge");
22079 self.synchronize_to_block_item();
22080 continue;
22081 }
22082 let Some((band_percent, band_span)) = self.parse_decl_number_text("guard band")
22083 else {
22084 self.synchronize_to_block_item();
22085 continue;
22086 };
22087 if !self.consume_ident("percent") {
22088 self.expected("`percent` after the guard band");
22089 self.synchronize_to_block_item();
22090 continue;
22091 }
22092 guard.push(CampaignGuard {
22093 gauge,
22094 band_percent,
22095 span: keyword.span.join(band_span),
22096 });
22097 } else if self.at_ident("sacrifice") {
22098 self.advance();
22099 if self
22100 .parse_gauge_ref_list("sacrifice gauge name", &mut sacrifice)
22101 .is_none()
22102 {
22103 self.synchronize_to_block_item();
22104 }
22105 } else if self.at_ident("proposer") {
22106 self.advance();
22107 if self.consume_ident("redacted") {
22108 proposer_redacted = true;
22109 } else {
22110 self.expected("`redacted` after `proposer`");
22111 self.synchronize_to_block_item();
22112 }
22113 } else {
22114 let span = self.peek().map(|token| token.span).unwrap_or(open.span);
22115 self.diagnostics.push(Diagnostic {
22116 related: Vec::new(),
22117 span,
22118 message: "unknown campaign clause".to_owned(),
22119 suggestion: Some(
22120 "campaign clauses are `ascend`, `reach`, `guard`, `sacrifice`, \
22121 and `proposer redacted`"
22122 .to_owned(),
22123 ),
22124 });
22125 self.synchronize_to_block_item();
22126 }
22127 }
22128 let end = self
22129 .expect_symbol('}')
22130 .map(|token| token.span.end)
22131 .unwrap_or(open.span.end);
22132 if ascend.is_empty() && reach.is_empty() {
22133 self.diagnostics.push(Diagnostic {
22134 related: Vec::new(),
22135 span: name.span,
22136 message: format!("campaign `{}` names nothing to improve", name.name),
22137 suggestion: Some("add an `ascend` or `reach` clause".to_owned()),
22138 });
22139 }
22140 Some(CampaignDecl {
22141 name,
22142 ascend,
22143 reach,
22144 guard,
22145 sacrifice,
22146 proposer_redacted,
22147 span: SourceSpan { start, end },
22148 })
22149 }
22150
22151 fn last_span_end(&self) -> usize {
22152 self.pos
22153 .checked_sub(1)
22154 .and_then(|index| self.tokens.get(index))
22155 .map(|token| token.span.end)
22156 .unwrap_or(0)
22157 }
22158
22159 fn parse_dotted_name(&mut self, label: &str) -> Option<String> {
22160 let first = self.expect_ident(label)?;
22161 let mut name = first.name.clone();
22162 while self.at_symbol('.') {
22163 self.advance();
22164 let segment = self.expect_ident(label)?;
22165 name.push('.');
22166 name.push_str(&segment.name);
22167 }
22168 Some(name)
22169 }
22170
22171 fn capture_expr_to_line_end(&mut self) -> (String, SourceSpan) {
22174 let start = self
22175 .peek()
22176 .map(|token| token.span.start)
22177 .unwrap_or(self.source.len());
22178 let line_end = self.source[start..]
22179 .find('\n')
22180 .map(|offset| start + offset)
22181 .unwrap_or(self.source.len());
22182 let mut end = start;
22183 while !self.is_at_end() {
22184 let Some(token) = self.peek() else { break };
22185 if token.span.start >= line_end {
22186 break;
22187 }
22188 let token_end = token.span.end.min(line_end);
22189 self.advance();
22190 end = token_end;
22191 }
22192 let span = SourceSpan { start, end };
22193 trimmed_source_text(self.source_text(span), span)
22194 }
22195
22196 fn capture_expr_until_ident(&mut self, terminator: &str) -> (String, SourceSpan) {
22199 let start = self
22200 .peek()
22201 .map(|token| token.span.start)
22202 .unwrap_or(self.source.len());
22203 let mut end = start;
22204 while !self.is_at_end() && !self.at_ident(terminator) && !self.at_symbol('}') {
22205 let Some(token) = self.peek() else { break };
22206 let token_end = token.span.end;
22207 self.advance();
22208 end = token_end;
22209 }
22210 let span = SourceSpan { start, end };
22211 trimmed_source_text(self.source_text(span), span)
22212 }
22213
22214 fn parse_test(&mut self) -> Option<TestDecl> {
22215 let start = self.expect_keyword("test")?.span.start;
22216 let name = self.expect_string("test name")?;
22217 let open = self.expect_symbol('{')?;
22218 let mut workflow = None;
22219 let mut clauses = Vec::new();
22220 while !self.is_at_end() && !self.at_symbol('}') {
22221 if self.at_ident("workflow") {
22222 self.advance();
22223 match self.expect_ident("workflow name") {
22224 Some(name) => {
22225 if workflow.is_some() {
22226 self.diagnostics.push(Diagnostic {
22227 related: Vec::new(),
22228 span: name.span,
22229 message: "a test scenario binds at most one `workflow`".to_owned(),
22230 suggestion: Some(
22231 "remove the extra `workflow <Name>` header".to_owned(),
22232 ),
22233 });
22234 }
22235 workflow = Some(name);
22236 }
22237 None => self.synchronize_to_block_item(),
22238 }
22239 } else if self.at_ident("given") {
22240 match self.parse_given() {
22241 Some(clause) => clauses.push(TestClause::Given(clause)),
22242 None => self.synchronize_to_block_item(),
22243 }
22244 } else if self.at_ident("stub") {
22245 match self.parse_stub() {
22246 Some(clause) => clauses.push(TestClause::Stub(clause)),
22247 None => self.synchronize_to_block_item(),
22248 }
22249 } else if self.at_ident("run") {
22250 match self.parse_run() {
22251 Some(clause) => clauses.push(TestClause::Run(clause)),
22252 None => self.synchronize_to_block_item(),
22253 }
22254 } else if self.at_ident("expect") {
22255 match self.parse_expect() {
22256 Some(clause) => clauses.push(TestClause::Expect(clause)),
22257 None => self.synchronize_to_block_item(),
22258 }
22259 } else {
22260 self.unexpected("a test clause (`workflow`, `given`, `stub`, `run`, or `expect`)");
22261 self.synchronize_to_block_item();
22262 }
22263 }
22264 let end = self
22265 .expect_symbol('}')
22266 .map(|token| token.span.end)
22267 .unwrap_or(open.span.end);
22268 Some(TestDecl {
22269 name,
22270 workflow,
22271 clauses,
22272 span: SourceSpan { start, end },
22273 })
22274 }
22275
22276 fn parse_test_record(&mut self) -> Option<(Vec<TestField>, usize)> {
22277 let open = self.expect_symbol('{')?;
22278 let mut fields = Vec::new();
22279 while !self.is_at_end() && !self.at_symbol('}') {
22280 let Some(name) = self.expect_ident("test field name") else {
22281 self.synchronize_to_block_item();
22282 continue;
22283 };
22284 let (value, value_span) = self.capture_expr_to_line_end();
22285 fields.push(TestField {
22286 span: name.span.join(value_span),
22287 name,
22288 value,
22289 });
22290 }
22291 let end = self
22292 .expect_symbol('}')
22293 .map(|token| token.span.end)
22294 .unwrap_or(open.span.end);
22295 Some((fields, end))
22296 }
22297
22298 fn parse_given(&mut self) -> Option<GivenClause> {
22299 let start = self.expect_keyword("given")?.span.start;
22300 if self.consume_ident("input") {
22301 let (fields, end) = self.parse_test_record()?;
22302 Some(GivenClause::Input {
22303 fields,
22304 span: SourceSpan { start, end },
22305 })
22306 } else if self.consume_ident("fact") {
22307 let ty = self.expect_ident("fact type")?;
22308 let (fields, end) = self.parse_test_record()?;
22309 Some(GivenClause::Fact {
22310 ty,
22311 fields,
22312 span: SourceSpan { start, end },
22313 })
22314 } else if self.consume_ident("signal") {
22315 let name = self.parse_dotted_name("signal name")?;
22316 let (fields, end) = self.parse_test_record()?;
22317 Some(GivenClause::Signal {
22318 name,
22319 fields,
22320 span: SourceSpan { start, end },
22321 })
22322 } else if self.consume_ident("clock") {
22323 if !self.consume_ident("at") {
22324 self.expected("`at <timestamp>` after `given clock`");
22325 }
22326 let at = self.expect_string("clock timestamp")?;
22327 let end = at.span.end;
22328 Some(GivenClause::Clock {
22329 at,
22330 span: SourceSpan { start, end },
22331 })
22332 } else if self.consume_ident("tracker") {
22333 let tracker = self.parse_dotted_name("tracker name")?;
22334 if !self.consume_ident("issue") {
22335 self.expected("`issue { … }` after `given tracker <name>`");
22336 }
22337 let (fields, end) = self.parse_test_record()?;
22338 Some(GivenClause::Tracker {
22339 tracker,
22340 fields,
22341 span: SourceSpan { start, end },
22342 })
22343 } else if self.consume_ident("file") {
22344 let store = self.parse_dotted_name("file store name")?;
22345 if !self.consume_ident("at") {
22346 self.expected("`at <path> \"<content>\"` after `given file <store>`");
22347 }
22348 let path = self.expect_string("file path")?;
22349 let content = self.expect_string("file content")?;
22350 let end = content.span.end;
22351 Some(GivenClause::File {
22352 store,
22353 path,
22354 content,
22355 span: SourceSpan { start, end },
22356 })
22357 } else {
22358 self.unexpected(
22359 "`input`, `fact`, `signal`, `clock`, `tracker`, or `file` after `given`",
22360 );
22361 None
22362 }
22363 }
22364
22365 fn parse_stub(&mut self) -> Option<StubClause> {
22366 let start = self.expect_keyword("stub")?.span.start;
22367 let line_end = self.source[start..]
22372 .find('\n')
22373 .map(|offset| start + offset)
22374 .unwrap_or(self.source.len());
22375 let mut segments = Vec::new();
22376 while matches!(
22377 self.peek().map(|token| &token.kind),
22378 Some(TokenKind::Ident(_))
22379 ) && self.peek().is_some_and(|token| token.span.start < line_end)
22380 {
22381 match self.parse_dotted_name("stub surface") {
22382 Some(segment) => segments.push(segment),
22383 None => break,
22384 }
22385 }
22386 if segments.len() < 2 {
22387 self.diagnostics.push(Diagnostic {
22388 related: Vec::new(),
22389 span: SourceSpan {
22390 start,
22391 end: self.last_span_end(),
22392 },
22393 message: "stub needs a surface and an outcome (e.g. `stub agent triager succeeds`)"
22394 .to_owned(),
22395 suggestion: Some("write `stub <surface...> <outcome> [payload]`".to_owned()),
22396 });
22397 return None;
22398 }
22399 let outcome = segments.pop().expect("outcome present");
22400 let surface = segments;
22401 let payload = if self.at_symbol('{') {
22402 let (fields, _) = self.parse_test_record()?;
22403 Some(StubPayload::Record(fields))
22404 } else if matches!(
22405 self.peek().map(|token| &token.kind),
22406 Some(TokenKind::String(_))
22407 ) {
22408 Some(StubPayload::Message(self.expect_string("stub message")?))
22409 } else {
22410 None
22411 };
22412 let end = self.last_span_end();
22413 Some(StubClause {
22414 surface,
22415 outcome,
22416 payload,
22417 span: SourceSpan { start, end },
22418 })
22419 }
22420
22421 fn parse_run(&mut self) -> Option<RunClause> {
22422 let start = self.expect_keyword("run")?.span.start;
22423 let kind = if self.consume_ident("until") {
22424 if self.consume_ident("idle") {
22425 RunKind::UntilIdle
22426 } else if self.consume_ident("workflow") {
22427 if self.consume_ident("completed") {
22428 RunKind::UntilWorkflowCompleted
22429 } else if self.consume_ident("failed") {
22430 RunKind::UntilWorkflowFailed
22431 } else {
22432 self.expected("`completed` or `failed` after `workflow`");
22433 return None;
22434 }
22435 } else {
22436 self.expected("`idle` or `workflow completed|failed` after `until`");
22437 return None;
22438 }
22439 } else if self.consume_ident("for") {
22440 let (steps, _) = self.expect_u32("step count")?;
22441 if !self.consume_ident("steps") {
22442 self.expected("`steps` after the step count");
22443 }
22444 RunKind::ForSteps(steps)
22445 } else {
22446 self.expected("`until ...` or `for <N> steps` after `run`");
22447 return None;
22448 };
22449 let end = self.last_span_end();
22450 Some(RunClause {
22451 kind,
22452 span: SourceSpan { start, end },
22453 })
22454 }
22455
22456 fn parse_expect(&mut self) -> Option<ExpectClause> {
22457 let start = self.expect_keyword("expect")?.span.start;
22458 let target = if self.consume_ident("workflow") {
22459 if self.consume_ident("completed") {
22460 ExpectTarget::WorkflowCompleted
22461 } else if self.consume_ident("failed") {
22462 let failure = if self.consume_ident("with") {
22463 self.expect_ident("failure type")
22464 } else {
22465 None
22466 };
22467 ExpectTarget::WorkflowFailed { failure }
22468 } else {
22469 self.expected("`completed` or `failed` after `workflow`");
22470 return None;
22471 }
22472 } else if self.consume_ident("rule") {
22473 let name = self.expect_ident("rule name")?;
22474 let status = if self.consume_ident("fired") {
22475 if matches!(
22476 self.peek().map(|token| &token.kind),
22477 Some(TokenKind::Number(_))
22478 ) {
22479 let (count, _) = self.expect_u32("fired count")?;
22480 if !self.consume_ident("times") {
22481 self.expected("`times` after the fired count");
22482 }
22483 RuleStatus::FiredTimes(count)
22484 } else {
22485 RuleStatus::Fired
22486 }
22487 } else if self.consume_ident("did") {
22488 if !self.consume_ident("not") {
22489 self.expected("`not` in `did not fire`");
22490 }
22491 if !self.consume_ident("fire") {
22492 self.expected("`fire` in `did not fire`");
22493 }
22494 RuleStatus::DidNotFire
22495 } else {
22496 self.expected("`fired`, `fired <N> times`, or `did not fire`");
22497 return None;
22498 };
22499 ExpectTarget::Rule { name, status }
22500 } else if self.consume_ident("effect") {
22501 let name = self.parse_dotted_name("effect name")?;
22502 let status = if self.consume_ident("requested") {
22503 EffectStatus::Requested
22504 } else if self.consume_ident("completed") {
22505 EffectStatus::Completed
22506 } else if self.consume_ident("failed") {
22507 EffectStatus::Failed
22508 } else {
22509 self.expected("`requested`, `completed`, or `failed` after the effect name");
22510 return None;
22511 };
22512 ExpectTarget::Effect { name, status }
22513 } else if self.consume_ident("diagnostic") {
22514 let code = self.parse_dotted_name("diagnostic code")?;
22515 ExpectTarget::Diagnostic { code }
22516 } else if self.consume_ident("no") {
22517 let name = self.parse_dotted_name("forbidden effect name")?;
22518 ExpectTarget::NoEffect { name }
22519 } else {
22520 let noun = self.parse_dotted_name("projection noun")?;
22521 let kind = self.parse_proj_query_kind()?;
22522 let end = self.last_span_end();
22523 ExpectTarget::Projection(ProjQuery {
22524 noun,
22525 kind,
22526 span: SourceSpan { start, end },
22527 })
22528 };
22529 let end = self.last_span_end();
22530 Some(ExpectClause {
22531 target,
22532 span: SourceSpan { start, end },
22533 })
22534 }
22535
22536 fn parse_proj_query_kind(&mut self) -> Option<ProjQueryKind> {
22537 if self.consume_ident("exists") {
22538 return Some(ProjQueryKind::Exists);
22539 }
22540 if self.consume_ident("count") {
22541 if !self.consume_ident("where") {
22542 self.expected("`where <predicate> is <N>` after `count`");
22543 return None;
22544 }
22545 let (predicate, _) = self.capture_expr_until_ident("is");
22546 if !self.consume_ident("is") {
22547 self.expected("`is <N>` after the count predicate");
22548 return None;
22549 }
22550 let (count, _) = self.expect_u32("count value")?;
22551 return Some(ProjQueryKind::Count { predicate, count });
22552 }
22553 if self.consume_ident("where") {
22554 let (predicate, _) = self.capture_expr_to_line_end();
22555 return Some(ProjQueryKind::Where { predicate });
22556 }
22557 self.expected("`exists`, `count where ... is <N>`, or `where ...`");
22558 None
22559 }
22560
22561 fn parse_source(&mut self) -> Option<SourceDecl> {
22562 let start = self.expect_keyword("source")?.span.start;
22563 let provider = self.expect_ident("source provider")?;
22564 let is_clock = provider.name == "clock";
22565 if !self.consume_ident("as") {
22566 self.expected("`as <name>` after the source provider");
22567 return None;
22568 }
22569 let name = self.expect_ident("source name")?;
22570 let open = self.expect_symbol('{')?;
22571
22572 let mut recurrence: Option<Recurrence> = None;
22573 let mut timezone: Option<StringLiteral> = None;
22574 let mut missed: Option<MissedPolicy> = None;
22575 let mut path: Option<StringLiteral> = None;
22576 let mut watch: Option<StringLiteral> = None;
22577 let mut url: Option<StringLiteral> = None;
22578 let mut dedup: Option<SourceValue> = None;
22579 let mut observe_binding: Option<Ident> = None;
22580 let mut emit: Option<SourceEmit> = None;
22581
22582 while !self.is_at_end() && !self.at_symbol('}') {
22583 if self.at_ident("every") || self.at_ident("at") {
22584 if let Some(parsed) = self.parse_recurrence() {
22585 recurrence = Some(parsed);
22586 } else {
22587 self.synchronize_to_block_item();
22588 }
22589 } else if self.at_ident("timezone") {
22590 self.advance();
22591 timezone = self.expect_string("timezone string");
22592 } else if self.at_ident("path") {
22593 self.advance();
22594 path = self.expect_string("path string");
22595 } else if self.at_ident("watch") {
22596 self.advance();
22597 watch = self.expect_string("watch glob string");
22598 } else if self.at_ident("url") {
22599 self.advance();
22600 url = self.expect_string("url string");
22601 } else if self.at_ident("dedup") {
22602 self.advance();
22603 dedup = self.parse_source_value();
22604 } else if self.at_ident("missed") {
22605 missed = self.parse_missed_policy();
22606 } else if self.at_ident("observe") {
22607 self.advance();
22608 if !self.consume_ident("as") {
22609 self.expected("`as <binding>` after `observe`");
22610 }
22611 observe_binding = self.expect_ident("observe binding");
22612 } else if self.at_ident("emit") {
22613 emit = self.parse_source_emit();
22614 } else {
22615 self.unexpected(
22616 "a source clause (`every`/`at`, `timezone`, `path`, `watch`, `url`, `dedup`, `missed`, `observe`, `emit`)",
22617 );
22618 self.synchronize_to_block_item();
22619 }
22620 }
22621 let end = self
22622 .expect_symbol('}')
22623 .map(|token| token.span.end)
22624 .unwrap_or(open.span.end);
22625 let span = SourceSpan { start, end };
22626
22627 let observe_binding = match observe_binding {
22628 Some(binding) => binding,
22629 None => {
22630 self.diagnostics.push(Diagnostic {
22631 related: Vec::new(),
22632 span,
22633 message: format!("source `{}` must declare `observe as <binding>`", name.name),
22634 suggestion: Some("add `observe as tick`".to_owned()),
22635 });
22636 return None;
22637 }
22638 };
22639 let emit = match emit {
22640 Some(emit) => emit,
22641 None => {
22642 self.diagnostics.push(Diagnostic {
22643 related: Vec::new(),
22644 span,
22645 message: format!(
22646 "source `{}` must declare `emit <signal> {{ ... }}`",
22647 name.name
22648 ),
22649 suggestion: Some("add `emit triage.tick { ... }`".to_owned()),
22650 });
22651 return None;
22652 }
22653 };
22654
22655 let clock = if is_clock {
22656 let recurrence = match recurrence {
22657 Some(recurrence) => recurrence,
22658 None => {
22659 self.diagnostics.push(Diagnostic {
22660 related: Vec::new(),
22661 span,
22662 message: format!("clock source `{}` must declare a recurrence", name.name),
22663 suggestion: Some(
22664 "add `every weekday at 09:00`, `every 5m`, or `at 09:00`".to_owned(),
22665 ),
22666 });
22667 return None;
22668 }
22669 };
22670 Some(ClockPolicy {
22671 recurrence,
22672 timezone,
22673 missed,
22674 span,
22675 })
22676 } else {
22677 if recurrence.is_some() || timezone.is_some() || missed.is_some() {
22678 self.diagnostics.push(Diagnostic {
22679 related: Vec::new(),
22680 span,
22681 message: format!(
22682 "source `{}` uses clock-only clauses but its provider is `{}`, not `clock`",
22683 name.name, provider.name
22684 ),
22685 suggestion: Some(
22686 "use `source clock as ...` for recurrence, timezone, or missed clauses"
22687 .to_owned(),
22688 ),
22689 });
22690 }
22691 None
22692 };
22693
22694 Some(SourceDecl {
22695 name,
22696 provider,
22697 clock,
22698 path,
22699 watch,
22700 url,
22701 dedup,
22702 observe_binding,
22703 emit,
22704 span,
22705 })
22706 }
22707
22708 fn parse_recurrence(&mut self) -> Option<Recurrence> {
22709 if self.at_ident("at") {
22710 let at = self.expect_keyword("at")?;
22711 let time = self.parse_time_of_day()?;
22712 return Some(Recurrence::At {
22713 span: at.span.join(time.span),
22714 time,
22715 });
22716 }
22717 let every = self.expect_keyword("every")?;
22718 if matches!(
22719 self.peek().map(|token| &token.kind),
22720 Some(TokenKind::Number(_))
22721 ) {
22722 let (value, _) = self.expect_u32("recurrence interval")?;
22723 let unit = self.expect_ident("duration unit (`s`, `m`, `h`, or `d`)")?;
22724 let seconds = match unit.name.as_str() {
22725 "s" => value as u64,
22726 "m" => value as u64 * 60,
22727 "h" => value as u64 * 3_600,
22728 "d" => value as u64 * 86_400,
22729 other => {
22730 self.diagnostics.push(Diagnostic {
22731 related: Vec::new(),
22732 span: unit.span,
22733 message: format!("unknown duration unit `{other}`"),
22734 suggestion: Some("use `s`, `m`, `h`, or `d`".to_owned()),
22735 });
22736 return None;
22737 }
22738 };
22739 return Some(Recurrence::EveryDuration {
22740 seconds,
22741 source: format!("{value}{}", unit.name),
22742 span: every.span.join(unit.span),
22743 });
22744 }
22745 let pattern_ident =
22746 self.expect_ident("calendar pattern (`day`, `weekday`, or a weekday)")?;
22747 let pattern = match pattern_ident.name.as_str() {
22748 "day" => CalendarPattern::Day,
22749 "weekday" => CalendarPattern::Weekday,
22750 "monday" => CalendarPattern::Weekly(Weekday::Monday),
22751 "tuesday" => CalendarPattern::Weekly(Weekday::Tuesday),
22752 "wednesday" => CalendarPattern::Weekly(Weekday::Wednesday),
22753 "thursday" => CalendarPattern::Weekly(Weekday::Thursday),
22754 "friday" => CalendarPattern::Weekly(Weekday::Friday),
22755 "saturday" => CalendarPattern::Weekly(Weekday::Saturday),
22756 "sunday" => CalendarPattern::Weekly(Weekday::Sunday),
22757 other => {
22758 self.diagnostics.push(Diagnostic {
22759 related: Vec::new(),
22760 span: pattern_ident.span,
22761 message: format!("unknown calendar pattern `{other}`"),
22762 suggestion: Some(
22763 "use `day`, `weekday`, or a weekday such as `monday`".to_owned(),
22764 ),
22765 });
22766 return None;
22767 }
22768 };
22769 if !self.consume_ident("at") {
22770 self.expected("`at <hh:mm>` after the calendar pattern");
22771 return None;
22772 }
22773 let time = self.parse_time_of_day()?;
22774 Some(Recurrence::EveryCalendar {
22775 pattern,
22776 span: every.span.join(time.span),
22777 time,
22778 })
22779 }
22780
22781 fn parse_time_of_day(&mut self) -> Option<TimeOfDay> {
22782 let (hour, hour_span) = self.expect_u32("hour")?;
22783 self.expect_symbol(':')?;
22784 let (minute, minute_span) = self.expect_u32("minute")?;
22785 if hour > 23 || minute > 59 {
22786 self.diagnostics.push(Diagnostic {
22787 related: Vec::new(),
22788 span: hour_span.join(minute_span),
22789 message: format!("invalid time of day `{hour:02}:{minute:02}`"),
22790 suggestion: Some("use a 24-hour `hh:mm` such as `09:00`".to_owned()),
22791 });
22792 return None;
22793 }
22794 Some(TimeOfDay {
22795 hour: hour as u8,
22796 minute: minute as u8,
22797 span: hour_span.join(minute_span),
22798 })
22799 }
22800
22801 fn parse_missed_policy(&mut self) -> Option<MissedPolicy> {
22802 self.expect_keyword("missed")?;
22803 if self.consume_ident("skip") {
22804 return Some(MissedPolicy::Skip);
22805 }
22806 if self.consume_ident("coalesce") {
22807 return Some(MissedPolicy::Coalesce);
22808 }
22809 if self.consume_ident("catch_up") {
22810 if !self.consume_ident("limit") {
22811 self.expected("`limit <N>` after `catch_up`");
22812 return None;
22813 }
22814 let (limit, _) = self.expect_u32("catch_up limit")?;
22815 return Some(MissedPolicy::CatchUp { limit });
22816 }
22817 self.expected("`skip`, `coalesce`, or `catch_up limit <N>`");
22818 None
22819 }
22820
22821 fn parse_source_emit(&mut self) -> Option<SourceEmit> {
22822 let emit = self.expect_keyword("emit")?;
22823 let first = self.expect_ident("emit signal name")?;
22824 let mut signal = first.name.clone();
22825 let mut signal_span = first.span;
22826 while self.at_symbol('.') {
22827 self.advance();
22828 let segment = self.expect_ident("signal name segment")?;
22829 signal.push('.');
22830 signal.push_str(&segment.name);
22831 signal_span = signal_span.join(segment.span);
22832 }
22833 let from = if self.consume_ident("from") {
22834 Some(self.expect_ident("binding name after `from`")?)
22835 } else {
22836 None
22837 };
22838 if from.is_some() && !self.at_symbol('{') {
22839 let end = from.as_ref().map(|ident| ident.span.end).unwrap_or(0);
22840 return Some(SourceEmit {
22841 signal,
22842 signal_span,
22843 from,
22844 fields: Vec::new(),
22845 span: SourceSpan {
22846 start: emit.span.start,
22847 end,
22848 },
22849 });
22850 }
22851 let open = self.expect_symbol('{')?;
22852 let mut fields = Vec::new();
22853 while !self.is_at_end() && !self.at_symbol('}') {
22854 let Some(field_name) = self.expect_ident("emit field name") else {
22855 self.synchronize_to_block_item();
22856 continue;
22857 };
22858 let Some(value) = self.parse_source_value() else {
22859 self.synchronize_to_block_item();
22860 continue;
22861 };
22862 let value_span = match &value {
22863 SourceValue::Path { span, .. } => *span,
22864 SourceValue::String(literal) => literal.span,
22865 SourceValue::Number(_, span) => *span,
22866 };
22867 fields.push(SourceEmitField {
22868 span: field_name.span.join(value_span),
22869 name: field_name,
22870 value,
22871 });
22872 }
22873 let end = self
22874 .expect_symbol('}')
22875 .map(|token| token.span.end)
22876 .unwrap_or(open.span.end);
22877 Some(SourceEmit {
22878 signal,
22879 signal_span,
22880 from,
22881 fields,
22882 span: SourceSpan {
22883 start: emit.span.start,
22884 end,
22885 },
22886 })
22887 }
22888
22889 fn parse_source_value(&mut self) -> Option<SourceValue> {
22890 match self.peek().map(|token| &token.kind) {
22891 Some(TokenKind::String(_)) => self.expect_string("value").map(SourceValue::String),
22892 Some(TokenKind::Number(_)) => {
22893 let token = self.advance().clone();
22894 if let TokenKind::Number(value) = token.kind {
22895 Some(SourceValue::Number(value, token.span))
22896 } else {
22897 None
22898 }
22899 }
22900 Some(TokenKind::Ident(_)) => {
22901 let binding = self.expect_ident("value path")?;
22902 let mut segments = Vec::new();
22903 let mut span = binding.span;
22904 while self.at_symbol('.') {
22905 self.advance();
22906 let segment = self.expect_ident("path segment")?;
22907 span = span.join(segment.span);
22908 segments.push(segment);
22909 }
22910 Some(SourceValue::Path {
22911 binding,
22912 segments,
22913 span,
22914 })
22915 }
22916 _ => {
22917 self.expected("a value (observation path, string, or number)");
22918 None
22919 }
22920 }
22921 }
22922
22923 fn parse_class(&mut self) -> Option<ClassDecl> {
22924 let start = self.expect_keyword("class")?.span.start;
22925 let name = self.expect_ident("class name")?;
22926 let open = self.expect_symbol('{')?;
22927 let mut fields = Vec::new();
22928
22929 while !self.is_at_end() && !self.at_symbol('}') {
22930 let Some(field_name) = self.expect_ident("class field name") else {
22931 self.synchronize_to_block_item();
22932 continue;
22933 };
22934 let Some(ty) = self.parse_type() else {
22935 self.synchronize_to_block_item();
22936 continue;
22937 };
22938 let mut is_key = false;
22941 if self.at_symbol('@') {
22942 if let Some(tag) = self.parse_tag() {
22943 if tag.name == "key" {
22944 is_key = true;
22945 } else {
22946 self.diagnostics.push(Diagnostic {
22947 related: Vec::new(),
22948 span: tag.span,
22949 message: format!("unknown field tag `@{}`", tag.name),
22950 suggestion: Some(
22951 "the only field tag is `@key` (the class natural key)".to_owned(),
22952 ),
22953 });
22954 }
22955 }
22956 }
22957 let presence_condition = self.parse_field_presence_condition();
22958 let span = field_name.span.join(ty.span());
22959 fields.push(ClassField {
22960 span,
22961 name: field_name,
22962 ty,
22963 is_key,
22964 presence_condition,
22965 });
22966 }
22967
22968 let end = self
22969 .expect_symbol('}')
22970 .map(|token| token.span.end)
22971 .unwrap_or(open.span.end);
22972
22973 Some(ClassDecl {
22974 name,
22975 fields,
22976 span: SourceSpan { start, end },
22977 })
22978 }
22979
22980 fn parse_table(
22981 &mut self,
22982 tags: Vec<TagDecl>,
22983 description: Option<StringLiteral>,
22984 ) -> Option<TableDecl> {
22985 let start = self.expect_keyword("table")?.span.start;
22986 let name = self.expect_ident("table name")?;
22987 self.expect_keyword("as")?;
22988 let schema = self.expect_ident("table row class")?;
22989 let open = self.expect_symbol('[')?;
22990 let mut rows = Vec::new();
22991
22992 while !self.is_at_end() && !self.at_symbol(']') {
22993 if self.at_symbol(',') {
22994 self.advance();
22995 continue;
22996 }
22997 if !self.at_symbol('{') {
22998 self.unexpected("table row `{ ... }`");
22999 self.synchronize_to_table_row();
23000 continue;
23001 }
23002 if let Some(row) = self.parse_table_row() {
23003 rows.push(row);
23004 }
23005 if self.at_symbol(',') {
23006 self.advance();
23007 }
23008 }
23009
23010 let end = self
23011 .expect_symbol(']')
23012 .map(|token| token.span.end)
23013 .unwrap_or(open.span.end);
23014 Some(TableDecl {
23015 name,
23016 tags,
23017 description,
23018 schema,
23019 rows,
23020 span: SourceSpan { start, end },
23021 })
23022 }
23023
23024 fn parse_table_row(&mut self) -> Option<TableRow> {
23025 let open = self.expect_symbol('{')?;
23026 let body_start = open.span.end;
23027 let mut depth = 1usize;
23028 let mut body_end = body_start;
23029 let mut close_end = open.span.end;
23030
23031 while !self.is_at_end() {
23032 let token = self.advance().clone();
23033 match token.kind {
23034 TokenKind::Symbol('{') => {
23035 depth += 1;
23036 body_end = token.span.end;
23037 }
23038 TokenKind::Symbol('}') => {
23039 depth -= 1;
23040 if depth == 0 {
23041 body_end = token.span.start;
23042 close_end = token.span.end;
23043 break;
23044 }
23045 body_end = token.span.end;
23046 }
23047 _ => body_end = token.span.end,
23048 }
23049 }
23050
23051 if depth != 0 {
23052 self.diagnostics.push(Diagnostic {
23053 related: Vec::new(),
23054 span: SourceSpan {
23055 start: open.span.start,
23056 end: body_end,
23057 },
23058 message: "unterminated table row".to_owned(),
23059 suggestion: Some("close the table row with `}`".to_owned()),
23060 });
23061 return None;
23062 }
23063
23064 let body_span = SourceSpan {
23065 start: body_start,
23066 end: body_end,
23067 };
23068 let (text, span) = trimmed_source_text(self.source_text(body_span), body_span);
23069 Some(TableRow {
23070 body: BlockSource { text, span },
23071 span: SourceSpan {
23072 start: open.span.start,
23073 end: close_end,
23074 },
23075 })
23076 }
23077
23078 fn parse_coerce(&mut self) -> Option<CoerceDecl> {
23079 let start = self.expect_keyword("coerce")?.span.start;
23080 let name = self.expect_ident("coerce name")?;
23081 let params = self.parse_param_list()?;
23082 self.expect_thin_arrow()?;
23083 let output = self.parse_type()?;
23084 if !self.at_symbol('{') {
23089 if let Some(TokenKind::String(_)) = self.peek().map(|token| &token.kind) {
23090 let token = self.advance().clone();
23091 let raw = self
23092 .source_text(SourceSpan {
23093 start: token.span.start,
23094 end: token.span.end,
23095 })
23096 .to_owned();
23097 let body = BlockSource {
23098 text: format!("prompt {raw}"),
23099 span: token.span,
23100 };
23101 let span = SourceSpan {
23102 start,
23103 end: body.span.end,
23104 };
23105 return Some(CoerceDecl {
23106 name,
23107 params,
23108 output,
23109 body,
23110 span,
23111 });
23112 }
23113 }
23114 let body = self.parse_block_source()?;
23115 let span = SourceSpan {
23116 start,
23117 end: body.span.end,
23118 };
23119 Some(CoerceDecl {
23120 name,
23121 params,
23122 output,
23123 body,
23124 span,
23125 })
23126 }
23127
23128 fn parse_param_list(&mut self) -> Option<Vec<ParamDecl>> {
23129 self.expect_symbol('(')?;
23130 let mut params = Vec::new();
23131
23132 while !self.is_at_end() && !self.at_symbol(')') {
23133 let name = self.expect_ident("parameter name")?;
23134 let ty = self.parse_type()?;
23135 params.push(ParamDecl {
23136 span: name.span.join(ty.span()),
23137 name,
23138 ty,
23139 });
23140
23141 if self.at_symbol(',') {
23142 self.advance();
23143 } else if !self.at_symbol(')') {
23144 self.unexpected("`,` or `)`");
23145 while !self.is_at_end() && !self.at_symbol(')') && !self.at_symbol(',') {
23146 self.advance();
23147 }
23148 }
23149 }
23150
23151 self.expect_symbol(')')?;
23152 Some(params)
23153 }
23154
23155 fn parse_action(&mut self) -> Option<ActionDecl> {
23158 let start = self.expect_keyword("action")?.span.start;
23159 let name = self.expect_ident("action name")?;
23160 self.expect_symbol('(')?;
23161 let mut params = Vec::new();
23162 while !self.is_at_end() && !self.at_symbol(')') {
23163 let param_name = self.expect_ident("action parameter name")?;
23164 let ty = self.parse_type()?;
23165 let span = param_name.span.join(ty.span());
23166 params.push(ActionParam {
23167 name: param_name,
23168 ty,
23169 span,
23170 });
23171 if self.at_symbol(',') {
23172 self.advance();
23173 }
23174 }
23175 self.expect_symbol(')')?;
23176 let body = self.parse_block_source()?;
23177 let span = SourceSpan {
23178 start,
23179 end: body.span.end,
23180 };
23181 Some(ActionDecl {
23182 name,
23183 params,
23184 body,
23185 span,
23186 })
23187 }
23188
23189 fn parse_rule(
23190 &mut self,
23191 tags: Vec<TagDecl>,
23192 description: Option<StringLiteral>,
23193 ) -> Option<RuleDecl> {
23194 let start = self.expect_keyword("rule")?.span.start;
23195 let name = self.expect_ident("rule name")?;
23196 let mut whens = Vec::new();
23197
23198 while !self.is_at_end() && !self.at_arrow() {
23199 if self.at_ident("when") {
23200 whens.extend(self.parse_when_clauses()?);
23201 } else if self.at_ident("with") {
23202 let span = self
23203 .peek()
23204 .map(|token| token.span)
23205 .unwrap_or(SourceSpan { start, end: start });
23206 self.diagnostics.push(Diagnostic {
23207 related: Vec::new(),
23208 span,
23209 message: "`with` is not a rule readiness clause".to_owned(),
23210 suggestion: Some("use `when` for rule conditions".to_owned()),
23211 });
23212 self.advance();
23213 } else {
23214 self.unexpected("`when` clause or `=>`");
23215 self.advance();
23216 }
23217 }
23218
23219 self.expect_arrow()?;
23220 let body = self.parse_block_source()?;
23221 let span = SourceSpan {
23222 start,
23223 end: body.span.end,
23224 };
23225 Some(RuleDecl {
23226 name,
23227 tags,
23228 description,
23229 whens,
23230 body,
23231 span,
23232 })
23233 }
23234
23235 fn parse_when_clauses(&mut self) -> Option<Vec<WhenClause>> {
23236 let when = self.expect_keyword("when")?;
23237 if self.at_symbol('{') {
23238 return self.parse_grouped_when_clauses(when.span);
23239 }
23240
23241 Some(vec![self.parse_when_clause_after_keyword(when.span)?])
23242 }
23243
23244 fn parse_assert(
23245 &mut self,
23246 tags: Vec<TagDecl>,
23247 description: Option<StringLiteral>,
23248 ) -> Option<AssertDecl> {
23249 let assert = self.expect_keyword("assert")?;
23250 let expr_start = assert.span.end;
23251 let line_end = self.source[expr_start..]
23252 .find('\n')
23253 .map(|offset| expr_start + offset)
23254 .unwrap_or(self.source.len());
23255 let mut expr_end = line_end;
23256
23257 while !self.is_at_end() && self.peek()?.span.start < line_end {
23258 expr_end = self.peek()?.span.end.min(line_end);
23259 self.advance();
23260 }
23261 expr_end = Self::extend_span_over_skipped_operators(self.source, expr_end, line_end);
23262
23263 let span = SourceSpan {
23264 start: expr_start,
23265 end: expr_end,
23266 };
23267 let (expr, span) = trimmed_source_text(self.source_text(span), span);
23268 Some(AssertDecl {
23269 tags,
23270 description,
23271 expr,
23272 span,
23273 })
23274 }
23275
23276 fn parse_when_clause_after_keyword(&mut self, when: SourceSpan) -> Option<WhenClause> {
23277 self.parse_when_clause_with_stop(when, false)
23278 }
23279
23280 fn parse_when_clause_with_stop(
23282 &mut self,
23283 when: SourceSpan,
23284 stop_at_brace: bool,
23285 ) -> Option<WhenClause> {
23286 let text_start = when.end;
23287 let mut text_end = text_start;
23288
23289 while !(self.is_at_end()
23290 || self.at_arrow()
23291 || self.at_ident("when")
23292 || self.at_ident("rule")
23293 || stop_at_brace && self.at_symbol('{'))
23294 {
23295 text_end = self.peek()?.span.end;
23296 self.advance();
23297 }
23298 let limit = self
23299 .peek()
23300 .map(|token| token.span.start)
23301 .unwrap_or(self.source.len());
23302 text_end = Self::extend_span_over_skipped_operators(self.source, text_end, limit);
23303
23304 let span = SourceSpan {
23305 start: text_start,
23306 end: text_end,
23307 };
23308 let (text, span) = trimmed_source_text(self.source_text(span), span);
23309 Some(WhenClause { text, span })
23310 }
23311
23312 fn extend_span_over_skipped_operators(source: &str, mut end: usize, limit: usize) -> usize {
23322 let bytes = source.as_bytes();
23323 loop {
23324 let mut cursor = end;
23325 while cursor < limit && bytes[cursor].is_ascii_whitespace() && bytes[cursor] != b'\n' {
23326 cursor += 1;
23327 }
23328 let width = match (bytes.get(cursor), bytes.get(cursor + 1)) {
23329 _ if cursor >= limit => break,
23330 (Some(b'='), Some(b'='))
23331 | (Some(b'!'), Some(b'='))
23332 | (Some(b'<'), Some(b'='))
23333 | (Some(b'>'), Some(b'='))
23334 | (Some(b'&'), Some(b'&'))
23335 | (Some(b'|'), Some(b'|')) => 2,
23336 (Some(b'/'), Some(b'/')) => break,
23337 (Some(b'-'), Some(b'>')) => break,
23338 (Some(b'*' | b'/' | b'-'), _) => 1,
23339 _ => break,
23340 };
23341 if cursor + width > limit {
23342 break;
23343 }
23344 end = cursor + width;
23345 }
23346 end
23347 }
23348
23349 fn parse_grouped_when_clauses(&mut self, when: SourceSpan) -> Option<Vec<WhenClause>> {
23350 let open = self.expect_symbol('{')?;
23351 let body_start = open.span.end;
23352 let mut depth = 1usize;
23353 let mut body_end = body_start;
23354 let mut close_end = open.span.end;
23355
23356 while !self.is_at_end() {
23357 let token = self.advance().clone();
23358 match token.kind {
23359 TokenKind::Symbol('{') => {
23360 depth += 1;
23361 body_end = token.span.end;
23362 }
23363 TokenKind::Symbol('}') => {
23364 depth -= 1;
23365 if depth == 0 {
23366 body_end = token.span.start;
23367 close_end = token.span.end;
23368 break;
23369 }
23370 body_end = token.span.end;
23371 }
23372 _ => body_end = token.span.end,
23373 }
23374 }
23375
23376 if depth != 0 {
23377 self.diagnostics.push(Diagnostic {
23378 related: Vec::new(),
23379 span: SourceSpan {
23380 start: when.start,
23381 end: body_end,
23382 },
23383 message: "unterminated grouped `when` block".to_owned(),
23384 suggestion: Some("close the grouped readiness block with `}`".to_owned()),
23385 });
23386 return Some(Vec::new());
23387 }
23388
23389 let body_span = SourceSpan {
23390 start: body_start,
23391 end: body_end,
23392 };
23393 let mut clauses = Vec::new();
23394 let mut offset = 0usize;
23395 for line in self.source_text(body_span).split_inclusive('\n') {
23396 let line_without_newline = line.trim_end_matches('\n');
23397 let line_start = body_span.start + offset;
23398 offset += line.len();
23399 let leading = line_without_newline.len() - line_without_newline.trim_start().len();
23400 let trailing = line_without_newline.len() - line_without_newline.trim_end().len();
23401 let trimmed_start = line_start + leading;
23402 let trimmed_end = line_start + line_without_newline.len().saturating_sub(trailing);
23403 if trimmed_start >= trimmed_end {
23404 continue;
23405 }
23406 clauses.push(WhenClause {
23407 text: self.source[trimmed_start..trimmed_end].to_owned(),
23408 span: SourceSpan {
23409 start: trimmed_start,
23410 end: trimmed_end,
23411 },
23412 });
23413 }
23414
23415 if clauses.is_empty() {
23416 self.diagnostics.push(Diagnostic {
23417 related: Vec::new(),
23418 span: SourceSpan {
23419 start: when.start,
23420 end: close_end,
23421 },
23422 message: "grouped `when` block has no readiness clauses".to_owned(),
23423 suggestion: Some(
23424 "add one condition per line, such as `started` or `Class as binding`"
23425 .to_owned(),
23426 ),
23427 });
23428 }
23429
23430 Some(clauses)
23431 }
23432
23433 fn parse_block_source(&mut self) -> Option<BlockSource> {
23434 let open = self.expect_symbol('{')?;
23435 let body_start = open.span.end;
23436 let mut depth = 1usize;
23437 let mut body_end = body_start;
23438
23439 while !self.is_at_end() {
23440 let token = self.advance().clone();
23441 match token.kind {
23442 TokenKind::Symbol('{') => {
23443 depth += 1;
23444 body_end = token.span.end;
23445 }
23446 TokenKind::Symbol('}') => {
23447 depth -= 1;
23448 if depth == 0 {
23449 body_end = token.span.start;
23450 return Some(BlockSource {
23451 text: self
23452 .source_text(SourceSpan {
23453 start: body_start,
23454 end: body_end,
23455 })
23456 .trim()
23457 .to_owned(),
23458 span: SourceSpan {
23459 start: open.span.start,
23460 end: token.span.end,
23461 },
23462 });
23463 }
23464 body_end = token.span.end;
23465 }
23466 _ => {
23467 body_end = token.span.end;
23468 }
23469 }
23470 }
23471
23472 self.diagnostics.push(Diagnostic {
23473 related: Vec::new(),
23474 span: SourceSpan {
23475 start: open.span.start,
23476 end: body_end,
23477 },
23478 message: "unterminated block".to_owned(),
23479 suggestion: Some("add a closing `}`".to_owned()),
23480 });
23481 Some(BlockSource {
23482 text: self
23483 .source_text(SourceSpan {
23484 start: body_start,
23485 end: body_end,
23486 })
23487 .trim()
23488 .to_owned(),
23489 span: SourceSpan {
23490 start: open.span.start,
23491 end: body_end,
23492 },
23493 })
23494 }
23495
23496 fn parse_type(&mut self) -> Option<TypeSyntax> {
23497 let first = self.parse_type_atom()?;
23498 let first = self.parse_type_suffixes(first);
23499
23500 if !self.at_symbol('|') {
23501 return Some(first);
23502 }
23503
23504 let start = first.span().start;
23505 let mut end = first.span().end;
23506 let mut variants = vec![first];
23507
23508 while self.at_symbol('|') {
23509 self.advance();
23510 let variant = self.parse_type_atom()?;
23511 let variant = self.parse_type_suffixes(variant);
23512 end = variant.span().end;
23513 variants.push(variant);
23514 }
23515
23516 Some(TypeSyntax::Union {
23517 variants,
23518 span: SourceSpan { start, end },
23519 })
23520 }
23521
23522 fn parse_type_atom(&mut self) -> Option<TypeSyntax> {
23523 Some(if self.at_ident("AgentRef") {
23524 let agent_ref = self.advance().clone();
23525 self.expect_symbol('<')?;
23526 let mut agents = Vec::new();
23527 while !self.is_at_end() && !self.at_symbol('>') {
23528 if self.at_symbol('|') {
23529 self.advance();
23530 continue;
23531 }
23532 let Some(agent) = self.expect_ident("agent reference") else {
23533 break;
23534 };
23535 agents.push(agent);
23536 }
23537 let close = self.expect_symbol('>')?;
23538 TypeSyntax::AgentRef {
23539 agents,
23540 span: agent_ref.span.join(close.span),
23541 }
23542 } else if self.at_ident("map") {
23543 let map = self.advance().clone();
23544 self.expect_symbol('<')?;
23545 let inner = self.parse_type()?;
23546 let close = self.expect_symbol('>')?;
23547 TypeSyntax::Map {
23548 span: map.span.join(close.span),
23549 inner: Box::new(inner),
23550 }
23551 } else if matches!(
23552 self.peek().map(|token| &token.kind),
23553 Some(TokenKind::String(_))
23554 ) {
23555 let literal = self.expect_string("literal type")?;
23556 TypeSyntax::LiteralString {
23557 value: literal.value,
23558 span: literal.span,
23559 }
23560 } else {
23561 let ident = self.expect_ident("type name")?;
23562 if is_primitive_type(&ident.name) {
23563 TypeSyntax::Primitive {
23564 name: ident.name,
23565 span: ident.span,
23566 }
23567 } else {
23568 TypeSyntax::Ref { name: ident }
23569 }
23570 })
23571 }
23572
23573 fn parse_type_suffixes(&mut self, mut ty: TypeSyntax) -> TypeSyntax {
23574 loop {
23575 if self.at_symbol('?') {
23576 let question = self.advance().clone();
23577 ty = TypeSyntax::Optional {
23578 span: ty.span().join(question.span),
23579 inner: Box::new(ty),
23580 };
23581 } else if self.at_symbol('[') {
23582 self.advance();
23583 let Some(close) = self.expect_symbol(']') else {
23584 return ty;
23585 };
23586 ty = TypeSyntax::Array {
23587 span: ty.span().join(close.span),
23588 inner: Box::new(ty),
23589 };
23590 } else {
23591 return ty;
23592 }
23593 }
23594 }
23595
23596 fn parse_string_list(&mut self) -> Option<(Vec<StringLiteral>, SourceSpan)> {
23597 let open = self.expect_symbol('[')?;
23598 let mut values = Vec::new();
23599
23600 while !self.is_at_end() && !self.at_symbol(']') {
23601 values.push(self.expect_string("skill string")?);
23602 if self.at_symbol(',') {
23603 self.advance();
23604 } else if !self.at_symbol(']') {
23605 self.unexpected("`,` or `]`");
23606 self.synchronize_to_block_item();
23607 break;
23608 }
23609 }
23610
23611 let close = self.expect_symbol(']')?;
23612 Some((values, open.span.join(close.span)))
23613 }
23614
23615 fn parse_ident_list(&mut self) -> Option<(Vec<Ident>, SourceSpan)> {
23618 let open = self.expect_symbol('[')?;
23619 let mut values = Vec::new();
23620
23621 while !self.is_at_end() && !self.at_symbol(']') {
23622 values.push(self.expect_ident("tool workflow name")?);
23623 if self.at_symbol(',') {
23624 self.advance();
23625 } else if !self.at_symbol(']') {
23626 self.unexpected("`,` or `]`");
23627 self.synchronize_to_block_item();
23628 break;
23629 }
23630 }
23631
23632 let close = self.expect_symbol(']')?;
23633 Some((values, open.span.join(close.span)))
23634 }
23635
23636 fn parse_feature_class_list(&mut self) -> Option<(Vec<Ident>, SourceSpan)> {
23641 let open = self.expect_symbol('[')?;
23642 let mut values = Vec::new();
23643
23644 while !self.is_at_end() && !self.at_symbol(']') {
23645 let head = self.expect_ident("feature class")?;
23646 let mut name = head.name.clone();
23647 let mut span = head.span;
23648 while self.at_symbol('.') {
23649 self.advance();
23650 let part = self.expect_ident("feature class segment")?;
23651 name.push('.');
23652 name.push_str(&part.name);
23653 span = span.join(part.span);
23654 }
23655 values.push(Ident { name, span });
23656 if self.at_symbol(',') {
23657 self.advance();
23658 } else if !self.at_symbol(']') {
23659 self.unexpected("`,` or `]`");
23660 self.synchronize_to_block_item();
23661 break;
23662 }
23663 }
23664
23665 let close = self.expect_symbol(']')?;
23666 Some((values, open.span.join(close.span)))
23667 }
23668
23669 fn expect_keyword(&mut self, keyword: &str) -> Option<Token> {
23670 if self.at_ident(keyword) {
23671 Some(self.advance().clone())
23672 } else {
23673 self.expected(format!("`{keyword}`"));
23674 None
23675 }
23676 }
23677
23678 fn expect_ident(&mut self, label: &str) -> Option<Ident> {
23679 let token = self.peek()?;
23680 if let TokenKind::Ident(name) = &token.kind {
23681 let ident = Ident {
23682 name: name.clone(),
23683 span: token.span,
23684 };
23685 self.advance();
23686 Some(ident)
23687 } else {
23688 self.expected(label);
23689 None
23690 }
23691 }
23692
23693 fn parse_field_presence_condition(&mut self) -> Option<(String, String)> {
23699 if !self.at_ident("when") {
23700 return None;
23701 }
23702 self.advance(); let disc = self.expect_ident("discriminant field name after `when`")?;
23704 if self.at_ident("is") {
23705 self.advance();
23706 } else {
23707 self.expected("`is` after the discriminant field");
23708 return None;
23709 }
23710 let literal = self.expect_string("discriminant literal value")?;
23711 Some((disc.name, literal.value))
23712 }
23713
23714 fn expect_string(&mut self, label: &str) -> Option<StringLiteral> {
23715 let token = self.peek()?;
23716 if let TokenKind::String(value) = &token.kind {
23717 let literal = StringLiteral {
23718 value: value.clone(),
23719 span: token.span,
23720 };
23721 self.advance();
23722 Some(literal)
23723 } else {
23724 self.expected(label);
23725 None
23726 }
23727 }
23728
23729 fn expect_use_name(&mut self, label: &str) -> Option<StringLiteral> {
23730 let token = self.peek()?;
23731 match &token.kind {
23732 TokenKind::Ident(value) => {
23735 let mut name = value.clone();
23736 let mut span = token.span;
23737 self.advance();
23738 while self.at_symbol('.') {
23739 self.expect_symbol('.');
23740 let Some(segment) = self.expect_ident("package name segment") else {
23741 break;
23742 };
23743 name.push('.');
23744 name.push_str(&segment.name);
23745 span = span.join(segment.span);
23746 }
23747 Some(StringLiteral { value: name, span })
23748 }
23749 TokenKind::String(value) => {
23750 let literal = StringLiteral {
23751 value: value.clone(),
23752 span: token.span,
23753 };
23754 self.advance();
23755 Some(literal)
23756 }
23757 _ => {
23758 self.expected(label);
23759 None
23760 }
23761 }
23762 }
23763
23764 fn expect_u32(&mut self, label: &str) -> Option<(u32, SourceSpan)> {
23765 let token = self.peek()?;
23766 if let TokenKind::Number(value) = &token.kind {
23767 let span = token.span;
23768 let parsed = value.parse::<u32>();
23769 self.advance();
23770 match parsed {
23771 Ok(value) => Some((value, span)),
23772 Err(_) => {
23773 self.diagnostics.push(Diagnostic {
23774 related: Vec::new(),
23775 span,
23776 message: format!("{label} must fit in u32"),
23777 suggestion: Some("use a non-negative integer such as `1`".to_owned()),
23778 });
23779 None
23780 }
23781 }
23782 } else {
23783 self.expected(label);
23784 None
23785 }
23786 }
23787
23788 fn expect_symbol(&mut self, symbol: char) -> Option<Token> {
23789 if self.at_symbol(symbol) {
23790 Some(self.advance().clone())
23791 } else {
23792 self.expected(format!("`{symbol}`"));
23793 None
23794 }
23795 }
23796
23797 fn expect_arrow(&mut self) -> Option<Token> {
23798 if self.at_arrow() {
23799 Some(self.advance().clone())
23800 } else {
23801 self.expected("`=>`");
23802 None
23803 }
23804 }
23805
23806 fn expect_thin_arrow(&mut self) -> Option<Token> {
23807 if self.at_thin_arrow() {
23808 Some(self.advance().clone())
23809 } else {
23810 self.expected("`->`");
23811 None
23812 }
23813 }
23814
23815 fn at_ident(&self, expected: &str) -> bool {
23816 matches!(self.peek().map(|token| &token.kind), Some(TokenKind::Ident(value)) if value == expected)
23817 }
23818
23819 fn consume_ident(&mut self, expected: &str) -> bool {
23820 if self.at_ident(expected) {
23821 self.advance();
23822 true
23823 } else {
23824 false
23825 }
23826 }
23827
23828 fn at_symbol(&self, expected: char) -> bool {
23829 matches!(self.peek().map(|token| &token.kind), Some(TokenKind::Symbol(value)) if *value == expected)
23830 }
23831
23832 fn at_arrow(&self) -> bool {
23833 matches!(self.peek().map(|token| &token.kind), Some(TokenKind::Arrow))
23834 }
23835
23836 fn at_thin_arrow(&self) -> bool {
23837 matches!(
23838 self.peek().map(|token| &token.kind),
23839 Some(TokenKind::ThinArrow)
23840 )
23841 }
23842
23843 fn peek(&self) -> Option<&Token> {
23844 self.tokens.get(self.pos)
23845 }
23846
23847 fn advance(&mut self) -> &Token {
23848 let index = self.pos;
23849 self.pos += 1;
23850 &self.tokens[index]
23851 }
23852
23853 fn is_at_end(&self) -> bool {
23854 self.pos >= self.tokens.len()
23855 }
23856
23857 fn expected(&mut self, expected: impl fmt::Display) {
23858 let expected = expected.to_string();
23859 let (span, found) = match self.peek() {
23860 Some(token) => (token.span, token.kind.label()),
23861 None => (
23862 SourceSpan {
23863 start: self.source.len(),
23864 end: self.source.len(),
23865 },
23866 "end of file".to_owned(),
23867 ),
23868 };
23869 self.diagnostics.push(Diagnostic {
23870 related: Vec::new(),
23871 span,
23872 message: format!("expected {expected}, found {found}"),
23873 suggestion: suggestion_for_expected(&expected),
23874 });
23875 }
23876
23877 fn unexpected(&mut self, expected: impl fmt::Display) {
23878 let Some(token) = self.peek() else {
23879 self.expected(expected);
23880 return;
23881 };
23882 let expected = expected.to_string();
23883 self.diagnostics.push(Diagnostic {
23884 related: Vec::new(),
23885 span: token.span,
23886 message: format!("expected {expected}, found {}", token.kind.label()),
23887 suggestion: suggestion_for_expected(&expected),
23888 });
23889 }
23890
23891 fn synchronize_to_block_item(&mut self) {
23892 while !self.is_at_end() {
23893 if self.at_symbol('}')
23894 || self.at_ident("profile")
23895 || self.at_ident("provider")
23896 || self.at_ident("capacity")
23897 || self.at_ident("skills")
23898 || self.at_ident("capabilities")
23899 || self.at_ident("tools")
23900 || self.at_ident("compaction")
23901 || self.at_ident("settings")
23902 {
23903 return;
23904 }
23905 self.advance();
23906 }
23907 }
23908
23909 fn synchronize_to_table_row(&mut self) {
23910 while !self.is_at_end() {
23911 if self.at_symbol('{') || self.at_symbol(']') {
23912 return;
23913 }
23914 self.advance();
23915 }
23916 }
23917
23918 fn source_text(&self, span: SourceSpan) -> &str {
23919 &self.source[span.start..span.end]
23920 }
23921}
23922
23923fn trimmed_source_text(source: &str, span: SourceSpan) -> (String, SourceSpan) {
23924 let leading = source.len() - source.trim_start().len();
23925 let trailing = source.len() - source.trim_end().len();
23926 let end = source.len().saturating_sub(trailing);
23927 if leading > end {
23928 return (
23929 String::new(),
23930 SourceSpan {
23931 start: span.end,
23932 end: span.end,
23933 },
23934 );
23935 }
23936 (
23937 source[leading..end].to_owned(),
23938 SourceSpan {
23939 start: span.start + leading,
23940 end: span.start + end,
23941 },
23942 )
23943}
23944
23945fn is_primitive_type(name: &str) -> bool {
23946 matches!(
23947 name,
23948 "string"
23949 | "int"
23950 | "float"
23951 | "bool"
23952 | "null"
23953 | "duration"
23954 | "time"
23955 | "image"
23956 | "audio"
23957 | "pdf"
23958 | "video"
23959 )
23960}
23961
23962fn is_gherkin_keyword(keyword: &str) -> bool {
23963 matches!(
23964 keyword,
23965 "Feature"
23966 | "Rule"
23967 | "Background"
23968 | "Scenario"
23969 | "ScenarioOutline"
23970 | "Scenario-Outline"
23971 | "Examples"
23972 | "Given"
23973 | "When"
23974 | "Then"
23975 | "And"
23976 | "But"
23977 )
23978}
23979
23980fn suggestion_for_expected(expected: &str) -> Option<String> {
23981 match expected {
23982 "`{`" => Some("add a `{ ... }` block".to_owned()),
23983 "`=>`" => Some("add `=> { ... }` after the rule conditions".to_owned()),
23984 "`->`" => Some("add `-> OutputType` before the coerce prompt block".to_owned()),
23985 "profile string" => Some("write `profile \"profile-name\"`".to_owned()),
23986 "capacity value" => Some("write `capacity 1`".to_owned()),
23987 "package library name" => Some("write a package library name, such as `memory`".to_owned()),
23988 "type name" => Some("write a primitive type or schema name".to_owned()),
23989 _ => None,
23990 }
23991}
23992
23993#[cfg(test)]
23994mod tests {
23995 use super::*;
23996
23997 #[test]
23998 fn parser_scaffold_links_to_core() {
23999 assert_eq!(parser_stage(), "release");
24000 }
24001
24002 #[test]
24003 fn declaration_block_grammar_table_is_complete() {
24004 let keywords: Vec<&str> = DECLARATION_BLOCK_GRAMMAR
24008 .iter()
24009 .map(|spec| spec.keyword)
24010 .collect();
24011 assert_eq!(
24012 keywords.len(),
24013 7,
24014 "expected exactly 7 declaration_block specs"
24015 );
24016 for expected in [
24017 "tracker",
24018 "channel",
24019 "counter",
24020 "lease",
24021 "ledger",
24022 "file store",
24023 "memory pool",
24024 ] {
24025 assert!(
24026 keywords.contains(&expected),
24027 "missing declaration_block keyword `{expected}`; got {keywords:?}"
24028 );
24029 }
24030
24031 let find = |keyword: &str| -> &DeclarationBlockSpec {
24032 DECLARATION_BLOCK_GRAMMAR
24033 .iter()
24034 .find(|spec| spec.keyword == keyword)
24035 .unwrap_or_else(|| panic!("no spec for `{keyword}`"))
24036 };
24037 let clause = |keyword: &str, name: &str| -> &ClauseSpec {
24038 find(keyword)
24039 .clauses
24040 .iter()
24041 .find(|clause| clause.name == name)
24042 .unwrap_or_else(|| panic!("no clause `{name}` on `{keyword}`"))
24043 };
24044
24045 assert_eq!(find("memory pool").keyword_words, &["memory", "pool"]);
24047 assert_eq!(find("file store").keyword_words, &["file", "store"]);
24048 assert_eq!(find("tracker").keyword_words, &["tracker"]);
24049
24050 assert_eq!(clause("ledger", "partition").connective, Some("by"));
24052
24053 assert!(matches!(clause("lease", "shared").kind, ClauseKind::Flag));
24055 assert!(!clause("lease", "shared").list);
24056 assert_eq!(clause("lease", "shared").connective, None);
24057
24058 for (name, words) in [
24060 ("allow read", ["allow", "read"]),
24061 ("allow write", ["allow", "write"]),
24062 ] {
24063 let allow = clause("file store", name);
24064 assert!(allow.list, "`{name}` must be list:true");
24065 assert_eq!(allow.words, words);
24066 assert!(matches!(allow.kind, ClauseKind::Glob));
24067 }
24068
24069 let mut parser = Parser {
24071 source: "memory pool p { }",
24072 tokens: lex("memory pool p { }").tokens,
24073 pos: 0,
24074 diagnostics: Vec::new(),
24075 pending_contract_classes: Vec::new(),
24076 };
24077 let spec = parser
24078 .declaration_block_spec_at()
24079 .expect("head word `memory` must resolve to the memory pool spec");
24080 assert_eq!(spec.keyword, "memory pool");
24081 assert_eq!(parser.pos, 0);
24083 parser.diagnostics.clear();
24084 }
24085
24086 const SEND_PROGRAM: &str = r##"
24087@service
24088workflow Notify
24089
24090class Trigger { id string }
24091
24092agent worker { provider fixture profile "r" capacity 1 }
24093
24094channel alerts { provider fixture destination "#ops" }
24095
24096table seed as Trigger [ { id "t" } ]
24097
24098rule notify
24099 when Trigger as t
24100=> {
24101 send via alerts {
24102 text "hello"
24103 } as sent
24104}
24105"##;
24106
24107 #[test]
24108 fn send_lowers_to_messaging_capability_call_without_builtin_registration() {
24109 let compiled = compile_program(SEND_PROGRAM);
24116 assert_eq!(
24117 compiled.diagnostics,
24118 Vec::new(),
24119 "{:?}",
24120 compiled.diagnostics
24121 );
24122 let ir = compiled.ir.expect("lowered IR");
24123 let uses = ir.construct_uses();
24124 assert_eq!(uses.len(), 1);
24125 assert_eq!(uses[0].keyword, "send");
24126 assert_eq!(uses[0].target_capability, "messaging.send");
24127 let registry = ir.contract_registry();
24128 assert!(
24129 registry.constructs.is_empty(),
24130 "the parser registers no builtin constructs: {:?}",
24131 registry.constructs
24132 );
24133 assert!(
24134 !registry
24135 .effect_contracts
24136 .iter()
24137 .any(|c| c.id == "messaging.send"),
24138 "the messaging.send contract comes from the embedded manifest, not the parser"
24139 );
24140 assert!(
24141 registry
24142 .libraries
24143 .iter()
24144 .any(|lib| lib.id == "std.messaging" && lib.standard),
24145 "the channel declaration still registers the std.messaging standard library"
24146 );
24147 }
24148
24149 #[test]
24150 fn send_to_unknown_channel_is_rejected() {
24151 let source = SEND_PROGRAM.replace("send via alerts", "send via ghost");
24152 let compiled = compile_program(&source);
24153 let violations: Vec<&Diagnostic> = compiled
24154 .diagnostics
24155 .iter()
24156 .filter(|d| d.message.contains("unknown channel"))
24157 .collect();
24158 assert_eq!(violations.len(), 1, "{:?}", compiled.diagnostics);
24159 assert!(violations[0].message.contains("ghost"));
24160 }
24161
24162 #[test]
24163 fn derives_contract_registry_from_imports_and_effects() {
24164 let source = r#"
24165workflow RegistrySlice
24166
24167use memory
24168
24169class Task {
24170 title string
24171}
24172
24173class Review {
24174 accepted bool
24175}
24176
24177coerce reviewTask(title string) -> Review {
24178 prompt """
24179 Review {{ title }}
24180 """
24181}
24182
24183agent worker {
24184 provider fixture
24185 profile "repo-writer"
24186 capacity 1
24187}
24188
24189rule start
24190 when Task as task
24191=> {
24192 tell worker as turn """
24193 Work on {{ task.title }}
24194 """
24195
24196 after turn succeeds {
24197 coerce reviewTask(task.title) as review
24198 }
24199}
24200"#;
24201
24202 let compiled = compile_program(source);
24203 assert_eq!(compiled.diagnostics, Vec::new());
24204 let ir = compiled.ir.expect("program compiles");
24205 let registry = ir.contract_registry();
24206 assert_eq!(registry.validate(), Vec::new());
24207
24208 assert!(registry
24209 .libraries
24210 .iter()
24211 .any(|library| library.id == "memory" && !library.standard));
24212 assert!(registry
24213 .libraries
24214 .iter()
24215 .any(|library| library.id == "std.agent" && library.standard));
24216 assert!(registry
24217 .libraries
24218 .iter()
24219 .any(|library| library.id == "std.coercion" && library.standard));
24220
24221 let coerce = registry
24222 .effect_contracts
24223 .iter()
24224 .find(|contract| contract.id == "schema.coerce")
24225 .expect("coerce contract");
24226 assert_eq!(coerce.library_id, "std.coercion");
24227 assert_eq!(coerce.validation, TypedOutputValidation::RuntimeBoundary);
24228 assert!(coerce.source_forms.contains(&"coerce".to_owned()));
24229 assert!(coerce.source_forms.contains(&"prompt".to_owned()));
24230 assert!(coerce
24231 .required_capabilities
24232 .contains(&"schema.coerce".to_owned()));
24233 assert_eq!(coerce.provider_kinds, vec!["schema_coercer".to_owned()]);
24234
24235 let agent = registry
24236 .effect_contracts
24237 .iter()
24238 .find(|contract| contract.id == "agent.tell")
24239 .expect("agent contract");
24240 assert_eq!(agent.library_id, "std.agent");
24241 assert_eq!(agent.output_schema.as_deref(), Some("AgentTurn"));
24242 }
24243
24244 #[test]
24245 fn capability_calls_require_the_target_capability() {
24246 let source = r#"
24247workflow PackageCall
24248
24249use memory
24250
24251class Task {
24252 title string
24253}
24254
24255rule start
24256 when Task as task
24257=> {
24258 call memory.query for task as context
24259}
24260"#;
24261
24262 let compiled = compile_program(source);
24263 assert_eq!(compiled.diagnostics, Vec::new());
24264 let ir = compiled.ir.expect("program compiles");
24265 let effect = ir.rules[0]
24266 .metadata
24267 .effects
24268 .iter()
24269 .find(|effect| effect.kind == IrEffectKind::CapabilityCall)
24270 .expect("capability call effect");
24271 assert_eq!(
24272 effect.required_capabilities,
24273 vec!["memory.query".to_owned()]
24274 );
24275
24276 let registry = ir.contract_registry();
24277 let contract = registry
24278 .effect_contracts
24279 .iter()
24280 .find(|contract| contract.id == "capability.call")
24281 .expect("capability call contract");
24282 assert!(contract
24283 .required_capabilities
24284 .contains(&"memory.query".to_owned()));
24285 assert!(!contract
24286 .required_capabilities
24287 .contains(&"capability.call".to_owned()));
24288 }
24289
24290 #[test]
24291 fn package_recall_form_lowers_to_capability_call_marker() {
24292 let source = r#"
24293workflow PackageRecall
24294
24295use memory
24296
24297memory pool project_memory {
24298 context limit 8
24299}
24300
24301class Task {
24302 title string
24303}
24304
24305rule start
24306 when Task as task
24307=> {
24308 recall project_memory for task as context
24309}
24310"#;
24311
24312 let compiled = compile_program(source);
24313 assert_eq!(compiled.diagnostics, Vec::new());
24314 let ir = compiled.ir.expect("program compiles");
24315 let effect = ir.rules[0]
24316 .metadata
24317 .effects
24318 .iter()
24319 .find(|effect| effect.kind == IrEffectKind::CapabilityCall)
24320 .expect("capability call effect");
24321 assert_eq!(effect.binding.as_deref(), Some("context"));
24322 assert_eq!(
24323 effect.required_capabilities,
24324 vec!["memory.query".to_owned()]
24325 );
24326 assert_eq!(
24327 effect.construct_use,
24328 Some(IrConstructUse {
24329 keyword: "recall".to_owned(),
24330 scope: "rule_body".to_owned(),
24331 construct_family: "effect_operation".to_owned(),
24332 lowering_target: "capability_call".to_owned(),
24333 target_capability: "memory.query".to_owned(),
24334 })
24335 );
24336 assert_eq!(ir.construct_uses().len(), 1);
24337 assert!(ir.to_snapshot().contains("construct=recall->memory.query"));
24338 }
24339
24340 fn b1g_body_matrix_program(body: &str) -> String {
24341 r#"
24342workflow B1gMatrix {
24343 use memory
24344
24345 memory pool project_memory {
24346 context limit 8
24347 }
24348
24349 output result Done
24350 failure error Failed
24351
24352 class Ticket {
24353 id string
24354 title string
24355 due_at time
24356 amount int
24357 }
24358
24359 class TicketPublic {
24360 id string
24361 title string
24362 }
24363
24364 class Workspace {
24365 id string
24366 }
24367
24368 class Note {
24369 text string
24370 }
24371
24372 class Done {
24373 ok bool
24374 }
24375
24376 class Failed {
24377 reason string
24378 }
24379
24380 class Review {
24381 summary string
24382 fixed bool
24383 }
24384
24385 class LedgerEntry {
24386 area string
24387 text string
24388 }
24389
24390 class Row {
24391 title string
24392 }
24393
24394 signal deploy.finished {
24395 service string
24396 status string
24397 }
24398
24399 tracker backlog {
24400 provider builtin
24401 }
24402
24403 lease workspace_slot {
24404 key Workspace
24405 slots 1
24406 ttl 30m
24407 }
24408
24409 ledger review_log {
24410 entry LedgerEntry
24411 partition by area
24412 retain 30d
24413 }
24414
24415 counter request_budget {
24416 key Ticket
24417 cap 10
24418 reset daily
24419 }
24420
24421 file store docs {
24422 root "./data"
24423 allow write ["**"]
24424 }
24425
24426 channel ops_room {
24427 provider fixture
24428 }
24429
24430 agent worker {
24431 provider fixture
24432 profile "repo-writer"
24433 capacity 1
24434 }
24435
24436 coerce classify(title string) -> Review {
24437 prompt "classify"
24438 }
24439
24440 rule probe
24441 when Ticket as ticket
24442 when Workspace as workspace
24443 when backlog has ready issue as item
24444 when worker is available
24445 => {
24446__BODY__
24447 }
24448}
24449
24450workflow Child {
24451 input task ChildTask
24452 output result ChildResult
24453
24454 class ChildTask {
24455 title string
24456 }
24457
24458 class ChildResult {
24459 summary string
24460 }
24461
24462 rule finish
24463 when ChildTask as task
24464 => {
24465 complete result {
24466 summary task.title
24467 }
24468 }
24469}
24470"#
24471 .replace("__BODY__", body)
24472 }
24473
24474 #[test]
24475 fn coordination_shared_declarations_lower_to_ir() {
24476 let source = r#"
24477workflow SharedCoord
24478
24479class Key {
24480 id string
24481}
24482
24483class Entry {
24484 area string
24485}
24486
24487lease shared_slot {
24488 shared
24489 key Key
24490 slots 1
24491 ttl 30m
24492}
24493
24494ledger shared_log {
24495 shared
24496 entry Entry
24497 partition by area
24498 retain 30d
24499}
24500
24501counter shared_budget {
24502 shared
24503 key Key
24504 cap 10
24505 reset daily
24506}
24507"#;
24508 let compiled = compile_program(source);
24509 assert!(
24510 compiled.diagnostics.is_empty(),
24511 "unexpected diagnostics: {:?}",
24512 compiled.diagnostics
24513 );
24514 let ir = compiled.ir.expect("valid IR");
24515 assert!(ir
24516 .leases
24517 .iter()
24518 .any(|lease| lease.name == "shared_slot" && lease.shared && lease.ttl_seconds == 1800));
24519 assert!(ir
24520 .ledgers
24521 .iter()
24522 .any(|ledger| ledger.name == "shared_log" && ledger.shared));
24523 assert!(ir
24524 .counters
24525 .iter()
24526 .any(|counter| counter.name == "shared_budget" && counter.shared));
24527 }
24528
24529 #[test]
24530 fn file_store_clause_spans_are_first_word_tokens() {
24531 let source = r#"
24537workflow FileSpanProbe
24538file store notes_store {
24539 root "./data"
24540 allow read ["notes/**"]
24541 allow write ["notes/**"]
24542}
24543"#;
24544 let parsed = parse_program(source);
24545 assert_eq!(
24546 parsed.diagnostics,
24547 Vec::new(),
24548 "unexpected diagnostics: {:?}",
24549 parsed.diagnostics
24550 );
24551 let store = parsed
24552 .program
24553 .items
24554 .iter()
24555 .find_map(|item| match item {
24556 Item::FileStore(decl) => Some(decl),
24557 _ => None,
24558 })
24559 .expect("file store decl");
24560 let text_at = |span: SourceSpan| &source[span.start..span.end];
24561 assert_eq!(text_at(store.root_span.expect("root span")), "root");
24562 assert_eq!(text_at(store.read_span.expect("read span")), "allow");
24563 assert_eq!(text_at(store.write_span.expect("write span")), "allow");
24564 assert_eq!(store.provider, None);
24567 let ir = compile_program(source).ir.expect("valid IR");
24568 assert_eq!(ir.file_stores[0].provider, None);
24569 assert!(!ir.to_snapshot().contains("provider"));
24570 }
24571
24572 #[test]
24573 fn file_store_provider_clause_parses_and_unknown_is_rejected() {
24574 let source = r#"
24579workflow FileProviderProbe
24580file store notes_store {
24581 root "./data"
24582 allow read ["notes/**"]
24583 provider local
24584}
24585"#;
24586 let compiled = compile_program(source);
24587 assert_eq!(
24588 compiled.diagnostics,
24589 Vec::new(),
24590 "unexpected diagnostics: {:?}",
24591 compiled.diagnostics
24592 );
24593 let ir = compiled.ir.expect("valid IR");
24594 assert_eq!(ir.file_stores[0].provider.as_deref(), Some("local"));
24595 assert!(ir.to_snapshot().contains(" provider local"));
24596
24597 let unknown = source.replace("provider local", "provider s3");
24598 let compiled = compile_program(&unknown);
24599 assert!(
24600 compiled.diagnostics.iter().any(|diagnostic| {
24601 diagnostic
24602 .message
24603 .contains("file store `notes_store` names unknown provider `s3`")
24604 }),
24605 "unknown provider must be a check error: {:?}",
24606 compiled.diagnostics
24607 );
24608 }
24609
24610 #[test]
24611 fn formatter_preserves_file_store_provider_clause() {
24612 let source = r#"
24613workflow FileProviderFmt
24614file store notes_store { root "./data" provider local }
24615"#;
24616 let formatted = format_program(source);
24617 assert_eq!(formatted.diagnostics, Vec::new());
24618 let formatted = formatted.formatted.expect("formats");
24619 assert!(
24620 formatted.contains("file store notes_store {\n root \"./data\"\n provider local\n}"),
24621 "{formatted}"
24622 );
24623 }
24624
24625 #[test]
24626 fn formatter_preserves_shared_coordination_declarations() {
24627 let source = r#"
24628workflow SharedCoord
24629class Key { id string }
24630lease shared_slot { shared key Key slots 1 ttl 30m }
24631"#;
24632 let formatted = format_program(source);
24633 assert_eq!(formatted.diagnostics, Vec::new());
24634 let formatted = formatted.formatted.expect("formats");
24635 assert!(formatted.contains("lease shared_slot {\n shared\n key Key"));
24636 }
24637
24638 fn b1g_probe_rule(case_name: &str, body: &str) -> IrRule {
24639 let source = b1g_body_matrix_program(body);
24640 let compiled = compile_program_with_root(&source, Some("B1gMatrix"));
24641 assert!(
24642 compiled.diagnostics.is_empty(),
24643 "{case_name} emitted diagnostics: {:?}",
24644 compiled.diagnostics
24645 );
24646 let ir = compiled.ir.expect("valid matrix IR");
24647 ir.rules
24648 .into_iter()
24649 .find(|rule| rule.name == "probe")
24650 .expect("probe rule")
24651 }
24652
24653 fn b1g_effect<'a>(
24654 rule: &'a IrRule,
24655 kind: IrEffectKind,
24656 binding: Option<&str>,
24657 case_name: &str,
24658 ) -> &'a IrEffectNode {
24659 rule.metadata
24660 .effects
24661 .iter()
24662 .find(|effect| effect.kind == kind && effect.binding.as_deref() == binding)
24663 .unwrap_or_else(|| {
24664 panic!(
24665 "{case_name} did not lower {kind:?} / {binding:?}; effects: {:?}",
24666 rule.metadata.effects
24667 )
24668 })
24669 }
24670
24671 #[test]
24672 fn accepted_rule_body_matrix_has_no_silent_noops() {
24673 let effect_cases = [
24674 (
24675 "tell",
24676 r#" tell worker as turn "go""#,
24677 IrEffectKind::AgentTell,
24678 Some("turn"),
24679 ),
24680 (
24681 "coerce",
24682 r#" coerce classify(ticket.title) as review"#,
24683 IrEffectKind::SchemaCoerce,
24684 Some("review"),
24685 ),
24686 (
24687 "prompt",
24688 r#" prompt "Summarize {{ ticket.title }}" using fixture as summary"#,
24689 IrEffectKind::SchemaCoerce,
24690 Some("summary"),
24691 ),
24692 (
24693 "decide",
24694 r#" decide "fixed?" -> { fixed bool } as verdict"#,
24695 IrEffectKind::SchemaCoerce,
24696 Some("verdict"),
24697 ),
24698 (
24699 "call",
24700 r#" call memory.query for ticket as called"#,
24701 IrEffectKind::CapabilityCall,
24702 Some("called"),
24703 ),
24704 (
24705 "recall",
24706 r#" recall project_memory for ticket.title as memories"#,
24707 IrEffectKind::CapabilityCall,
24708 Some("memories"),
24709 ),
24710 (
24711 "send",
24712 r#" send via ops_room { text ticket.title } as sent"#,
24713 IrEffectKind::CapabilityCall,
24714 Some("sent"),
24715 ),
24716 (
24717 "invoke",
24718 r#" invoke Child { task { title ticket.title } } as child"#,
24719 IrEffectKind::WorkflowInvoke,
24720 Some("child"),
24721 ),
24722 (
24723 "timer_duration",
24724 r#" timer 5m as wait"#,
24725 IrEffectKind::TimerWait,
24726 Some("wait"),
24727 ),
24728 (
24729 "timer_until",
24730 r#" timer until ticket.due_at as deadline"#,
24731 IrEffectKind::TimerWait,
24732 Some("deadline"),
24733 ),
24734 (
24735 "exec_raw",
24736 r#" exec "echo hi" as run"#,
24737 IrEffectKind::ExecCommand,
24738 Some("run"),
24739 ),
24740 (
24741 "queue_file",
24742 r#" file issue into backlog { title ticket.title body "body" } as filed"#,
24743 IrEffectKind::TrackerFile,
24744 Some("filed"),
24745 ),
24746 (
24747 "queue_claim",
24748 r#" claim item as lease"#,
24749 IrEffectKind::TrackerClaim,
24750 Some("lease"),
24751 ),
24752 (
24753 "queue_release",
24754 r#" release item"#,
24755 IrEffectKind::TrackerRelease,
24756 None,
24757 ),
24758 (
24759 "queue_finish",
24760 r#" finish item { summary ticket.title }"#,
24761 IrEffectKind::TrackerFinish,
24762 None,
24763 ),
24764 (
24765 "lease_acquire",
24766 r#" acquire workspace_slot for workspace until ttl as slot"#,
24767 IrEffectKind::LeaseAcquire,
24768 Some("slot"),
24769 ),
24770 (
24771 "ledger_append",
24772 r#" append LedgerEntry { area ticket.id text ticket.title } to review_log as entry"#,
24773 IrEffectKind::LedgerAppend,
24774 Some("entry"),
24775 ),
24776 (
24777 "counter_consume",
24778 r#" consume request_budget for ticket amount ticket.amount as spend
24779
24780 after spend ok {
24781 record Note { text "ok" }
24782 }
24783
24784 after spend over {
24785 record Note { text "over" }
24786 }"#,
24787 IrEffectKind::CounterConsume,
24788 Some("spend"),
24789 ),
24790 (
24791 "notify",
24792 r#" emit signal deploy.finished to ticket.id { service ticket.title status "ok" } as signal_sent"#,
24793 IrEffectKind::SignalEmit,
24794 Some("signal_sent"),
24795 ),
24796 (
24797 "file_read",
24798 r#" read text from docs at "note.md" as file_read"#,
24799 IrEffectKind::FileRead,
24800 Some("file_read"),
24801 ),
24802 (
24803 "file_write",
24804 r#" write text to docs at "out.md" { body ticket.title mode create } as file_write"#,
24805 IrEffectKind::FileWrite,
24806 Some("file_write"),
24807 ),
24808 (
24809 "file_import",
24810 r#" import json Row from docs at "rows.json" as imported"#,
24811 IrEffectKind::FileImport,
24812 Some("imported"),
24813 ),
24814 (
24815 "file_export",
24816 r#" export json Row to docs at "rows.json" { mode create } as exported"#,
24817 IrEffectKind::FileExport,
24818 Some("exported"),
24819 ),
24820 ];
24821
24822 for (case_name, body, kind, binding) in effect_cases {
24823 let rule = b1g_probe_rule(case_name, body);
24824 let effect = b1g_effect(&rule, kind, binding, case_name);
24825 match case_name {
24826 "send" => {
24827 assert_eq!(effect.resource.as_deref(), Some("ops_room"));
24828 assert_eq!(
24829 effect
24830 .construct_use
24831 .as_ref()
24832 .map(|use_| use_.keyword.as_str()),
24833 Some("send")
24834 );
24835 }
24836 "notify" => {
24837 assert_eq!(effect.resource.as_deref(), Some("signal:deploy.finished"));
24838 }
24839 "file_read" | "file_write" | "file_import" | "file_export" => {
24840 assert_eq!(effect.resource.as_deref(), Some("docs"));
24841 }
24842 _ => {}
24843 }
24844 }
24845
24846 let record = b1g_probe_rule("record", r#" record Note { text ticket.title }"#);
24847 assert!(record
24848 .metadata
24849 .fact_writes
24850 .contains(&"schema:Note".to_owned()));
24851 assert!(record
24852 .metadata
24853 .egress_payload_reads
24854 .get("fact:Note")
24855 .is_some_and(|roots| roots.contains("ticket")));
24856
24857 let done = b1g_probe_rule("done", r#" done ticket"#);
24858 assert!(done
24859 .metadata
24860 .fact_consumes
24861 .contains(&"schema:Ticket".to_owned()));
24862
24863 let done_replacement = b1g_probe_rule(
24864 "done_replacement",
24865 r#" done ticket -> record Note { text ticket.title }"#,
24866 );
24867 assert!(done_replacement
24868 .metadata
24869 .fact_consumes
24870 .contains(&"schema:Ticket".to_owned()));
24871 assert!(done_replacement
24872 .metadata
24873 .fact_writes
24874 .contains(&"schema:Note".to_owned()));
24875
24876 let complete = b1g_probe_rule("complete", r#" complete result { ok true }"#);
24877 assert!(complete
24878 .metadata
24879 .terminal_completes
24880 .contains(&"result".to_owned()));
24881
24882 let fail = b1g_probe_rule("fail", r#" fail error { reason "bad" }"#);
24883 assert_eq!(fail.metadata.effects, Vec::new());
24884
24885 let exec_each = b1g_probe_rule("exec_each", r#" exec "printf '{}'" -> each Row"#);
24886 b1g_effect(&exec_each, IrEffectKind::ExecCommand, None, "exec_each");
24887 assert!(exec_each
24888 .metadata
24889 .fact_writes
24890 .contains(&"schema:Row".to_owned()));
24891
24892 let bounded = b1g_probe_rule(
24893 "bounded_record",
24894 r#" record TicketPublic from ticket {
24895 id
24896 title
24897 }"#,
24898 );
24899 assert!(
24900 bounded
24901 .metadata
24902 .bounded_egresses
24903 .iter()
24904 .any(|egress| egress.sink == "fact:TicketPublic"
24905 && egress.keep == vec!["id".to_owned(), "title".to_owned()]),
24906 "{:?}",
24907 bounded.metadata.bounded_egresses
24908 );
24909
24910 let redaction = b1g_probe_rule(
24911 "redaction",
24912 r#" redact ticket keep [id, title] as safe
24913 record TicketPublic from safe {
24914 id
24915 title
24916 }"#,
24917 );
24918 assert!(redaction
24919 .metadata
24920 .redactions
24921 .iter()
24922 .any(|projection| projection.source == "ticket" && projection.binding == "safe"));
24923 assert!(redaction
24924 .metadata
24925 .fact_writes
24926 .contains(&"schema:TicketPublic".to_owned()));
24927 }
24928
24929 #[test]
24930 fn prompt_lowers_to_coerce_with_string_payload() {
24931 let source = r#"
24932workflow PromptText
24933
24934output result string
24935
24936class Ticket {
24937 title string
24938}
24939
24940rule ask
24941 when Ticket as ticket
24942=> {
24943 prompt "Summarize {{ ticket.title }}" using fixture as answer
24944
24945 after answer succeeds as text {
24946 complete result text
24947 }
24948}
24949"#;
24950
24951 let compiled = compile_program(source);
24952 assert!(
24953 compiled.diagnostics.is_empty(),
24954 "prompt program diagnostics: {:?}",
24955 compiled.diagnostics
24956 );
24957 let ir = compiled.ir.expect("program compiles");
24958 let rule = ir
24959 .rules
24960 .iter()
24961 .find(|rule| rule.name == "ask")
24962 .expect("ask rule");
24963 let effect = rule
24964 .metadata
24965 .effects
24966 .iter()
24967 .find(|effect| effect.binding.as_deref() == Some("answer"))
24968 .expect("prompt effect");
24969 assert_eq!(effect.kind, IrEffectKind::SchemaCoerce);
24970
24971 let coerce = ir
24972 .contract_registry()
24973 .effect_contracts
24974 .into_iter()
24975 .find(|contract| contract.id == "schema.coerce")
24976 .expect("coerce contract");
24977 assert!(coerce.source_forms.contains(&"prompt".to_owned()));
24978 }
24979
24980 #[test]
24981 fn parses_schema_agent_and_rule_slice() {
24982 let source = r#"
24983workflow QueueWorkerSlice
24984
24985use memory
24986
24987tracker backlog {
24988 provider builtin
24989}
24990
24991enum ReviewStatus {
24992 Accept
24993 Revise
24994}
24995
24996class WorkReview {
24997 state "accepted" | "rejected"
24998 status ReviewStatus
24999 followups string[]
25000 maybeReason string?
25001 scores map<int>
25002}
25003
25004coerce reviewWork(issueTitle string, changedFiles string[]) -> WorkReview {
25005 prompt """
25006 Review {{ issueTitle }} with files {{ changedFiles }}
25007 """
25008}
25009
25010agent worker {
25011 provider fixture
25012 profile "repo-writer"
25013 capacity 1
25014 skills ["repo-user"]
25015}
25016
25017rule start_ready_item
25018 when backlog has ready issue as item
25019 when worker is available
25020=> {
25021 claim item as claim
25022
25023 after claim succeeds {
25024 tell worker """
25025 Implement {{ item.title }}
25026 """
25027 }
25028}
25029"#;
25030
25031 let parsed = parse_program(source);
25032 assert_eq!(parsed.diagnostics, Vec::new());
25033 let workflow = parsed
25034 .program
25035 .workflow
25036 .as_ref()
25037 .map(|ident| ident.name.as_str());
25038 assert_eq!(workflow, Some("QueueWorkerSlice"));
25039 assert_eq!(parsed.program.items.len(), 7);
25040
25041 let coerce = parsed.program.items.iter().find_map(|item| match item {
25042 Item::Coerce(coerce) => Some(coerce),
25043 _ => None,
25044 });
25045 let coerce = match coerce {
25046 Some(coerce) => coerce,
25047 None => panic!("expected coerce item"),
25048 };
25049 assert_eq!(coerce.params.len(), 2);
25050
25051 let rule = parsed.program.items.iter().find_map(|item| match item {
25052 Item::Rule(rule) => Some(rule),
25053 _ => None,
25054 });
25055 let rule = match rule {
25056 Some(rule) => rule,
25057 None => panic!("expected rule item"),
25058 };
25059 assert_eq!(rule.whens.len(), 2);
25060 assert_eq!(rule.whens[0].text, "backlog has ready issue as item");
25061 assert!(rule.body.text.contains("after claim succeeds"));
25062 }
25063
25064 #[test]
25065 fn parses_and_lowers_static_table_rows() {
25066 let source = r#"
25067workflow TableSeed
25068
25069agent codex {
25070 provider codex
25071 profile "repo-writer"
25072 capacity 1
25073}
25074
25075class Task {
25076 provider AgentRef<codex>
25077 title string
25078 priority int
25079 status "queued"
25080}
25081
25082table tasks as Task [
25083 {
25084 provider codex
25085 title "Review parser"
25086 priority 1
25087 status "queued"
25088 }
25089
25090 {
25091 provider codex
25092 title "Review runtime"
25093 priority 2
25094 status "queued"
25095 }
25096]
25097"#;
25098
25099 let parsed = parse_program(source);
25100 assert_eq!(parsed.diagnostics, Vec::new());
25101 let table = parsed
25102 .program
25103 .items
25104 .iter()
25105 .find_map(|item| match item {
25106 Item::Table(table) => Some(table),
25107 _ => None,
25108 })
25109 .expect("table item");
25110 assert_eq!(table.rows.len(), 2);
25111 let row_spans = table.rows.iter().map(|row| row.span).collect::<Vec<_>>();
25112
25113 let compiled = compile_program(source);
25114 let ir = compiled
25115 .ir
25116 .unwrap_or_else(|| panic!("source compiles: {:?}", compiled.diagnostics));
25117 let table_rule = ir
25118 .rules
25119 .iter()
25120 .find(|rule| rule.name == "table_tasks")
25121 .expect("table lowers to generated started rule");
25122 assert_eq!(table_rule.whens[0].pattern, "started");
25123 assert!(table_rule.body.contains("record Task"));
25124 assert_eq!(table_rule.metadata.fact_writes, vec!["schema:Task"]);
25125 assert_eq!(table_rule.metadata.record_sources.len(), 2);
25126 assert_eq!(
25127 table_rule
25128 .metadata
25129 .record_sources
25130 .iter()
25131 .map(|source| (
25132 source.schema.as_str(),
25133 source.construct.as_str(),
25134 source.span
25135 ))
25136 .collect::<Vec<_>>(),
25137 row_spans
25138 .iter()
25139 .map(|span| ("Task", "table_row", *span))
25140 .collect::<Vec<_>>()
25141 );
25142 }
25143
25144 #[test]
25145 fn rejects_old_matrix_declarations() {
25146 let source = r#"
25147workflow MatrixSeed
25148
25149class Task {
25150 title string
25151 status "queued"
25152}
25153
25154matrix tasks as Task [
25155 {
25156 title "Review parser"
25157 status "queued"
25158 }
25159]
25160"#;
25161
25162 let compiled = compile_program(source);
25163 assert!(compiled.ir.is_none());
25164 assert!(compiled.diagnostics.iter().any(|diagnostic| {
25165 diagnostic
25166 .message
25167 .contains("expected top-level declaration, found identifier `matrix`")
25168 }));
25169 }
25170
25171 #[test]
25172 fn rejects_table_rows_that_violate_row_schema() {
25173 let source = r#"
25174workflow BadTable
25175
25176agent codex {
25177 provider codex
25178 profile "repo-writer"
25179 capacity 1
25180}
25181
25182class Task {
25183 provider AgentRef<codex>
25184 status "queued"
25185}
25186
25187table tasks as Task [
25188 {
25189 provider "codex"
25190 status "done"
25191 }
25192]
25193"#;
25194
25195 let compiled = compile_program(source);
25196
25197 assert!(compiled.ir.is_none());
25198 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
25199 .message
25200 .contains("expects an AgentRef value, not string `codex`")));
25201 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
25202 .message
25203 .contains("expects literal string `queued`")));
25204 }
25205
25206 #[test]
25207 fn parses_formats_and_lowers_source_tags_as_metadata() {
25208 let source = r#"
25209@fixture
25210@release-gate
25211workflow Tagged
25212
25213class Task {
25214 status "queued"
25215}
25216
25217@seed
25218table tasks as Task [
25219 {
25220 status "queued"
25221 }
25222]
25223
25224@acceptance
25225assert count(Task where status == "queued") == 1
25226
25227@dispatch
25228rule consume_task
25229 when Task as task
25230=> {
25231 done task
25232}
25233"#;
25234
25235 let parsed = parse_program(source);
25236 assert_eq!(parsed.diagnostics, Vec::new());
25237 assert_eq!(
25238 parsed
25239 .program
25240 .workflow_tags
25241 .iter()
25242 .map(|tag| tag.name.as_str())
25243 .collect::<Vec<_>>(),
25244 vec!["fixture", "release-gate"]
25245 );
25246
25247 let formatted = format_program(source).formatted.expect("formats");
25248 assert!(formatted.contains("@fixture\n@release-gate\nworkflow Tagged"));
25249 assert!(formatted.contains("@seed\ntable tasks as Task"));
25250 assert!(formatted.contains("@acceptance\nassert count"));
25251 assert!(formatted.contains("@dispatch\nrule consume_task"));
25252
25253 let compiled = compile_program(source);
25254 let ir = compiled
25255 .ir
25256 .unwrap_or_else(|| panic!("source compiles: {:?}", compiled.diagnostics));
25257 let tags = ir
25258 .source_tags
25259 .iter()
25260 .map(|tag| {
25261 (
25262 tag.name.as_str(),
25263 tag.target_kind.as_str(),
25264 tag.target.as_str(),
25265 )
25266 })
25267 .collect::<Vec<_>>();
25268 assert!(tags.contains(&("fixture", "workflow", "Tagged")));
25269 assert!(tags.contains(&("release-gate", "workflow", "Tagged")));
25270 assert!(tags.contains(&("seed", "table", "tasks")));
25271 assert!(tags.contains(&("dispatch", "rule", "consume_task")));
25272 assert!(ir
25273 .source_tags
25274 .iter()
25275 .any(|tag| tag.name == "acceptance" && tag.target_kind == "assertion"));
25276 }
25277
25278 #[test]
25279 fn parses_formats_and_lowers_source_descriptions_as_metadata() {
25280 let source = r#"
25281@fixture
25282description "Fixture-backed acceptance workflow"
25283workflow Described
25284
25285class Task {
25286 status "queued"
25287}
25288
25289description "Static task seed rows"
25290table tasks as Task [
25291 {
25292 status "queued"
25293 }
25294]
25295
25296description "All seed tasks were consumed"
25297assert count(Task where status == "queued") == 0
25298
25299description "Consume one queued task"
25300rule consume_task
25301 when Task as task
25302=> {
25303 done task
25304}
25305"#;
25306
25307 let parsed = parse_program(source);
25308 assert_eq!(parsed.diagnostics, Vec::new());
25309 assert_eq!(
25310 parsed
25311 .program
25312 .workflow_description
25313 .as_ref()
25314 .map(|description| description.value.as_str()),
25315 Some("Fixture-backed acceptance workflow")
25316 );
25317
25318 let formatted = format_program(source).formatted.expect("formats");
25319 assert!(formatted.contains(
25320 "@fixture\ndescription \"Fixture-backed acceptance workflow\"\nworkflow Described"
25321 ));
25322 assert!(formatted.contains("description \"Static task seed rows\"\ntable tasks as Task"));
25323 assert!(formatted.contains("description \"All seed tasks were consumed\"\nassert count"));
25324 assert!(formatted.contains("description \"Consume one queued task\"\nrule consume_task"));
25325
25326 let compiled = compile_program(source);
25327 let ir = compiled
25328 .ir
25329 .unwrap_or_else(|| panic!("source compiles: {:?}", compiled.diagnostics));
25330 let descriptions = ir
25331 .source_descriptions
25332 .iter()
25333 .map(|description| {
25334 (
25335 description.value.as_str(),
25336 description.target_kind.as_str(),
25337 description.target.as_str(),
25338 )
25339 })
25340 .collect::<Vec<_>>();
25341 assert!(descriptions.contains(&(
25342 "Fixture-backed acceptance workflow",
25343 "workflow",
25344 "Described"
25345 )));
25346 assert!(descriptions.contains(&("Static task seed rows", "table", "tasks")));
25347 assert!(descriptions.contains(&("Consume one queued task", "rule", "consume_task")));
25348 assert!(ir
25349 .source_descriptions
25350 .iter()
25351 .any(
25352 |description| description.value == "All seed tasks were consumed"
25353 && description.target_kind == "assertion"
25354 ));
25355 }
25356
25357 #[test]
25358 fn rejects_descriptions_on_unsupported_declarations_for_now() {
25359 let source = r#"
25360workflow BadDescriptions
25361
25362description "Task schema"
25363class Task {
25364 status "queued"
25365}
25366"#;
25367
25368 let parsed = parse_program(source);
25369
25370 assert_eq!(parsed.diagnostics.len(), 1);
25371 assert_eq!(
25372 parsed.diagnostics[0].message,
25373 "description cannot be attached to class"
25374 );
25375 }
25376
25377 #[test]
25378 fn rejects_tags_on_unsupported_declarations_for_now() {
25379 let source = r#"
25380workflow BadTags
25381
25382@schema
25383class Task {
25384 status "queued"
25385}
25386"#;
25387
25388 let parsed = parse_program(source);
25389
25390 assert_eq!(parsed.diagnostics.len(), 1);
25391 assert_eq!(
25392 parsed.diagnostics[0].message,
25393 "tag `@schema` cannot be attached to class"
25394 );
25395 }
25396
25397 #[test]
25398 fn use_short_form_imports_package_libraries_and_rejects_removed_kinds() {
25399 let parsed = parse_program("workflow Imports\n\nuse memory\n");
25400 assert_eq!(parsed.diagnostics, Vec::new());
25401 let use_decl = parsed.program.items.iter().find_map(|item| match item {
25402 Item::Use(use_decl) => Some(use_decl),
25403 _ => None,
25404 });
25405 assert_eq!(
25406 use_decl.map(|decl| decl.name.value.as_str()),
25407 Some("memory")
25408 );
25409
25410 let removed_plugin = parse_program("workflow Imports\n\nuse plugin \"memory\"\n");
25411 assert_eq!(removed_plugin.diagnostics.len(), 1);
25412 assert_eq!(
25413 removed_plugin.diagnostics[0].message,
25414 "`use plugin` is no longer supported"
25415 );
25416
25417 let removed_skill = parse_program("workflow Imports\n\nuse skill \"repo-user\"\n");
25418 assert_eq!(removed_skill.diagnostics.len(), 1);
25419 assert_eq!(
25420 removed_skill.diagnostics[0].message,
25421 "`use skill` is no longer supported"
25422 );
25423 }
25424
25425 #[test]
25426 fn parses_include_declarations_and_records_ir_metadata() {
25427 let source = r#"include "library.whip"
25428
25429workflow Imports
25430
25431class Task {
25432 id string
25433}
25434"#;
25435 let parsed = parse_program(source);
25436 assert_eq!(parsed.diagnostics, Vec::new());
25437 let include = parsed.program.items.iter().find_map(|item| match item {
25438 Item::Include(include) => Some(include),
25439 _ => None,
25440 });
25441 assert_eq!(
25442 include.map(|decl| decl.path.value.as_str()),
25443 Some("library.whip")
25444 );
25445
25446 let compiled = compile_program(source);
25447 let ir = compiled.ir.expect("source compiles");
25448 assert_eq!(ir.includes[0].path, "library.whip");
25449 assert!(ir.to_snapshot().contains("includes\n library.whip\n"));
25450 }
25451
25452 #[test]
25453 fn parses_explicit_workflow_block_and_contracts() {
25454 let source = r#"
25455workflow ReviewPhase {
25456 input phase PhaseReviewRequest
25457 output result PhaseReviewResult
25458 failure error ReviewFailure
25459
25460 class PhaseReviewRequest {
25461 title string
25462 }
25463
25464 class PhaseReviewResult {
25465 accepted bool
25466 }
25467
25468 class ReviewFailure {
25469 reason string
25470 }
25471
25472 rule noop
25473 when started
25474 => {
25475 }
25476}
25477"#;
25478 let compiled = compile_program(source);
25479 assert_eq!(compiled.diagnostics, Vec::new());
25480 let ir = compiled.ir.expect("source compiles");
25481 assert_eq!(ir.workflow, "ReviewPhase");
25482 assert_eq!(ir.workflow_contracts.len(), 3);
25483 let snapshot = ir.to_snapshot();
25484 assert!(snapshot.contains("workflow_contracts\n input phase ref<PhaseReviewRequest>"));
25485 assert!(snapshot.contains(" output result ref<PhaseReviewResult>"));
25486 assert!(snapshot.contains(" failure error ref<ReviewFailure>"));
25487 }
25488
25489 #[test]
25490 fn revision_fixture_bundles_compile_with_expected_contract_shapes() {
25491 let compatible_v1 =
25492 compile_program(include_str!("../fixtures/revision-compatible-v1.whip"));
25493 let compatible_v2 =
25494 compile_program(include_str!("../fixtures/revision-compatible-v2.whip"));
25495 let incompatible_v2 =
25496 compile_program(include_str!("../fixtures/revision-incompatible-v2.whip"));
25497 for compiled in [&compatible_v1, &compatible_v2, &incompatible_v2] {
25498 assert_eq!(compiled.diagnostics, Vec::new());
25499 }
25500 let compatible_v1 = compatible_v1.ir.expect("compatible v1 compiles");
25501 let compatible_v2 = compatible_v2.ir.expect("compatible v2 compiles");
25502 let incompatible_v2 = incompatible_v2.ir.expect("incompatible v2 compiles");
25503
25504 assert_eq!(compatible_v1.workflow, "RevisionFixture");
25505 assert_eq!(compatible_v2.workflow, "RevisionFixture");
25506 assert_eq!(incompatible_v2.workflow, "RevisionFixture");
25507 assert_eq!(
25508 compatible_v1
25509 .workflow_contracts
25510 .iter()
25511 .map(|contract| (&contract.kind, contract.name.as_str(), &contract.ty))
25512 .collect::<Vec<_>>(),
25513 compatible_v2
25514 .workflow_contracts
25515 .iter()
25516 .map(|contract| (&contract.kind, contract.name.as_str(), &contract.ty))
25517 .collect::<Vec<_>>()
25518 );
25519 assert_ne!(
25520 compatible_v1
25521 .workflow_contracts
25522 .iter()
25523 .map(|contract| (&contract.kind, contract.name.as_str(), &contract.ty))
25524 .collect::<Vec<_>>(),
25525 incompatible_v2
25526 .workflow_contracts
25527 .iter()
25528 .map(|contract| (&contract.kind, contract.name.as_str(), &contract.ty))
25529 .collect::<Vec<_>>()
25530 );
25531 assert!(compatible_v2
25532 .schemas
25533 .iter()
25534 .any(|schema| matches!(schema, IrSchema::Class(class) if class.name == "AuditTrail")));
25535 }
25536
25537 #[test]
25538 fn expands_pattern_applications_with_hygienic_names() {
25539 let source = r#"
25540pattern Review<Input> {
25541 class Result {
25542 item Input
25543 }
25544
25545 rule dispatch
25546 when Input as item
25547 => {
25548 }
25549}
25550
25551workflow Root {
25552 class Task {
25553 title string
25554 }
25555
25556 apply Review<Task> as taskReview {
25557 }
25558}
25559"#;
25560 let compiled = compile_program(source);
25561 assert_eq!(compiled.diagnostics, Vec::new());
25562 let ir = compiled.ir.expect("source compiles");
25563 let snapshot = ir.to_snapshot();
25564 assert!(snapshot.contains("pattern_applications\n Review as taskReview<ref<Task>>"));
25565 assert!(snapshot.contains(" generated class:taskReview_Result"));
25566 assert!(snapshot.contains(" generated rule:taskReview_dispatch"));
25567 assert!(snapshot.contains("class taskReview_Result"));
25568 assert!(snapshot.contains(" item ref<Task>"));
25569 assert!(snapshot.contains("rule taskReview_dispatch"));
25570 assert!(snapshot.contains(" when Task as item"));
25571 }
25572
25573 #[test]
25574 fn pattern_application_records_definition_and_application_spans() {
25575 let source = r#"
25576pattern Review<Input> {
25577 rule dispatch
25578 when Input as item
25579 => {
25580 }
25581}
25582
25583workflow Root {
25584 class Task {
25585 title string
25586 }
25587
25588 apply Review<Task> as taskReview {
25589 }
25590}
25591"#;
25592 let compiled = compile_program(source);
25593 assert_eq!(compiled.diagnostics, Vec::new());
25594 let ir = compiled.ir.expect("source compiles");
25595 let application = ir
25596 .pattern_applications
25597 .first()
25598 .expect("one pattern application");
25599
25600 let definition =
25603 &source[application.definition_span.start..application.definition_span.end];
25604 assert!(definition.starts_with("pattern Review"));
25605 assert!(definition.ends_with('}'));
25606 let application_site =
25607 &source[application.application_span.start..application.application_span.end];
25608 assert!(application_site.starts_with("apply Review<Task> as taskReview"));
25609 assert!(application_site.ends_with('}'));
25610
25611 let snapshot = ir.to_snapshot();
25612 assert!(snapshot.contains(&format!(
25613 " defined-at {}..{}",
25614 application.definition_span.start, application.definition_span.end
25615 )));
25616 assert!(snapshot.contains(&format!(
25617 " applied-at {}..{}",
25618 application.application_span.start, application.application_span.end
25619 )));
25620 }
25621
25622 #[test]
25623 fn rejects_terminal_statement_in_pattern_body() {
25624 let source = r#"
25625pattern Finisher<Input> {
25626 rule wrap_up
25627 when Input as item
25628 => {
25629 complete result {
25630 done 1
25631 }
25632 }
25633}
25634
25635workflow Root {
25636 output result Summary
25637
25638 class Summary {
25639 done int
25640 }
25641
25642 class Task {
25643 title string
25644 }
25645
25646 apply Finisher<Task> as finish {
25647 }
25648}
25649"#;
25650 let compiled = compile_program(source);
25651 assert!(compiled.ir.is_none());
25652 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
25653 .message
25654 .contains("cannot reach a workflow terminal")));
25655 }
25656
25657 #[test]
25658 fn rejects_workflow_contract_in_pattern_body() {
25659 let source = r#"
25660pattern Contracted<Input> {
25661 output result Input
25662
25663 rule dispatch
25664 when Input as item
25665 => {
25666 }
25667}
25668
25669workflow Root {
25670 class Task {
25671 title string
25672 }
25673
25674 apply Contracted<Task> as contracted {
25675 }
25676}
25677"#;
25678 let compiled = compile_program(source);
25679 assert!(compiled.ir.is_none());
25680 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
25681 .message
25682 .contains("workflow contracts are not allowed in pattern bodies")));
25683 }
25684
25685 #[test]
25686 fn parses_workflow_invoke_effect_metadata() {
25687 let source = r#"
25688workflow Parent {
25689 class Task {
25690 title string
25691 }
25692
25693 rule dispatch
25694 when Task as task
25695 => {
25696 invoke Child { task task } as child
25697 }
25698}
25699
25700workflow Child {
25701 input task Task
25702
25703 class Task {
25704 title string
25705 }
25706}
25707"#;
25708 let compiled = compile_program_with_root(source, Some("Parent"));
25709 assert_eq!(compiled.diagnostics, Vec::new());
25710 let ir = compiled.ir.expect("source compiles");
25711 let rule = ir
25712 .rules
25713 .iter()
25714 .find(|rule| rule.name == "dispatch")
25715 .expect("dispatch rule lowers");
25716 assert_eq!(rule.metadata.effects.len(), 1);
25717 assert_eq!(rule.metadata.effects[0].kind, IrEffectKind::WorkflowInvoke);
25718 assert_eq!(rule.metadata.effects[0].binding.as_deref(), Some("child"));
25719 assert_eq!(
25720 rule.metadata.effects[0].workflow_target.as_deref(),
25721 Some("Child")
25722 );
25723 assert!(ir
25724 .to_snapshot()
25725 .contains("child kind=workflow.invoke binding=child"));
25726 }
25727
25728 #[test]
25729 fn rejects_unknown_workflow_invocation_target() {
25730 let source = r#"
25731workflow Parent {
25732 class Task {
25733 title string
25734 }
25735
25736 rule dispatch
25737 when Task as task
25738 => {
25739 invoke Missing { task task } as child
25740 }
25741}
25742"#;
25743 let compiled = compile_program(source);
25744 assert!(compiled.ir.is_none());
25745 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
25746 .message
25747 .contains("invokes unknown workflow `Missing`")));
25748 }
25749
25750 #[test]
25751 fn validates_workflow_invocation_inputs_against_target_contract() {
25752 let source = r#"
25753workflow Parent {
25754 class Task {
25755 title string
25756 }
25757
25758 rule dispatch
25759 when Task as task
25760 => {
25761 invoke Child { wrong task } as child
25762 }
25763}
25764
25765workflow Child {
25766 input task Task
25767
25768 class Task {
25769 title string
25770 }
25771}
25772"#;
25773 let compiled = compile_program_with_root(source, Some("Parent"));
25774 assert!(compiled.ir.is_none());
25775 let messages = compiled
25776 .diagnostics
25777 .iter()
25778 .map(|diagnostic| diagnostic.message.as_str())
25779 .collect::<Vec<_>>();
25780 assert!(
25781 messages
25782 .iter()
25783 .any(|message| message.contains("workflow `Child` has no input `wrong`")),
25784 "{messages:#?}"
25785 );
25786 assert!(
25787 messages
25788 .iter()
25789 .any(|message| message
25790 .contains("workflow invocation `Child` is missing input `task`")),
25791 "{messages:#?}"
25792 );
25793 }
25794
25795 #[test]
25796 fn validates_nested_workflow_invocation_input_payloads() {
25797 let source = r#"
25798workflow Parent {
25799 class Task {
25800 title string
25801 }
25802
25803 rule dispatch
25804 when Task as task
25805 => {
25806 invoke Child { task { count "bad" } } as child
25807 }
25808}
25809
25810workflow Child {
25811 input task ChildTask
25812
25813 class ChildTask {
25814 count int
25815 }
25816}
25817"#;
25818 let compiled = compile_program_with_root(source, Some("Parent"));
25819 assert!(compiled.ir.is_none());
25820 assert!(compiled.diagnostics.iter().any(|diagnostic| {
25821 diagnostic
25822 .message
25823 .contains("field `ChildTask.count` expects `int`")
25824 }));
25825 }
25826
25827 #[test]
25828 fn rejects_direct_recursive_workflow_invocation() {
25829 let source = r#"
25830workflow Parent {
25831 input task Task
25832
25833 class Task {
25834 title string
25835 }
25836
25837 rule dispatch
25838 when Task as task
25839 => {
25840 invoke Parent { task task } as next
25841 }
25842}
25843"#;
25844 let compiled = compile_program(source);
25845 assert!(compiled.ir.is_none());
25846 assert!(compiled.diagnostics.iter().any(|diagnostic| {
25847 diagnostic
25848 .message
25849 .contains("recursively invokes workflow `Parent`")
25850 }));
25851 }
25852
25853 #[test]
25854 fn expands_pattern_application_value_arguments() {
25855 let source = r#"
25856pattern Review<Input> {
25857 rule dispatch
25858 when Input as item
25859 => {
25860 }
25861}
25862
25863workflow Root {
25864 class Task {
25865 title string
25866 }
25867
25868 apply Review<Task> as taskReview {
25869 item task
25870 }
25871}
25872"#;
25873 let compiled = compile_program(source);
25874 assert_eq!(compiled.diagnostics, Vec::new());
25875 let snapshot = compiled.ir.expect("source compiles").to_snapshot();
25876 assert!(snapshot.contains(" arg item task"));
25877 }
25878
25879 #[test]
25880 fn rejects_malformed_pattern_application_arguments() {
25881 let source = r#"
25882pattern Review<Input> {
25883 rule dispatch
25884 when Input as item
25885 => {
25886 }
25887}
25888
25889workflow Root {
25890 class Task {
25891 title string
25892 }
25893
25894 apply Review<Task> as taskReview {
25895 item
25896 item task
25897 }
25898}
25899"#;
25900 let compiled = compile_program(source);
25901 assert!(compiled.ir.is_none());
25902 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
25903 .message
25904 .contains("argument `item` is missing a value")));
25905 }
25906
25907 #[test]
25908 fn rejects_unknown_workflow_terminal_actions() {
25909 let source = r#"
25910workflow BadTerminal {
25911 output result Result
25912
25913 class Result {
25914 status "ok"
25915 }
25916
25917 rule bad
25918 when started
25919 => {
25920 complete missing {
25921 status "ok"
25922 }
25923 }
25924}
25925"#;
25926 let compiled = compile_program(source);
25927 assert!(compiled.ir.is_none());
25928 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
25929 .message
25930 .contains("completes unknown workflow terminal `missing`")));
25931 }
25932
25933 #[test]
25934 fn rejects_duplicate_workflow_inputs() {
25935 let source = r#"
25936workflow DuplicateInput {
25937 input phase PhaseRequest
25938 input phase PhaseRequest
25939
25940 class PhaseRequest {
25941 title string
25942 }
25943
25944 rule noop
25945 when started
25946 => {
25947 }
25948}
25949"#;
25950 let compiled = compile_program(source);
25951 assert!(compiled.ir.is_none());
25952 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
25953 .message
25954 .contains("workflow declares input `phase` more than once")));
25955 }
25956
25957 #[test]
25958 fn rejects_with_as_rule_readiness_alias() {
25959 let source = r#"
25960workflow WithIsNotWhen
25961
25962rule bad
25963 with started
25964=> {
25965}
25966"#;
25967 let compiled = compile_program(source);
25968
25969 assert!(compiled.ir.is_none());
25970 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
25971 .message
25972 .contains("`with` is not a rule readiness clause")));
25973 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
25974 .suggestion
25975 .as_deref()
25976 .is_some_and(|suggestion| suggestion.contains("use `when` for rule conditions"))));
25977 }
25978
25979 #[test]
25980 fn parses_grouped_when_clauses_as_ordinary_readiness_clauses() {
25981 let source = r#"
25982workflow GroupedWhen
25983
25984class Task {
25985 status "queued"
25986}
25987
25988agent worker {
25989 provider fixture
25990 profile "repo-writer"
25991 capacity 1
25992}
25993
25994rule start
25995 when {
25996 Task as task where task.status == "queued"
25997 worker is available
25998 }
25999=> {
26000 tell worker "do it"
26001}
26002"#;
26003 let compiled = compile_program(source);
26004 let ir = compiled.ir.expect("program compiles");
26005 let rule = &ir.rules[0];
26006
26007 assert_eq!(rule.whens.len(), 2);
26008 assert_eq!(rule.whens[0].pattern, "Task as task");
26009 assert_eq!(
26010 rule.whens[0]
26011 .guard
26012 .as_ref()
26013 .map(|guard| guard.expr.to_snapshot()),
26014 Some("task.status == \"queued\"".to_owned())
26015 );
26016 assert_eq!(rule.whens[1].pattern, "worker is available");
26017 assert!(ir
26018 .to_snapshot()
26019 .contains(" when Task as task where task.status == \"queued\""));
26020 assert!(ir.to_snapshot().contains(" when worker is available"));
26021 }
26022
26023 #[test]
26024 fn accepts_harness_declarations_and_agent_bindings() {
26025 let source = r#"
26026workflow HarnessTopology
26027
26028harness coder: codex
26029harness reviewer: claude
26030
26031agent implementer using coder {
26032 profile "repo-writer"
26033 capacity 1
26034}
26035
26036agent critic using reviewer {
26037 profile "repo-reader"
26038 capacity 1
26039}
26040
26041rule start
26042 when started
26043=> {
26044 tell implementer as turn "implement"
26045}
26046"#;
26047
26048 let compiled = compile_program(source);
26049 assert_eq!(compiled.diagnostics, Vec::new());
26050 let ir = compiled.ir.expect("program compiles");
26051 assert_eq!(ir.harnesses.len(), 2);
26052 assert_eq!(ir.harnesses[0].name, "coder");
26053 assert_eq!(ir.harnesses[0].kind, "codex");
26054 assert_eq!(
26055 ir.agents
26056 .iter()
26057 .find(|agent| agent.name == "implementer")
26058 .and_then(|agent| agent.harness.as_deref()),
26059 Some("coder")
26060 );
26061 let snapshot = ir.to_snapshot();
26062 assert!(snapshot.contains("harness coder kind=codex"));
26063 assert!(snapshot.contains("agent implementer harness=coder"));
26064 }
26065
26066 #[test]
26067 fn rejects_agent_binding_to_unknown_harness() {
26068 let source = r#"
26069workflow UnknownHarness
26070
26071agent worker using missing {
26072 profile "repo-writer"
26073 capacity 1
26074}
26075"#;
26076
26077 let compiled = compile_program(source);
26078 assert!(compiled.ir.is_none());
26079 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
26080 .message
26081 .contains("agent `worker` uses unknown harness `missing`")));
26082 }
26083
26084 #[test]
26085 fn rejects_duplicate_harness_declarations_and_accepts_kinds_structurally() {
26086 let source = r#"
26087workflow BadHarnesses
26088
26089harness coder: spaceship
26090harness coder: codex
26091
26092agent worker using coder {
26093 profile "repo-writer"
26094 capacity 1
26095}
26096"#;
26097
26098 let compiled = compile_program(source);
26099 assert!(compiled.ir.is_none());
26100 let messages = compiled
26101 .diagnostics
26102 .iter()
26103 .map(|diagnostic| diagnostic.message.as_str())
26104 .collect::<Vec<_>>();
26105 assert!(
26106 messages
26107 .iter()
26108 .any(|message| message.contains("harness `coder` is declared more than once")),
26109 "{messages:#?}"
26110 );
26111 assert!(
26116 !messages
26117 .iter()
26118 .any(|message| message.contains("unsupported kind")),
26119 "{messages:#?}"
26120 );
26121 }
26122
26123 #[test]
26124 fn validates_workflow_terminal_payload_fields() {
26125 let source = r#"
26126workflow BadTerminalPayload {
26127 output result Result
26128
26129 class Result {
26130 status "ok"
26131 summary string
26132 }
26133
26134 rule bad
26135 when started
26136 => {
26137 complete result {
26138 status "bad"
26139 extra "ignored"
26140 }
26141 }
26142}
26143"#;
26144 let compiled = compile_program(source);
26145 assert!(compiled.ir.is_none());
26146 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
26147 .message
26148 .contains("field `Result.status` expects literal string `ok`")));
26149 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
26150 .message
26151 .contains("class `Result` has no field `extra`")));
26152 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
26153 .message
26154 .contains("workflow terminal `result` is missing required field `Result.summary`")));
26155 }
26156
26157 #[test]
26158 fn accepts_workflow_terminal_actions_in_header_style_workflows() {
26159 let source = r#"
26160workflow ImplicitTerminal
26161
26162output result Result
26163
26164class Result {
26165 status "ok"
26166}
26167
26168rule finish
26169 when started
26170=> {
26171 complete result {
26172 status "ok"
26173 }
26174}
26175"#;
26176 let compiled = compile_program(source);
26177 assert_eq!(compiled.diagnostics, Vec::new());
26178 let ir = compiled.ir.expect("header-style terminals compile");
26179 assert_eq!(ir.workflow_contracts.len(), 1);
26180 }
26181
26182 #[test]
26183 fn rejects_header_style_terminal_for_undeclared_contract() {
26184 let source = r#"
26185workflow ImplicitTerminal
26186
26187class Result {
26188 status "ok"
26189}
26190
26191rule bad
26192 when started
26193=> {
26194 complete result {
26195 status "ok"
26196 }
26197}
26198"#;
26199 let compiled = compile_program(source);
26200 assert!(compiled.ir.is_none());
26201 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
26202 .message
26203 .contains("completes unknown workflow terminal `result`")));
26204 }
26205
26206 #[test]
26207 fn scalar_terminal_contract_accepts_bare_value() {
26208 let source = r#"
26210workflow ScalarTerminal {
26211 output result float
26212 failure error string
26213
26214 rule good
26215 when started
26216 => {
26217 complete result 0.9
26218 }
26219}
26220"#;
26221 let compiled = compile_program(source);
26222 assert!(
26223 compiled.diagnostics.is_empty(),
26224 "scalar terminal payload rejected: {:?}",
26225 compiled.diagnostics
26226 );
26227 assert!(compiled.ir.is_some());
26228 }
26229
26230 #[test]
26231 fn scalar_terminal_contract_rejects_a_field_block() {
26232 let source = r#"
26234workflow ScalarTerminal {
26235 output result float
26236
26237 rule bad
26238 when started
26239 => {
26240 complete result {
26241 value 0.9
26242 }
26243 }
26244}
26245"#;
26246 let compiled = compile_program(source);
26247 assert!(compiled.ir.is_none());
26248 assert!(
26249 compiled.diagnostics.iter().any(|d| d
26250 .message
26251 .contains("has a scalar payload contract but is given a field block")),
26252 "{:?}",
26253 compiled.diagnostics
26254 );
26255 }
26256
26257 #[test]
26258 fn class_terminal_contract_rejects_a_bare_scalar() {
26259 let source = r#"
26261workflow ClassTerminal {
26262 output result Score
26263 class Score { value number }
26264
26265 rule bad
26266 when started
26267 => {
26268 complete result 0.9
26269 }
26270}
26271"#;
26272 let compiled = compile_program(source);
26273 assert!(compiled.ir.is_none());
26274 assert!(
26275 compiled.diagnostics.iter().any(|d| d
26276 .message
26277 .contains("has a class payload contract `Score` but is given a bare scalar value")),
26278 "{:?}",
26279 compiled.diagnostics
26280 );
26281 }
26282
26283 #[test]
26284 fn scalar_terminal_value_is_typechecked_against_the_contract() {
26285 let source = r#"
26287workflow ScalarTerminal {
26288 output result float
26289
26290 rule bad
26291 when started
26292 => {
26293 complete result "not a number"
26294 }
26295}
26296"#;
26297 let compiled = compile_program(source);
26298 assert!(compiled.ir.is_none());
26299 assert!(
26300 compiled
26301 .diagnostics
26302 .iter()
26303 .any(|d| d.message.contains("result.value") || d.message.contains("number")),
26304 "{:?}",
26305 compiled.diagnostics
26306 );
26307 }
26308
26309 #[test]
26310 fn selects_root_from_multiple_explicit_workflows() {
26311 let source = r#"
26312class Shared {
26313 id string
26314}
26315
26316workflow First {
26317 rule one
26318 when started
26319 => {
26320 record Shared {
26321 id "first"
26322 }
26323 }
26324}
26325
26326workflow Second {
26327 rule two
26328 when started
26329 => {
26330 record Shared {
26331 id "second"
26332 }
26333 }
26334}
26335"#;
26336 let ambiguous = compile_program(source);
26337 assert!(ambiguous.ir.is_none());
26338 assert!(ambiguous.diagnostics.iter().any(|diagnostic| diagnostic
26339 .message
26340 .contains("multiple workflow declarations require an explicit root")));
26341
26342 let compiled = compile_program_with_root(source, Some("Second"));
26343 assert_eq!(compiled.diagnostics, Vec::new());
26344 let ir = compiled.ir.expect("selected root compiles");
26345 assert_eq!(ir.workflow, "Second");
26346 assert_eq!(ir.rules.len(), 1);
26347 assert_eq!(ir.rules[0].name, "two");
26348 assert!(ir.to_snapshot().contains("class Shared"));
26349 }
26350
26351 #[test]
26352 fn reports_recoverable_diagnostics() {
26353 let source = r#"
26354workflow Broken
26355
26356agent worker {
26357 provider fixture
26358 profile 42
26359 capacity nope
26360}
26361
26362rule missing_body
26363 when started
26364=>
26365"#;
26366
26367 let parsed = parse_program(source);
26368 assert!(parsed.diagnostics.len() >= 3);
26369 assert!(parsed
26370 .diagnostics
26371 .iter()
26372 .any(|diagnostic| diagnostic.message.contains("profile string")));
26373 assert!(parsed
26374 .diagnostics
26375 .iter()
26376 .any(|diagnostic| diagnostic.suggestion.as_deref()
26377 == Some("write `profile \"profile-name\"`")));
26378 assert!(parsed
26379 .diagnostics
26380 .iter()
26381 .any(|diagnostic| diagnostic.message.contains("capacity value")));
26382 assert!(parsed
26383 .diagnostics
26384 .iter()
26385 .any(|diagnostic| diagnostic.message.contains("`{`")));
26386 }
26387
26388 #[test]
26389 fn lowers_and_formats_agent_tools_grant() {
26390 let source = r#"
26394workflow GrantHost
26395
26396agent worker {
26397 provider owned
26398 profile "repo-writer"
26399 capacity 1
26400 tools [WordCount, OpenPr]
26401}
26402"#;
26403 let compiled = compile_program(source);
26404 assert_eq!(compiled.diagnostics, Vec::new());
26405 let ir = compiled.ir.expect("valid ir");
26406 let agent = ir
26407 .agents
26408 .iter()
26409 .find(|agent| agent.name == "worker")
26410 .expect("worker agent");
26411 assert_eq!(
26412 agent.tools,
26413 vec!["WordCount".to_owned(), "OpenPr".to_owned()]
26414 );
26415
26416 let formatted = format_program(source).formatted.expect("formats");
26417 assert!(
26418 formatted.contains("tools [WordCount, OpenPr]"),
26419 "formatted: {formatted}"
26420 );
26421
26422 let dup = compile_program(
26424 "workflow Dup\nagent a {\n provider owned\n profile \"p\"\n tools [X, X]\n}\n",
26425 );
26426 assert!(
26427 dup.diagnostics
26428 .iter()
26429 .any(|d| d.message.contains("grants tool `X` more than once")),
26430 "diagnostics: {:?}",
26431 dup.diagnostics
26432 );
26433 }
26434
26435 #[test]
26436 fn harness_class_classifies_managed_vs_delegated_and_emits_only_delegated() {
26437 assert_eq!(harness_class("owned"), HarnessClass::Managed);
26439 assert_eq!(harness_class("fixture"), HarnessClass::Managed);
26440 assert_eq!(harness_class("claude"), HarnessClass::Delegated);
26441 assert_eq!(harness_class("codex"), HarnessClass::Delegated);
26442 assert_eq!(harness_class("native-fixture"), HarnessClass::Delegated);
26443 assert_eq!(harness_class("command"), HarnessClass::Delegated);
26444
26445 let managed = compile_program(
26447 "workflow W\nagent m {\n provider owned\n profile \"p\"\n capacity 1\n}\n",
26448 );
26449 let managed_ir = managed.ir.expect("ir");
26450 assert_eq!(managed_ir.agents[0].harness_class, HarnessClass::Managed);
26451 assert!(!managed_ir.to_snapshot().contains("class="));
26452
26453 let delegated = compile_program(
26455 "workflow W\nagent d {\n provider claude\n profile \"repo-writer\"\n capacity 1\n}\n",
26456 );
26457 let delegated_ir = delegated.ir.expect("ir");
26458 assert_eq!(
26459 delegated_ir.agents[0].harness_class,
26460 HarnessClass::Delegated
26461 );
26462 assert!(delegated_ir.to_snapshot().contains("class=delegated"));
26463
26464 let via_harness = compile_program(
26466 "workflow W\nharness box: claude\nagent d using box {\n profile \"repo-writer\"\n capacity 1\n}\n",
26467 );
26468 let via_ir = via_harness.ir.expect("ir");
26469 assert_eq!(via_ir.agents[0].harness_class, HarnessClass::Delegated);
26470 }
26471
26472 #[test]
26473 fn tell_with_skills_lowers_to_effect_turn_skills_and_ir_snapshot() {
26474 let source = concat!(
26475 "workflow W\n",
26476 "agent coder {\n provider owned\n profile \"p\"\n capacity 1\n}\n",
26477 "class Task {\n note string\n}\n",
26478 "rule go\n when Task as t\n=> {\n tell coder with skills [\"review\", \"lint\"] \"do it\" as turn\n}\n",
26479 );
26480 let compiled = compile_program(source);
26481 assert!(
26482 compiled.diagnostics.is_empty(),
26483 "{:?}",
26484 compiled.diagnostics
26485 );
26486 let ir = compiled.ir.expect("ir");
26487 let effect = ir
26488 .rules
26489 .iter()
26490 .flat_map(|rule| &rule.metadata.effects)
26491 .find(|effect| effect.kind == IrEffectKind::AgentTell)
26492 .expect("tell effect");
26493 assert_eq!(
26494 effect.turn_skills,
26495 vec!["review".to_owned(), "lint".to_owned()]
26496 );
26497 assert!(
26499 ir.to_snapshot().contains("skills=review,lint"),
26500 "{}",
26501 ir.to_snapshot()
26502 );
26503 }
26504
26505 #[test]
26506 fn agent_compaction_strategy_parses_lowers_formats_and_validates() {
26507 let source = compile_program(
26508 "workflow C\nagent w {\n provider owned\n profile \"p\"\n capacity 1\n compaction hard_reset\n}\n",
26509 );
26510 assert!(source.diagnostics.is_empty(), "{:?}", source.diagnostics);
26511 let ir = source.ir.expect("ir");
26512 let agent = ir.agents.iter().find(|a| a.name == "w").expect("agent");
26513 assert_eq!(agent.compaction.as_deref(), Some("hard_reset"));
26514
26515 let formatted = format_program(
26517 "workflow C\nagent w {\n provider owned\n profile \"p\"\n capacity 1\n compaction hard_reset\n}\n",
26518 )
26519 .formatted
26520 .expect("formats");
26521 assert!(formatted.contains("compaction hard_reset"), "{formatted}");
26522 assert!(ir.to_snapshot().contains("compaction=hard_reset"));
26523
26524 let bad = compile_program(
26526 "workflow C\nagent w {\n provider owned\n profile \"p\"\n capacity 1\n compaction squish\n}\n",
26527 );
26528 assert!(
26529 bad.diagnostics
26530 .iter()
26531 .any(|d| d.message.contains("unknown compaction strategy `squish`")),
26532 "diagnostics: {:?}",
26533 bad.diagnostics
26534 );
26535
26536 let plain = compile_program(
26538 "workflow C\nagent w {\n provider owned\n profile \"p\"\n capacity 1\n}\n",
26539 );
26540 let plain_ir = plain.ir.expect("ir");
26541 assert_eq!(plain_ir.agents[0].compaction, None);
26542 assert!(!plain_ir.to_snapshot().contains("compaction="));
26543 }
26544
26545 #[test]
26546 fn agent_settings_source_parses_lowers_formats_and_validates() {
26547 let source = compile_program(
26549 "workflow C\nagent w {\n provider claude\n profile \"p\"\n capacity 1\n settings project\n}\n",
26550 );
26551 assert!(source.diagnostics.is_empty(), "{:?}", source.diagnostics);
26552 let ir = source.ir.expect("ir");
26553 let agent = ir.agents.iter().find(|a| a.name == "w").expect("agent");
26554 assert_eq!(agent.settings.as_deref(), Some("project"));
26555
26556 let formatted = format_program(
26558 "workflow C\nagent w {\n provider claude\n profile \"p\"\n capacity 1\n settings project\n}\n",
26559 )
26560 .formatted
26561 .expect("formats");
26562 assert!(formatted.contains("settings project"), "{formatted}");
26563 assert!(ir.to_snapshot().contains("settings=project"));
26564
26565 let bad = compile_program(
26567 "workflow C\nagent w {\n provider claude\n profile \"p\"\n capacity 1\n settings everything\n}\n",
26568 );
26569 assert!(
26570 bad.diagnostics
26571 .iter()
26572 .any(|d| d.message.contains("unknown settings source `everything`")),
26573 "diagnostics: {:?}",
26574 bad.diagnostics
26575 );
26576
26577 let dup = compile_program(
26579 "workflow C\nagent w {\n provider claude\n profile \"p\"\n capacity 1\n settings project\n settings user\n}\n",
26580 );
26581 assert!(
26582 dup.diagnostics
26583 .iter()
26584 .any(|d| d.message.contains("declares settings more than once")),
26585 "diagnostics: {:?}",
26586 dup.diagnostics
26587 );
26588
26589 let plain = compile_program(
26592 "workflow C\nagent w {\n provider claude\n profile \"p\"\n capacity 1\n}\n",
26593 );
26594 let plain_ir = plain.ir.expect("ir");
26595 assert_eq!(plain_ir.agents[0].settings, None);
26596 assert!(!plain_ir.to_snapshot().contains("settings="));
26597 }
26598
26599 #[test]
26600 fn agent_thread_mode_parses_lowers_and_partitions() {
26601 let source = "workflow ChatDemo\n\noutput result Done\n\nclass Done {\n ok int\n}\n\n\
26603 agent helper {\n provider owned\n profile \"repo-reader\"\n capacity 1\n thread continue\n}\n\n\
26604 rule go\n when started\n=> {\n tell helper as reply \"\"\"\n Hi.\n \"\"\"\n\n\
26605 \x20 after reply succeeds {\n complete result { ok 1 }\n }\n}\n";
26606 let compiled = compile_program(source);
26607 let ir = compiled.ir.expect("thread continue compiles");
26608 let agent = ir
26609 .agents
26610 .iter()
26611 .find(|agent| agent.name == "helper")
26612 .expect("agent lowered");
26613 assert_eq!(agent.thread.as_deref(), Some("continue"));
26614
26615 let bad = source.replace("thread continue", "thread sometimes");
26617 let compiled = compile_program(&bad);
26618 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
26619 .message
26620 .contains("unknown thread mode `sometimes`")));
26621
26622 let delegated = source.replace("provider owned", "provider codex");
26624 let compiled = compile_program(&delegated);
26625 assert!(
26626 compiled.diagnostics.iter().any(|diagnostic| diagnostic
26627 .message
26628 .contains("is delegated; `thread` is a managed-harness knob")),
26629 "{:?}",
26630 compiled
26631 .diagnostics
26632 .iter()
26633 .map(|d| &d.message)
26634 .collect::<Vec<_>>()
26635 );
26636 }
26637
26638 #[test]
26639 fn agent_knobs_partition_by_harness_class() {
26640 let bad = compile_program(
26646 "workflow C\nagent w {\n provider claude\n profile \"p\"\n capacity 1\n compaction summarize\n}\n",
26647 );
26648 assert!(
26649 bad.diagnostics.iter().any(|d| d
26650 .message
26651 .contains("is delegated; `compaction` is a managed-harness knob")),
26652 "diagnostics: {:?}",
26653 bad.diagnostics
26654 );
26655
26656 let bad = compile_program(
26659 "workflow C\nagent w {\n provider owned\n profile \"p\"\n capacity 1\n settings project\n}\n",
26660 );
26661 assert!(
26662 bad.diagnostics.iter().any(|d| d
26663 .message
26664 .contains("is managed; `settings` is a delegated-harness knob")),
26665 "diagnostics: {:?}",
26666 bad.diagnostics
26667 );
26668
26669 let bad = compile_program(
26671 "workflow C\nharness box: claude\nagent w using box {\n profile \"p\"\n capacity 1\n compaction summarize\n}\n",
26672 );
26673 assert!(
26674 bad.diagnostics.iter().any(|d| d
26675 .message
26676 .contains("is delegated; `compaction` is a managed-harness knob")),
26677 "diagnostics: {:?}",
26678 bad.diagnostics
26679 );
26680
26681 let good = compile_program(
26683 "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",
26684 );
26685 assert!(good.diagnostics.is_empty(), "{:?}", good.diagnostics);
26686
26687 let unbound = compile_program(
26690 "workflow C\nagent w {\n profile \"p\"\n capacity 1\n compaction summarize\n}\n",
26691 );
26692 assert!(
26693 !unbound
26694 .diagnostics
26695 .iter()
26696 .any(|d| d.message.contains("managed-harness knob")),
26697 "diagnostics: {:?}",
26698 unbound.diagnostics
26699 );
26700 }
26701
26702 #[test]
26707 fn agent_requires_parses_taxonomy_classes() {
26708 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";
26709 let compiled = compile_program(program);
26710 assert!(
26711 compiled.diagnostics.is_empty(),
26712 "{:?}",
26713 compiled.diagnostics
26714 );
26715 let ir = compiled.ir.expect("ir");
26716 let agent = ir.agents.first().expect("agent");
26717 assert_eq!(
26718 agent.requires,
26719 vec!["session.resume".to_owned(), "turn.cancel".to_owned()]
26720 );
26721 assert!(ir
26722 .to_snapshot()
26723 .contains("requires=[session.resume, turn.cancel]"));
26724
26725 let formatted = format_program(program).formatted.expect("formats");
26727 assert!(
26728 formatted.contains(" requires [session.resume, turn.cancel]"),
26729 "{formatted}"
26730 );
26731
26732 let plain = compile_program(
26734 "workflow R\n\nagent a {\n provider owned\n profile \"p\"\n capacity 1\n}\n",
26735 );
26736 assert!(!plain.ir.expect("ir").to_snapshot().contains("requires="));
26737
26738 let unknown = compile_program(
26740 "workflow R\n\nagent a {\n provider owned\n profile \"p\"\n capacity 1\n requires [warp.drive]\n}\n",
26741 );
26742 assert!(
26743 unknown.diagnostics.iter().any(|d| d
26744 .message
26745 .contains("requires unknown feature class `warp.drive`")),
26746 "{:?}",
26747 unknown.diagnostics
26748 );
26749
26750 let duplicate = compile_program(
26752 "workflow R\n\nagent a {\n provider owned\n profile \"p\"\n capacity 1\n requires [turn.cancel, turn.cancel]\n}\n",
26753 );
26754 assert!(
26755 duplicate.diagnostics.iter().any(|d| d
26756 .message
26757 .contains("requires feature class `turn.cancel` more than once")),
26758 "{:?}",
26759 duplicate.diagnostics
26760 );
26761 }
26762
26763 #[test]
26764 fn agent_delegated_to_sugar_and_managed_default() {
26765 let program = "workflow C\nagent d delegated to claude {\n profile \"p\"\n capacity 1\n settings project\n}\n";
26768 let source = compile_program(program);
26769 assert!(source.diagnostics.is_empty(), "{:?}", source.diagnostics);
26770 let ir = source.ir.expect("ir");
26771 let agent = ir.agents.iter().find(|a| a.name == "d").expect("agent");
26772 assert_eq!(agent.provider.as_deref(), Some("claude"));
26773 assert_eq!(agent.harness_class, HarnessClass::Delegated);
26774 assert!(ir.to_snapshot().contains("class=delegated"));
26775
26776 let formatted = format_program(program).formatted.expect("formats");
26778 assert!(
26779 formatted.contains("agent d delegated to claude {"),
26780 "{formatted}"
26781 );
26782
26783 let bad = compile_program(
26785 "workflow C\nagent d delegated to owned {\n profile \"p\"\n capacity 1\n}\n",
26786 );
26787 assert!(
26788 bad.diagnostics.iter().any(|d| d
26789 .message
26790 .contains("delegates to `owned`, which is a managed kind")),
26791 "diagnostics: {:?}",
26792 bad.diagnostics
26793 );
26794
26795 let unknown = compile_program(
26800 "workflow C\nagent d delegated to mystery {\n profile \"p\"\n capacity 1\n}\n",
26801 );
26802 assert!(
26803 !unknown
26804 .diagnostics
26805 .iter()
26806 .any(|d| d.message.contains("unsupported provider")),
26807 "diagnostics: {:?}",
26808 unknown.diagnostics
26809 );
26810
26811 let both = compile_program(
26813 "workflow C\nagent d delegated to claude {\n provider codex\n profile \"p\"\n capacity 1\n}\n",
26814 );
26815 assert!(
26816 both.diagnostics.iter().any(|d| d
26817 .message
26818 .contains("declares both `delegated to` and direct provider")),
26819 "diagnostics: {:?}",
26820 both.diagnostics
26821 );
26822
26823 let plain = compile_program("workflow C\nagent m {\n profile \"p\"\n capacity 1\n}\n");
26826 assert!(plain.diagnostics.is_empty(), "{:?}", plain.diagnostics);
26827 let plain_ir = plain.ir.expect("ir");
26828 assert_eq!(plain_ir.agents[0].provider.as_deref(), Some("owned"));
26829 assert_eq!(plain_ir.agents[0].harness_class, HarnessClass::Managed);
26830 }
26831
26832 #[test]
26833 fn accepts_agent_ref_dynamic_tell_targets() {
26834 let source = r#"
26835workflow AgentRefRouting
26836
26837agent codex {
26838 provider codex
26839 profile "repo-writer"
26840 capacity 1
26841 capabilities ["agent.tell"]
26842}
26843
26844agent claude {
26845 provider claude
26846 profile "repo-writer"
26847 capacity 1
26848 capabilities ["agent.tell"]
26849}
26850
26851class LanguageTask {
26852 provider AgentRef<codex | claude>
26853 prompt string
26854}
26855
26856rule run_task
26857 when LanguageTask as task
26858 when task.provider is available
26859=> {
26860 tell task.provider requires ["agent.tell"] as turn "{{ task.prompt }}"
26861}
26862"#;
26863
26864 let compiled = compile_program(source);
26865 assert_eq!(compiled.diagnostics, Vec::new());
26866 let ir = compiled.ir.expect("valid ir");
26867 let rule = ir
26868 .rules
26869 .iter()
26870 .find(|rule| rule.name == "run_task")
26871 .expect("run_task");
26872 assert_eq!(rule.metadata.effects.len(), 1);
26873 assert_eq!(rule.metadata.effects[0].kind, IrEffectKind::AgentTell);
26874 }
26875
26876 #[test]
26877 fn rejects_agent_ref_targets_missing_required_capabilities() {
26878 let source = r#"
26879workflow BadAgentRefCapabilities
26880
26881agent codex {
26882 provider codex
26883 profile "repo-writer"
26884 capacity 1
26885 capabilities ["agent.tell", "repo.write"]
26886}
26887
26888agent claude {
26889 provider claude
26890 profile "repo-reader"
26891 capacity 1
26892 capabilities ["agent.tell"]
26893}
26894
26895class LanguageTask {
26896 provider AgentRef<codex | claude>
26897 prompt string
26898}
26899
26900rule run_task
26901 when LanguageTask as task
26902=> {
26903 tell task.provider requires ["repo.write"] as turn """
26904 {{ task.prompt }}
26905 """
26906}
26907"#;
26908
26909 let compiled = compile_program(source);
26910 assert!(compiled.ir.is_none());
26911 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
26912 .message
26913 .contains("agent `claude` requiring undeclared capability `repo.write`")));
26914 }
26915
26916 #[test]
26917 fn rejects_plain_string_dynamic_tell_targets() {
26918 let source = r#"
26919workflow BadAgentRefRouting
26920
26921agent codex {
26922 provider codex
26923 profile "repo-writer"
26924 capacity 1
26925}
26926
26927class LanguageTask {
26928 provider string
26929}
26930
26931rule run_task
26932 when LanguageTask as task
26933=> {
26934 tell task.provider "bad"
26935}
26936"#;
26937
26938 let compiled = compile_program(source);
26939 assert!(compiled.ir.is_none());
26940 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
26941 .message
26942 .contains("non-AgentRef dynamic tell target `task.provider`")));
26943 }
26944
26945 #[test]
26946 fn rejects_unknown_agent_ref_domain_values() {
26947 let source = r#"
26948workflow BadAgentRefDomain
26949
26950agent codex {
26951 provider codex
26952 profile "repo-writer"
26953 capacity 1
26954}
26955
26956class LanguageTask {
26957 provider AgentRef<codex | ghost>
26958}
26959
26960rule seed
26961 when started
26962=> {
26963 record LanguageTask {
26964 provider claude
26965 }
26966}
26967"#;
26968
26969 let compiled = compile_program(source);
26970 assert!(compiled.ir.is_none());
26971 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
26972 .message
26973 .contains("AgentRef references unknown agent `ghost`")));
26974 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
26975 .message
26976 .contains("field `LanguageTask.provider` cannot reference agent `claude`")));
26977 }
26978
26979 #[test]
26980 fn rejects_quoted_agent_ref_record_values() {
26981 let source = r#"
26982workflow BadQuotedAgentRef
26983
26984agent codex {
26985 provider codex
26986 profile "repo-writer"
26987 capacity 1
26988}
26989
26990class LanguageTask {
26991 provider AgentRef<codex>
26992}
26993
26994rule seed
26995 when started
26996=> {
26997 record LanguageTask {
26998 provider "codex"
26999 }
27000}
27001"#;
27002
27003 let compiled = compile_program(source);
27004 assert!(compiled.ir.is_none());
27005 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27006 .message
27007 .contains("expects an AgentRef value, not string `codex`")));
27008 }
27009
27010 #[test]
27011 fn requires_presence_proof_for_optional_field_access() {
27012 let source = r#"
27013workflow OptionalProof
27014
27015class Person {
27016 name string
27017}
27018
27019class Issue {
27020 assignee Person?
27021}
27022
27023rule unsafe_optional
27024 when Issue as issue where issue.assignee.name == "Ada"
27025=> {
27026}
27027"#;
27028
27029 let compiled = compile_program(source);
27030 assert!(compiled.ir.is_none());
27031 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27032 .message
27033 .contains("unsafe optional path `issue.assignee.name`")));
27034 }
27035
27036 #[test]
27037 fn accepts_presence_proof_before_optional_field_access() {
27038 let source = r#"
27039workflow OptionalProof
27040
27041class Person {
27042 name string
27043}
27044
27045class Issue {
27046 assignee Person?
27047}
27048
27049rule safe_optional
27050 when Issue as issue where issue.assignee != null && issue.assignee.name == "Ada"
27051=> {
27052}
27053
27054rule safe_exists
27055 when Issue as issue where exists issue.assignee && issue.assignee.name == "Ada"
27056=> {
27057}
27058
27059rule safe_not_null
27060 when Issue as issue where !(issue.assignee == null) && issue.assignee.name == "Ada"
27061=> {
27062}
27063"#;
27064
27065 let compiled = compile_program(source);
27066 assert_eq!(compiled.diagnostics, Vec::new());
27067 assert!(compiled.ir.is_some());
27068 }
27069
27070 #[test]
27071 fn parses_expression_kernel_surface() {
27072 let cases = [
27073 ("true || false && !ready", "true || (false && !ready)"),
27074 (
27075 "count(task.labels) == 0 || exists(Result where status == \"done\")",
27076 "(count(task.labels) == 0) || exists(Result where status == \"done\")",
27077 ),
27078 (
27079 "task.labels[\"priority\"] == [\"high\", \"urgent\"][0]",
27080 "task.labels[\"priority\"] == [\"high\", \"urgent\"][0]",
27081 ),
27082 (
27083 "exists issue.assignee && issue.assignee.name == \"Ada\"",
27084 "exists(issue.assignee) && (issue.assignee.name == \"Ada\")",
27085 ),
27086 (
27087 "{title task.title, metadata {phase \"kernel\"}}",
27088 "{title task.title, metadata {phase \"kernel\"}}",
27089 ),
27090 (
27091 "count(effect agent.tell where target == \"worker\") >= 1",
27092 "count(effect agent.tell where target == \"worker\") >= 1",
27093 ),
27094 ];
27095
27096 for (source, expected) in cases {
27097 let expr = parse_expression(source).expect(source);
27098 assert_eq!(expr.to_snapshot(), expected);
27099 }
27100
27101 for source in ["task.labels[", "count(Result where)", "[1,,2]"] {
27102 assert!(
27103 parse_expression(source).is_err(),
27104 "{source} unexpectedly parsed"
27105 );
27106 }
27107 }
27108
27109 #[test]
27113 fn deeply_nested_expression_errors_instead_of_overflowing_the_stack() {
27114 std::thread::Builder::new()
27119 .stack_size(8 * 1024 * 1024)
27120 .spawn(|| {
27121 let deep = format!("{}task.done{}", "(".repeat(8000), ")".repeat(8000));
27122 let result = parse_expression(&deep);
27123 assert!(
27124 result
27125 .as_ref()
27126 .err()
27127 .is_some_and(|message| message.contains("nested too deeply")),
27128 "expected a depth-limit diagnostic, got {result:?}"
27129 );
27130 let ok = format!("{}task.done{}", "(".repeat(64), ")".repeat(64));
27132 assert!(parse_expression(&ok).is_ok(), "64-deep nesting must parse");
27133 })
27134 .expect("spawn")
27135 .join()
27136 .expect("nested-expression parse must not crash");
27137 }
27138
27139 #[test]
27140 fn parses_every_expression_form_with_pinned_precedence() {
27141 let cases = [
27142 ("\"text\"", "\"text\""),
27144 ("42", "42"),
27145 ("2.5", "2.5"),
27146 ("true && false", "true && false"),
27147 ("task.note == null", "task.note == null"),
27148 (
27150 "task.meta[\"a\"][\"b\"] == \"c\"",
27151 "task.meta[\"a\"][\"b\"] == \"c\"",
27152 ),
27153 ("not task.done", "!task.done"),
27155 ("!!task.done", "!!task.done"),
27156 (
27157 "task.a and task.b or task.c",
27158 "(task.a && task.b) || task.c",
27159 ),
27160 ("not task.state == \"open\"", "!(task.state == \"open\")"),
27162 (
27164 "task.a || task.b && !task.c",
27165 "task.a || (task.b && !task.c)",
27166 ),
27167 ("1 + 2 * 3 == 7", "(1 + (2 * 3)) == 7"),
27169 ("10 - 4 / 2 >= 8", "(10 - (4 / 2)) >= 8"),
27170 ("task.a == task.b < task.c", "(task.a == task.b) < task.c"),
27176 ("task.n <= 5 && task.n > 0", "(task.n <= 5) && (task.n > 0)"),
27178 ("\"x\" in task.labels", "\"x\" in task.labels"),
27179 ("\"x\" not in task.labels", "\"x\" not in task.labels"),
27180 ("exists task.owner", "exists(task.owner)"),
27182 (
27183 "exists(Task where done == false)",
27184 "exists(Task where done == false)",
27185 ),
27186 ("count([1, 2]) == 2", "count([1, 2]) == 2"),
27188 (
27189 "empty(Task where done == false)",
27190 "empty(Task where done == false)",
27191 ),
27192 ("empty(task.labels)", "empty(task.labels)"),
27193 ("empty([])", "empty([])"),
27194 (
27195 "count(effect kind agent.tell where target == \"w\") == 0",
27196 "count(effect kind agent.tell where target == \"w\") == 0",
27197 ),
27198 (
27199 "exists(effect kind schema.coerce)",
27200 "exists(effect kind schema.coerce)",
27201 ),
27202 ("[\"a\", \"b\"]", "[\"a\", \"b\"]"),
27204 (
27205 "{title task.title, meta {phase \"kernel\"}}",
27206 "{title task.title, meta {phase \"kernel\"}}",
27207 ),
27208 ];
27209
27210 for (source, expected) in cases {
27211 let expr = parse_expression(source).expect(source);
27212 assert_eq!(expr.to_snapshot(), expected, "for `{source}`");
27213 }
27214 }
27215
27216 #[test]
27220 fn invalid_expression_syntax_produces_deterministic_errors() {
27221 let cases = [
27222 ("task.a ==", "expected expression"),
27224 ("1 +", "expected expression"),
27225 ("task.a && || task.b", "expected expression"),
27226 ("task.a == == 1", "expected expression"),
27227 ("!", "expected expression"),
27228 ("(task.a == 1", "expected `)`"),
27230 ("task.labels[\"k\"", "expected `]`"),
27231 ("[1, 2", "expected `,`"),
27232 ("{a 1", "expected object field name"),
27233 ("task.a == 1)", "unexpected token"),
27235 ("in task.a", "unexpected token"),
27236 ("count(Task where", "expected expression"),
27238 ("count(Task where )", "expected expression"),
27239 ("task..a", "expected field name after `.`"),
27240 ("task.a not b", "expected `in` after `not`"),
27241 ];
27242
27243 for (source, expected) in cases {
27244 let message = parse_expression(source).expect_err(source);
27245 assert!(
27246 message.contains(expected),
27247 "`{source}` -> `{message}` (expected `{expected}`)"
27248 );
27249 }
27250 }
27251
27252 #[test]
27255 fn guard_and_assertion_syntax_errors_surface_with_context() {
27256 let source = r#"
27257workflow BadExpressionSyntax
27258
27259class Task {
27260 title string
27261}
27262
27263assert count(Task) ==
27264
27265rule dangling_guard
27266 when Task as task where task.title ==
27267=> {
27268}
27269"#;
27270
27271 let compiled = compile_program(source);
27272 assert!(compiled.ir.is_none());
27273 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27274 .message
27275 .contains("invalid assertion expression: expected expression")));
27276 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27277 .message
27278 .contains("rule `dangling_guard` has invalid guard expression: expected expression")));
27279 }
27280
27281 #[test]
27285 fn validates_empty_call_arity_and_optional_arguments() {
27286 let source = r#"
27287workflow EmptyCallChecks
27288
27289class Task {
27290 title string
27291 note string?
27292 age int?
27293 done bool
27294}
27295
27296assert empty() == true
27297assert empty(["a"], ["b"]) == true
27298assert count(Task where empty(note) && empty(title)) == 0
27299assert count(Task where empty(age)) == 0
27300assert count(Task where empty(done)) == 0
27301"#;
27302
27303 let compiled = compile_program(source);
27304 assert!(compiled.ir.is_none());
27305 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27306 .message
27307 .contains("calls `empty` with 0 arguments, expected 1")));
27308 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27309 .message
27310 .contains("calls `empty` with 2 arguments, expected 1")));
27311 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27312 .message
27313 .contains("calls `empty` with unsupported optional argument type `int?`")));
27314 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27315 .message
27316 .contains("calls `empty` with unsupported argument type `bool`")));
27317 assert!(!compiled
27319 .diagnostics
27320 .iter()
27321 .any(|diagnostic| diagnostic.message.contains("`string?`")));
27322 assert!(!compiled.diagnostics.iter().any(|diagnostic| diagnostic
27323 .message
27324 .contains("unsupported argument type `string`")));
27325 }
27326
27327 #[test]
27333 fn format_preserves_expression_source_text_verbatim() {
27334 let source = r#"workflow FormatExpressions
27335
27336class Task {
27337 title string
27338 done bool
27339}
27340
27341assert count(Task where (done == false) and not done) == 0
27342
27343rule keep_spelling
27344 when Task as task where not task.done and (task.title == "a" or task.title == "b")
27345=> {
27346}
27347"#;
27348
27349 let formatted = format_program(source);
27350 assert_eq!(formatted.diagnostics, Vec::new());
27351 let once = formatted.formatted.expect("formats");
27352 assert!(once.contains(
27353 "when Task as task where not task.done and (task.title == \"a\" or task.title == \"b\")"
27354 ));
27355 assert!(once.contains("assert count(Task where (done == false) and not done) == 0"));
27356
27357 let twice = format_program(&once).formatted.expect("formats twice");
27358 assert_eq!(once, twice, "formatting is idempotent over expressions");
27359 }
27360
27361 #[test]
27362 fn validates_expected_schema_object_and_map_record_fields() {
27363 let source = r#"
27364workflow ObjectRecordFields
27365
27366class Owner {
27367 name string
27368}
27369
27370class Task {
27371 title string
27372 metadata map<string>
27373 owner Owner?
27374}
27375
27376rule seed
27377 when started
27378=> {
27379 record Task {
27380 title "Implement object literals"
27381 metadata { phase "kernel" }
27382 owner { name "Ada" }
27383 }
27384
27385 record Task {
27386 title "Implement multiline object literals"
27387 metadata {
27388 phase "kernel"
27389 owner "Ada"
27390 }
27391 owner {
27392 name "Ada"
27393 }
27394 }
27395}
27396"#;
27397
27398 let compiled = compile_program(source);
27399 assert_eq!(compiled.diagnostics, Vec::new());
27400 assert!(compiled.ir.is_some());
27401 }
27402
27403 #[test]
27404 fn rejects_invalid_expected_schema_object_and_map_record_fields() {
27405 let source = r#"
27406workflow BadObjectRecordFields
27407
27408class Owner {
27409 name string
27410}
27411
27412class Task {
27413 metadata map<string>
27414 owner Owner
27415}
27416
27417rule seed
27418 when started
27419=> {
27420 record Task {
27421 metadata { phase 1 }
27422 owner { alias "Ada" }
27423 }
27424}
27425
27426rule bad_guard
27427 when Task as task where { phase "kernel" } == task.metadata
27428=> {
27429}
27430"#;
27431
27432 let compiled = compile_program(source);
27433 assert!(compiled.ir.is_none());
27434 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27435 .message
27436 .contains("field `Task.metadata` expects `string`")));
27437 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27438 .message
27439 .contains("class `Owner` has no field `alias`")));
27440 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27441 .message
27442 .contains("missing required object field `Owner.name`")));
27443 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27444 .message
27445 .contains("compares incompatible expression types")));
27446 }
27447
27448 #[test]
27449 fn rejects_invalid_expression_types() {
27450 let source = r#"
27451workflow BadExpressionTypes
27452
27453class Task {
27454 title string
27455 labels map<string>
27456 priority int
27457 ready bool
27458}
27459
27460rule non_bool_guard
27461 when Task as task where task.priority
27462=> {
27463}
27464
27465rule bad_ordering
27466 when Task as task where task.title > "abc"
27467=> {
27468}
27469
27470rule bad_membership
27471 when Task as task where task.title in task.priority
27472=> {
27473}
27474
27475rule bad_equality
27476 when Task as task where task.ready == "yes"
27477=> {
27478}
27479
27480rule bad_array
27481 when Task as task where task.title in ["abc", 1]
27482=> {
27483}
27484
27485rule bad_map_key
27486 when Task as task where task.labels[1] == "urgent"
27487=> {
27488}
27489
27490rule bad_map_membership
27491 when Task as task where 1 in task.labels
27492=> {
27493}
27494"#;
27495
27496 let compiled = compile_program(source);
27497 assert!(compiled.ir.is_none());
27498 assert!(compiled
27499 .diagnostics
27500 .iter()
27501 .any(|diagnostic| diagnostic.message.contains("non-boolean guard expression")));
27502 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27503 .message
27504 .contains("orders non-orderable expression values")));
27505 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27506 .message
27507 .contains("uses membership against a non-array/non-map expression")));
27508 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27509 .message
27510 .contains("compares incompatible expression types")));
27511 assert!(compiled
27512 .diagnostics
27513 .iter()
27514 .any(|diagnostic| diagnostic.message.contains("mixed-type array literal")));
27515 assert!(compiled
27516 .diagnostics
27517 .iter()
27518 .any(|diagnostic| diagnostic.message.contains("non-string key")));
27519 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27520 .message
27521 .contains("map membership with a non-string key")));
27522 }
27523
27524 #[test]
27525 fn validates_duration_and_time_ordering_and_literals() {
27526 let source = r#"
27527workflow DurationTimeExpressions
27528
27529class Window {
27530 elapsed duration
27531 limit duration
27532 opened_at time
27533 due_at time
27534}
27535
27536assert exists(Window where elapsed < limit)
27537assert exists(Window where opened_at <= due_at)
27538
27539rule seed
27540 when started
27541=> {
27542 record Window {
27543 elapsed "PT30.5M"
27544 limit "PT1.25H"
27545 opened_at "2026-05-29T10:00:00.250-04:00"
27546 due_at "2026-05-29T14:00:00.500Z"
27547 }
27548}
27549"#;
27550
27551 let compiled = compile_program(source);
27552 assert_eq!(compiled.diagnostics, Vec::new());
27553 assert!(compiled.ir.is_some());
27554 }
27555
27556 #[test]
27557 fn rejects_invalid_duration_and_time_literals() {
27558 let source = r#"
27559workflow BadDurationTimeExpressions
27560
27561class Window {
27562 elapsed duration
27563 limit duration
27564 opened_at time
27565}
27566
27567rule seed
27568 when started
27569=> {
27570 record Window {
27571 elapsed "thirty minutes"
27572 limit "P1M"
27573 opened_at "morning"
27574 }
27575}
27576"#;
27577
27578 let compiled = compile_program(source);
27579 assert!(compiled.ir.is_none());
27580 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27581 .message
27582 .contains("field `Window.elapsed` has invalid duration literal")));
27583 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27584 .message
27585 .contains("field `Window.limit` has invalid duration literal")));
27586 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27587 .message
27588 .contains("field `Window.opened_at` has invalid time literal")));
27589 }
27590
27591 #[test]
27592 fn validates_assertion_expression_types_and_paths() {
27593 let source = r#"
27594workflow BadAssertions
27595
27596class Task {
27597 provider "codex" | "claude"
27598 priority int
27599}
27600
27601assert count(Task where provider == "bad") == 0
27602assert count(Task)
27603assert missing.root == "value"
27604"#;
27605
27606 let compiled = compile_program(source);
27607 assert!(compiled.ir.is_none());
27608 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27609 .message
27610 .contains("assertion compares finite-domain value to unknown `bad`")));
27611 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27612 .message
27613 .contains("assertion has non-boolean assertion expression")));
27614 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27615 .message
27616 .contains("assertion has unknown expression root `missing`")));
27617 }
27618
27619 #[test]
27620 fn validates_symmetric_finite_domain_literals_and_unknown_guard_roots() {
27621 let source = r#"
27622workflow SymmetricFiniteDomain
27623
27624enum ReviewStatus {
27625 Accept
27626 Revise
27627}
27628
27629class Task {
27630 status ReviewStatus
27631 provider "codex" | "claude"
27632}
27633
27634rule symmetric_literal
27635 when Task as task where "bad" == task.provider
27636=> {
27637}
27638
27639rule enum_variant_literal
27640 when Task as task where Missing == task.status
27641=> {
27642}
27643
27644rule array_membership_literal
27645 when Task as task where task.provider in ["codex", "bad"]
27646=> {
27647}
27648
27649rule implicit_query_head
27650 when Task as task where exists(Task where status == Missing)
27651=> {
27652}
27653
27654rule unknown_root
27655 when Task as task where other.provider == "codex"
27656=> {
27657}
27658"#;
27659
27660 let compiled = compile_program(source);
27661 assert!(compiled.ir.is_none());
27662 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27663 .message
27664 .contains("finite-domain value to unknown `bad`")));
27665 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27666 .message
27667 .contains("finite-domain value to unknown `Missing`")));
27668 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27669 .message
27670 .contains("unknown expression root `other`")));
27671 }
27672
27673 #[test]
27674 fn rejects_unsatisfiable_finite_domain_expression_relations() {
27675 let source = r#"
27676workflow UnsatisfiableFiniteDomains
27677
27678class Task {
27679 provider "codex" | "claude"
27680 route "cache" | "coerce"
27681}
27682
27683rule disjoint_equality
27684 when Task as task where task.provider == task.route
27685=> {
27686}
27687
27688rule empty_membership
27689 when Task as task where task.provider in []
27690=> {
27691}
27692
27693rule excluded_membership
27694 when Task as task where task.provider not in ["codex", "claude"]
27695=> {
27696}
27697"#;
27698
27699 let compiled = compile_program(source);
27700 assert!(compiled.ir.is_none());
27701 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27702 .message
27703 .contains("statically unsatisfiable finite-domain equality")));
27704 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27705 .message
27706 .contains("statically unsatisfiable finite-domain membership")));
27707 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27708 .message
27709 .contains("statically unsatisfiable finite-domain exclusion")));
27710 }
27711
27712 #[test]
27713 fn accepts_map_index_expressions() {
27714 let source = r#"
27715workflow MapIndex
27716
27717class Task {
27718 labels map<string>
27719}
27720
27721rule route
27722 when Task as task where task.labels["priority"] == "high"
27723=> {
27724}
27725"#;
27726
27727 let compiled = compile_program(source);
27728 assert_eq!(compiled.diagnostics, Vec::new());
27729 let ir = compiled.ir.expect("valid ir");
27730 let guard = ir.rules[0].whens[0].guard.as_ref().expect("guard");
27731 assert_eq!(
27732 guard.expr.to_snapshot(),
27733 "task.labels[\"priority\"] == \"high\""
27734 );
27735 }
27736
27737 #[test]
27738 fn lowers_deterministic_ir_snapshot() {
27739 let source = r#"
27740workflow Snapshot
27741
27742
27743class Work {
27744 title string
27745 files string[]
27746 state "open" | "done"
27747}
27748
27749class Result {
27750 title string
27751 files string[]
27752}
27753
27754agent worker {
27755 provider fixture
27756 profile "repo-writer"
27757 capacity 2
27758 skills ["repo-user"]
27759}
27760
27761rule start
27762 when Work as work
27763=>
27764{
27765 tell worker "{{ work.title }}"
27766}
27767
27768rule finish
27769 when Result as result
27770=>
27771{
27772 record Work {
27773 title result.title
27774 files result.files
27775 state "done"
27776 }
27777}
27778"#;
27779
27780 let compiled = compile_program(source);
27781 assert_eq!(compiled.diagnostics, Vec::new());
27782 let ir = match compiled.ir {
27783 Some(ir) => ir,
27784 None => panic!("expected lowered IR"),
27785 };
27786
27787 let expected = "\
27788workflow Snapshot
27789schemas
27790 class Work
27791 title string
27792 files array<string>
27793 state union<literal<\"open\"> | literal<\"done\">>
27794 class Result
27795 title string
27796 files array<string>
27797agents
27798 agent worker harness=<fallback> provider=fixture profile=repo-writer capacity=2 skills=[repo-user] capabilities=[] tools=[]
27799rules
27800 rule start
27801 when Work as work
27802 reads
27803 schema:Work
27804 effects
27805 effect1 kind=agent.tell binding=- key=ef8ed2edd19578a222b6ad56ea1bffa8
27806 body_hash 96e148d2d421ee97960ef8f3edf61db9
27807 rule finish
27808 when Result as result
27809 reads
27810 schema:Result
27811 writes
27812 schema:Work
27813 body_hash 4a5ce925842b5b0bceb64bf33361d523
27814rule_dependencies
27815 finish --schema:Work--> start
27816";
27817
27818 assert_eq!(ir.to_snapshot(), expected);
27819 }
27820
27821 #[test]
27822 fn example_ir_snapshots_are_stable() {
27823 let examples = [
27824 (
27825 include_str!("../../../examples/minimal-noop.whip"),
27826 include_str!("../../../examples/minimal-noop.ir"),
27827 ),
27828 (
27829 include_str!("../../../examples/queue-worker-with-review.whip"),
27830 include_str!("../../../examples/queue-worker-with-review.ir"),
27831 ),
27832 (
27833 include_str!("../../../examples/circuit-breaker.whip"),
27834 include_str!("../../../examples/circuit-breaker.ir"),
27835 ),
27836 (
27837 include_str!("../../../examples/coerce-branch.whip"),
27838 include_str!("../../../examples/coerce-branch.ir"),
27839 ),
27840 (
27841 include_str!("../../../examples/terminal-output-union.whip"),
27842 include_str!("../../../examples/terminal-output-union.ir"),
27843 ),
27844 (
27845 include_str!("../../../examples/triage-chain.whip"),
27846 include_str!("../../../examples/triage-chain.ir"),
27847 ),
27848 (
27849 include_str!("../../../examples/incident-router.whip"),
27850 include_str!("../../../examples/incident-router.ir"),
27851 ),
27852 (
27853 include_str!("../../../examples/expression-kernel.whip"),
27854 include_str!("../../../examples/expression-kernel.ir"),
27855 ),
27856 (
27857 include_str!("../../../examples/multi-agent-bounded-concurrency.whip"),
27858 include_str!("../../../examples/multi-agent-bounded-concurrency.ir"),
27859 ),
27860 (
27865 include_str!("../../../examples/scheduled-escalation.whip"),
27866 include_str!("../../../examples/scheduled-escalation.ir"),
27867 ),
27868 (
27869 include_str!("../../../examples/event-bridge.whip"),
27870 include_str!("../../../examples/event-bridge.ir"),
27871 ),
27872 (
27873 include_str!("../../../examples/reusable-review-pattern.whip"),
27874 include_str!("../../../examples/reusable-review-pattern.ir"),
27875 ),
27876 (
27877 include_str!("../../../examples/reusable-action-chain.whip"),
27878 include_str!("../../../examples/reusable-action-chain.ir"),
27879 ),
27880 (
27881 include_str!("../../../examples/exec-json-ingest.whip"),
27882 include_str!("../../../examples/exec-json-ingest.ir"),
27883 ),
27884 (
27885 include_str!("../../../examples/deterministic-validation.whip"),
27886 include_str!("../../../examples/deterministic-validation.ir"),
27887 ),
27888 (
27889 include_str!("../../../examples/autoresearch-lite.whip"),
27890 include_str!("../../../examples/autoresearch-lite.ir"),
27891 ),
27892 (
27893 include_str!("../../../examples/gastown-lite.whip"),
27894 include_str!("../../../examples/gastown-lite.ir"),
27895 ),
27896 (
27897 include_str!("../../../examples/ralph.whip"),
27898 include_str!("../../../examples/ralph.ir"),
27899 ),
27900 ];
27901
27902 for (source, expected) in examples {
27903 let compiled = compile_program(source);
27904 assert_eq!(compiled.diagnostics, Vec::new());
27905 let ir = match compiled.ir {
27906 Some(ir) => ir,
27907 None => panic!("expected lowered IR"),
27908 };
27909 assert_eq!(ir.to_snapshot(), expected);
27910 }
27911 }
27912
27913 #[test]
27914 fn revision_examples_compile() {
27915 let examples = [
27916 (
27917 include_str!("../../../examples/revision-ticket-v1.whip"),
27918 Some("RevisionTicket"),
27919 ),
27920 (
27921 include_str!("../../../examples/revision-ticket-v2.whip"),
27922 Some("RevisionTicket"),
27923 ),
27924 (
27925 include_str!("../../../examples/revision-repair-planner.whip"),
27926 Some("RevisionRepairPlanner"),
27927 ),
27928 (
27929 include_str!("../../../examples/revision-running-cancel.whip"),
27930 Some("RevisionRunningCancel"),
27931 ),
27932 (
27933 include_str!("../../../examples/revision-parent-child.whip"),
27934 Some("ParentRevisionExample"),
27935 ),
27936 (
27937 include_str!("../../../examples/revision-validation-approval.whip"),
27938 Some("RevisionValidation"),
27939 ),
27940 ];
27941
27942 for (source, root) in examples {
27943 let compiled = compile_program_with_root(source, root);
27944 assert_eq!(compiled.diagnostics, Vec::new());
27945 assert!(compiled.ir.is_some());
27946 }
27947 }
27948
27949 #[test]
27950 fn rejects_unknown_schema_references() {
27951 let source = include_str!("../../../examples/invalid/unknown-schema.whip");
27952 let compiled = compile_program(source);
27953
27954 assert!(compiled.ir.is_none());
27955 assert_eq!(compiled.diagnostics.len(), 2);
27956 assert!(compiled
27957 .diagnostics
27958 .iter()
27959 .any(|diagnostic| diagnostic.message == "unknown schema reference `MissingStatus`"));
27960 assert!(compiled
27961 .diagnostics
27962 .iter()
27963 .any(|diagnostic| diagnostic.message == "unknown schema reference `MissingOutput`"));
27964 }
27965
27966 #[test]
27967 fn emit_of_undeclared_signal_is_flagged_statically() {
27968 let source = "\
27971workflow Emitter
27972signal trigger.x { peer string }
27973signal known.sig { note string }
27974rule relay
27975 when trigger.x as t
27976=> {
27977 emit signal known.sig to t.peer { note \"ok\" } as a
27978 emit signal unknown.sig to t.peer { note \"bad\" } as b
27979}
27980";
27981 let compiled = compile_program(source);
27982 let messages: Vec<&str> = compiled
27983 .diagnostics
27984 .iter()
27985 .map(|d| d.message.as_str())
27986 .collect();
27987 assert!(
27988 messages.contains(&"rule `relay` emits undeclared signal `unknown.sig`"),
27989 "expected the undeclared-emit diagnostic, got {messages:?}"
27990 );
27991 assert!(
27993 !messages
27994 .iter()
27995 .any(|m| m.contains("emits undeclared signal `known.sig`")),
27996 "a declared signal must not be flagged: {messages:?}"
27997 );
27998 }
27999
28000 #[test]
28001 fn source_emit_of_undeclared_signal_is_flagged_statically() {
28002 let source = "\
28006workflow SourceEmit
28007signal ingress.known { text string }
28008source file as feed {
28009 path \"/tmp/x.txt\"
28010 observe as obs
28011 emit ingress.unknown { text obs.line }
28012}
28013output result Done
28014class Done { ok string }
28015rule react
28016 when ingress.known as k
28017=> {
28018 complete result { ok \"ok\" }
28019}
28020";
28021 let compiled = compile_program(source);
28022 let messages: Vec<&str> = compiled
28023 .diagnostics
28024 .iter()
28025 .map(|d| d.message.as_str())
28026 .collect();
28027 assert!(
28028 messages.contains(&"source `feed` emits undeclared signal `ingress.unknown`"),
28029 "expected the undeclared source-emit diagnostic, got {messages:?}"
28030 );
28031 let ok_source = source.replace("ingress.unknown", "ingress.known");
28033 let ok_compiled = compile_program(&ok_source);
28034 assert!(
28035 !ok_compiled
28036 .diagnostics
28037 .iter()
28038 .any(|d| d.message.contains("emits undeclared signal")),
28039 "a declared signal must not be flagged: {:?}",
28040 ok_compiled
28041 .diagnostics
28042 .iter()
28043 .map(|d| d.message.as_str())
28044 .collect::<Vec<_>>()
28045 );
28046 }
28047
28048 #[test]
28049 fn source_emit_of_unknown_observation_field_is_flagged_statically() {
28050 let source = "\
28055workflow BadObs
28056signal ingress.fed { text string }
28057source file as feed {
28058 path \"/tmp/x.txt\"
28059 observe as obs
28060 emit ingress.fed { text obs.nosuchfield }
28061}
28062output result Done
28063class Done { ok string }
28064rule react
28065 when ingress.fed as f
28066=> { complete result { ok \"ok\" } }
28067";
28068 let messages: Vec<String> = compile_program(source)
28069 .diagnostics
28070 .iter()
28071 .map(|d| d.message.clone())
28072 .collect();
28073 assert!(
28074 messages.iter().any(|m| m.contains(
28075 "emit reads `obs.nosuchfield`, but a `file` source's observation has no field"
28076 )),
28077 "expected the unknown-observation-field diagnostic, got {messages:?}"
28078 );
28079 let ok = source.replace("obs.nosuchfield", "obs.line");
28081 assert!(
28082 !compile_program(&ok)
28083 .diagnostics
28084 .iter()
28085 .any(|d| d.message.contains("observation has no field")),
28086 "a valid observation field must not be flagged"
28087 );
28088 }
28089
28090 #[test]
28091 fn renew_of_unacquired_lease_is_flagged_statically() {
28092 let source = "\
28096workflow RenewTypo
28097class Ticket { id string }
28098class Done { ok string }
28099lease slot { shared key Ticket slots 1 ttl 60s }
28100output result Done
28101table seed as Ticket [ { id \"t\" } ]
28102rule grab
28103 when Ticket as t
28104=> {
28105 acquire slot for t.id until ttl as held
28106 after held held {
28107 renew nonexistent until 300s as r
28108 complete result { ok \"ok\" }
28109 }
28110}
28111";
28112 let messages: Vec<String> = compile_program(source)
28113 .diagnostics
28114 .iter()
28115 .map(|d| d.message.clone())
28116 .collect();
28117 assert!(
28118 messages
28119 .iter()
28120 .any(|m| m.contains("renews unbound coordination binding `nonexistent`")),
28121 "expected the unbound-renew diagnostic, got {messages:?}"
28122 );
28123 let ok = source.replace("renew nonexistent", "renew held");
28125 assert!(
28126 !compile_program(&ok)
28127 .diagnostics
28128 .iter()
28129 .any(|d| d.message.contains("renews unbound coordination binding")),
28130 "renewing an acquired lease must not be flagged"
28131 );
28132 }
28133
28134 #[test]
28138 fn renew_of_a_claim_binding_is_accepted_and_lowers_to_tracker_renew() {
28139 let source = "\
28140workflow RenewClaim
28141class Done { ok string }
28142tracker backlog { provider builtin }
28143output result Done
28144rule work
28145 when backlog has ready issue as issue
28146=> {
28147 claim issue ttl 1h as active
28148
28149 after active succeeds {
28150 renew active as renewed
28151 }
28152
28153 after renewed succeeds {
28154 complete result { ok \"ok\" }
28155 }
28156}
28157";
28158 let compiled = compile_program(source);
28159 assert!(
28160 !compiled
28161 .diagnostics
28162 .iter()
28163 .any(|d| d.message.contains("renews unbound coordination binding")),
28164 "renewing a claim binding must not be flagged: {:?}",
28165 compiled.diagnostics
28166 );
28167 let ir = compiled.ir.expect("compiles");
28168 let work = ir
28169 .rules
28170 .iter()
28171 .find(|rule| rule.name == "work")
28172 .expect("work rule");
28173 assert!(
28174 work.metadata
28175 .effects
28176 .iter()
28177 .any(|effect| effect.kind == IrEffectKind::TrackerRenew),
28178 "a renew of a claim binding lowers to TrackerRenew: {:?}",
28179 work.metadata.effects
28180 );
28181 assert!(
28183 !work
28184 .metadata
28185 .effects
28186 .iter()
28187 .any(|effect| effect.kind == IrEffectKind::LeaseRenew),
28188 "no lease.renew for a claim-binding renew: {:?}",
28189 work.metadata.effects
28190 );
28191 }
28192
28193 #[test]
28194 fn release_of_each_bound_coordination_form_is_accepted() {
28195 let source = "\
28201workflow ReleaseForms
28202class Ticket { id string }
28203class Done { ok string }
28204lease slot { key Ticket slots 1 ttl 60s }
28205tracker backlog { provider builtin }
28206agent worker { provider fixture profile \"repo-writer\" capacity 1 }
28207output result Done
28208rule work
28209 when backlog has ready issue as issue
28210 when worker is available
28211=> {
28212 acquire slot for issue.id until ttl as held
28213 claim issue as active_claim
28214 after active_claim succeeds {
28215 release held
28216 release issue
28217 complete result { ok \"done\" }
28218 }
28219 after active_claim fails {
28220 release held
28221 complete result { ok \"gave-up\" }
28222 }
28223}
28224";
28225 let messages: Vec<String> = compile_program(source)
28226 .diagnostics
28227 .iter()
28228 .map(|d| d.message.clone())
28229 .collect();
28230 assert!(
28231 !messages
28232 .iter()
28233 .any(|m| m.contains("releases unbound coordination item")),
28234 "no bound release form must be flagged, got {messages:?}"
28235 );
28236 }
28237
28238 #[test]
28239 fn release_of_unbound_coordination_item_is_flagged_statically() {
28240 let source = "\
28245workflow ReleaseTypo
28246class Done { ok string }
28247tracker backlog { provider builtin }
28248agent worker { provider fixture profile \"repo-writer\" capacity 1 }
28249output result Done
28250rule work
28251 when backlog has ready issue as issue
28252 when worker is available
28253=> {
28254 claim issue as active_claim
28255 after active_claim succeeds {
28256 release nonexistent
28257 complete result { ok \"done\" }
28258 }
28259 after active_claim fails {
28260 complete result { ok \"gave-up\" }
28261 }
28262}
28263";
28264 let messages: Vec<String> = compile_program(source)
28265 .diagnostics
28266 .iter()
28267 .map(|d| d.message.clone())
28268 .collect();
28269 assert!(
28270 messages
28271 .iter()
28272 .any(|m| m.contains("releases unbound coordination item `nonexistent`")),
28273 "expected the unbound-release diagnostic, got {messages:?}"
28274 );
28275 let ok = source.replace("release nonexistent", "release issue");
28277 assert!(
28278 !compile_program(&ok)
28279 .diagnostics
28280 .iter()
28281 .any(|d| d.message.contains("releases unbound coordination item")),
28282 "releasing a bound work item must not be flagged"
28283 );
28284 }
28285
28286 #[test]
28287 fn http_source_url_must_have_an_http_scheme() {
28288 let source = "\
28290workflow BadUrl
28291signal ingress.fed { text string }
28292source http as feed {
28293 url \"not-a-real-url\"
28294 observe as obs
28295 emit ingress.fed { text obs.item }
28296}
28297output result Done
28298class Done { ok string }
28299rule react
28300 when ingress.fed as f
28301=> { complete result { ok \"ok\" } }
28302";
28303 let messages: Vec<String> = compile_program(source)
28304 .diagnostics
28305 .iter()
28306 .map(|d| d.message.clone())
28307 .collect();
28308 assert!(
28309 messages
28310 .iter()
28311 .any(|m| m.contains("is not an absolute http(s) URL")),
28312 "expected the http url-scheme diagnostic, got {messages:?}"
28313 );
28314 let ok = source.replace("not-a-real-url", "https://example.com/feed.json");
28316 assert!(
28317 !compile_program(&ok)
28318 .diagnostics
28319 .iter()
28320 .any(|d| d.message.contains("absolute http(s) URL")),
28321 "a well-formed url must not be flagged"
28322 );
28323 }
28324
28325 #[test]
28330 fn file_watch_source_parses_lowers_and_formats() {
28331 let source = "\
28332workflow WatchSource
28333signal drop.arrived { path string digest string }
28334source file as drops {
28335 watch \"./drops/*.json\"
28336 observe as obs
28337 emit drop.arrived {
28338 path obs.path
28339 digest obs.content_hash
28340 }
28341}
28342output result Done
28343class Done { ok string }
28344rule react
28345 when drop.arrived as f
28346=> { complete result { ok \"ok\" } }
28347";
28348 let compiled = compile_program(source);
28349 let ir = compiled.ir.expect("watch source compiles");
28350 let decl = ir.sources.first().expect("source lowered");
28351 assert!(decl.is_file);
28352 assert_eq!(decl.watch.as_deref(), Some("./drops/*.json"));
28353 assert_eq!(decl.path, None);
28354 let formatted = format_program(source).formatted.expect("formats");
28355 assert!(
28356 formatted.contains("watch \"./drops/*.json\""),
28357 "{formatted}"
28358 );
28359 assert_eq!(
28360 format_program(&formatted).formatted.expect("reformats"),
28361 formatted,
28362 "fmt must be idempotent over the watch clause"
28363 );
28364 let bad = source.replace("digest obs.content_hash", "digest obs.line");
28367 let messages: Vec<String> = compile_program(&bad)
28368 .diagnostics
28369 .iter()
28370 .map(|d| d.message.clone())
28371 .collect();
28372 assert!(
28373 messages
28374 .iter()
28375 .any(|m| m.contains("observation has no field `line`")),
28376 "watch-mode emit must validate against the occurrence schema, got {messages:?}"
28377 );
28378 }
28379
28380 #[test]
28384 fn file_source_clause_set_is_closed() {
28385 let watch_on_clock = "\
28386workflow BadWatch
28387signal tick.fired { at time }
28388source clock as ticker {
28389 every 5m
28390 missed skip
28391 watch \"./drops/*.json\"
28392 observe as tick
28393 emit tick.fired { at tick.scheduled_at }
28394}
28395output result Done
28396class Done { ok string }
28397rule react
28398 when tick.fired as f
28399=> { complete result { ok \"ok\" } }
28400";
28401 let messages: Vec<String> = compile_program(watch_on_clock)
28402 .diagnostics
28403 .iter()
28404 .map(|d| d.message.clone())
28405 .collect();
28406 assert!(
28407 messages
28408 .iter()
28409 .any(|m| m.contains("`watch` clause but its provider is `clock`")),
28410 "watch outside `file` must be rejected, got {messages:?}"
28411 );
28412
28413 let both_modes = "\
28414workflow BothModes
28415signal ingress.fed { text string }
28416source file as feed {
28417 path \"./inbox.txt\"
28418 watch \"./drops/*.txt\"
28419 observe as obs
28420 emit ingress.fed { text obs.path }
28421}
28422output result Done
28423class Done { ok string }
28424rule react
28425 when ingress.fed as f
28426=> { complete result { ok \"ok\" } }
28427";
28428 let messages: Vec<String> = compile_program(both_modes)
28429 .diagnostics
28430 .iter()
28431 .map(|d| d.message.clone())
28432 .collect();
28433 assert!(
28434 messages
28435 .iter()
28436 .any(|m| m.contains("declares both `path` and `watch`")),
28437 "path+watch must be rejected as exclusive modes, got {messages:?}"
28438 );
28439
28440 let neither = "\
28441workflow Neither
28442signal ingress.fed { text string }
28443source file as feed {
28444 observe as obs
28445 emit ingress.fed { text obs.line }
28446}
28447output result Done
28448class Done { ok string }
28449rule react
28450 when ingress.fed as f
28451=> { complete result { ok \"ok\" } }
28452";
28453 let messages: Vec<String> = compile_program(neither)
28454 .diagnostics
28455 .iter()
28456 .map(|d| d.message.clone())
28457 .collect();
28458 assert!(
28459 messages
28460 .iter()
28461 .any(|m| m.contains("requires a `path` or `watch` clause")),
28462 "a file source with neither mode must be rejected, got {messages:?}"
28463 );
28464 }
28465
28466 #[test]
28471 fn dedup_clause_parses_lowers_and_validates() {
28472 let source = "\
28473workflow DedupSource
28474signal ingress.ingested { text string }
28475source http as feed {
28476 url \"https://example.com/feed.json\"
28477 dedup obs.item
28478 observe as obs
28479 emit ingress.ingested { text obs.item }
28480}
28481output result Done
28482class Done { ok string }
28483rule react
28484 when ingress.ingested as f
28485=> { complete result { ok \"ok\" } }
28486";
28487 let compiled = compile_program(source);
28488 let ir = compiled.ir.expect("dedup source compiles");
28489 let decl = ir.sources.first().expect("source lowered");
28490 assert!(decl.is_http);
28491 assert_eq!(decl.dedup_field.as_deref(), Some("item"));
28492 let formatted = format_program(source).formatted.expect("formats");
28493 assert!(formatted.contains("dedup obs.item"), "{formatted}");
28494 assert_eq!(
28495 format_program(&formatted).formatted.expect("reformats"),
28496 formatted,
28497 "fmt must be idempotent over the dedup clause"
28498 );
28499
28500 let bad_field = source.replace("dedup obs.item", "dedup obs.delivery_id");
28502 let messages: Vec<String> = compile_program(&bad_field)
28503 .diagnostics
28504 .iter()
28505 .map(|d| d.message.clone())
28506 .collect();
28507 assert!(
28508 messages
28509 .iter()
28510 .any(|m| m.contains("observation has no field `delivery_id`")),
28511 "an unknown dedup field must be rejected, got {messages:?}"
28512 );
28513
28514 let bad_root = source.replace("dedup obs.item", "dedup other.item");
28516 let messages: Vec<String> = compile_program(&bad_root)
28517 .diagnostics
28518 .iter()
28519 .map(|d| d.message.clone())
28520 .collect();
28521 assert!(
28522 messages
28523 .iter()
28524 .any(|m| m.contains("`dedup` must name one observation field")),
28525 "a dedup path off a foreign binding must be rejected, got {messages:?}"
28526 );
28527
28528 let on_clock = "\
28530workflow DedupClock
28531signal tick.fired { at time }
28532source clock as ticker {
28533 every 5m
28534 missed skip
28535 dedup tick.occurrence_id
28536 observe as tick
28537 emit tick.fired { at tick.scheduled_at }
28538}
28539output result Done
28540class Done { ok string }
28541rule react
28542 when tick.fired as f
28543=> { complete result { ok \"ok\" } }
28544";
28545 let messages: Vec<String> = compile_program(on_clock)
28546 .diagnostics
28547 .iter()
28548 .map(|d| d.message.clone())
28549 .collect();
28550 assert!(
28551 messages
28552 .iter()
28553 .any(|m| m.contains("declares a `dedup` clause but its provider is `clock`")),
28554 "dedup on a clock source must be rejected, got {messages:?}"
28555 );
28556 }
28557
28558 #[test]
28559 fn enum_variants_are_one_per_line() {
28560 let garbage = compile_program(
28563 "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",
28564 );
28565 assert!(garbage.diagnostics.iter().any(|d| d
28566 .message
28567 .contains("on the same line as the previous variant")));
28568 let payload = compile_program(
28570 "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",
28571 );
28572 assert!(
28573 !payload
28574 .diagnostics
28575 .iter()
28576 .any(|d| d.message.contains("same line")),
28577 "{:?}",
28578 payload.diagnostics
28579 );
28580 }
28581
28582 #[test]
28583 fn unknown_std_package_import_is_a_check_error() {
28584 let typo = compile_program(
28587 "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",
28588 );
28589 assert!(typo
28590 .diagnostics
28591 .iter()
28592 .any(|d| d.message.contains("unknown standard package `std.coercon`")));
28593 for id in STD_PACKAGE_IDS {
28595 let ok = compile_program(&format!(
28596 "use {id}\nworkflow T\noutput result D\nclass D {{ a string }}\nrule r\n when started\n=> {{\n complete result {{ a \"x\" }}\n}}\n"
28597 ));
28598 assert!(
28599 !ok.diagnostics
28600 .iter()
28601 .any(|d| d.message.contains("unknown standard package")),
28602 "{id}: {:?}",
28603 ok.diagnostics
28604 );
28605 }
28606 let nonstd = compile_program(
28608 "use notes\nworkflow T\noutput result D\nclass D { a string }\nrule r\n when started\n=> {\n complete result { a \"x\" }\n}\n",
28609 );
28610 assert!(!nonstd
28611 .diagnostics
28612 .iter()
28613 .any(|d| d.message.contains("unknown standard package")));
28614 }
28615
28616 #[test]
28617 fn blockless_coerce_desugars_to_the_prompt_clause() {
28618 let block = compile_program(
28619 "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",
28620 );
28621 let blockless = compile_program(
28622 "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",
28623 );
28624 let block_ir = block.ir.expect("block form compiles");
28625 let blockless_ir = blockless.ir.expect("blockless form compiles");
28626 assert!(blockless_ir.coerces[0].body.starts_with("prompt \"\"\""));
28629 assert_eq!(block_ir.coerces[0].name, blockless_ir.coerces[0].name);
28630 }
28631
28632 #[test]
28633 fn coerce_body_is_a_validated_clause_list() {
28634 let source = |body: &str| {
28635 format!(
28636 "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"
28637 )
28638 };
28639 let typo = compile_program(&source(" promt \"Judge {{ x }}\""));
28641 assert!(typo
28642 .diagnostics
28643 .iter()
28644 .any(|d| d.message.contains("unknown coerce field `promt`")));
28645 let junk = compile_program(&source(" prompt \"Judge {{ x }}\"\n mystery field"));
28647 assert!(junk
28648 .diagnostics
28649 .iter()
28650 .any(|d| d.message.contains("unknown coerce field `mystery`")));
28651 let legal = compile_program(&source(
28654 " # choose the fixture\n provider fixture\n\n prompt \"\"\"markdown\n Judge {{ x }}.\n {{ ctx.output_format }}\n \"\"\"",
28655 ));
28656 assert!(
28657 !legal
28658 .diagnostics
28659 .iter()
28660 .any(|d| d.message.contains("unknown coerce field")),
28661 "{:?}",
28662 legal.diagnostics
28663 );
28664 let malformed = compile_program(&source(" provider one two\n prompt \"Judge {{ x }}\""));
28666 assert!(malformed
28667 .diagnostics
28668 .iter()
28669 .any(|d| d.message.contains("malformed `provider` clause")));
28670 }
28671
28672 #[test]
28673 fn rejects_invalid_agent_declarations() {
28674 let source = include_str!("../../../examples/invalid/bad-agent.whip");
28675 let compiled = compile_program(source);
28676
28677 assert!(compiled.ir.is_none());
28678 assert_eq!(compiled.diagnostics.len(), 3);
28679 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
28680 .message
28681 .contains("capacity must be greater than zero")));
28682 assert!(compiled
28683 .diagnostics
28684 .iter()
28685 .any(|diagnostic| diagnostic.message.contains("more than once")));
28686 assert!(compiled
28687 .diagnostics
28688 .iter()
28689 .any(|diagnostic| diagnostic.message.contains("unknown agent field")));
28690 assert!(!compiled
28693 .diagnostics
28694 .iter()
28695 .any(|diagnostic| diagnostic.message.contains("missing a profile")));
28696 }
28697
28698 #[test]
28699 fn rejects_invalid_effect_dependencies() {
28700 let source = include_str!("../../../examples/invalid/bad-effect-graph.whip");
28701 let compiled = compile_program(source);
28702
28703 assert!(compiled.ir.is_none());
28704 assert!(compiled
28705 .diagnostics
28706 .iter()
28707 .any(|diagnostic| diagnostic.message.contains("unknown effect binding")));
28708 assert!(compiled
28709 .diagnostics
28710 .iter()
28711 .any(|diagnostic| diagnostic.message.contains("unsupported `after`")));
28712 }
28713
28714 #[test]
28715 fn accepts_equality_guards_in_when_clauses() {
28716 let source = r#"
28717workflow GuardGuess
28718
28719class WorkItem {
28720 state "ready" | "blocked"
28721}
28722
28723rule branch
28724 when WorkItem as item where item.state == "ready"
28725=> {
28726}
28727"#;
28728 let compiled = compile_program(source);
28729 assert_eq!(compiled.diagnostics, Vec::new());
28730 let ir = compiled.ir.expect("valid ir");
28731 let when = ir
28732 .rules
28733 .iter()
28734 .flat_map(|rule| &rule.whens)
28735 .find(|when| when.source == "WorkItem as item where item.state == \"ready\"")
28736 .expect("guarded when");
28737 assert_eq!(when.pattern, "WorkItem as item");
28738 assert_eq!(
28739 when.guard.as_ref().map(|guard| guard.expr.to_snapshot()),
28740 Some("item.state == \"ready\"".to_owned())
28741 );
28742 }
28743
28744 #[test]
28745 fn lowers_assertions_to_parsed_expression_ir() {
28746 let source = r#"
28747workflow AssertionGuess
28748
28749class Result {
28750 status "done"
28751}
28752
28753assert count(Result where status == "done") == 1
28754"#;
28755 let compiled = compile_program(source);
28756 assert_eq!(compiled.diagnostics, Vec::new());
28757 let ir = compiled.ir.expect("valid ir");
28758 let assertion = ir.assertions.first().expect("assertion");
28759 assert_eq!(
28760 assertion.expr.source,
28761 "count(Result where status == \"done\") == 1"
28762 );
28763 assert_eq!(
28764 assertion.expr.expr.to_snapshot(),
28765 "count(Result where status == \"done\") == 1"
28766 );
28767 assert_eq!(
28768 assertion
28769 .projection_reads
28770 .iter()
28771 .map(IrProjectionRead::to_snapshot)
28772 .collect::<Vec<_>>(),
28773 vec!["fact:Result where status == \"done\""]
28774 );
28775 }
28776
28777 #[test]
28778 fn lowers_guard_projection_reads_to_rule_metadata() {
28779 let source = r#"
28780workflow GuardProjection
28781
28782class Task {
28783 status "ready"
28784}
28785
28786class Result {
28787 status "done"
28788}
28789
28790rule gated
28791 when Task as task where exists(Result where status == "done")
28792=> {
28793}
28794"#;
28795 let compiled = compile_program(source);
28796 assert_eq!(compiled.diagnostics, Vec::new());
28797 let ir = compiled.ir.expect("valid ir");
28798 let rule = ir.rules.first().expect("rule");
28799 assert_eq!(
28800 rule.metadata
28801 .projection_reads
28802 .iter()
28803 .map(IrProjectionRead::to_snapshot)
28804 .collect::<Vec<_>>(),
28805 vec!["fact:Result where status == \"done\""]
28806 );
28807 }
28808
28809 fn read_codec_program(format: &str) -> String {
28810 format!(
28811 r#"
28812workflow ReadBody
28813
28814output result Result
28815
28816class Result {{
28817 status string
28818}}
28819
28820file store project_files {{
28821 root "./data"
28822}}
28823
28824rule pick
28825 when started
28826=> {{
28827 read {format} from project_files at "note.md" as fileResult
28828 after fileResult succeeds as result {{
28829 complete result {{
28830 status "ok"
28831 }}
28832 }}
28833}}
28834"#
28835 )
28836 }
28837
28838 #[test]
28839 fn read_accepts_text_and_markdown_body_codecs() {
28840 for format in ["text", "markdown"] {
28841 let compiled = compile_program(&read_codec_program(format));
28842 assert_eq!(
28843 compiled.diagnostics,
28844 Vec::new(),
28845 "`read {format}` compiles clean"
28846 );
28847 assert!(compiled.ir.is_some(), "`read {format}` produces IR");
28848 }
28849 }
28850
28851 #[test]
28852 fn read_rejects_structured_and_binary_codecs() {
28853 for format in ["json", "jsonl", "csv", "bytes"] {
28856 let compiled = compile_program(&read_codec_program(format));
28857 assert!(
28858 compiled
28859 .diagnostics
28860 .iter()
28861 .any(|diagnostic| diagnostic.message.contains("not supported")),
28862 "`read {format}` is rejected with a diagnostic; got {:?}",
28863 compiled.diagnostics
28864 );
28865 }
28866 }
28867
28868 fn write_program(format: &str, mode_clause: &str) -> String {
28869 format!(
28870 r#"
28871workflow WriteBody
28872
28873output result Result
28874
28875class Result {{
28876 status string
28877}}
28878
28879file store out_files {{
28880 root "./data"
28881 allow write ["**"]
28882}}
28883
28884rule pick
28885 when started
28886=> {{
28887 write {format} to out_files at "report.md" {{
28888 body "hello"
28889 {mode_clause}
28890 }} as written
28891 after written succeeds as result {{
28892 complete result {{
28893 status "ok"
28894 }}
28895 }}
28896}}
28897"#
28898 )
28899 }
28900
28901 #[test]
28902 fn write_accepts_text_and_markdown_with_explicit_mode() {
28903 for format in ["text", "markdown"] {
28904 let compiled = compile_program(&write_program(format, "mode create"));
28905 assert_eq!(
28906 compiled.diagnostics,
28907 Vec::new(),
28908 "`write {format}` with an explicit mode compiles clean"
28909 );
28910 assert!(compiled.ir.is_some(), "`write {format}` produces IR");
28911 }
28912 }
28913
28914 #[test]
28915 fn write_rejects_structured_codecs() {
28916 for format in ["json", "csv", "bytes"] {
28917 let compiled = compile_program(&write_program(format, "mode create"));
28918 assert!(
28919 compiled
28920 .diagnostics
28921 .iter()
28922 .any(|diagnostic| diagnostic.message.contains("not supported")),
28923 "`write {format}` is rejected; got {:?}",
28924 compiled.diagnostics
28925 );
28926 }
28927 }
28928
28929 #[test]
28930 fn write_requires_an_explicit_mode() {
28931 let compiled = compile_program(&write_program("text", ""));
28933 assert!(
28934 compiled
28935 .diagnostics
28936 .iter()
28937 .any(|diagnostic| diagnostic.message.contains("explicit `mode`")),
28938 "`write` without a mode is rejected; got {:?}",
28939 compiled.diagnostics
28940 );
28941 }
28942
28943 #[test]
28944 fn write_rejects_unknown_mode() {
28945 let compiled = compile_program(&write_program("text", "mode clobber"));
28946 assert!(
28947 compiled
28948 .diagnostics
28949 .iter()
28950 .any(|diagnostic| diagnostic.message.contains("unknown write mode")),
28951 "an unknown write mode is rejected; got {:?}",
28952 compiled.diagnostics
28953 );
28954 }
28955
28956 fn import_program(format: &str) -> String {
28957 format!(
28958 r#"
28959workflow ImportRows
28960
28961output result Result
28962
28963class Result {{
28964 status string
28965}}
28966
28967class IssueRow {{
28968 title string
28969 priority string
28970}}
28971
28972file store data_files {{
28973 root "./data"
28974}}
28975
28976rule pick
28977 when started
28978=> {{
28979 import {format} IssueRow from data_files at "issues.in" as imported
28980 after imported succeeds as r {{
28981 complete result {{
28982 status "ok"
28983 }}
28984 }}
28985}}
28986"#
28987 )
28988 }
28989
28990 #[test]
28991 fn import_accepts_structured_codecs_and_lowers_to_file_import() {
28992 for format in ["jsonl", "json", "csv"] {
28993 let compiled = compile_program(&import_program(format));
28994 assert_eq!(
28995 compiled.diagnostics,
28996 Vec::new(),
28997 "`import {format}` compiles clean"
28998 );
28999 let ir = compiled.ir.expect("import produces IR");
29000 let rule = ir.rules.first().expect("rule");
29001 assert!(
29002 rule.metadata
29003 .effects
29004 .iter()
29005 .any(|effect| effect.kind == IrEffectKind::FileImport),
29006 "`import {format}` lowers to a file.import effect"
29007 );
29008 }
29009 }
29010
29011 #[test]
29012 fn import_rejects_unsupported_codecs() {
29013 for format in ["xml", "text", "markdown", "bytes"] {
29016 let compiled = compile_program(&import_program(format));
29017 assert!(
29018 compiled
29019 .diagnostics
29020 .iter()
29021 .any(|diagnostic| diagnostic.message.contains("not supported")),
29022 "`import {format}` is rejected; got {:?}",
29023 compiled.diagnostics
29024 );
29025 }
29026 }
29027
29028 #[test]
29029 fn class_field_key_annotation_lowers_and_rejects_duplicates() {
29030 let single = compile_program(
29031 r#"
29032workflow Keyed
29033
29034class Row {
29035 id string @key
29036 title string
29037}
29038"#,
29039 );
29040 assert_eq!(
29041 single.diagnostics,
29042 Vec::new(),
29043 "single `@key` compiles clean"
29044 );
29045 let ir = single.ir.expect("ir");
29046 let class = ir
29047 .schemas
29048 .iter()
29049 .find_map(|schema| match schema {
29050 IrSchema::Class(class) if class.name == "Row" => Some(class),
29051 _ => None,
29052 })
29053 .expect("Row class");
29054 let key_fields = class
29055 .fields
29056 .iter()
29057 .filter(|field| field.is_key)
29058 .map(|field| field.name.as_str())
29059 .collect::<Vec<_>>();
29060 assert_eq!(key_fields, vec!["id"], "the `@key` field is recorded");
29061
29062 let dual = compile_program(
29063 r#"
29064workflow Keyed
29065
29066class Row {
29067 a string @key
29068 b string @key
29069}
29070"#,
29071 );
29072 assert!(
29073 dual.diagnostics
29074 .iter()
29075 .any(|diagnostic| diagnostic.message.contains("more than one `@key`")),
29076 "two `@key` fields are rejected; got {:?}",
29077 dual.diagnostics
29078 );
29079 }
29080
29081 #[test]
29082 fn single_line_terminal_block_validates_its_fields() {
29083 for body in [
29088 " complete result { status \"ok\" }",
29089 " complete result {\n status \"ok\"\n }",
29090 ] {
29091 let source = format!(
29092 r#"
29093workflow S
29094
29095output result Result
29096
29097class Result {{
29098 status string
29099}}
29100
29101rule go
29102 when started
29103=> {{
29104{body}
29105}}
29106"#
29107 );
29108 let compiled = compile_program(&source);
29109 assert!(
29110 !compiled
29111 .diagnostics
29112 .iter()
29113 .any(|diagnostic| diagnostic.message.contains("missing required field")),
29114 "terminal block validates its field; got {:?}",
29115 compiled.diagnostics
29116 );
29117 }
29118 }
29119
29120 #[test]
29121 fn action_declaration_parses_and_is_inert_until_expansion() {
29122 let compiled = compile_program(
29127 r#"
29128workflow A
29129
29130output result Result
29131
29132class Result {
29133 status string
29134}
29135
29136class Task {
29137 name string
29138}
29139
29140action do_it(task Task, label string) {
29141 record Result {
29142 status label
29143 }
29144}
29145
29146rule go
29147 when started
29148=> {
29149 complete result {
29150 status "ok"
29151 }
29152}
29153"#,
29154 );
29155 assert_eq!(
29156 compiled.diagnostics,
29157 Vec::new(),
29158 "an unused action declaration compiles clean"
29159 );
29160 let ir = compiled.ir.expect("program with an action lowers");
29161 assert!(
29164 ir.rules.iter().any(|rule| rule.name == "go"),
29165 "the ordinary rule still lowers alongside the action template"
29166 );
29167 }
29168
29169 #[test]
29170 fn accepts_typed_case_branches_in_rule_bodies() {
29171 let source = r#"
29172workflow CaseGuess
29173
29174enum ReviewStatus {
29175 Accept
29176 Revise
29177 Blocked
29178}
29179
29180class Review {
29181 status ReviewStatus
29182 assignee string?
29183}
29184
29185class Routed {
29186 status ReviewStatus
29187}
29188
29189rule route
29190 when Review as review
29191=> {
29192 case review.status {
29193 Accept => {
29194 record Routed {
29195 status Accept
29196 }
29197 }
29198 Revise => {
29199 record Routed {
29200 status Revise
29201 }
29202 }
29203 Blocked => {
29204 record Routed {
29205 status Blocked
29206 }
29207 }
29208 }
29209
29210 case review.assignee {
29211 Some owner => {
29212 record Routed {
29213 status Accept
29214 }
29215 }
29216 None => {
29217 record Routed {
29218 status Blocked
29219 }
29220 }
29221 }
29222}
29223"#;
29224 let compiled = compile_program(source);
29225 assert_eq!(compiled.diagnostics, Vec::new());
29226 assert!(compiled.ir.is_some());
29227 }
29228
29229 #[test]
29230 fn accepts_terminal_output_case_branches_inside_completes_after() {
29231 let source = r#"
29232workflow TerminalCaseGuess
29233
29234class WorkItem {
29235 title string
29236}
29237
29238class MessageClassification {
29239 summary string
29240}
29241
29242class Routed {
29243 branch string
29244 detail string
29245}
29246
29247coerce classifyMessage(title string) -> MessageClassification {
29248 prompt "Classify"
29249}
29250
29251rule classify
29252 when WorkItem as item
29253=> {
29254 coerce classifyMessage(item.title) as classification
29255
29256 after classification completes {
29257 case classification {
29258 Completed as result => {
29259 record Routed {
29260 branch "completed"
29261 detail result.summary
29262 }
29263 }
29264 Failed as failure => {
29265 record Routed {
29266 branch "failed"
29267 detail failure.reason
29268 }
29269 }
29270 TimedOut as timeout => {
29271 record Routed {
29272 branch "timed_out"
29273 detail timeout.summary
29274 }
29275 }
29276 Cancelled as cancel => {
29277 record Routed {
29278 branch "cancelled"
29279 detail cancel.summary
29280 }
29281 }
29282 }
29283 }
29284}
29285"#;
29286 let compiled = compile_program(source);
29287 assert_eq!(compiled.diagnostics, Vec::new());
29288 assert!(compiled.ir.is_some());
29289 }
29290
29291 #[test]
29292 fn accepts_terminal_output_case_as_binding_form() {
29293 let source = r#"
29296workflow T
29297
29298class WorkItem { title string }
29299class MessageClassification { summary string }
29300class Routed {
29301 branch string
29302 detail string
29303}
29304
29305coerce classifyMessage(title string) -> MessageClassification {
29306 prompt "Classify"
29307}
29308
29309rule classify
29310 when WorkItem as item
29311=> {
29312 coerce classifyMessage(item.title) as classification
29313
29314 after classification completes {
29315 case classification {
29316 Completed as result => {
29317 record Routed { branch "completed" detail result.summary }
29318 }
29319 Failed as failure => {
29320 record Routed { branch "failed" detail failure.reason }
29321 }
29322 TimedOut as timeout => {
29323 record Routed { branch "timed_out" detail timeout.summary }
29324 }
29325 Cancelled as cancel => {
29326 record Routed { branch "cancelled" detail cancel.summary }
29327 }
29328 }
29329 }
29330}
29331"#;
29332 let compiled = compile_program(source);
29333 assert_eq!(compiled.diagnostics, Vec::new());
29334 assert!(compiled.ir.is_some());
29335 }
29336
29337 #[test]
29338 fn accepts_after_times_out_branch_and_types_payload_alias() {
29339 let source = r#"
29340workflow TimedOutBranch
29341
29342class WorkItem {
29343 title string
29344}
29345
29346class MessageClassification {
29347 summary string
29348}
29349
29350class Routed {
29351 branch string
29352 detail string
29353}
29354
29355coerce classifyMessage(title string) -> MessageClassification {
29356 prompt "Classify"
29357}
29358
29359rule classify
29360 when WorkItem as item
29361=> {
29362 coerce classifyMessage(item.title) as classification
29363
29364 after classification times out as t {
29365 record Routed {
29366 branch "timed_out"
29367 detail t.summary
29368 }
29369 }
29370}
29371"#;
29372 let compiled = compile_program(source);
29373 assert_eq!(compiled.diagnostics, Vec::new());
29374 assert!(compiled.ir.is_some());
29375 }
29376
29377 #[test]
29378 fn accepts_after_cancelled_branch_and_types_payload_alias() {
29379 let source = r#"
29380workflow CancelledBranch
29381
29382class WorkItem {
29383 title string
29384}
29385
29386class MessageClassification {
29387 summary string
29388}
29389
29390class Routed {
29391 branch string
29392 detail string
29393}
29394
29395coerce classifyMessage(title string) -> MessageClassification {
29396 prompt "Classify"
29397}
29398
29399rule classify
29400 when WorkItem as item
29401=> {
29402 coerce classifyMessage(item.title) as classification
29403
29404 after classification cancelled as c {
29405 record Routed {
29406 branch "cancelled"
29407 detail c.summary
29408 }
29409 }
29410}
29411"#;
29412 let compiled = compile_program(source);
29413 assert_eq!(compiled.diagnostics, Vec::new());
29414 assert!(compiled.ir.is_some());
29415 }
29416
29417 #[test]
29418 fn rejects_invalid_after_predicate_during_compilation() {
29419 let source = r#"
29420workflow BadPredicate
29421
29422class WorkItem {
29423 title string
29424}
29425
29426class MessageClassification {
29427 summary string
29428}
29429
29430class Routed {
29431 branch string
29432}
29433
29434coerce classifyMessage(title string) -> MessageClassification {
29435 prompt "Classify"
29436}
29437
29438rule classify
29439 when WorkItem as item
29440=> {
29441 coerce classifyMessage(item.title) as classification
29442
29443 after classification explodes {
29444 record Routed {
29445 branch "boom"
29446 }
29447 }
29448}
29449"#;
29450 let compiled = compile_program(source);
29451 assert!(compiled.diagnostics.iter().any(|d| d
29452 .message
29453 .contains("unsupported `after` predicate `explodes`")));
29454 }
29455
29456 #[test]
29457 fn lowers_terminal_output_case_branches_to_typed_ir() {
29458 let source = include_str!("../../../examples/terminal-output-union.whip");
29459 let compiled = compile_program(source);
29460 assert_eq!(compiled.diagnostics, Vec::new());
29461 let ir = compiled.ir.expect("expected lowered IR");
29462 let rule = ir
29463 .rules
29464 .iter()
29465 .find(|rule| rule.name == "classify_work")
29466 .expect("rule");
29467
29468 let terminal_output = rule
29469 .metadata
29470 .terminal_outputs
29471 .iter()
29472 .find(|output| output.binding == "classification")
29473 .expect("terminal output");
29474 assert_eq!(terminal_output.alternatives.len(), 4);
29475 assert_eq!(
29476 terminal_output.alternatives[0].payload_type,
29477 IrType::Ref("Classification".to_owned())
29478 );
29479 assert_eq!(
29480 rule.metadata
29481 .terminal_branches
29482 .iter()
29483 .map(|branch| {
29484 (
29485 branch.tag.as_deref().unwrap_or("_"),
29486 branch.binding.as_deref().unwrap_or("-"),
29487 )
29488 })
29489 .collect::<Vec<_>>(),
29490 vec![
29491 ("Completed", "result"),
29492 ("Failed", "failure"),
29493 ("TimedOut", "timeout"),
29494 ("Cancelled", "cancel"),
29495 ]
29496 );
29497 }
29498
29499 #[test]
29500 fn rejects_terminal_payload_fields_outside_refined_tag_schema() {
29501 let source = r#"
29502workflow BadTerminalPayload
29503
29504class WorkItem {
29505 title string
29506}
29507
29508class Classification {
29509 summary string
29510}
29511
29512class TerminalRoute {
29513 detail string
29514}
29515
29516coerce classify(title string) -> Classification {
29517 prompt "Classify"
29518}
29519
29520rule classify_work
29521 when WorkItem as item
29522=> {
29523 coerce classify(item.title) as classification
29524
29525 after classification completes {
29526 case classification {
29527 Completed as result => {
29528 record TerminalRoute {
29529 detail result.reason
29530 }
29531 }
29532 Failed as failure => {
29533 record TerminalRoute {
29534 detail failure.reason
29535 }
29536 }
29537 TimedOut as timeout => {
29538 record TerminalRoute {
29539 detail timeout.summary
29540 }
29541 }
29542 Cancelled as cancel => {
29543 record TerminalRoute {
29544 detail cancel.summary
29545 }
29546 }
29547 }
29548 }
29549}
29550"#;
29551 let compiled = compile_program(source);
29552 assert!(compiled.ir.is_none());
29553 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
29554 .message
29555 .contains("invalid field path `result.reason`")));
29556 }
29557
29558 #[test]
29559 fn rejects_invalid_terminal_output_case_branches() {
29560 let source = r#"
29561workflow BadTerminalCaseGuess
29562
29563class WorkItem {
29564 title string
29565}
29566
29567class MessageClassification {
29568 summary string
29569}
29570
29571coerce classifyMessage(title string) -> MessageClassification {
29572 prompt "Classify"
29573}
29574
29575rule classify
29576 when WorkItem as item
29577=> {
29578 coerce classifyMessage(item.title) as classification
29579
29580 after classification completes {
29581 case classification {
29582 Success as result => {
29583 }
29584 Completed as result => {
29585 }
29586 }
29587 }
29588}
29589"#;
29590 let compiled = compile_program(source);
29591 assert!(compiled.ir.is_none());
29592 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
29593 .message
29594 .contains("terminal-output case pattern cannot be `Success`")));
29595 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
29596 .message
29597 .contains("non-exhaustive terminal-output case; missing Failed, TimedOut, Cancelled")));
29598 }
29599
29600 fn terminal_case_program(cases: &str) -> String {
29604 format!(
29605 r#"
29606workflow TerminalCaseMatrix
29607
29608class WorkItem {{
29609 title string
29610}}
29611
29612class MessageClassification {{
29613 summary string
29614}}
29615
29616class Routed {{
29617 branch string
29618}}
29619
29620coerce classifyMessage(title string) -> MessageClassification {{
29621 prompt "Classify"
29622}}
29623
29624rule classify
29625 when WorkItem as item
29626=> {{
29627 coerce classifyMessage(item.title) as classification
29628
29629 after classification completes {{
29630 case classification {{
29631{cases}
29632 }}
29633 }}
29634}}
29635"#
29636 )
29637 }
29638
29639 #[test]
29640 fn accepts_guarded_terminal_case_branch_referencing_refined_payload() {
29641 let source = terminal_case_program(
29646 " Completed as result where result.summary == \"ok\" => { record Routed { branch \"ok\" } }\n _ => { record Routed { branch \"other\" } }",
29647 );
29648 let compiled = compile_program(&source);
29649 assert_eq!(
29650 compiled.diagnostics,
29651 Vec::new(),
29652 "{:?}",
29653 compiled.diagnostics
29654 );
29655 assert!(compiled.ir.is_some());
29656 }
29657
29658 #[test]
29659 fn rejects_terminal_case_guard_referencing_unknown_payload_field() {
29660 let source = terminal_case_program(
29661 " Completed as result where result.nonexistent == \"ok\" => { record Routed { branch \"ok\" } }\n _ => { record Routed { branch \"other\" } }",
29662 );
29663 let compiled = compile_program(&source);
29664 assert!(compiled.ir.is_none());
29665 assert!(
29666 compiled.diagnostics.iter().any(|d| d
29667 .message
29668 .contains("schema `MessageClassification` has no field `nonexistent`")),
29669 "{:?}",
29670 compiled.diagnostics
29671 );
29672 }
29673
29674 #[test]
29675 fn rejects_non_boolean_terminal_case_guard() {
29676 let source = terminal_case_program(
29677 " Completed as result where result.summary => { record Routed { branch \"ok\" } }\n _ => { record Routed { branch \"other\" } }",
29678 );
29679 let compiled = compile_program(&source);
29680 assert!(compiled.ir.is_none());
29681 assert!(
29682 compiled
29683 .diagnostics
29684 .iter()
29685 .any(|d| d.message.contains("non-boolean case guard expression")),
29686 "{:?}",
29687 compiled.diagnostics
29688 );
29689 }
29690
29691 #[test]
29692 fn rejects_duplicate_terminal_output_case_tag() {
29693 let source = terminal_case_program(
29694 " 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\" } }",
29695 );
29696 let compiled = compile_program(&source);
29697 assert!(compiled.ir.is_none());
29698 assert!(
29699 compiled.diagnostics.iter().any(|d| d
29700 .message
29701 .contains("duplicate unguarded terminal-output case pattern `Completed`")),
29702 "{:?}",
29703 compiled.diagnostics
29704 );
29705 }
29706
29707 #[test]
29708 fn rejects_terminal_output_case_branch_without_payload_binding() {
29709 let source = terminal_case_program(
29710 " 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\" } }",
29711 );
29712 let compiled = compile_program(&source);
29713 assert!(compiled.ir.is_none());
29714 assert!(
29715 compiled.diagnostics.iter().any(|d| d
29716 .message
29717 .contains("malformed terminal-output case pattern `Completed`")),
29718 "{:?}",
29719 compiled.diagnostics
29720 );
29721 }
29722
29723 #[test]
29724 fn rejects_invalid_case_branch_patterns() {
29725 let source = r#"
29726workflow BadCaseGuess
29727
29728enum ReviewStatus {
29729 Accept
29730 Revise
29731}
29732
29733class Review {
29734 status ReviewStatus
29735 assignee string
29736}
29737
29738rule route
29739 when Review as review
29740=> {
29741 case review.status {
29742 Missing => {
29743 }
29744 }
29745
29746 case review.assignee {
29747 Some owner => {
29748 }
29749 }
29750}
29751"#;
29752 let compiled = compile_program(source);
29753 assert!(compiled.ir.is_none());
29754 let missing = compiled
29755 .diagnostics
29756 .iter()
29757 .find(|diagnostic| {
29758 diagnostic
29759 .message
29760 .contains("enum `ReviewStatus` has no variant `Missing`")
29761 })
29762 .expect("missing variant diagnostic");
29763 assert!(source[missing.span.start..missing.span.end].contains("Mis"));
29764 let some = compiled
29765 .diagnostics
29766 .iter()
29767 .find(|diagnostic| {
29768 diagnostic
29769 .message
29770 .contains("uses `Some` for a non-optional case")
29771 })
29772 .expect("some diagnostic");
29773 assert!(source[some.span.start..some.span.end].contains("Some"));
29774 }
29775
29776 #[test]
29777 fn diagnoses_non_exhaustive_and_duplicate_case_branches() {
29778 let source = r#"
29779workflow CaseCoverageGuess
29780
29781enum ReviewStatus {
29782 Accept
29783 Revise
29784 Blocked
29785}
29786
29787class Review {
29788 status ReviewStatus
29789 provider "codex" | "claude"
29790 owner string?
29791}
29792
29793rule route
29794 when Review as review
29795=> {
29796 case review.status {
29797 Accept => {
29798 }
29799 Accept => {
29800 }
29801 Revise => {
29802 }
29803 }
29804
29805 case review.provider {
29806 "codex" => {
29807 }
29808 }
29809
29810 case review.owner {
29811 Some owner => {
29812 }
29813 }
29814}
29815"#;
29816 let compiled = compile_program(source);
29817 assert!(compiled.ir.is_none());
29818 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
29819 .message
29820 .contains("duplicate unguarded case pattern `Accept`")));
29821 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
29822 .message
29823 .contains("non-exhaustive case; missing Blocked")));
29824 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
29825 .message
29826 .contains("non-exhaustive case; missing claude")));
29827 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
29828 .message
29829 .contains("non-exhaustive case; missing None")));
29830 }
29831
29832 #[test]
29833 fn accepts_fallback_and_guarded_duplicate_case_branches() {
29834 let source = r#"
29835workflow CaseFallbackGuess
29836
29837enum ReviewStatus {
29838 Accept
29839 Revise
29840 Blocked
29841}
29842
29843class Review {
29844 status ReviewStatus
29845 owner string?
29846}
29847
29848rule route
29849 when Review as review
29850=> {
29851 case review.status {
29852 Accept where review.owner != null => {
29853 }
29854 Accept where review.owner == null => {
29855 }
29856 _ => {
29857 }
29858 }
29859}
29860"#;
29861 let compiled = compile_program(source);
29862 assert_eq!(compiled.diagnostics, Vec::new());
29863 assert!(compiled.ir.is_some());
29864 }
29865
29866 #[test]
29867 fn rejects_unreachable_case_branch_after_wildcard() {
29868 let source = r#"
29871workflow CaseUnreachableGuess
29872
29873enum ReviewStatus {
29874 Accept
29875 Revise
29876 Blocked
29877}
29878
29879class Review {
29880 status ReviewStatus
29881}
29882
29883rule route
29884 when Review as review
29885=> {
29886 case review.status {
29887 Accept => {
29888 }
29889 _ => {
29890 }
29891 Revise => {
29892 }
29893 }
29894}
29895"#;
29896 let compiled = compile_program(source);
29897 assert!(compiled.ir.is_none());
29898 assert!(
29899 compiled.diagnostics.iter().any(|diagnostic| diagnostic
29900 .message
29901 .contains("unreachable case branch after the `_` wildcard")),
29902 "expected unreachable-after-wildcard diagnostic: {:?}",
29903 compiled.diagnostics
29904 );
29905 }
29906
29907 #[test]
29908 fn family_b_presence_condition_validates_discriminant() {
29909 let program = |fields: &str| {
29910 format!(
29911 r#"
29912workflow B
29913input e Event
29914output result Done
29915class Done {{ ok bool }}
29916class Event {{
29917{fields}
29918}}
29919rule r
29920 when Event as e
29921=> {{
29922 complete result {{ ok true }}
29923}}
29924"#
29925 )
29926 };
29927 let ok = compile_program(&program(
29929 " kind \"deploy\" | \"rollback\"\n region string when kind is \"deploy\"",
29930 ));
29931 assert_eq!(ok.diagnostics, Vec::new());
29932 assert!(ok.ir.is_some());
29933 let bad1 = compile_program(&program(
29935 " kind \"deploy\" | \"rollback\"\n region string when missing is \"deploy\"",
29936 ));
29937 assert!(bad1
29938 .diagnostics
29939 .iter()
29940 .any(|d| d.message.contains("unknown discriminant `missing`")));
29941 let bad2 = compile_program(&program(
29943 " kind \"deploy\" | \"rollback\"\n region string when kind is \"ship\"",
29944 ));
29945 assert!(bad2
29946 .diagnostics
29947 .iter()
29948 .any(|d| d.message.contains("not a value of `kind`")));
29949 let bad3 = compile_program(&program(
29951 " kind string\n region string when kind is \"deploy\"",
29952 ));
29953 assert!(bad3
29954 .diagnostics
29955 .iter()
29956 .any(|d| d.message.contains("not a string-literal discriminant")));
29957 }
29958
29959 #[test]
29960 fn case_arm_effect_records_its_selector() {
29961 let source = r#"
29965workflow S
29966
29967input item WorkItem
29968output result R
29969class WorkItem { kind "a" | "b" }
29970class R { ok bool }
29971class V { ok bool }
29972
29973coerce f(t string) -> V { prompt "x" }
29974
29975rule r
29976 when WorkItem as item
29977=> {
29978 case item.kind {
29979 "a" => {
29980 coerce f("hi") as v
29981 after v succeeds {
29982 complete result { ok v.ok }
29983 }
29984 }
29985 "b" => {
29986 complete result { ok false }
29987 }
29988 }
29989}
29990"#;
29991 let ir = compile_program(source).ir.expect("compiles");
29992 let rule = ir.rules.iter().find(|r| r.name == "r").expect("rule r");
29993 let coerce = rule
29994 .metadata
29995 .effects
29996 .iter()
29997 .find(|e| e.binding.as_deref() == Some("v"))
29998 .expect("coerce effect v");
29999 let (scrutinee, pattern) = coerce
30000 .selected_by
30001 .as_ref()
30002 .expect("coerce in a case arm records its selector");
30003 assert_eq!(scrutinee, "item.kind");
30004 assert_eq!(pattern, "\"a\"");
30005 }
30009
30010 #[test]
30011 fn family_b_read_narrowing_restricts_conditioned_reads() {
30012 let program = |body: &str| {
30013 format!(
30014 r#"
30015workflow B
30016input e Event
30017output result Done
30018class Done {{ region string }}
30019class Event {{
30020 kind "deploy" | "rollback"
30021 region string when kind is "deploy"
30022}}
30023rule r
30024 when Event as e
30025=> {{
30026{body}
30027}}
30028"#
30029 )
30030 };
30031 let outside = compile_program(&program(" complete result { region e.region }"));
30033 assert!(
30034 outside
30035 .diagnostics
30036 .iter()
30037 .any(|d| d.message.contains("conditional field `e.region`")),
30038 "{:?}",
30039 outside.diagnostics
30040 );
30041 let matching = compile_program(&program(
30043 " case e.kind {\n \"deploy\" => { complete result { region e.region } }\n \"rollback\" => { complete result { region \"none\" } }\n }",
30044 ));
30045 assert_eq!(matching.diagnostics, Vec::new());
30046 assert!(matching.ir.is_some());
30047 let wrong = compile_program(&program(
30049 " case e.kind {\n \"deploy\" => { complete result { region \"x\" } }\n \"rollback\" => { complete result { region e.region } }\n }",
30050 ));
30051 assert!(
30052 wrong
30053 .diagnostics
30054 .iter()
30055 .any(|d| d.message.contains("conditional field `e.region`")),
30056 "{:?}",
30057 wrong.diagnostics
30058 );
30059 }
30060
30061 #[test]
30062 fn rejects_conflicting_reused_effect_binding() {
30063 let source = r#"
30066workflow D
30067
30068output result R
30069class R { x string }
30070class WorkItem { title string }
30071class A { a string }
30072class B { b string }
30073
30074coerce fa(t string) -> A { prompt "x" }
30075coerce fb(t string) -> B { prompt "x" }
30076
30077rule r
30078 when WorkItem as item
30079=> {
30080 coerce fa(item.title) as v
30081 coerce fb(item.title) as v
30082 complete result { x "done" }
30083}
30084"#;
30085 let compiled = compile_program(source);
30086 assert!(
30087 compiled
30088 .diagnostics
30089 .iter()
30090 .any(|d| d.message.contains("reuses effect binding `v`")),
30091 "{:?}",
30092 compiled.diagnostics
30093 );
30094 }
30095
30096 #[test]
30097 fn rejects_transitive_workflow_invocation_cycle() {
30098 let source = r#"
30101workflow A {
30102 input task TA
30103 output result RA
30104 class TA { id string }
30105 class RA { id string }
30106 rule go
30107 when TA as t
30108 => {
30109 invoke B { task { id t.id } } as b
30110 after b succeeds as r { complete result { id r.id } }
30111 }
30112}
30113
30114workflow B {
30115 input task TB
30116 output result RB
30117 class TB { id string }
30118 class RB { id string }
30119 rule go
30120 when TB as t
30121 => {
30122 invoke A { task { id t.id } } as a
30123 after a succeeds as r { complete result { id r.id } }
30124 }
30125}
30126"#;
30127 let compiled = compile_program_with_root(source, Some("A"));
30128 assert!(compiled.ir.is_none());
30129 assert!(
30130 compiled.diagnostics.iter().any(|d| d
30131 .message
30132 .contains("graph.unbounded_workflow_invocation_recursion")
30133 && d.message.contains("A -> B -> A")),
30134 "{:?}",
30135 compiled.diagnostics
30136 );
30137 }
30138
30139 #[test]
30140 fn accepts_acyclic_workflow_invocation_chain() {
30141 let source = r#"
30144workflow A {
30145 input task TA
30146 output result RA
30147 class TA { id string }
30148 class RA { id string }
30149 rule go
30150 when TA as t
30151 => {
30152 invoke B { task { id t.id } } as b
30153 after b succeeds as r { complete result { id r.id } }
30154 }
30155}
30156
30157workflow B {
30158 input task TB
30159 output result RB
30160 class TB { id string }
30161 class RB { id string }
30162 rule go
30163 when TB as t
30164 => {
30165 invoke C { task { id t.id } } as c
30166 after c succeeds as r { complete result { id r.id } }
30167 }
30168}
30169
30170workflow C {
30171 input task TC
30172 output result RC
30173 class TC { id string }
30174 class RC { id string }
30175 rule go
30176 when TC as t
30177 => {
30178 complete result { id t.id }
30179 }
30180}
30181"#;
30182 let compiled = compile_program_with_root(source, Some("A"));
30183 assert!(
30184 !compiled.diagnostics.iter().any(|d| d
30185 .message
30186 .contains("graph.unbounded_workflow_invocation_recursion")),
30187 "acyclic chain wrongly flagged: {:?}",
30188 compiled.diagnostics
30189 );
30190 }
30191
30192 #[test]
30193 fn rejects_invoking_private_sibling_workflow() {
30194 let source = r#"
30195class Job { id string }
30196class Report { id string }
30197
30198@private
30199workflow Child {
30200 input task Job
30201 output result Report
30202 rule work
30203 when Job as t
30204 => {
30205 complete result { id t.id }
30206 }
30207}
30208
30209workflow Parent {
30210 input task Job
30211 output result Report
30212 rule go
30213 when Job as t
30214 => {
30215 invoke Child { task t } as child
30216 after child succeeds as r { complete result { id r.id } }
30217 }
30218}
30219"#;
30220 let compiled = compile_program_with_root(source, Some("Parent"));
30221 assert!(compiled.ir.is_none());
30222 assert!(
30223 compiled
30224 .diagnostics
30225 .iter()
30226 .any(|d| d.message.contains("private workflow `Child`")),
30227 "{:?}",
30228 compiled.diagnostics
30229 );
30230 }
30231
30232 #[test]
30233 fn accepts_private_workflow_as_selected_root() {
30234 let source = r#"
30235class Job { id string }
30236class Report { id string }
30237
30238@private
30239workflow Child {
30240 input task Job
30241 output result Report
30242 rule work
30243 when Job as t
30244 => {
30245 complete result { id t.id }
30246 }
30247}
30248"#;
30249 let compiled = compile_program_with_root(source, Some("Child"));
30250 let ir = compiled
30251 .ir
30252 .unwrap_or_else(|| panic!("private root compiles: {:?}", compiled.diagnostics));
30253 assert!(ir.source_tags.iter().any(|tag| {
30254 tag.name == "private" && tag.target_kind == "workflow" && tag.target == "Child"
30255 }));
30256 }
30257
30258 #[test]
30259 fn typed_invoke_result_checks_field_access_against_child_output() {
30260 let source = r#"
30264class Report { id string }
30265class Job { id string }
30266
30267workflow Parent {
30268 input task Job
30269 output result Report
30270 rule go
30271 when Job as t
30272 => {
30273 invoke Child { task { id t.id } } as child
30274 after child succeeds as r {
30275 complete result { id r.missing }
30276 }
30277 }
30278}
30279
30280workflow Child {
30281 input task Job
30282 output result Report
30283 rule work
30284 when Job as t
30285 => {
30286 complete result { id t.id }
30287 }
30288}
30289"#;
30290 let compiled = compile_program_with_root(source, Some("Parent"));
30291 assert!(
30292 compiled.ir.is_none(),
30293 "unknown field on invoke result must not compile"
30294 );
30295 assert!(
30296 compiled
30297 .diagnostics
30298 .iter()
30299 .any(|d| d.message.contains("r.missing") || d.message.contains("missing")),
30300 "typed invoke result did not reject r.missing: {:?}",
30301 compiled.diagnostics
30302 );
30303 }
30304
30305 #[test]
30306 fn typed_invoke_result_accepts_a_valid_child_output_field() {
30307 let source = r#"
30310class Report { id string }
30311class Job { id string }
30312
30313workflow Parent {
30314 input task Job
30315 output result Report
30316 rule go
30317 when Job as t
30318 => {
30319 invoke Child { task { id t.id } } as child
30320 after child succeeds as r {
30321 complete result { id r.id }
30322 }
30323 }
30324}
30325
30326workflow Child {
30327 input task Job
30328 output result Report
30329 rule work
30330 when Job as t
30331 => {
30332 complete result { id t.id }
30333 }
30334}
30335"#;
30336 let compiled = compile_program_with_root(source, Some("Parent"));
30337 assert!(
30338 compiled.diagnostics.is_empty(),
30339 "valid invoke-result field access wrongly rejected: {:?}",
30340 compiled.diagnostics
30341 );
30342 assert!(compiled.ir.is_some());
30343 }
30344
30345 #[test]
30346 fn typed_invoke_failure_checks_field_access_against_child_failure() {
30347 let source = r#"
30352class Report { id string }
30353class Job { id string }
30354class ChildError { reason string }
30355class ParentError { detail string }
30356
30357workflow Parent {
30358 input task Job
30359 output result Report
30360 failure err ParentError
30361 rule go
30362 when Job as t
30363 => {
30364 invoke Child { task { id t.id } } as child
30365 after child succeeds as r {
30366 complete result { id r.id }
30367 }
30368 after child fails as f {
30369 fail err { detail f.nonexistent }
30370 }
30371 }
30372}
30373
30374workflow Child {
30375 input task Job
30376 output result Report
30377 failure err ChildError
30378 rule work
30379 when Job as t
30380 => {
30381 fail err { reason t.id }
30382 }
30383}
30384"#;
30385 let compiled = compile_program_with_root(source, Some("Parent"));
30386 assert!(
30387 compiled.ir.is_none(),
30388 "unknown field on invoke failure must not compile"
30389 );
30390 assert!(
30391 compiled
30392 .diagnostics
30393 .iter()
30394 .any(|d| d.message.contains("f.nonexistent") || d.message.contains("nonexistent")),
30395 "typed invoke failure did not reject f.nonexistent: {:?}",
30396 compiled.diagnostics
30397 );
30398 }
30399
30400 #[test]
30401 fn typed_invoke_failure_accepts_a_valid_child_failure_field() {
30402 let source = r#"
30406class Report { id string }
30407class Job { id string }
30408class ChildError { reason string }
30409class ParentError { detail string }
30410
30411workflow Parent {
30412 input task Job
30413 output result Report
30414 failure err ParentError
30415 rule go
30416 when Job as t
30417 => {
30418 invoke Child { task { id t.id } } as child
30419 after child succeeds as r {
30420 complete result { id r.id }
30421 }
30422 after child fails as f {
30423 fail err { detail f.reason }
30424 }
30425 }
30426}
30427
30428workflow Child {
30429 input task Job
30430 output result Report
30431 failure err ChildError
30432 rule work
30433 when Job as t
30434 => {
30435 fail err { reason t.id }
30436 }
30437}
30438"#;
30439 let compiled = compile_program_with_root(source, Some("Parent"));
30440 assert!(
30441 compiled.diagnostics.is_empty(),
30442 "valid invoke-failure field access wrongly rejected: {:?}",
30443 compiled.diagnostics
30444 );
30445 assert!(compiled.ir.is_some());
30446 }
30447
30448 #[test]
30449 fn whole_program_validation_catches_a_broken_sibling_under_any_root() {
30450 let source = r#"
30456workflow Good {
30457 input task TG
30458 output result RG
30459 class TG { id string }
30460 class RG { id string }
30461 rule go
30462 when TG as t
30463 => {
30464 complete result { id t.id }
30465 }
30466}
30467
30468workflow Broken {
30469 input task TB
30470 output result RB
30471 class TB { id string }
30472 class RB { id string }
30473 rule go
30474 when Nonexistent as t
30475 => {
30476 complete result { id t.id }
30477 }
30478}
30479"#;
30480 let compiled = compile_program_with_root(source, Some("Good"));
30481 assert!(
30482 compiled.ir.is_none(),
30483 "a program with a broken sibling must not compile"
30484 );
30485 assert!(
30486 compiled
30487 .diagnostics
30488 .iter()
30489 .any(|d| d.message.contains("Nonexistent")),
30490 "the broken sibling's error was not surfaced: {:?}",
30491 compiled.diagnostics
30492 );
30493 }
30494
30495 #[test]
30496 fn cross_workflow_reference_to_sibling_local_is_annotated() {
30497 let source = r#"
30502workflow Owner {
30503 input task TO
30504 output result RO
30505 class TO { id string }
30506 class RO { id string }
30507 class Secret { id string }
30508 rule go
30509 when TO as t
30510 => {
30511 complete result { id t.id }
30512 }
30513}
30514
30515workflow Consumer {
30516 input task TC
30517 output result RC
30518 class TC { id string }
30519 class RC { id string }
30520 rule go
30521 when Secret as s
30522 => {
30523 complete result { id s.id }
30524 }
30525}
30526"#;
30527 let compiled = compile_program_with_root(source, Some("Consumer"));
30528 assert!(
30529 compiled.ir.is_none(),
30530 "sibling-local reference must not compile"
30531 );
30532 let leak = compiled
30533 .diagnostics
30534 .iter()
30535 .find(|d| d.message.contains("`Secret`"))
30536 .expect("an unknown-name diagnostic for Secret");
30537 assert!(
30538 leak.related
30539 .iter()
30540 .any(|r| r.message.contains("workflow `Owner`")
30541 && r.message.contains("private to that workflow")),
30542 "missing sibling-local leak note: {:?}",
30543 leak.related
30544 );
30545 }
30546
30547 #[test]
30548 fn shared_top_level_name_is_not_annotated_as_a_leak() {
30549 let source = r#"
30553class Shared { id string }
30554
30555workflow Alpha {
30556 input task Shared
30557 output result RA
30558 class RA { id string }
30559 rule go
30560 when Shared as s
30561 => {
30562 complete result { id s.id }
30563 }
30564}
30565
30566workflow Beta {
30567 input task Shared
30568 output result RB
30569 class RB { id string }
30570 rule go
30571 when Shared as s
30572 => {
30573 complete result { id s.id }
30574 }
30575}
30576"#;
30577 let compiled = compile_program_with_root(source, Some("Alpha"));
30578 assert!(
30579 compiled.diagnostics.is_empty(),
30580 "shared top-level global wrongly rejected: {:?}",
30581 compiled.diagnostics
30582 );
30583 assert!(compiled.ir.is_some());
30584 }
30585
30586 #[test]
30587 fn whole_program_validation_accepts_all_well_formed_workflows() {
30588 let source = r#"
30591workflow Alpha {
30592 input task TA
30593 output result RA
30594 class TA { id string }
30595 class RA { id string }
30596 rule go
30597 when TA as t
30598 => {
30599 complete result { id t.id }
30600 }
30601}
30602
30603workflow Beta {
30604 input task TB
30605 output result RB
30606 class TB { id string }
30607 class RB { id string }
30608 rule go
30609 when TB as t
30610 => {
30611 complete result { id t.id }
30612 }
30613}
30614"#;
30615 let compiled = compile_program_with_root(source, Some("Alpha"));
30616 assert!(
30617 compiled.diagnostics.is_empty(),
30618 "well-formed multi-workflow program emitted diagnostics: {:?}",
30619 compiled.diagnostics
30620 );
30621 assert!(compiled.ir.is_some(), "selected root failed to compile");
30622 }
30623
30624 #[test]
30625 fn compact_workflow_signature_desugars_to_keyword_contracts() {
30626 let compact = r#"
30629workflow Triage(ticket: Ticket) -> Resolution ! TriageFailed
30630
30631class Ticket { id string }
30632class Resolution { id string }
30633class TriageFailed { reason string }
30634
30635rule go
30636 when Ticket as t
30637=> {
30638 complete result { id t.id }
30639}
30640"#;
30641 let keyword = r#"
30642workflow Triage
30643
30644input ticket Ticket
30645output result Resolution
30646failure error TriageFailed
30647
30648class Ticket { id string }
30649class Resolution { id string }
30650class TriageFailed { reason string }
30651
30652rule go
30653 when Ticket as t
30654=> {
30655 complete result { id t.id }
30656}
30657"#;
30658 let compact_ir = compile_program_with_root(compact, None);
30659 let keyword_ir = compile_program_with_root(keyword, None);
30660 assert!(
30661 compact_ir.diagnostics.is_empty(),
30662 "compact form did not compile: {:?}",
30663 compact_ir.diagnostics
30664 );
30665 assert!(
30666 keyword_ir.diagnostics.is_empty(),
30667 "keyword form did not compile: {:?}",
30668 keyword_ir.diagnostics
30669 );
30670 let project = |ir: &IrProgram| {
30673 ir.workflow_contracts
30674 .iter()
30675 .map(|c| {
30676 (
30677 format!("{:?}", c.kind),
30678 c.name.clone(),
30679 format!("{:?}", c.ty),
30680 )
30681 })
30682 .collect::<Vec<_>>()
30683 };
30684 assert_eq!(
30685 project(&compact_ir.ir.expect("compact ir")),
30686 project(&keyword_ir.ir.expect("keyword ir")),
30687 "compact signature did not desugar to the same contracts"
30688 );
30689 }
30690
30691 #[test]
30692 fn compact_signature_supports_multiple_inputs_and_optional_failure() {
30693 let source = r#"
30695workflow Merge(left: LeftIn, right: RightIn) -> Merged
30696
30697class LeftIn { id string }
30698class RightIn { id string }
30699class Merged { id string }
30700
30701rule go
30702 when {
30703 LeftIn as l
30704 RightIn as r
30705 }
30706=> {
30707 complete result { id l.id }
30708}
30709"#;
30710 let compiled = compile_program_with_root(source, None);
30711 assert!(
30712 compiled.diagnostics.is_empty(),
30713 "multi-input compact form did not compile: {:?}",
30714 compiled.diagnostics
30715 );
30716 let ir = compiled.ir.expect("ir");
30717 let inputs = ir
30718 .workflow_contracts
30719 .iter()
30720 .filter(|c| matches!(c.kind, IrWorkflowContractKind::Input))
30721 .count();
30722 let failures = ir
30723 .workflow_contracts
30724 .iter()
30725 .filter(|c| matches!(c.kind, IrWorkflowContractKind::Failure))
30726 .count();
30727 assert_eq!(inputs, 2, "expected two inputs");
30728 assert_eq!(
30729 failures, 0,
30730 "omitted failure clause must add no failure contract"
30731 );
30732 }
30733
30734 #[test]
30735 fn rejects_headerless_program_with_no_workflow() {
30736 let source = r#"
30740class SharedTicket {
30741 id string
30742}
30743
30744pattern TagReviewed<Input> {
30745 rule tag
30746 when Input as item
30747 => {
30748 record SharedTicket { id item.id }
30749 }
30750}
30751"#;
30752 let compiled = compile_program_with_root(source, None);
30753 assert!(compiled.ir.is_none());
30754 assert!(
30755 compiled
30756 .diagnostics
30757 .iter()
30758 .any(|d| d.message.contains("program declares no `workflow`")),
30759 "{:?}",
30760 compiled.diagnostics
30761 );
30762 }
30763
30764 #[test]
30765 fn accepts_single_workflow_header_program() {
30766 let source = r#"
30769workflow OnlyOne
30770
30771input item Job
30772output result Done
30773
30774class Job { id string }
30775class Done { id string }
30776
30777rule go
30778 when Job as j
30779=> {
30780 complete result { id j.id }
30781}
30782"#;
30783 let compiled = compile_program_with_root(source, None);
30784 assert!(
30785 !compiled
30786 .diagnostics
30787 .iter()
30788 .any(|d| d.message.contains("program declares no `workflow`")),
30789 "header-form program wrongly rejected as headerless: {:?}",
30790 compiled.diagnostics
30791 );
30792 }
30793
30794 #[test]
30795 fn rejects_recording_observer_only_terminal_schema() {
30796 for schema in ["TerminalFailed", "TerminalTimedOut", "TerminalCancelled"] {
30800 let source = format!(
30801 r#"
30802workflow Forge
30803
30804input item Job
30805output result Done
30806
30807class Job {{ id string }}
30808class Done {{ id string }}
30809
30810rule sneak
30811 when Job as q
30812=> {{
30813 record {schema} {{ reason "x" summary "y" }}
30814 complete result {{ id q.id }}
30815}}
30816"#
30817 );
30818 let compiled = compile_program(&source);
30819 assert!(
30820 compiled
30821 .diagnostics
30822 .iter()
30823 .any(|d| d.message.contains(&format!(
30824 "cannot record kernel-owned terminal schema `{schema}`"
30825 ))),
30826 "expected rejection for {schema}, got {:?}",
30827 compiled.diagnostics
30828 );
30829 }
30830 }
30831
30832 #[test]
30833 fn allows_recording_user_writable_builtin_schema() {
30834 let source = r#"
30838workflow WriteWork
30839
30840input item Job
30841output result Done
30842
30843class Job { id string }
30844class Done { id string }
30845
30846rule track
30847 when Job as q
30848=> {
30849 record WorkItem { title "t" status "reviewed" }
30850 complete result { id q.id }
30851}
30852"#;
30853 let compiled = compile_program(source);
30854 assert!(
30855 !compiled.diagnostics.iter().any(|d| d
30856 .message
30857 .contains("cannot record kernel-owned terminal schema")),
30858 "WorkItem must remain user-writable, got {:?}",
30859 compiled.diagnostics
30860 );
30861 }
30862
30863 #[test]
30864 fn exhaustive_bool_case_compiles() {
30865 let source = r#"
30868workflow BoolCaseOk
30869
30870output result Done
30871
30872class Done {
30873 note string
30874}
30875
30876class Flag {
30877 ready bool
30878}
30879
30880rule route
30881 when Flag as f
30882=> {
30883 case f.ready {
30884 true => {
30885 complete result {
30886 note "t"
30887 }
30888 }
30889 false => {
30890 complete result {
30891 note "f"
30892 }
30893 }
30894 }
30895}
30896"#;
30897 let compiled = compile_program(source);
30898 assert_eq!(
30899 compiled.diagnostics,
30900 Vec::new(),
30901 "{:?}",
30902 compiled.diagnostics
30903 );
30904 assert!(compiled.ir.is_some());
30905 }
30906
30907 #[test]
30908 fn bool_case_rejects_non_exhaustive_and_non_bool_patterns() {
30909 let source = r#"
30910workflow BoolCaseBad
30911
30912class Flag {
30913 ready bool
30914}
30915
30916rule route
30917 when Flag as f
30918=> {
30919 case f.ready {
30920 true => {
30921 }
30922 }
30923
30924 case f.ready {
30925 maybe => {
30926 }
30927 false => {
30928 }
30929 }
30930}
30931"#;
30932 let compiled = compile_program(source);
30933 assert!(compiled.ir.is_none());
30934 assert!(
30935 compiled
30936 .diagnostics
30937 .iter()
30938 .any(|d| d.message.contains("non-exhaustive case; missing false")),
30939 "expected non-exhaustive diagnostic: {:?}",
30940 compiled.diagnostics
30941 );
30942 assert!(
30943 compiled.diagnostics.iter().any(|d| d
30944 .message
30945 .contains("case pattern `maybe` that is not a `bool` value")),
30946 "expected non-bool pattern diagnostic: {:?}",
30947 compiled.diagnostics
30948 );
30949 }
30950
30951 #[test]
30952 fn exec_schema_result_resolves_typed_fields_for_case() {
30953 let source = r#"
30959@service
30960workflow ExecTyped
30961
30962class Pick { kind "a" | "b" }
30963class R { choice string }
30964
30965output result R
30966
30967signal go.now {
30968 x string
30969}
30970
30971rule j
30972 when go.now as g
30973=> {
30974 exec "echo hi" -> Pick as v
30975
30976 after v succeeds as r {
30977 case r.kind {
30978 "a" => {
30979 complete result {
30980 choice "a"
30981 }
30982 }
30983 "b" => {
30984 complete result {
30985 choice "b"
30986 }
30987 }
30988 }
30989 }
30990}
30991"#;
30992 let compiled = compile_program(source);
30993 assert!(
30994 !compiled
30995 .diagnostics
30996 .iter()
30997 .any(|d| d.message.contains("not a typed path")),
30998 "exec -> Schema result fields should resolve: {:?}",
30999 compiled.diagnostics
31000 );
31001 assert!(compiled.ir.is_some(), "{:?}", compiled.diagnostics);
31002 }
31003
31004 #[test]
31005 fn exec_with_requires_typed_record_binding() {
31006 let source = |with_line: &str, when_line: &str| {
31011 format!(
31012 r#"
31013@service
31014workflow ExecWith
31015
31016class Request {{ text string }}
31017class Report {{ message string }}
31018
31019output result Report
31020
31021rule go
31022 when {when_line}
31023=> {{
31024 exec echo_report with {with_line} -> Report as report
31025
31026 after report succeeds as out {{
31027 complete result {{
31028 message out.message
31029 }}
31030 }}
31031}}
31032"#
31033 )
31034 };
31035
31036 let compiled = compile_program(&source("request", "Request as request"));
31038 assert!(
31039 !compiled
31040 .diagnostics
31041 .iter()
31042 .any(|d| d.message.contains("typed record binding")),
31043 "typed record binding must pass: {:?}",
31044 compiled.diagnostics
31045 );
31046 assert!(compiled.ir.is_some(), "{:?}", compiled.diagnostics);
31047
31048 let compiled = compile_program(&source("missing", "Request as request"));
31050 assert!(
31051 compiled.diagnostics.iter().any(|d| d
31052 .message
31053 .contains("uses unknown binding `missing` in `exec echo_report with missing`")),
31054 "unknown binding must be rejected: {:?}",
31055 compiled.diagnostics
31056 );
31057
31058 let compiled = compile_program(&source("g", "fact foo.bar as g"));
31060 assert!(
31061 compiled.diagnostics.iter().any(|d| d
31062 .message
31063 .contains("passes untyped fact binding `g` to `exec echo_report with`")),
31064 "untyped fact binding must be rejected: {:?}",
31065 compiled.diagnostics
31066 );
31067
31068 let chained = r#"
31071@service
31072workflow ExecWithChained
31073
31074class Request { text string }
31075class Report { message string }
31076
31077output result Report
31078
31079rule go
31080 when Request as request
31081=> {
31082 exec fetch_request with request -> Request as fetched
31083
31084 after fetched succeeds as staged {
31085 exec echo_report with staged -> Report as report
31086
31087 after report succeeds as out {
31088 complete result {
31089 message out.message
31090 }
31091 }
31092 }
31093}
31094"#;
31095 let compiled = compile_program(chained);
31096 assert!(
31097 !compiled
31098 .diagnostics
31099 .iter()
31100 .any(|d| d.message.contains("typed record binding")),
31101 "typed exec-result binding must pass: {:?}",
31102 compiled.diagnostics
31103 );
31104 assert!(compiled.ir.is_some(), "{:?}", compiled.diagnostics);
31105 }
31106
31107 #[test]
31108 fn redact_projection_keeps_only_kept_fields() {
31109 let kept = r#"
31114@service
31115workflow RedactKept
31116
31117class Customer { id string ssn string status string }
31118class Result { tag string }
31119output result Result
31120
31121signal go.now { x string }
31122
31123coerce read_customer(x string) -> Customer { prompt "x" }
31124
31125rule r
31126 when go.now as g
31127=> {
31128 coerce read_customer(g.x) as c
31129 after c succeeds as cust {
31130 redact cust keep [id, status] as safe
31131 complete result {
31132 tag safe.id
31133 }
31134 }
31135}
31136"#;
31137 let compiled = compile_program(kept);
31138 assert!(
31139 !compiled
31140 .diagnostics
31141 .iter()
31142 .any(|d| d.message.contains("unknown field") || d.message.contains("not a typed")),
31143 "kept field `safe.id` should resolve: {:?}",
31144 compiled.diagnostics
31145 );
31146
31147 assert!(
31148 compiled.ir.is_some(),
31149 "kept program should compile: {compiled:?}"
31150 );
31151
31152 let dropped = kept.replace("tag safe.id", "tag safe.ssn");
31153 let compiled = compile_program(&dropped);
31154 assert!(
31155 compiled
31156 .diagnostics
31157 .iter()
31158 .any(|d| d.message.contains("safe.ssn") || d.message.contains("`ssn`")),
31159 "dropped field `safe.ssn` should be rejected: {:?}",
31160 compiled.diagnostics
31161 );
31162 }
31163
31164 #[test]
31165 fn redact_unknown_kept_field_is_rejected() {
31166 let source = r#"
31167@service
31168workflow RedactBadKeep
31169
31170class Customer { id string status string }
31171class Result { tag string }
31172output result Result
31173
31174signal go.now { x string }
31175
31176coerce read_customer(x string) -> Customer { prompt "x" }
31177
31178rule r
31179 when go.now as g
31180=> {
31181 coerce read_customer(g.x) as c
31182 after c succeeds as cust {
31183 redact cust keep [id, nonexistent] as safe
31184 complete result {
31185 tag safe.id
31186 }
31187 }
31188}
31189"#;
31190 let compiled = compile_program(source);
31191 assert!(
31192 compiled
31193 .diagnostics
31194 .iter()
31195 .any(|d| d.message.contains("keeping unknown field `nonexistent`")),
31196 "expected unknown-kept-field rejection: {:?}",
31197 compiled.diagnostics
31198 );
31199 }
31200
31201 #[test]
31202 fn inline_decide_result_resolves_typed_fields_for_case() {
31203 let source = r#"
31209@service
31210workflow InlineDecideTyped
31211
31212class R { choice string }
31213output result R
31214
31215signal go.now {
31216 x string
31217}
31218
31219rule j
31220 when go.now as g
31221=> {
31222 decide "is it fixed?" -> { fixed bool } as v
31223
31224 after v succeeds as r {
31225 case r.fixed {
31226 true => {
31227 complete result {
31228 choice "a"
31229 }
31230 }
31231 false => {
31232 complete result {
31233 choice "b"
31234 }
31235 }
31236 }
31237 }
31238}
31239"#;
31240 let compiled = compile_program(source);
31241 assert!(
31242 !compiled
31243 .diagnostics
31244 .iter()
31245 .any(|d| d.message.contains("not a typed path")),
31246 "inline decide result fields should resolve: {:?}",
31247 compiled.diagnostics
31248 );
31249 let ir = compiled.ir.expect("compiles");
31250 assert!(
31253 ir.schemas.iter().any(|schema| matches!(
31254 schema,
31255 IrSchema::Class(class) if class.name == "decide.j.v"
31256 )),
31257 "expected synthesized inline-decide class `decide.j.v` in IR schemas"
31258 );
31259 }
31260
31261 #[test]
31262 fn rejects_malformed_multiline_prompt_content_type_on_rule_prompt() {
31263 let source = r#"
31264workflow PromptAnnotationGuess
31265
31266agent worker {
31267 provider fixture
31268 profile "repo-writer"
31269 capacity 1
31270}
31271
31272rule ask
31273 when started
31274=> {
31275 tell worker as turn """markdown extra
31276 do work
31277 """
31278}
31279"#;
31280 let compiled = compile_program(source);
31281
31282 assert!(compiled.ir.is_none());
31283 assert!(compiled.diagnostics.iter().any(|diagnostic| {
31284 diagnostic
31285 .message
31286 .contains("malformed multiline prompt content type `markdown extra`")
31287 && diagnostic.suggestion.as_deref().is_some_and(|suggestion| {
31288 suggestion.contains("put prompt text on the next line")
31289 })
31290 }));
31291 }
31292
31293 #[test]
31294 fn rejects_malformed_multiline_prompt_content_type_on_coerce_prompt() {
31295 let source = r#"
31296workflow CoerceAnnotationGuess
31297
31298class Review {
31299 status "ok"
31300}
31301
31302coerce review() -> Review {
31303 prompt """text/markdown extra
31304 classify the review
31305 """
31306}
31307
31308rule run
31309 when started
31310=> {
31311 coerce review() as result
31312}
31313"#;
31314 let compiled = compile_program(source);
31315
31316 assert!(compiled.ir.is_none());
31317 assert!(compiled
31318 .diagnostics
31319 .iter()
31320 .any(|diagnostic| diagnostic.message.contains(
31321 "coerce `review` has malformed multiline prompt content type `text/markdown extra`"
31322 )));
31323 }
31324
31325 #[test]
31326 fn rejects_pasted_top_level_gherkin_with_targeted_diagnostic() {
31327 let source = r#"
31328Feature: provider language routing
31329
31330Scenario: fixture provider reviews every language task
31331 Given a queued language task
31332 When the provider turn completes
31333 Then the language result is reviewed
31334"#;
31335 let compiled = compile_program(source);
31336
31337 assert!(compiled.ir.is_none());
31338 assert!(compiled.diagnostics.iter().any(|diagnostic| {
31339 diagnostic
31340 .message
31341 .contains("Gherkin keyword `Feature` is not WhippleScript workflow syntax")
31342 && diagnostic.suggestion.as_deref().is_some_and(|suggestion| {
31343 suggestion.contains("use `workflow`, `table`, `rule")
31344 && suggestion.contains("instead of free-text Given/When/Then steps")
31345 })
31346 }));
31347 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
31348 .message
31349 .contains("Gherkin keyword `Given` is not WhippleScript workflow syntax")));
31350 }
31351
31352 #[test]
31353 fn rejects_pasted_gherkin_inside_workflow_body_with_targeted_diagnostic() {
31354 let source = r#"
31355workflow PastedGherkin {
31356 Scenario: fixture provider reviews every language task
31357 Given a queued language task
31358 When the provider turn completes
31359 Then the language result is reviewed
31360}
31361"#;
31362 let compiled = compile_program(source);
31363
31364 assert!(compiled.ir.is_none());
31365 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
31366 .message
31367 .contains("Gherkin keyword `Scenario` is not WhippleScript workflow syntax")));
31368 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
31369 .message
31370 .contains("Gherkin keyword `Then` is not WhippleScript workflow syntax")));
31371 }
31372
31373 #[test]
31374 fn rejects_pasted_gherkin_background_outline_examples_and_continuations() {
31375 let source = r#"
31376Feature: provider language routing
31377
31378Rule: provider execution remains explicit
31379
31380Background:
31381 Given a seeded provider table
31382 And all provider profiles are available
31383
31384Scenario Outline: provider reviews language task
31385 When <provider> completes <language>
31386 But the review is missing
31387 Then the fixture fails
31388
31389Examples:
31390 | provider | language |
31391 | codex | French |
31392"#;
31393 let compiled = compile_program(source);
31394
31395 assert!(compiled.ir.is_none());
31396 for keyword in ["Rule", "Background", "And", "Scenario", "But", "Examples"] {
31397 assert!(
31398 compiled
31399 .diagnostics
31400 .iter()
31401 .any(|diagnostic| diagnostic.message.contains(&format!(
31402 "Gherkin keyword `{keyword}` is not WhippleScript workflow syntax"
31403 ))),
31404 "missing diagnostic for {keyword}: {:?}",
31405 compiled
31406 .diagnostics
31407 .iter()
31408 .map(|diagnostic| diagnostic.message.as_str())
31409 .collect::<Vec<_>>()
31410 );
31411 }
31412 }
31413
31414 #[test]
31415 fn explains_multiline_string_binding_position() {
31416 let source = r#"
31417workflow BindingGuess
31418
31419agent worker {
31420 provider fixture
31421 profile "repo-writer"
31422 capacity 1
31423}
31424
31425rule branch
31426 when started
31427=> {
31428 tell worker """
31429 do work
31430 """ as turn
31431
31432 after turn succeeds {
31433 tell worker "review" as review
31434 }
31435}
31436"#;
31437 let compiled = compile_program(source);
31438
31439 assert!(compiled.ir.is_none());
31440 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
31441 .message
31442 .contains("places effect binding `turn` after a multiline string delimiter")
31443 && diagnostic.suggestion.as_deref().is_some_and(
31444 |suggestion| suggestion.contains("move `as turn` onto the effect line")
31445 )));
31446 }
31447
31448 #[test]
31449 fn invalid_fixtures_have_actionable_diagnostics() {
31450 let fixtures = [
31451 (
31452 "bad-agent",
31453 include_str!("../../../examples/invalid/bad-agent.whip"),
31454 ),
31455 (
31456 "bad-record",
31457 include_str!("../../../examples/invalid/bad-record.whip"),
31458 ),
31459 (
31460 "bad-terminal-payload",
31461 include_str!("../../../examples/invalid/bad-terminal-payload.whip"),
31462 ),
31463 (
31464 "recursive-workflow-invocation",
31465 include_str!("../../../examples/invalid/recursive-workflow-invocation.whip"),
31466 ),
31467 (
31468 "bad-effect-graph",
31469 include_str!("../../../examples/invalid/bad-effect-graph.whip"),
31470 ),
31471 (
31472 "bad-effect-payload",
31473 include_str!("../../../examples/invalid/bad-effect-payload.whip"),
31474 ),
31475 (
31476 "bad-expression-functions",
31477 include_str!("../../../examples/invalid/bad-expression-functions.whip"),
31478 ),
31479 (
31480 "bad-finite-domain",
31481 include_str!("../../../examples/invalid/bad-finite-domain.whip"),
31482 ),
31483 (
31484 "broken",
31485 include_str!("../../../examples/invalid/broken.whip"),
31486 ),
31487 (
31488 "effect-output-scope",
31489 include_str!("../../../examples/invalid/effect-output-scope.whip"),
31490 ),
31491 (
31492 "effectful-self-loop",
31493 include_str!("../../../examples/invalid/effectful-self-loop.whip"),
31494 ),
31495 (
31496 "recursive-pattern",
31497 include_str!("../../../examples/invalid/recursive-pattern.whip"),
31498 ),
31499 (
31500 "evidence-fact-match",
31501 include_str!("../../../examples/invalid/evidence-fact-match.whip"),
31502 ),
31503 (
31504 "unknown-schema",
31505 include_str!("../../../examples/invalid/unknown-schema.whip"),
31506 ),
31507 (
31508 "headerless-library",
31509 include_str!("../../../examples/invalid/headerless-library.whip"),
31510 ),
31511 ];
31512
31513 for (name, source) in fixtures {
31514 let compiled = compile_program(source);
31515 assert!(compiled.ir.is_none(), "{name} unexpectedly compiled");
31516 assert!(
31517 !compiled.diagnostics.is_empty(),
31518 "{name} did not emit diagnostics"
31519 );
31520 assert!(
31521 compiled
31522 .diagnostics
31523 .iter()
31524 .all(|diagnostic| diagnostic.suggestion.is_some()),
31525 "{name} emitted a diagnostic without a suggestion: {:?}",
31526 compiled.diagnostics
31527 );
31528 }
31529 }
31530
31531 #[test]
31532 fn rejects_dangling_root_in_record_value() {
31533 let source = r#"
31536@service
31537workflow DanglingRoot
31538
31539class Ticket { id string }
31540class Note { text string }
31541
31542table seed as Ticket [ { id "1" } ]
31543
31544rule r
31545 when Ticket as ticket
31546=> {
31547 record Note {
31548 text tikcet.id
31549 }
31550}
31551"#;
31552 let compiled = compile_program(source);
31553 assert!(compiled.ir.is_none());
31554 assert!(
31555 compiled
31556 .diagnostics
31557 .iter()
31558 .any(|d| d.message.contains("unknown binding `tikcet`")),
31559 "{:?}",
31560 compiled.diagnostics
31561 );
31562 }
31563
31564 #[test]
31565 fn rejects_dangling_root_in_single_line_record() {
31566 let source = r#"
31570@service
31571workflow DanglingSingleLine
31572
31573class Ticket { id string }
31574class Note { text string }
31575
31576table seed as Ticket [ { id "1" } ]
31577
31578rule r
31579 when Ticket as ticket
31580=> {
31581 record Note { text tikcet.id }
31582}
31583"#;
31584 let compiled = compile_program(source);
31585 assert!(compiled.ir.is_none());
31586 assert!(
31587 compiled
31588 .diagnostics
31589 .iter()
31590 .any(|d| d.message.contains("unknown binding `tikcet`")),
31591 "{:?}",
31592 compiled.diagnostics
31593 );
31594 }
31595
31596 #[test]
31597 fn rejects_dangling_root_in_coerce_argument() {
31598 let source = r#"
31601@service
31602workflow DanglingCoerceArg
31603
31604class Ticket { id string title string }
31605class Review { summary string }
31606
31607coerce classify(title string) -> Review { prompt "c" }
31608
31609agent reviewer { provider fixture profile "r" capacity 1 }
31610
31611table seed as Ticket [ { id "1" title "t" } ]
31612
31613rule r
31614 when Ticket as ticket
31615 when reviewer is available
31616=> {
31617 coerce classify(tikcet.title) as rev
31618}
31619"#;
31620 let compiled = compile_program(source);
31621 assert!(compiled.ir.is_none());
31622 assert!(
31623 compiled
31624 .diagnostics
31625 .iter()
31626 .any(|d| d.message.contains("unknown binding `tikcet`")
31627 && d.message.contains("coerce `classify`")),
31628 "{:?}",
31629 compiled.diagnostics
31630 );
31631 }
31632
31633 #[test]
31634 fn rejects_dangling_root_in_counter_consume_operand() {
31635 let source = r#"
31638@service
31639workflow CounterOperandDangling
31640
31641class CallFailed { service string }
31642class Service { id string }
31643
31644counter failure_budget { key Service cap 3 reset daily }
31645
31646table seed as CallFailed [ { service "x" } ]
31647
31648rule strike
31649 when CallFailed as f
31650=> {
31651 consume failure_budget for fff.service amount 1 as strike
31652}
31653"#;
31654 let compiled = compile_program(source);
31655 assert!(compiled.ir.is_none());
31656 assert!(
31657 compiled
31658 .diagnostics
31659 .iter()
31660 .any(|d| d.message.contains("unknown binding `fff`")
31661 && d.message.contains("consume")),
31662 "{:?}",
31663 compiled.diagnostics
31664 );
31665 }
31666
31667 #[test]
31668 fn rejects_dangling_root_in_queue_file_payload() {
31669 let source = r#"
31673@service
31674workflow QueueFieldDangling
31675
31676class Ticket { id string }
31677
31678tracker backlog { provider builtin }
31679
31680table seed as Ticket [ { id "1" } ]
31681
31682rule r
31683 when Ticket as ticket
31684=> {
31685 file issue into backlog {
31686 title tikcet.id
31687 body "x"
31688 }
31689}
31690"#;
31691 let compiled = compile_program(source);
31692 assert!(compiled.ir.is_none());
31693 assert!(
31694 compiled
31695 .diagnostics
31696 .iter()
31697 .any(|d| d.message.contains("unknown binding `tikcet`")
31698 && d.message.contains("file into")),
31699 "{:?}",
31700 compiled.diagnostics
31701 );
31702 }
31703
31704 #[test]
31705 fn rejects_dangling_root_in_invoke_input() {
31706 let source = r#"
31709workflow Parent {
31710 input task Task
31711 output result Out
31712
31713 class Task { id string }
31714 class Out { x string }
31715
31716 rule r
31717 when Task as task
31718 => {
31719 invoke Child { item tikcet.id } as c
31720 after c succeeds as cr {
31721 done task
31722 complete result { x cr.summary }
31723 }
31724 }
31725}
31726
31727workflow Child {
31728 input item string
31729 output result ChildOut
31730 class ChildOut { y string }
31731 rule c
31732 when item as i
31733 => {
31734 complete result { y "done" }
31735 }
31736}
31737"#;
31738 let compiled = compile_program_with_root(source, Some("Parent"));
31739 assert!(compiled.ir.is_none());
31740 assert!(
31741 compiled
31742 .diagnostics
31743 .iter()
31744 .any(|d| d.message.contains("unknown binding `tikcet`")
31745 && d.message.contains("invoke Child")),
31746 "{:?}",
31747 compiled.diagnostics
31748 );
31749 }
31750
31751 #[test]
31752 fn rejects_dangling_root_in_tell_target() {
31753 let source = r#"
31756@service
31757workflow DanglingTellTarget
31758
31759class Ticket { id string provider AgentRef<reviewer> }
31760
31761agent reviewer { provider fixture profile "r" capacity 1 }
31762
31763table seed as Ticket [ { id "1" provider reviewer } ]
31764
31765rule r
31766 when Ticket as ticket
31767=> {
31768 tell tikcet.provider as turn "go"
31769}
31770"#;
31771 let compiled = compile_program(source);
31772 assert!(compiled.ir.is_none());
31773 assert!(
31774 compiled
31775 .diagnostics
31776 .iter()
31777 .any(|d| d.message.contains("unknown binding `tikcet`")
31778 && d.message.contains("tell target")),
31779 "{:?}",
31780 compiled.diagnostics
31781 );
31782 }
31783
31784 #[test]
31785 fn accepts_effect_binding_root_in_record_value() {
31786 let source = r#"
31791@service
31792workflow EffectRoot
31793
31794class Ticket { id string }
31795class Note { text string }
31796
31797agent reviewer { provider fixture profile "r" capacity 1 }
31798
31799table seed as Ticket [ { id "1" } ]
31800
31801rule r
31802 when Ticket as ticket
31803 when reviewer is available
31804=> {
31805 tell reviewer as turn "review"
31806 after turn succeeds {
31807 record Note {
31808 text turn.summary
31809 }
31810 }
31811}
31812"#;
31813 let compiled = compile_program(source);
31814 assert_eq!(
31815 compiled.diagnostics,
31816 Vec::new(),
31817 "{:?}",
31818 compiled.diagnostics
31819 );
31820 assert!(compiled.ir.is_some());
31821 }
31822
31823 #[test]
31824 fn rejects_invalid_record_fields_paths_and_literals() {
31825 let source = include_str!("../../../examples/invalid/bad-record.whip");
31826 let compiled = compile_program(source);
31827
31828 assert!(compiled.ir.is_none());
31829 assert_eq!(compiled.diagnostics.len(), 5);
31830 assert!(compiled
31831 .diagnostics
31832 .iter()
31833 .any(|diagnostic| diagnostic.message.contains("request.missing")));
31834 assert!(compiled
31835 .diagnostics
31836 .iter()
31837 .any(|diagnostic| diagnostic.message.contains("no variant `Maybe`")));
31838 assert!(compiled
31839 .diagnostics
31840 .iter()
31841 .any(|diagnostic| diagnostic.message.contains("expects `float`")));
31842 assert!(compiled
31843 .diagnostics
31844 .iter()
31845 .any(|diagnostic| diagnostic.message.contains("cannot be `scripted`")));
31846 assert!(compiled
31847 .diagnostics
31848 .iter()
31849 .any(|diagnostic| diagnostic.message.contains("no field `extra`")));
31850 }
31851
31852 #[test]
31853 fn rejects_effect_output_outside_after_scope() {
31854 let source = include_str!("../../../examples/invalid/effect-output-scope.whip");
31855 let compiled = compile_program(source);
31856
31857 assert!(compiled.ir.is_none());
31858 assert_eq!(compiled.diagnostics.len(), 1);
31859 assert!(compiled.diagnostics[0]
31860 .message
31861 .contains("outside a matching `after claim ...` block"));
31862 }
31863
31864 #[test]
31869 fn region_compiles_and_ir_carries_variants() {
31870 let source = r#"
31871workflow Deploy
31872
31873output result Done
31874failure error Halted
31875
31876class Incident {
31877 sev string
31878}
31879
31880class Done {
31881 note string
31882}
31883
31884class Halted {
31885 reason string
31886}
31887
31888rule ship
31889 when started
31890=> {
31891 until exists(Incident where sev == "sev1") {
31892 then plan <- timer 1s
31893 then approved <- timer 1s
31894 complete result {
31895 note "shipped"
31896 }
31897 } on lapse as got {
31898 fail error {
31899 reason "halted"
31900 }
31901 }
31902}
31903"#;
31904 let compiled = compile_program(source);
31905 assert!(
31906 compiled.diagnostics.is_empty(),
31907 "region must compile: {:?}",
31908 compiled.diagnostics
31909 );
31910 let ir = compiled.ir.expect("ir");
31911 let rule = &ir.rules[0];
31912 assert!(
31913 !rule.body.contains("until exists") && !rule.body.contains("on lapse"),
31914 "canonical body is the HOLDS splice: {}",
31915 rule.body
31916 );
31917 let region = rule.metadata.region.as_ref().expect("region metadata");
31918 assert!(region.until);
31919 assert_eq!(region.condition, "exists(Incident where sev == \"sev1\")");
31920 assert_eq!(region.lapse_binding.as_deref(), Some("got"));
31921 assert!(
31922 region.body_lapsed.contains("fail error"),
31923 "lapsed variant carries the arm: {}",
31924 region.body_lapsed
31925 );
31926 assert!(
31927 !region.body_removed.contains("timer") && !region.body_removed.contains("fail error"),
31928 "removed variant drops region AND arm: {}",
31929 region.body_removed
31930 );
31931 let bindings: Vec<&str> = region
31932 .effects
31933 .iter()
31934 .map(|effect| effect.binding.as_str())
31935 .collect();
31936 assert!(
31937 bindings.contains(&"__then_plan") && bindings.contains(&"__then_approved"),
31938 "region effects recorded: {bindings:?}"
31939 );
31940 }
31941
31942 #[test]
31944 fn two_regions_in_one_rule_rejected() {
31945 let source = r#"
31946workflow Two
31947
31948output result Done
31949
31950class Done {
31951 note string
31952}
31953
31954class Flag {
31955 on string
31956}
31957
31958rule go
31959 when started
31960=> {
31961 during empty(Flag) {
31962 timer 1s as a
31963
31964 after a completes {
31965 record Flag {
31966 on "x"
31967 }
31968 }
31969 } on lapse {
31970 complete result {
31971 note "one"
31972 }
31973 }
31974
31975 during empty(Flag) {
31976 timer 1s as b
31977
31978 after b completes {
31979 complete result {
31980 note "two"
31981 }
31982 }
31983 } on lapse {
31984 complete result {
31985 note "three"
31986 }
31987 }
31988}
31989"#;
31990 let compiled = compile_program(source);
31991 assert!(
31992 compiled
31993 .diagnostics
31994 .iter()
31995 .any(|d| d.message.contains("more than one `during`/`until` region")),
31996 "second region rejected: {:?}",
31997 compiled.diagnostics
31998 );
31999 }
32000
32001 #[test]
32004 fn lapse_arm_referencing_region_binding_rejected() {
32005 let source = r#"
32006workflow Scope
32007
32008output result Done
32009failure error Halted
32010
32011class Incident {
32012 sev string
32013}
32014
32015class Done {
32016 note string
32017}
32018
32019class Halted {
32020 reason string
32021}
32022
32023rule go
32024 when started
32025=> {
32026 until exists(Incident where sev == "sev1") {
32027 then plan <- timer 1s
32028 complete result {
32029 note plan.status
32030 }
32031 } on lapse {
32032 fail error {
32033 reason plan.status
32034 }
32035 }
32036}
32037"#;
32038 let compiled = compile_program(source);
32039 assert!(
32040 compiled
32041 .diagnostics
32042 .iter()
32043 .any(|d| d.message.contains("references `plan`, a binding the")),
32044 "arm scope violation rejected: {:?}",
32045 compiled.diagnostics
32046 );
32047 }
32048
32049 #[test]
32055 fn full_line_comments_in_rule_bodies_compile_and_prompts_keep_hashes() {
32056 let source = r#"
32057use std.script
32058
32059workflow Commented
32060
32061output result Done
32062
32063class Done {
32064 note string
32065}
32066
32067agent helper
32068
32069rule go
32070 when started
32071=> {
32072 # request the probe command
32073 exec "true" as probe
32074
32075 after probe succeeds {
32076 # a comment with braces { and quotes " should be inert
32077 then turn <- tell helper """markdown
32078 # This heading is prompt CONTENT, not a comment.
32079 Summarize.
32080 """
32081 # comment between then chain and terminal
32082 complete result {
32083 note turn.summary
32084 }
32085 }
32086
32087 after probe fails {
32088 # losing is fine
32089 }
32090}
32091"#;
32092 let compiled = compile_program(source);
32093 assert!(
32094 compiled.diagnostics.is_empty(),
32095 "comments must not produce diagnostics: {:?}",
32096 compiled.diagnostics
32097 );
32098 let ir = compiled.ir.expect("compiles");
32099 let rule = &ir.rules[0];
32100 assert!(
32101 !rule.body.contains("# request"),
32102 "compile-path body text is comment-blanked: {}",
32103 rule.body
32104 );
32105 assert!(
32106 rule.body.contains("# This heading is prompt CONTENT"),
32107 "prompt interiors are untouched by blanking: {}",
32108 rule.body
32109 );
32110 }
32111
32112 #[test]
32115 fn trailing_comment_in_rule_body_still_rejected() {
32116 let source = r#"
32117workflow Trailing
32118
32119output result Done
32120
32121class Done {
32122 note string
32123}
32124
32125rule go
32126 when started
32127=> {
32128 complete result {
32129 note "x"
32130 } # not allowed here
32131}
32132"#;
32133 let compiled = compile_program(source);
32134 assert!(
32135 compiled
32136 .diagnostics
32137 .iter()
32138 .any(|d| d.message.contains("unexpected character `#`")),
32139 "trailing comment must still be rejected: {:?}",
32140 compiled.diagnostics
32141 );
32142 }
32143
32144 #[test]
32145 fn rejects_effectful_self_trigger_loop() {
32146 let source = include_str!("../../../examples/invalid/effectful-self-loop.whip");
32147 let compiled = compile_program(source);
32148
32149 assert!(compiled.ir.is_none());
32150 assert_eq!(compiled.diagnostics.len(), 1);
32151 assert!(compiled.diagnostics[0]
32152 .message
32153 .contains("preserves trigger fact `schema:WorkItem`"));
32154 }
32155
32156 #[test]
32157 fn rejects_non_file_operation_on_a_file_store_grant() {
32158 let program = |op: &str, resource: &str, store: &str| {
32161 format!(
32162 r#"
32163@service
32164workflow FileGrant
32165
32166output result R
32167class R {{ ok bool }}
32168class Ticket {{ id string status "open" }}
32169
32170agent coder {{ provider fixture profile "repo-writer" capacity 1 }}
32171
32172file store {store} {{ root "./data" allow read ["docs/**"] }}
32173
32174table seed as Ticket [ {{ id "T1" status "open" }} ]
32175
32176rule work
32177 when Ticket as ticket where ticket.status == "open"
32178 when coder is available
32179=> {{
32180 tell coder as turn
32181 with access to {resource} {{
32182 {op}
32183 }}
32184 "go"
32185
32186 after turn succeeds as outcome {{
32187 complete result {{ ok true }}
32188 }}
32189}}
32190"#
32191 )
32192 };
32193
32194 let bad = compile_program(&program(
32196 "recall for ticket",
32197 "project_files",
32198 "project_files",
32199 ));
32200 assert!(
32201 bad.diagnostics
32202 .iter()
32203 .any(|d| d.message.contains("not a file operation")),
32204 "{:?}",
32205 bad.diagnostics
32206 );
32207 let ok = compile_program(&program(
32210 "recall for ticket",
32211 "project_memory",
32212 "project_files",
32213 ));
32214 assert!(
32215 !ok.diagnostics
32216 .iter()
32217 .any(|d| d.message.contains("not a file operation")),
32218 "{:?}",
32219 ok.diagnostics
32220 );
32221 }
32222
32223 #[test]
32224 fn parses_memory_pool_declaration_and_snapshots_it() {
32225 let source = r#"
32228workflow PoolDecl
32229
32230memory pool project_memory {
32231 context limit 8
32232}
32233"#;
32234 let compiled = compile_program(source);
32235 let ir = compiled.ir.expect("compiles");
32236 assert_eq!(ir.memory_pools.len(), 1);
32237 assert_eq!(ir.memory_pools[0].name, "project_memory");
32238 assert_eq!(ir.memory_pools[0].context_limit, Some(8));
32239 let snapshot = ir.to_snapshot();
32240 assert!(snapshot.contains("memory_pools"), "{snapshot}");
32241 assert!(
32242 snapshot.contains("memory pool project_memory"),
32243 "{snapshot}"
32244 );
32245 assert!(snapshot.contains("context limit 8"), "{snapshot}");
32246
32247 let bare = compile_program("workflow Bare\n\nmemory pool p {\n}\n")
32250 .ir
32251 .expect("bare pool compiles");
32252 assert_eq!(bare.memory_pools[0].context_limit, None);
32253 assert!(!bare.to_snapshot().contains("context limit"));
32254 }
32255
32256 #[test]
32257 fn rejects_unknown_and_provider_memory_pool_clauses() {
32258 let unknown = compile_program("workflow U\n\nmemory pool p {\n retention 5\n}\n");
32263 assert!(
32264 unknown
32265 .diagnostics
32266 .iter()
32267 .any(|d| d.message.contains("unknown memory pool field `retention`")),
32268 "{:?}",
32269 unknown.diagnostics
32270 );
32271 let provider = compile_program("workflow P\n\nmemory pool p {\n provider local\n}\n");
32272 assert!(
32273 provider
32274 .diagnostics
32275 .iter()
32276 .any(|d| d.message.contains("unknown memory pool field `provider`")),
32277 "{:?}",
32278 provider.diagnostics
32279 );
32280 }
32281
32282 #[test]
32283 fn rejects_non_memory_operation_on_a_memory_pool_grant() {
32284 let program = |op: &str, resource: &str, pool: &str| {
32290 format!(
32291 r#"
32292@service
32293workflow MemoryGrant
32294
32295output result R
32296class R {{ ok bool }}
32297class Ticket {{ id string status "open" }}
32298
32299agent coder {{ provider fixture profile "repo-writer" capacity 1 }}
32300
32301memory pool {pool} {{ context limit 8 }}
32302
32303table seed as Ticket [ {{ id "T1" status "open" }} ]
32304
32305rule work
32306 when Ticket as ticket where ticket.status == "open"
32307 when coder is available
32308=> {{
32309 tell coder as turn
32310 with access to {resource} {{
32311 {op}
32312 }}
32313 "go"
32314
32315 after turn succeeds as outcome {{
32316 complete result {{ ok true }}
32317 }}
32318}}
32319"#
32320 )
32321 };
32322
32323 let bad = compile_program(&program(
32325 r#"read ["docs/**"]"#,
32326 "project_memory",
32327 "project_memory",
32328 ));
32329 assert!(
32330 bad.diagnostics
32331 .iter()
32332 .any(|d| d.message.contains("not a memory operation")),
32333 "{:?}",
32334 bad.diagnostics
32335 );
32336
32337 let ok_recall = compile_program(&program(
32339 "recall for ticket\n learn for ticket",
32340 "project_memory",
32341 "project_memory",
32342 ));
32343 assert!(
32344 !ok_recall
32345 .diagnostics
32346 .iter()
32347 .any(|d| d.message.contains("not a memory operation")),
32348 "{:?}",
32349 ok_recall.diagnostics
32350 );
32351
32352 let ok_other = compile_program(&program(
32355 r#"read ["docs/**"]"#,
32356 "project_files",
32357 "project_memory",
32358 ));
32359 assert!(
32360 !ok_other
32361 .diagnostics
32362 .iter()
32363 .any(|d| d.message.contains("not a memory operation")),
32364 "{:?}",
32365 ok_other.diagnostics
32366 );
32367 }
32368
32369 #[test]
32370 fn rejects_malformed_turn_access_grants() {
32371 let program = |grant_block: &str| {
32374 format!(
32375 r#"
32376@service
32377workflow GrantCheck
32378
32379output result R
32380class R {{ ok bool }}
32381class Ticket {{ id string status "open" }}
32382
32383agent coder {{ provider fixture profile "repo-writer" capacity 1 }}
32384
32385table seed as Ticket [ {{ id "T1" status "open" }} ]
32386
32387rule work
32388 when Ticket as ticket where ticket.status == "open"
32389 when coder is available
32390=> {{
32391 tell coder as turn
32392{grant_block}
32393 "Work it."
32394
32395 after turn succeeds as outcome {{
32396 complete result {{ ok true }}
32397 }}
32398}}
32399"#
32400 )
32401 };
32402
32403 let empty = compile_program(&program(" with access to project_memory {\n }\n"));
32404 assert!(
32405 empty
32406 .diagnostics
32407 .iter()
32408 .any(|d| d.message.contains("grants no operations")),
32409 "{:?}",
32410 empty.diagnostics
32411 );
32412
32413 let duplicate = compile_program(&program(
32414 " with access to project_memory {\n recall for ticket\n }\n with access to project_memory {\n learn for ticket\n }\n",
32415 ));
32416 assert!(
32417 duplicate
32418 .diagnostics
32419 .iter()
32420 .any(|d| d.message.contains("more than once")),
32421 "{:?}",
32422 duplicate.diagnostics
32423 );
32424 }
32425
32426 #[test]
32430 fn warns_inert_memory_grant_on_a_native_adapter_tell() {
32431 let program = |harness_kind: &str| {
32432 format!(
32433 r#"
32434@service
32435workflow InertGrant
32436
32437output result R
32438class R {{ ok bool }}
32439class Ticket {{ id string status "open" }}
32440
32441memory pool project_memory {{
32442 context limit 4
32443}}
32444
32445harness h: {harness_kind}
32446agent coder using h {{ profile "repo-writer" capacity 1 }}
32447
32448table seed as Ticket [ {{ id "T1" status "open" }} ]
32449
32450rule work
32451 when Ticket as ticket where ticket.status == "open"
32452 when coder is available
32453=> {{
32454 tell coder as turn
32455 with access to project_memory {{
32456 recall for ticket
32457 }}
32458 "Work it."
32459
32460 after turn succeeds as outcome {{
32461 complete result {{ ok true }}
32462 }}
32463}}
32464"#
32465 )
32466 };
32467 let native = compile_program(&program("codex"));
32468 assert!(
32469 native.diagnostics.is_empty(),
32470 "the grant itself is legal: {:?}",
32471 native.diagnostics
32472 );
32473 assert!(
32474 native
32475 .warnings
32476 .iter()
32477 .any(|warning| warning.message.contains("inert")),
32478 "a codex-harness tell warns: {:?}",
32479 native.warnings
32480 );
32481 let owned = compile_program(&program("owned"));
32482 assert!(
32483 owned
32484 .warnings
32485 .iter()
32486 .all(|warning| !warning.message.contains("inert")),
32487 "an owned-harness tell does not warn: {:?}",
32488 owned.warnings
32489 );
32490 }
32491
32492 #[test]
32493 fn counter_timezone_clause_parses_and_default_utc_warns() {
32494 let program = |timezone_clause: &str| {
32498 format!(
32499 r#"
32500@service
32501workflow CounterTz
32502
32503class CallFailed {{ service string }}
32504class Service {{ id string }}
32505output result CallFailed
32506failure trouble CallFailed
32507
32508counter failure_budget {{ key Service cap 3 reset daily {timezone_clause} }}
32509
32510rule strike
32511 when CallFailed as f
32512=> {{
32513 consume failure_budget for f.service amount 1 as strike
32514 after strike ok {{
32515 complete result {{ service f.service }}
32516 }}
32517 after strike over {{
32518 fail trouble {{ service f.service }}
32519 }}
32520}}
32521"#
32522 )
32523 };
32524 let anchored = compile_program(&program(r#"timezone "America/New_York""#));
32525 assert!(
32526 anchored.diagnostics.is_empty(),
32527 "timezone clause parses: {:?}",
32528 anchored.diagnostics
32529 );
32530 let ir = anchored.ir.expect("anchored program compiles");
32531 assert_eq!(ir.counters[0].timezone.as_deref(), Some("America/New_York"));
32532 assert!(
32533 anchored
32534 .warnings
32535 .iter()
32536 .all(|warning| !warning.message.contains("timezone")),
32537 "an anchored counter does not warn: {:?}",
32538 anchored.warnings
32539 );
32540
32541 let unanchored = compile_program(&program(""));
32542 assert!(
32543 unanchored.diagnostics.is_empty(),
32544 "omitting timezone stays legal: {:?}",
32545 unanchored.diagnostics
32546 );
32547 let ir = unanchored.ir.expect("unanchored program compiles");
32548 assert_eq!(ir.counters[0].timezone, None);
32549 assert!(
32550 unanchored
32551 .warnings
32552 .iter()
32553 .any(|warning| warning.message.contains("anchors to UTC")),
32554 "an unanchored counter draws the default-UTC warning: {:?}",
32555 unanchored.warnings
32556 );
32557 }
32558
32559 #[test]
32560 fn then_sugar_desugars_to_nested_after_and_composes_in_after_blocks() {
32561 let source = r#"
32566use std.script
32567
32568workflow ThenSugar
32569
32570output result Done
32571
32572class Done {
32573 note string
32574}
32575
32576class Trigger {
32577 id string
32578}
32579
32580table seed as Trigger [
32581 { id "t" }
32582]
32583
32584rule pipeline
32585 when Trigger as t
32586=> {
32587 exec "true" as pre
32588
32589 after pre succeeds {
32590 then a <- exec "one"
32591 then b <- exec "two"
32592 complete result {
32593 note b.stdout
32594 }
32595 }
32596}
32597"#;
32598 let compiled = compile_program(source);
32599 assert_eq!(compiled.diagnostics, Vec::new());
32600 let ir = compiled.ir.expect("compiles");
32601 let body = &ir
32602 .rules
32603 .iter()
32604 .find(|rule| rule.name == "pipeline")
32605 .expect("rule")
32606 .body;
32607 assert!(
32608 body.contains("exec \"one\" as __then_a"),
32609 "the chained effect binds the synthetic handle:\n{body}"
32610 );
32611 assert!(
32612 body.contains("after __then_a succeeds as a {"),
32613 "the continuation nests under the success predicate:\n{body}"
32614 );
32615 assert!(
32616 body.contains("after __then_b succeeds as b {"),
32617 "chained thens nest:\n{body}"
32618 );
32619 assert!(!body.contains("then a <-"), "no sugar survives:\n{body}");
32620
32621 let reserved = compile_program(
32622 r#"
32623use std.script
32624
32625workflow Reserved
32626
32627output result Done
32628
32629class Done {
32630 note string
32631}
32632
32633rule r
32634 when started
32635=> {
32636 exec "true" as __then_x
32637
32638 after __then_x succeeds {
32639 complete result { note "no" }
32640 }
32641}
32642"#,
32643 );
32644 assert!(
32645 reserved
32646 .diagnostics
32647 .iter()
32648 .any(|d| d.message.contains("reserved `__then_` binding namespace")),
32649 "{:?}",
32650 reserved.diagnostics
32651 );
32652 }
32653
32654 #[test]
32655 fn warns_on_unhandled_effect_failure_and_stays_quiet_when_observed() {
32656 let program = |handler: &str| {
32660 format!(
32661 r#"
32662use std.script
32663
32664workflow AutoFailWarn
32665
32666output result Done
32667failure error Broken
32668
32669class Done {{ note string }}
32670class Broken {{ reason string }}
32671class Trigger {{ id string }}
32672
32673table seed as Trigger [
32674 {{ id "t" }}
32675]
32676
32677rule r
32678 when Trigger as t
32679=> {{
32680 exec "true" as x
32681
32682 after x succeeds {{
32683 complete result {{ note "ok" }}
32684 }}
32685{handler}}}
32686"#
32687 )
32688 };
32689 let unhandled = compile_program(&program(""));
32690 assert!(
32691 unhandled.diagnostics.is_empty(),
32692 "{:?}",
32693 unhandled.diagnostics
32694 );
32695 assert!(
32696 unhandled
32697 .warnings
32698 .iter()
32699 .any(|warning| warning.message.contains("`x`'s failure is unhandled")),
32700 "succeeds-only handling draws the R1a warning: {:?}",
32701 unhandled.warnings
32702 );
32703
32704 for observer in [
32705 "\n after x fails {\n fail error { reason \"broken\" }\n }\n",
32706 "\n after x completes {\n complete result { note \"any\" }\n }\n",
32707 "\n after x times out {\n fail error { reason \"slow\" }\n }\n",
32708 ] {
32709 let observed = compile_program(&program(observer));
32710 assert!(
32711 observed.diagnostics.is_empty(),
32712 "{:?}",
32713 observed.diagnostics
32714 );
32715 assert!(
32716 observed
32717 .warnings
32718 .iter()
32719 .all(|warning| !warning.message.contains("failure is unhandled")),
32720 "an observer silences the warning ({observer:?}): {:?}",
32721 observed.warnings
32722 );
32723 }
32724 }
32725
32726 #[test]
32727 fn unhandled_failure_warning_exempts_services_timers_and_coordination() {
32728 let service = compile_program(
32733 r#"
32734use std.script
32735
32736@service
32737workflow ServiceQuiet
32738
32739class Trigger { id string }
32740class Seen { note string }
32741
32742table seed as Trigger [
32743 { id "t" }
32744]
32745
32746rule r
32747 when Trigger as t
32748=> {
32749 exec "true" as x
32750
32751 after x succeeds {
32752 record Seen { note "ok" }
32753 }
32754}
32755"#,
32756 );
32757 assert!(service.diagnostics.is_empty(), "{:?}", service.diagnostics);
32758 assert!(
32759 service
32760 .warnings
32761 .iter()
32762 .all(|warning| !warning.message.contains("failure is unhandled")),
32763 "@service is exempt: {:?}",
32764 service.warnings
32765 );
32766
32767 let timer = compile_program(
32768 r#"
32769workflow TimerQuiet
32770
32771output result Done
32772
32773class Done { note string }
32774class Trigger { id string }
32775
32776table seed as Trigger [
32777 { id "t" }
32778]
32779
32780rule r
32781 when Trigger as t
32782=> {
32783 timer 5m as pause
32784
32785 after pause completes {
32786 complete result { note "ok" }
32787 }
32788}
32789"#,
32790 );
32791 assert!(timer.diagnostics.is_empty(), "{:?}", timer.diagnostics);
32792 assert!(
32793 timer
32794 .warnings
32795 .iter()
32796 .all(|warning| !warning.message.contains("failure is unhandled")),
32797 "timers are exempt: {:?}",
32798 timer.warnings
32799 );
32800
32801 let coordination = compile_program(
32802 r#"
32803workflow CoordQuiet
32804
32805output result Done
32806failure error Broken
32807
32808class Done { note string }
32809class Broken { reason string }
32810class Trigger { id string }
32811
32812lease build_slot { key Trigger ttl 10m }
32813
32814table seed as Trigger [
32815 { id "t" }
32816]
32817
32818rule r
32819 when Trigger as t
32820=> {
32821 acquire build_slot for t.id as slot
32822
32823 after slot held {
32824 complete result { note "ok" }
32825 }
32826
32827 after slot contended {
32828 fail error { reason "busy" }
32829 }
32830}
32831"#,
32832 );
32833 assert!(
32834 coordination.diagnostics.is_empty(),
32835 "{:?}",
32836 coordination.diagnostics
32837 );
32838 assert!(
32839 coordination
32840 .warnings
32841 .iter()
32842 .all(|warning| !warning.message.contains("failure is unhandled")),
32843 "coordination outcome observers count at check time: {:?}",
32844 coordination.warnings
32845 );
32846 }
32847
32848 #[test]
32849 fn lowers_turn_access_grants_onto_the_agent_tell_effect() {
32850 let source = r#"
32853@service
32854workflow GrantDemo
32855
32856output result R
32857class R { ok bool }
32858class Ticket { id string status "open" }
32859
32860agent coder { provider fixture profile "repo-writer" capacity 1 }
32861
32862table seed as Ticket [ { id "T1" status "open" } ]
32863
32864rule work
32865 when Ticket as ticket where ticket.status == "open"
32866 when coder is available
32867=> {
32868 tell coder as turn
32869 with access to project_memory {
32870 recall for ticket
32871 learn for ticket
32872 }
32873 with access to project_files {
32874 read ["docs/**"]
32875 }
32876 "Work it."
32877
32878 after turn succeeds as outcome {
32879 complete result { ok true }
32880 }
32881}
32882"#;
32883 let compiled = compile_program(source);
32884 let ir = compiled.ir.expect("compiles");
32885 let tell = ir
32886 .rules
32887 .iter()
32888 .flat_map(|rule| rule.metadata.effects.iter())
32889 .find(|effect| effect.kind == IrEffectKind::AgentTell)
32890 .expect("agent.tell effect");
32891 assert_eq!(tell.access_grants.len(), 2);
32892 let memory = &tell.access_grants[0];
32893 assert_eq!(memory.resource, "project_memory");
32894 assert_eq!(memory.operations.len(), 2);
32895 assert_eq!(memory.operations[0].operation, "recall");
32896 assert_eq!(memory.operations[0].target.as_deref(), Some("ticket"));
32897 let files = &tell.access_grants[1];
32898 assert_eq!(files.resource, "project_files");
32899 assert_eq!(files.operations[0].operation, "read");
32900 assert_eq!(files.operations[0].globs, vec!["docs/**".to_owned()]);
32901 }
32902
32903 #[test]
32904 fn lowers_start_access_grants_onto_the_workflow_invoke_effect() {
32905 let source = r#"
32908workflow Parent {
32909 class Task { id string }
32910
32911 rule dispatch
32912 when Task as task
32913 => {
32914 invoke Child { task task }
32915 with access to project_files {
32916 read ["docs/**"]
32917 }
32918 as child
32919 }
32920}
32921
32922workflow Child {
32923 input task Task
32924 class Task { id string }
32925}
32926"#;
32927 let compiled = compile_program_with_root(source, Some("Parent"));
32928 let ir = compiled.ir.unwrap_or_else(|| {
32929 panic!(
32930 "source should compile, diagnostics: {:?}",
32931 compiled
32932 .diagnostics
32933 .iter()
32934 .map(|d| &d.message)
32935 .collect::<Vec<_>>()
32936 )
32937 });
32938 let invoke = ir
32939 .rules
32940 .iter()
32941 .flat_map(|rule| rule.metadata.effects.iter())
32942 .find(|effect| effect.kind == IrEffectKind::WorkflowInvoke)
32943 .expect("workflow.invoke effect");
32944 assert_eq!(invoke.binding.as_deref(), Some("child"));
32945 assert_eq!(invoke.access_grants.len(), 1);
32946 let files = &invoke.access_grants[0];
32947 assert_eq!(files.resource, "project_files");
32948 assert_eq!(files.operations[0].operation, "read");
32949 assert_eq!(files.operations[0].globs, vec!["docs/**".to_owned()]);
32950 }
32951
32952 #[test]
32953 fn lowers_resource_less_start_access_grant_shorthand_onto_the_workflow_invoke_effect() {
32954 let source = r#"
32957workflow Parent {
32958 class Task { id string }
32959
32960 rule dispatch
32961 when Task as task
32962 => {
32963 invoke Child { task task }
32964 with access to {
32965 project_memory {
32966 recall for task
32967 }
32968 project_files {
32969 read ["docs/**"]
32970 }
32971 }
32972 as child
32973 }
32974}
32975
32976workflow Child {
32977 input task Task
32978 class Task { id string }
32979}
32980"#;
32981 let compiled = compile_program_with_root(source, Some("Parent"));
32982 let ir = compiled.ir.unwrap_or_else(|| {
32983 panic!(
32984 "source should compile, diagnostics: {:?}",
32985 compiled
32986 .diagnostics
32987 .iter()
32988 .map(|d| &d.message)
32989 .collect::<Vec<_>>()
32990 )
32991 });
32992 let invoke = ir
32993 .rules
32994 .iter()
32995 .flat_map(|rule| rule.metadata.effects.iter())
32996 .find(|effect| effect.kind == IrEffectKind::WorkflowInvoke)
32997 .expect("workflow.invoke effect");
32998 assert_eq!(invoke.binding.as_deref(), Some("child"));
32999 assert_eq!(invoke.access_grants.len(), 2);
33000 let memory = &invoke.access_grants[0];
33001 assert_eq!(memory.resource, "project_memory");
33002 assert_eq!(memory.operations[0].operation, "recall");
33003 assert_eq!(memory.operations[0].target.as_deref(), Some("task"));
33004 let files = &invoke.access_grants[1];
33005 assert_eq!(files.resource, "project_files");
33006 assert_eq!(files.operations[0].operation, "read");
33007 assert_eq!(files.operations[0].globs, vec!["docs/**".to_owned()]);
33008 }
33009
33010 #[test]
33011 fn rejects_rule_matching_evidence_only_turn_fact() {
33012 for evidence in [
33016 "agent.turn.streamed",
33017 "agent.turn.tool_requested",
33018 "agent.turn.artifact_captured",
33019 ] {
33020 let source = format!(
33021 "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"
33022 );
33023 let compiled = compile_program(&source);
33024 assert!(compiled.ir.is_none(), "{evidence} should be rejected");
33025 assert!(
33026 compiled
33027 .diagnostics
33028 .iter()
33029 .any(|d| d.message.contains("evidence-only fact")
33030 && d.message.contains(evidence)),
33031 "{evidence}: {:?}",
33032 compiled.diagnostics
33033 );
33034 }
33035 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("}}", "}");
33038 let compiled = compile_program(&matchable);
33039 assert!(
33040 !compiled
33041 .diagnostics
33042 .iter()
33043 .any(|d| d.message.contains("evidence-only fact")),
33044 "completed must not be flagged as evidence-only: {:?}",
33045 compiled.diagnostics
33046 );
33047 }
33048
33049 #[test]
33050 fn rejects_self_recursive_pattern_application() {
33051 let source = include_str!("../../../examples/invalid/recursive-pattern.whip");
33054 let compiled = compile_program(source);
33055
33056 assert!(compiled.ir.is_none());
33057 assert_eq!(compiled.diagnostics.len(), 1, "{:?}", compiled.diagnostics);
33060 let diagnostic = &compiled.diagnostics[0];
33061 assert!(
33062 diagnostic
33063 .message
33064 .contains("graph.unbounded_pattern_recursion"),
33065 "{}",
33066 diagnostic.message
33067 );
33068 assert!(
33069 diagnostic.message.contains("expansion cycle Loop -> Loop"),
33070 "the diagnostic names the cycle: {}",
33071 diagnostic.message
33072 );
33073 }
33074
33075 #[test]
33076 fn rejects_mutually_recursive_pattern_application() {
33077 let source = r#"
33079workflow MutualRecursion
33080
33081class Item {
33082 id string
33083}
33084
33085pattern Ping<T> {
33086 apply Pong<T> as a {
33087 }
33088}
33089
33090pattern Pong<T> {
33091 apply Ping<T> as b {
33092 }
33093}
33094
33095apply Ping<Item> as top {
33096}
33097"#;
33098 let compiled = compile_program(source);
33099
33100 assert!(compiled.ir.is_none());
33101 let recursion: Vec<&Diagnostic> = compiled
33102 .diagnostics
33103 .iter()
33104 .filter(|d| d.message.contains("graph.unbounded_pattern_recursion"))
33105 .collect();
33106 assert_eq!(recursion.len(), 1, "{:?}", compiled.diagnostics);
33108 assert!(
33109 recursion[0].message.contains("Ping -> Pong -> Ping"),
33110 "names the full cycle: {}",
33111 recursion[0].message
33112 );
33113 }
33114
33115 #[test]
33116 fn allows_non_recursive_nested_apply_without_recursion_error() {
33117 let source = r#"
33120workflow NonRecursive
33121
33122class Item {
33123 id string
33124}
33125
33126pattern Inner<T> {
33127}
33128
33129pattern Outer<T> {
33130 apply Inner<T> as x {
33131 }
33132}
33133
33134apply Outer<Item> as top {
33135}
33136"#;
33137 let compiled = compile_program(source);
33138
33139 assert!(
33140 !compiled
33141 .diagnostics
33142 .iter()
33143 .any(|d| d.message.contains("graph.unbounded_pattern_recursion")),
33144 "non-recursive nesting must not be flagged as recursion: {:?}",
33145 compiled.diagnostics
33146 );
33147 }
33148
33149 #[test]
33150 fn rejects_unknown_or_wrong_arity_coerce_calls() {
33151 let source = r#"
33152workflow BadCoerce
33153
33154class Review {
33155 reason string
33156}
33157
33158coerce review(summary string) -> Review {
33159 prompt "review"
33160}
33161
33162rule bad
33163 when started
33164=> {
33165 coerce missing("x") as one
33166 coerce review("x", "y") as two
33167}
33168"#;
33169 let compiled = compile_program(source);
33170
33171 assert!(compiled.ir.is_none());
33172 assert_eq!(compiled.diagnostics.len(), 2);
33173 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
33174 .message
33175 .contains("unknown coerce function `missing`")));
33176 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
33177 .message
33178 .contains("with 2 argument(s), expected 1")));
33179 }
33180
33181 #[test]
33182 fn rejects_bad_effect_payload_argument_types() {
33183 let source = r#"
33184workflow BadEffectPayloads
33185
33186class Owner {
33187 name string
33188}
33189
33190class Payload {
33191 title string
33192 owner Owner
33193 metadata map<string>
33194 tags string[]
33195}
33196
33197class Task {
33198 title string
33199 owner string
33200}
33201
33202class Review {
33203 accepted bool
33204}
33205
33206coerce reviewPayload(payload Payload, metadata map<string>, score int) -> Review {
33207 prompt "review"
33208}
33209
33210rule bad_coerce
33211 when Task as task where { owner "Ada" } == task.owner
33212=> {
33213 coerce reviewPayload(
33214 {
33215 title task.title
33216 owner { handle task.owner }
33217 metadata { phase 3 }
33218 tags ["object", 7]
33219 extra "bad"
33220 },
33221 { phase task.owner, count 3 },
33222 "high"
33223 ) as review
33224}
33225"#;
33226 let compiled = compile_program(source);
33227
33228 assert!(compiled.ir.is_none());
33229 let messages = compiled
33230 .diagnostics
33231 .iter()
33232 .map(|diagnostic| diagnostic.message.as_str())
33233 .collect::<Vec<_>>();
33234 assert!(messages
33235 .iter()
33236 .any(|message| message.contains("object literal without an expected object")));
33237 assert!(messages
33238 .iter()
33239 .any(|message| message.contains("class `Owner` has no field `handle`")));
33240 assert!(messages
33241 .iter()
33242 .any(|message| message.contains("missing required object field `Owner.name`")));
33243 assert!(messages
33244 .iter()
33245 .any(|message| message.contains("class `Payload` has no field `extra`")));
33246 assert!(messages
33247 .iter()
33248 .any(|message| message
33249 .contains("field `coerce `reviewPayload`.metadata` expects `string`")));
33250 assert!(messages.iter().any(|message| {
33251 message.contains("field `coerce `reviewPayload`.score` expects `int`")
33252 }));
33253 }
33254
33255 #[test]
33256 fn lowers_fact_consumption_metadata() {
33257 let source = r#"
33258workflow ConsumeTask
33259
33260class Task {
33261 status "queued"
33262}
33263
33264rule finish
33265 when Task as task
33266=> {
33267 done task
33268}
33269"#;
33270 let compiled = compile_program(source);
33271 let ir = compiled.ir.expect("program compiles");
33272
33273 assert_eq!(ir.rules[0].metadata.fact_consumes, vec!["schema:Task"]);
33274 assert!(ir.to_snapshot().contains("consumes\n schema:Task"));
33275 }
33276
33277 #[test]
33278 fn rejects_unknown_fact_consumption_binding() {
33279 let source = r#"
33280workflow BadConsume
33281
33282class Task {
33283 status "queued"
33284}
33285
33286rule finish
33287 when Task as task
33288=> {
33289 done missing
33290}
33291"#;
33292 let compiled = compile_program(source);
33293
33294 assert!(compiled.ir.is_none());
33295 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
33296 .message
33297 .contains("consumes unknown fact binding `missing`")));
33298 }
33299
33300 #[test]
33301 fn rejects_then_sequencing() {
33302 let source = r#"
33303workflow NoThen
33304
33305class Task {
33306 topic string
33307 status "queued"
33308}
33309
33310class Result {
33311 topic string
33312 turn AgentTurn
33313 status "done"
33314}
33315
33316agent codex {
33317 provider codex
33318 profile "repo-writer"
33319 capacity 1
33320}
33321
33322assert count(Task where status == "queued") == 0
33323assert count(Result where status == "done") == 1
33324
33325rule finish
33326 when Task as task where task.status == "queued"
33327 when codex is available
33328=> {
33329 tell codex as turn "write"
33330 then done task -> record Result from task {
33331 topic
33332 turn turn
33333 status "done"
33334 }
33335}
33336"#;
33337 let compiled = compile_program(source);
33338 assert!(compiled.ir.is_none());
33339 assert!(compiled
33340 .diagnostics
33341 .iter()
33342 .any(|diagnostic| diagnostic.message.contains("unsupported `then` sequencing")));
33343 }
33344
33345 #[test]
33346 fn rejects_after_arrow_sequencing() {
33347 let source = r#"
33348workflow NoAfterArrow
33349
33350agent codex {
33351 provider codex
33352 profile "repo-writer"
33353 capacity 1
33354}
33355
33356rule finish
33357 when started
33358 when codex is available
33359=> {
33360 tell codex as turn "write"
33361
33362 after turn succeeds => {
33363 record Done {
33364 status "done"
33365 }
33366 }
33367}
33368"#;
33369 let compiled = compile_program(source);
33370 assert!(compiled.ir.is_none());
33371 assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
33372 .message
33373 .contains("unsupported `after ... =>` sequencing")));
33374 }
33375
33376 #[test]
33377 fn formats_top_level_syntax_scaffold() {
33378 let source = r#"workflow Messy
33379class Status {
33380kind "open"|"done"
33381}
33382rule start
33383when started
33384=> {tell worker "hi"}
33385"#;
33386
33387 let formatted = format_program(source);
33388 assert_eq!(formatted.diagnostics, Vec::new());
33389 let expected = concat!(
33390 "workflow Messy\n",
33391 "\n",
33392 "class Status {\n",
33393 " kind \"open\" | \"done\"\n",
33394 "}\n",
33395 "\n",
33396 "rule start\n",
33397 " when started\n",
33398 "=> {\n",
33399 " tell worker \"hi\"\n",
33400 "}\n",
33401 );
33402
33403 assert_eq!(formatted.formatted.as_deref(), Some(expected));
33404 }
33405
33406 #[test]
33407 fn formats_content_typed_multiline_prompts() {
33408 let source = r#"workflow PromptFormat
33409class Review {
33410status "ok"
33411}
33412coerce review() -> Review {
33413prompt """markdown
33414classify
33415"""
33416}
33417agent worker {
33418 provider fixture
33419profile "repo-writer"
33420capacity 1
33421}
33422rule start
33423when started
33424=> {tell worker as turn """markdown
33425write
33426"""
33427tell worker """application/json
33428{"question":"approve?"}
33429"""}
33430"#;
33431
33432 let formatted = format_program(source);
33433 assert_eq!(formatted.diagnostics, Vec::new());
33434 let expected = concat!(
33435 "workflow PromptFormat\n",
33436 "\n",
33437 "class Review {\n",
33438 " status \"ok\"\n",
33439 "}\n",
33440 "\n",
33441 "coerce review() -> Review {\n",
33442 " prompt \"\"\"markdown\n",
33443 " classify\n",
33444 " \"\"\"\n",
33445 "}\n",
33446 "\n",
33447 "agent worker {\n",
33448 " provider fixture\n",
33449 " profile \"repo-writer\"\n",
33450 " capacity 1\n",
33451 "}\n",
33452 "\n",
33453 "rule start\n",
33454 " when started\n",
33455 "=> {\n",
33456 " tell worker as turn \"\"\"markdown\n",
33457 " write\n",
33458 " \"\"\"\n",
33459 " tell worker \"\"\"application/json\n",
33460 " {\"question\":\"approve?\"}\n",
33461 " \"\"\"\n",
33462 "}\n",
33463 );
33464
33465 assert_eq!(formatted.formatted.as_deref(), Some(expected));
33466 }
33467
33468 #[test]
33469 fn formats_harness_declarations_and_agent_bindings() {
33470 let source = r#"workflow HarnessFormat
33471harness coder: codex
33472agent implementer using coder {
33473profile "repo-writer"
33474capacity 1
33475}
33476"#;
33477
33478 let formatted = format_program(source);
33479 assert_eq!(formatted.diagnostics, Vec::new());
33480 let expected = concat!(
33481 "workflow HarnessFormat\n",
33482 "\n",
33483 "harness coder: codex\n",
33484 "\n",
33485 "agent implementer using coder {\n",
33486 " profile \"repo-writer\"\n",
33487 " capacity 1\n",
33488 "}\n",
33489 );
33490
33491 assert_eq!(formatted.formatted.as_deref(), Some(expected));
33492 }
33493
33494 #[test]
33495 fn formats_explicit_workflow_blocks() {
33496 let source = r#"class Shared {
33497id string
33498}
33499workflow One {
33500input item Shared
33501rule start
33502when Shared as item
33503=> {complete result {id item.id}}
33504}
33505"#;
33506
33507 let formatted = format_program(source);
33508 assert_eq!(formatted.diagnostics, Vec::new());
33509 let expected = concat!(
33510 "class Shared {\n",
33511 " id string\n",
33512 "}\n",
33513 "\n",
33514 "workflow One {\n",
33515 " input item Shared\n",
33516 "\n",
33517 " rule start\n",
33518 " when Shared as item\n",
33519 " => {\n",
33520 " complete result {id item.id}\n",
33521 " }\n",
33522 "}\n",
33523 );
33524
33525 assert_eq!(formatted.formatted.as_deref(), Some(expected));
33526 }
33527
33528 #[test]
33529 fn formats_invoke_start_access_grants() {
33530 let source = r#"workflow Parent {
33531file store project_files { root "./data" allow read ["docs/**"] allow write ["reports/**"] }
33532class Task { id string }
33533rule dispatch
33534when Task as task
33535=> {
33536invoke Child {
33537task task
33538}
33539with access to project_files {
33540read ["docs/**"]
33541write ["reports/**"]
33542}
33543as child
33544}
33545}
33546
33547workflow Child {
33548input task Task
33549class Task { id string }
33550}
33551"#;
33552
33553 let formatted = format_program(source);
33554 assert_eq!(formatted.diagnostics, Vec::new());
33555 let expected = concat!(
33556 "workflow Parent {\n",
33557 " file store project_files {\n",
33558 " root \"./data\"\n",
33559 " allow read [\"docs/**\"]\n",
33560 " allow write [\"reports/**\"]\n",
33561 " }\n",
33562 "\n",
33563 " class Task {\n",
33564 " id string\n",
33565 " }\n",
33566 "\n",
33567 " rule dispatch\n",
33568 " when Task as task\n",
33569 " => {\n",
33570 " invoke Child {\n",
33571 " task task\n",
33572 " }\n",
33573 " with access to project_files {\n",
33574 " read [\"docs/**\"]\n",
33575 " write [\"reports/**\"]\n",
33576 " }\n",
33577 " as child\n",
33578 " }\n",
33579 "}\n",
33580 "\n",
33581 "workflow Child {\n",
33582 " input task Task\n",
33583 "\n",
33584 " class Task {\n",
33585 " id string\n",
33586 " }\n",
33587 "}\n",
33588 );
33589
33590 assert_eq!(formatted.formatted.as_deref(), Some(expected));
33591 }
33592
33593 #[test]
33594 fn formats_patterns_and_apply_syntax() {
33595 let source = r#"pattern Review<Input>{
33596rule dispatch
33597when Input as item
33598=> {}
33599}
33600workflow Root {
33601apply Review<Task> as taskReview {}
33602}
33603"#;
33604
33605 let formatted = format_program(source);
33606 assert_eq!(formatted.diagnostics, Vec::new());
33607 let expected = concat!(
33608 "pattern Review<Input> {\n",
33609 " rule dispatch\n",
33610 " when Input as item\n",
33611 " => {\n",
33612 " }\n",
33613 "}\n",
33614 "\n",
33615 "workflow Root {\n",
33616 " apply Review<Task> as taskReview {\n",
33617 " }\n",
33618 "}\n",
33619 );
33620
33621 assert_eq!(formatted.formatted.as_deref(), Some(expected));
33622 }
33623
33624 #[test]
33625 fn lexer_captures_comments_without_affecting_tokens() {
33626 let source =
33627 "# top comment\nworkflow Demo\n\nclass Task {\n title string // trailing\n}\n";
33628 let comments = lex_comments(source);
33629 assert_eq!(comments.len(), 2);
33630 assert_eq!(comments[0].marker, CommentMarker::Hash);
33631 assert_eq!(comments[0].text, "top comment");
33632 assert_eq!(comments[1].marker, CommentMarker::Slash);
33633 assert_eq!(comments[1].text, "trailing");
33634 let first = &comments[0];
33636 assert_eq!(&source[first.span.start..first.span.end], "# top comment");
33637 let compiled = compile_program(source);
33639 assert_eq!(compiled.diagnostics, Vec::new());
33640 }
33641
33642 #[test]
33643 fn test_block_parses_given_run_and_expect_clauses() {
33644 let source = r#"
33645@service
33646workflow Demo
33647
33648test "ci triage" {
33649 given signal github.workflow_failed {
33650 run_id "run_123"
33651 }
33652 stub agent triager succeeds
33653 run until idle
33654 expect issue count where external_id == "run_123" is 1
33655 expect rule triage_failed_run fired
33656}
33657"#;
33658 let compiled = compile_program(source);
33659 assert_eq!(compiled.diagnostics, Vec::new());
33660 let ir = compiled.ir.expect("program compiles");
33661 assert_eq!(ir.tests.len(), 1);
33662 let test = &ir.tests[0];
33663 assert_eq!(test.name, "ci triage");
33664 assert_eq!(test.clauses.len(), 5);
33665
33666 match &test.clauses[0] {
33667 TestClause::Given(GivenClause::Signal { name, fields, .. }) => {
33668 assert_eq!(name, "github.workflow_failed");
33669 assert_eq!(fields.len(), 1);
33670 assert_eq!(fields[0].name.name, "run_id");
33671 assert_eq!(fields[0].value, "\"run_123\"");
33672 }
33673 other => panic!("expected given signal, got {other:?}"),
33674 }
33675 match &test.clauses[1] {
33676 TestClause::Stub(stub) => {
33677 assert_eq!(stub.surface, vec!["agent".to_owned(), "triager".to_owned()]);
33678 assert_eq!(stub.outcome, "succeeds");
33679 }
33680 other => panic!("expected stub, got {other:?}"),
33681 }
33682 assert!(matches!(
33683 &test.clauses[2],
33684 TestClause::Run(RunClause {
33685 kind: RunKind::UntilIdle,
33686 ..
33687 })
33688 ));
33689 match &test.clauses[3] {
33690 TestClause::Expect(ExpectClause {
33691 target: ExpectTarget::Projection(query),
33692 ..
33693 }) => {
33694 assert_eq!(query.noun, "issue");
33695 match &query.kind {
33696 ProjQueryKind::Count { predicate, count } => {
33697 assert_eq!(predicate, "external_id == \"run_123\"");
33698 assert_eq!(*count, 1);
33699 }
33700 other => panic!("expected count query, got {other:?}"),
33701 }
33702 }
33703 other => panic!("expected expect projection, got {other:?}"),
33704 }
33705 match &test.clauses[4] {
33706 TestClause::Expect(ExpectClause {
33707 target: ExpectTarget::Rule { name, status },
33708 ..
33709 }) => {
33710 assert_eq!(name.name, "triage_failed_run");
33711 assert_eq!(*status, RuleStatus::Fired);
33712 }
33713 other => panic!("expected expect rule, got {other:?}"),
33714 }
33715 }
33716
33717 #[test]
33718 fn test_block_rejects_a_malformed_predicate() {
33719 let source = r#"
33720@service
33721workflow Demo
33722
33723test "bad predicate" {
33724 run until idle
33725 expect issue count where == == is 1
33726}
33727"#;
33728 let compiled = compile_program(source);
33729 assert!(
33730 compiled
33731 .diagnostics
33732 .iter()
33733 .any(|diagnostic| diagnostic.message.contains("predicate on `issue`")),
33734 "{:?}",
33735 compiled.diagnostics
33736 );
33737 }
33738
33739 #[test]
33740 fn source_clock_block_lowers_to_clock_source() {
33741 let source = r#"
33742workflow ClockSource
33743
33744signal triage.tick {
33745 scheduled_at time
33746 observed_at time
33747 occurrence_id string
33748 missed_count int
33749}
33750
33751source clock as daily_triage {
33752 every weekday at 09:00
33753 timezone "America/New_York"
33754 missed coalesce
33755
33756 observe as tick
33757 emit triage.tick {
33758 scheduled_at tick.scheduled_at
33759 observed_at tick.observed_at
33760 occurrence_id tick.occurrence_id
33761 missed_count tick.missed_count
33762 }
33763}
33764"#;
33765 let compiled = compile_program(source);
33766 assert_eq!(compiled.diagnostics, Vec::new());
33767 let ir = compiled.ir.expect("program compiles");
33768 assert_eq!(ir.sources.len(), 1);
33769 let decl = &ir.sources[0];
33770 assert_eq!(decl.name, "daily_triage");
33771 assert_eq!(decl.provider, "clock");
33772 assert!(decl.is_clock);
33773 assert_eq!(decl.observe_binding, "tick");
33774 assert_eq!(decl.emit_signal, "triage.tick");
33775 assert_eq!(decl.emit_fields.len(), 4);
33776 assert_eq!(decl.timezone.as_deref(), Some("America/New_York"));
33777 assert_eq!(decl.missed, Some(MissedPolicy::Coalesce));
33778 match &decl.recurrence {
33779 Some(Recurrence::EveryCalendar { pattern, time, .. }) => {
33780 assert_eq!(*pattern, CalendarPattern::Weekday);
33781 assert_eq!(time.hour, 9);
33782 assert_eq!(time.minute, 0);
33783 }
33784 other => panic!("expected calendar recurrence, got {other:?}"),
33785 }
33786 let registry = ir.contract_registry();
33790 assert!(
33791 registry
33792 .libraries
33793 .iter()
33794 .any(|library| library.id == "std.time" && library.standard),
33795 "clock source registers std.time: {:?}",
33796 registry.libraries
33797 );
33798 }
33799
33800 #[test]
33801 fn gauge_and_campaign_declarations_parse_and_lower() {
33802 let source = r##"
33803@service
33804workflow Improve
33805
33806output result R
33807class R { v string }
33808signal go.now { x string }
33809
33810coerce DueDateJudge(v string) -> R {
33811 prompt """markdown
33812 Judge {{ v }}.
33813
33814 {{ ctx.output_format }}
33815 """
33816}
33817
33818gauge extract_quality on j.result {
33819 judge via coerce DueDateJudge
33820 expect P(due_date_correct) at least 0.9
33821}
33822
33823gauge tail_latency {
33824 judge via exec "./latency_check.py"
33825 expect p90 at most 800
33826}
33827
33828gauge fulfillment_cost {
33829 judge via exec "./cost_model.py"
33830 inputs extract_quality, std.spend
33831}
33832
33833campaign release_tuning {
33834 ascend extract_quality
33835 reach std.latency at most 800ms
33836 guard tail_latency within 2 percent
33837 sacrifice fulfillment_cost
33838 proposer redacted
33839}
33840
33841rule j
33842 when go.now as g
33843=> {
33844 complete result {
33845 v "ok"
33846 }
33847}
33848"##;
33849 let compiled = compile_program(source);
33850 assert_eq!(compiled.diagnostics, Vec::new());
33851 let ir = compiled.ir.expect("program compiles");
33852 assert_eq!(ir.gauges.len(), 3);
33853 let extract = &ir.gauges[0];
33854 assert_eq!(extract.name, "extract_quality");
33855 assert_eq!(extract.site.as_deref(), Some("j.result"));
33856 assert_eq!(extract.judge_kind, "coerce");
33857 assert_eq!(extract.judge_target, "DueDateJudge");
33858 let bar = extract.expect.as_ref().expect("bar declared");
33859 assert_eq!(
33860 (
33861 bar.form.as_str(),
33862 bar.subject.as_str(),
33863 bar.op.as_str(),
33864 bar.threshold.as_str()
33865 ),
33866 ("chance", "due_date_correct", ">=", "0.9")
33867 );
33868 let tail = &ir.gauges[1];
33869 let tail_bar = tail.expect.as_ref().expect("stat bar declared");
33870 assert_eq!(
33871 (
33872 tail_bar.form.as_str(),
33873 tail_bar.subject.as_str(),
33874 tail_bar.op.as_str()
33875 ),
33876 ("stat", "p90", "<=")
33877 );
33878 let derived = &ir.gauges[2];
33879 assert_eq!(derived.judge_kind, "exec");
33880 assert_eq!(derived.inputs, vec!["extract_quality", "std.spend"]);
33881 assert_eq!(ir.campaigns.len(), 1);
33882 let campaign = &ir.campaigns[0];
33883 assert_eq!(campaign.ascend, vec!["extract_quality"]);
33884 assert_eq!(campaign.reach.len(), 1);
33885 assert_eq!(campaign.reach[0].gauge, "std.latency");
33886 assert_eq!(campaign.reach[0].op, "<=");
33887 assert_eq!(campaign.reach[0].threshold, "800");
33888 assert_eq!(campaign.reach[0].unit.as_deref(), Some("ms"));
33889 assert_eq!(campaign.guard[0].gauge, "tail_latency");
33890 assert_eq!(campaign.guard[0].band_percent, "2");
33891 assert_eq!(campaign.sacrifice, vec!["fulfillment_cost"]);
33892 assert!(campaign.proposer_redacted);
33893 let snapshot = ir.to_snapshot();
33894 assert!(snapshot.contains("gauge extract_quality judge=coerce:DueDateJudge site=j.result expect=chance:due_date_correct>=0.9"));
33895 assert!(snapshot.contains(
33896 "campaign release_tuning ascend=extract_quality reach=std.latency<=800ms guard=tail_latency:within:2% sacrifice=fulfillment_cost proposer=redacted"
33897 ));
33898 }
33899
33900 #[test]
33901 fn mark_declaration_parses_lowers_and_validates() {
33902 let source = r##"
33903@service
33904workflow Improve
33905
33906output result R
33907class R { v string }
33908signal go.now { x string }
33909
33910mark "triaged" after j
33911
33912rule j
33913 when go.now as g
33914=> {
33915 complete result {
33916 v "ok"
33917 }
33918}
33919"##;
33920 let compiled = compile_program(source);
33921 assert_eq!(compiled.diagnostics, Vec::new());
33922 let ir = compiled.ir.expect("program compiles");
33923 assert_eq!(ir.marks.len(), 1);
33924 assert_eq!(ir.marks[0].name, "triaged");
33925 assert_eq!(ir.marks[0].site, "j");
33926 assert!(ir.to_snapshot().contains("mark \"triaged\" after j"));
33927 let unknown = compile_program(&source.replace(
33929 "mark \"triaged\" after j",
33930 "mark \"nowhere\" after missing_rule",
33931 ));
33932 assert!(unknown.diagnostics.iter().any(|d| d
33933 .message
33934 .contains("mark `nowhere` rides unknown site `missing_rule`")));
33935 let dup = compile_program(&source.replace(
33937 "mark \"triaged\" after j",
33938 "mark \"triaged\" after j\nmark \"triaged\" after j",
33939 ));
33940 assert!(dup.diagnostics.iter().any(|d| d
33941 .message
33942 .contains("mark `triaged` is declared more than once")));
33943 let formatted = format_program(source).formatted.expect("formats");
33945 assert!(formatted.contains("mark \"triaged\" after j"));
33946 assert_eq!(
33947 format_program(&formatted).formatted.expect("reformats"),
33948 formatted
33949 );
33950 }
33951
33952 #[test]
33953 fn coerce_judge_explicit_arguments_parse_lower_and_validate() {
33954 let program = |judge_line: &str| {
33955 format!(
33956 r##"
33957@service
33958workflow Improve
33959
33960output result R
33961class R {{ v string }}
33962class Ticket {{ title string }}
33963signal go.now {{ x string }}
33964
33965coerce Assess(title string, priority string) -> R {{
33966 prompt """markdown
33967 Judge {{{{ title }}}} at {{{{ priority }}}}.
33968
33969 {{{{ ctx.output_format }}}}
33970 """
33971}}
33972
33973gauge quality {{
33974 {judge_line}
33975}}
33976
33977rule j
33978 when go.now as g
33979=> {{
33980 complete result {{
33981 v "ok"
33982 }}
33983}}
33984"##
33985 )
33986 };
33987 let source =
33989 program("judge via coerce Assess(input.ticket.title, facts.Assessment.priority)");
33990 let compiled = compile_program(&source);
33991 assert_eq!(compiled.diagnostics, Vec::new());
33992 let ir = compiled.ir.expect("compiles");
33993 assert_eq!(
33994 ir.gauges[0].judge_args,
33995 vec!["input.ticket.title", "facts.Assessment.priority"]
33996 );
33997 let formatted = format_program(&source).formatted.expect("formats");
33998 assert!(
33999 formatted
34000 .contains("judge via coerce Assess(input.ticket.title, facts.Assessment.priority)"),
34001 "fmt keeps the binding: {formatted}"
34002 );
34003 let compiled = compile_program(&program("judge via coerce Assess(input.ticket.title)"));
34006 assert!(
34007 compiled
34008 .diagnostics
34009 .iter()
34010 .any(|diagnostic| diagnostic.message.contains("passes 1 argument")),
34011 "{:?}",
34012 compiled.diagnostics
34013 );
34014 let compiled = compile_program(&program(
34016 "judge via coerce Assess(whatever.title, facts.Assessment.priority)",
34017 ));
34018 assert!(
34019 compiled
34020 .diagnostics
34021 .iter()
34022 .any(|diagnostic| diagnostic.message.contains("not a record path")),
34023 "{:?}",
34024 compiled.diagnostics
34025 );
34026 let compiled = compile_program(&program("judge via coerce Assess(record)"));
34028 assert!(
34029 compiled
34030 .diagnostics
34031 .iter()
34032 .any(|diagnostic| diagnostic.message.contains("single-parameter")),
34033 "{:?}",
34034 compiled.diagnostics
34035 );
34036 let compiled = compile_program(&program("judge via coerce Assess"));
34038 assert_eq!(compiled.diagnostics, Vec::new());
34039 assert!(compiled.ir.expect("compiles").gauges[0]
34040 .judge_args
34041 .is_empty());
34042 }
34043
34044 #[test]
34045 fn gauge_and_campaign_cross_reference_validation() {
34046 let source = r##"
34047@service
34048workflow Improve
34049
34050output result R
34051class R { v string }
34052signal go.now { x string }
34053
34054gauge broken_judge {
34055 judge via coerce MissingJudge
34056}
34057
34058gauge broken_inputs {
34059 judge via prompt "score this"
34060 inputs nowhere
34061}
34062
34063campaign confused {
34064 ascend broken_judge
34065 sacrifice broken_judge
34066}
34067
34068campaign unknown_ref {
34069 ascend nowhere_else
34070}
34071
34072rule j
34073 when go.now as g
34074=> {
34075 complete result {
34076 v "ok"
34077 }
34078}
34079"##;
34080 let compiled = compile_program(source);
34081 let messages: Vec<String> = compiled
34082 .diagnostics
34083 .iter()
34084 .map(|diagnostic| diagnostic.message.clone())
34085 .collect();
34086 assert!(messages
34087 .iter()
34088 .any(|m| m.contains("judges via undeclared coerce `MissingJudge`")));
34089 assert!(messages
34090 .iter()
34091 .any(|m| m.contains("derived gauge `broken_inputs` must judge via exec")));
34092 assert!(messages
34093 .iter()
34094 .any(|m| m.contains("unknown gauge `nowhere`")));
34095 assert!(messages
34096 .iter()
34097 .any(|m| m.contains("unknown gauge `nowhere_else`")));
34098 assert!(messages
34099 .iter()
34100 .any(|m| m.contains("names gauge `broken_judge` as both ascend and sacrifice")));
34101 }
34102
34103 #[test]
34104 fn campaign_naming_nothing_is_rejected_at_parse() {
34105 let source = r##"
34106@service
34107workflow Improve
34108
34109output result R
34110class R { v string }
34111signal go.now { x string }
34112
34113campaign nothing_named {
34114 guard std.spend within 5 percent
34115}
34116
34117rule j
34118 when go.now as g
34119=> {
34120 complete result {
34121 v "ok"
34122 }
34123}
34124"##;
34125 let compiled = compile_program(source);
34126 assert!(compiled.diagnostics.iter().any(|d| d
34127 .message
34128 .contains("campaign `nothing_named` names nothing to improve")));
34129 }
34130
34131 #[test]
34132 fn gauge_bar_operator_gets_word_form_diagnostic() {
34133 let source = r##"
34134@service
34135workflow Improve
34136
34137output result R
34138class R { v string }
34139signal go.now { x string }
34140
34141gauge extract_quality {
34142 judge via exec "./judge.py"
34143 expect P(ok) >= 0.9
34144}
34145
34146rule j
34147 when go.now as g
34148=> {
34149 complete result {
34150 v "ok"
34151 }
34152}
34153"##;
34154 let compiled = compile_program(source);
34155 assert!(compiled.diagnostics.iter().any(|diagnostic| {
34156 diagnostic
34157 .suggestion
34158 .as_deref()
34159 .is_some_and(|s| s.contains("write `at least`"))
34160 }));
34161 }
34162
34163 #[test]
34164 fn formats_gauge_and_campaign_declarations() {
34165 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";
34166 let formatted = format_program(source);
34167 assert_eq!(formatted.diagnostics, Vec::new());
34168 let once = formatted.formatted.expect("formats");
34169 assert!(once.contains("gauge extract_quality on j.result {"));
34170 assert!(once.contains(" judge via exec \"./judge.py\""));
34171 assert!(once.contains(" expect P(ok) at least 0.9"));
34172 assert!(once.contains("campaign release_tuning {"));
34173 assert!(once.contains(" reach std.latency at most 800ms"));
34174 assert!(once.contains(" guard std.tokens within 2 percent"));
34175 assert!(once.contains(" proposer redacted"));
34176 let twice = format_program(&once).formatted.expect("reformats");
34177 assert_eq!(once, twice, "gauge/campaign formatting is idempotent");
34178 }
34179
34180 #[test]
34181 fn channel_declaration_parses_and_lowers() {
34182 let source = r##"
34183@service
34184workflow ChannelDecl
34185
34186use std.messaging
34187
34188channel release_room {
34189 provider fixture
34190 workspace ops
34191 destination "#release"
34192}
34193
34194output result R
34195class R { v string }
34196signal go.now { x string }
34197
34198rule j
34199 when go.now as g
34200=> {
34201 complete result {
34202 v "ok"
34203 }
34204}
34205"##;
34206 let compiled = compile_program(source);
34207 assert_eq!(compiled.diagnostics, Vec::new());
34208 let ir = compiled.ir.expect("program compiles");
34209 assert_eq!(ir.channels.len(), 1);
34210 let channel = &ir.channels[0];
34211 assert_eq!(channel.name, "release_room");
34212 assert_eq!(channel.provider, "fixture");
34213 assert_eq!(channel.workspace.as_deref(), Some("ops"));
34214 assert_eq!(channel.destination.as_deref(), Some("#release"));
34215 let registry = ir.contract_registry();
34219 assert!(registry
34220 .libraries
34221 .iter()
34222 .any(|library| library.id == "std.messaging"));
34223 assert!(SchemaIndex::with_builtins().class_exists("Message"));
34225 assert!(SchemaIndex::with_builtins().class_exists("MessageSendReceipt"));
34228 }
34229
34230 #[test]
34231 fn single_line_multi_field_terminal_payload_collects_every_field() {
34232 let source = r#"
34236workflow OneLine
34237
34238output result Done
34239
34240class Done {
34241 first string
34242 second string
34243}
34244
34245rule r
34246 when started
34247=> {
34248 complete result { first "a" second "b" }
34249}
34250"#;
34251 let compiled = compile_program(source);
34252 assert_eq!(compiled.diagnostics, Vec::new());
34253 assert!(compiled.ir.is_some());
34254 }
34255
34256 #[test]
34257 fn file_store_is_read_only_by_default() {
34258 let program = |allow: &str| {
34261 format!(
34262 r#"
34263use std.files
34264
34265workflow Posture
34266
34267output result Done
34268
34269class Done {{
34270 note string
34271}}
34272
34273file store docs {{
34274 root "./docs"
34275{allow}}}
34276
34277rule r
34278 when started
34279=> {{
34280 write text to docs at "out.txt" {{
34281 body "x"
34282 mode create
34283 }} as out
34284
34285 after out completes {{
34286 complete result {{ note "done" }}
34287 }}
34288}}
34289"#
34290 )
34291 };
34292 let denied = compile_program(&program(""));
34293 assert!(
34294 denied
34295 .diagnostics
34296 .iter()
34297 .any(|d| d.message.contains("permits no writes")),
34298 "{:?}",
34299 denied.diagnostics
34300 );
34301 let allowed = compile_program(&program(" allow write [\"**\"]\n"));
34302 assert_eq!(allowed.diagnostics, Vec::new());
34303
34304 let read_only = compile_program(
34306 r#"
34307use std.files
34308
34309workflow ReadOnly
34310
34311output result Done
34312
34313class Done {
34314 note string
34315}
34316
34317file store docs {
34318 root "./docs"
34319}
34320
34321rule r
34322 when started
34323=> {
34324 read text from docs at "in.txt" as doc
34325
34326 after doc completes {
34327 complete result { note "done" }
34328 }
34329}
34330"#,
34331 );
34332 assert_eq!(read_only.diagnostics, Vec::new());
34333 }
34334
34335 #[test]
34336 fn tracker_bare_defaults_provider_to_builtin() {
34337 let source = r#"
34340@service
34341workflow TrackerBare
34342
34343tracker backlog
34344
34345class Item { id string }
34346signal go.now { x string }
34347rule j
34348 when go.now as g
34349=> {
34350 file issue into backlog {
34351 title g.x
34352 }
34353}
34354"#;
34355 let compiled = compile_program(source);
34356 let ir = compiled.ir.expect("compiles");
34357 assert_eq!(ir.trackers.len(), 1);
34358 assert_eq!(ir.trackers[0].provider, "builtin");
34359 }
34360
34361 #[test]
34362 fn emit_signal_from_projects_bounded_fields() {
34363 let source = r#"
34367use std.ingress
34368
34369@service
34370workflow EmitFrom
34371
34372signal deploy.finished {
34373 service string
34374 peer string
34375}
34376
34377signal deploy.acknowledged {
34378 service string
34379}
34380
34381rule relay
34382 when deploy.finished as deployed
34383=> {
34384 emit signal deploy.acknowledged to deployed.peer from deployed as sent
34385}
34386"#;
34387 let compiled = compile_program(source);
34388 assert_eq!(compiled.diagnostics, Vec::new());
34389 assert!(compiled.ir.is_some());
34390 }
34391
34392 #[test]
34393 fn inline_contract_payload_synthesizes_anonymous_class() {
34394 let source = r#"
34398workflow Inline
34399
34400output result {
34401 message string
34402}
34403
34404failure error {
34405 reason string
34406}
34407
34408rule r
34409 when started
34410=> {
34411 complete result {
34412 message "hello"
34413 }
34414}
34415"#;
34416 let compiled = compile_program(source);
34417 assert_eq!(compiled.diagnostics, Vec::new());
34418 let ir = compiled.ir.expect("compiles");
34419 assert!(ir.schemas.iter().any(|schema| matches!(
34420 schema,
34421 IrSchema::Class(class) if class.name == "output.result"
34422 )));
34423 assert!(ir.schemas.iter().any(|schema| matches!(
34424 schema,
34425 IrSchema::Class(class) if class.name == "failure.error"
34426 )));
34427
34428 let bad = compile_program(
34430 r#"
34431workflow InlineBad
34432
34433output result {
34434 message string
34435}
34436
34437rule r
34438 when started
34439=> {
34440 complete result {
34441 wrong "hello"
34442 }
34443}
34444"#,
34445 );
34446 assert!(
34447 !bad.diagnostics.is_empty(),
34448 "unknown field on the synthesized class must be rejected"
34449 );
34450 }
34451
34452 #[test]
34453 fn channel_defaults_provider_to_local() {
34454 let source = r#"
34457use std.messaging
34458use std.ingress
34459
34460@service
34461workflow ChannelDefault
34462
34463channel orphan {
34464 workspace ops
34465}
34466
34467channel bare
34468
34469output result R
34470class R { v string }
34471signal go.now { x string }
34472rule j
34473 when go.now as g
34474=> { complete result { v "ok" } }
34475"#;
34476 let compiled = compile_program(source);
34477 assert_eq!(compiled.diagnostics, Vec::new());
34478 let ir = compiled.ir.expect("compiles");
34479 assert!(
34480 ir.channels
34481 .iter()
34482 .all(|channel| channel.provider == "local"),
34483 "{:?}",
34484 ir.channels
34485 );
34486 assert_eq!(ir.channels.len(), 2);
34487 }
34488
34489 #[test]
34490 fn duplicate_channel_is_rejected() {
34491 let source = r#"
34492@service
34493workflow DupChannel
34494
34495channel room {
34496 provider fixture
34497}
34498channel room {
34499 provider discord
34500}
34501
34502output result R
34503class R { v string }
34504signal go.now { x string }
34505rule j
34506 when go.now as g
34507=> { complete result { v "ok" } }
34508"#;
34509 let compiled = compile_program(source);
34510 let dup = compiled
34511 .diagnostics
34512 .iter()
34513 .find(|d| d.message.contains("declared more than once"))
34514 .expect("expected duplicate-channel diagnostic");
34515 assert_eq!(dup.related.len(), 1, "expected one related-info entry");
34518 assert_eq!(dup.related[0].message, "first declared here");
34519 assert!(dup.related[0].span.start < dup.span.start);
34520 }
34521
34522 #[test]
34523 fn when_message_from_binds_message_and_validates_channel() {
34524 let ok = compile_program(
34527 r#"
34528@service
34529workflow Inbound
34530
34531channel release_room {
34532 provider fixture
34533}
34534
34535output result Decision
34536class Decision { note string }
34537
34538rule react
34539 when message from release_room as msg
34540=> {
34541 complete result { note msg.text }
34542}
34543"#,
34544 );
34545 assert!(
34546 ok.diagnostics.is_empty(),
34547 "expected clean compile, got {:?}",
34548 ok.diagnostics
34549 );
34550 let bad = compile_program(
34553 r#"
34554@service
34555workflow Inbound
34556
34557channel release_room {
34558 provider fixture
34559}
34560
34561output result Decision
34562class Decision { note string }
34563
34564rule react
34565 when message from typo_room as msg
34566=> {
34567 complete result { note msg.text }
34568}
34569"#,
34570 );
34571 assert!(
34572 bad.diagnostics.iter().any(|d| d
34573 .message
34574 .contains("`when message from typo_room` names an unknown channel")),
34575 "expected unknown-channel diagnostic, got {:?}",
34576 bad.diagnostics
34577 );
34578 }
34579
34580 #[test]
34581 fn unknown_channel_provider_is_a_check_error() {
34582 let compiled = compile_program(
34586 r##"
34587@service
34588workflow UnknownProvider
34589
34590channel ops_room {
34591 provider slack
34592 destination "#ops"
34593}
34594
34595output result R
34596class R { v string }
34597signal go.now { x string }
34598rule j
34599 when go.now as g
34600=> { complete result { v "ok" } }
34601"##,
34602 );
34603 let unknown = compiled
34604 .diagnostics
34605 .iter()
34606 .find(|d| {
34607 d.message
34608 .contains("channel `ops_room` names unknown messaging provider `slack`")
34609 })
34610 .expect("expected unknown-provider diagnostic");
34611 assert!(
34612 unknown
34613 .suggestion
34614 .as_deref()
34615 .is_some_and(|s| s.contains("fixture") && s.contains("desktop")),
34616 "suggestion lists the v1 providers: {:?}",
34617 unknown.suggestion
34618 );
34619 }
34620
34621 #[test]
34622 fn desktop_channel_is_outbound_only_at_check_time() {
34623 let send_ok = compile_program(
34628 r#"
34629@service
34630workflow DesktopSend
34631
34632use std.messaging
34633
34634channel alerts {
34635 provider desktop
34636}
34637
34638output result R
34639class R { v string }
34640signal go.now { x string }
34641
34642rule j
34643 when go.now as g
34644=> {
34645 send via alerts {
34646 text "ping"
34647 } as sent
34648
34649 after sent succeeds {
34650 complete result { v "ok" }
34651 }
34652}
34653"#,
34654 );
34655 assert!(
34656 send_ok.diagnostics.is_empty(),
34657 "outbound send over desktop passes: {:?}",
34658 send_ok.diagnostics
34659 );
34660
34661 let inbound_bad = compile_program(
34662 r#"
34663@service
34664workflow DesktopInbound
34665
34666channel alerts {
34667 provider desktop
34668}
34669
34670output result R
34671class R { v string }
34672
34673rule react
34674 when message from alerts as msg
34675=> { complete result { v msg.text } }
34676"#,
34677 );
34678 assert!(
34679 inbound_bad.diagnostics.iter().any(|d| d.message.contains(
34680 "`when message from alerts` observes a channel whose provider `desktop` is outbound-only"
34681 )),
34682 "expected outbound-only diagnostic, got {:?}",
34683 inbound_bad.diagnostics
34684 );
34685
34686 let bidirectional = compile_program(
34688 r#"
34689@service
34690workflow LocalInbound
34691
34692channel alerts {
34693 provider local
34694}
34695
34696output result R
34697class R { v string }
34698
34699rule react
34700 when message from alerts as msg
34701=> { complete result { v msg.text } }
34702"#,
34703 );
34704 assert!(
34705 bidirectional.diagnostics.is_empty(),
34706 "bidirectional provider admits inbound observation: {:?}",
34707 bidirectional.diagnostics
34708 );
34709 }
34710
34711 #[test]
34712 fn channel_provider_reports_cover_the_v1_matrix() {
34713 let shorts: Vec<&str> = CHANNEL_PROVIDER_REPORTS
34716 .iter()
34717 .map(|r| r.short_name)
34718 .collect();
34719 assert_eq!(shorts, ["fixture", "local", "desktop", "stdio"]);
34720 for report in CHANNEL_PROVIDER_REPORTS {
34721 assert!(
34722 matches!(
34723 report.direction,
34724 "outbound_only" | "inbound_only" | "bidirectional"
34725 ),
34726 "direction vocabulary: {}",
34727 report.direction
34728 );
34729 assert!(
34730 matches!(report.identity, "anonymous" | "claimed_actor"),
34731 "identity ladder is v1-narrowed (no verified_actor): {}",
34732 report.identity
34733 );
34734 assert_eq!(report.delivery_receipts, &["accepted", "failed"]);
34735 assert_eq!(
34736 channel_provider_report(report.short_name),
34737 Some(report),
34738 "short name resolves"
34739 );
34740 assert_eq!(
34741 channel_provider_report(report.provider_id),
34742 Some(report),
34743 "provider id resolves"
34744 );
34745 }
34746 assert_eq!(channel_provider_report("slack"), None);
34747 assert_eq!(
34748 channel_provider_report("desktop").map(|r| r.direction),
34749 Some("outbound_only")
34750 );
34751 }
34752
34753 #[test]
34754 fn duplicate_schema_diagnostic_points_at_first_declaration() {
34755 let source = r#"
34756@service
34757workflow DupSchema
34758
34759class Thing { v string }
34760class Thing { w string }
34761
34762output result R
34763class R { v string }
34764signal go.now { x string }
34765rule j
34766 when go.now as g
34767=> { complete result { v "ok" } }
34768"#;
34769 let compiled = compile_program(source);
34770 let dup = compiled
34771 .diagnostics
34772 .iter()
34773 .find(|d| {
34774 d.message
34775 .contains("schema `Thing` is declared more than once")
34776 })
34777 .expect("expected duplicate-schema diagnostic");
34778 assert_eq!(dup.related.len(), 1);
34779 assert_eq!(dup.related[0].message, "first declared here");
34780 assert!(dup.related[0].span.start < dup.span.start);
34781 }
34782
34783 #[test]
34784 fn interval_clock_source_parses_duration() {
34785 let source = r#"
34786workflow Interval
34787
34788signal tick.beat {
34789 at_time time
34790}
34791
34792source clock as heartbeat {
34793 every 5m
34794 missed skip
34795
34796 observe as tick
34797 emit tick.beat {
34798 at_time tick.scheduled_at
34799 }
34800}
34801"#;
34802 let compiled = compile_program(source);
34803 assert_eq!(compiled.diagnostics, Vec::new());
34804 let ir = compiled.ir.expect("program compiles");
34805 match &ir.sources[0].recurrence {
34806 Some(Recurrence::EveryDuration { seconds, .. }) => assert_eq!(*seconds, 300),
34807 other => panic!("expected duration recurrence, got {other:?}"),
34808 }
34809 assert_eq!(ir.sources[0].missed, Some(MissedPolicy::Skip));
34810 }
34811
34812 #[test]
34813 fn fails_binding_types_to_effecterror_base() {
34814 let source = r#"
34818workflow W {
34819 input task T
34820 output result R
34821 failure error E
34822 class T { x string }
34823 class R { y string }
34824 class E { reason string detail string }
34825
34826 rule go when T as task => {
34827 exec "true" as e
34828 after e fails as f {
34829 fail error { reason f.reason detail f.kind }
34830 }
34831 after e succeeds {
34832 complete result { y task.x }
34833 }
34834 }
34835}
34836"#;
34837 let compiled = compile_program(source);
34838 assert!(
34839 !compiled
34840 .diagnostics
34841 .iter()
34842 .any(|d| d.message.contains("invalid field path")),
34843 "base fields should type-check: {:?}",
34844 compiled.diagnostics
34845 );
34846 }
34847
34848 #[test]
34849 fn fails_binding_rejects_non_base_field() {
34850 let exec_source = r#"
34856workflow W {
34857 input task T
34858 output result R
34859 failure error E
34860 class T { x string }
34861 class R { y string }
34862 class E { reason string }
34863
34864 rule go when T as task => {
34865 exec "true" as e
34866 after e fails as f {
34867 fail error { reason f.stderr }
34868 }
34869 after e succeeds {
34870 complete result { y task.x }
34871 }
34872 }
34873}
34874"#;
34875 let compiled = compile_program(exec_source);
34876 assert!(
34877 compiled
34878 .diagnostics
34879 .iter()
34880 .any(|d| d.message.contains("invalid field path `f.stderr`")),
34881 "{:?}",
34882 compiled.diagnostics
34883 );
34884
34885 let cross_kind = r#"
34886workflow W {
34887 input task T
34888 output result R
34889 failure error E
34890 class T { x string }
34891 class R { y string }
34892 class E { reason string }
34893 class V { note string }
34894
34895 coerce judge(x string) -> V {
34896 prompt "Classify {{ x }}"
34897 }
34898
34899 rule go when T as task => {
34900 coerce judge(task.x) as c
34901 after c fails as f {
34902 fail error { reason f.exit_code }
34903 }
34904 after c succeeds {
34905 complete result { y task.x }
34906 }
34907 }
34908}
34909"#;
34910 let compiled = compile_program(cross_kind);
34911 assert!(
34912 compiled
34913 .diagnostics
34914 .iter()
34915 .any(|d| d.message.contains("invalid field path `f.exit_code`")
34916 && d.message.contains("TerminalFailedCoerce")),
34917 "a coerce binding must not read exec extras: {:?}",
34918 compiled.diagnostics
34919 );
34920 }
34921
34922 #[test]
34923 fn fails_binding_narrows_to_per_kind_failure_extras() {
34924 let source = r#"
34928workflow W {
34929 input task T
34930 output result R
34931 failure error E
34932 class T { x string }
34933 class R { y string }
34934 class E { reason string code int klass string }
34935 class V { note string }
34936
34937 agent worker {
34938 provider fixture
34939 profile "repo-reader"
34940 capacity 1
34941 }
34942
34943 coerce judge(x string) -> V {
34944 prompt "Classify {{ x }}"
34945 }
34946
34947 rule go when T as task => {
34948 exec "true" as e
34949 coerce judge(task.x) as c
34950 tell worker as turn "go"
34951
34952 after e fails as fe {
34953 fail error { reason fe.reason code fe.exit_code klass "x" }
34954 }
34955 after c fails as fc {
34956 fail error { reason fc.reason code 0 klass fc.error_class }
34957 }
34958 after turn fails as ft {
34959 fail error { reason ft.reason code 0 klass ft.error_class }
34960 }
34961 after e succeeds {
34962 complete result { y task.x }
34963 }
34964 }
34965}
34966"#;
34967 let compiled = compile_program(source);
34968 assert!(
34969 !compiled
34970 .diagnostics
34971 .iter()
34972 .any(|d| d.message.contains("invalid field path")),
34973 "per-kind extras must type-check under the matching kind: {:?}",
34974 compiled.diagnostics
34975 );
34976 }
34977
34978 #[test]
34979 fn milestone_reaches_rejects_undeclared_milestone() {
34980 let source = r#"
34983workflow Parent {
34984 input task Task
34985 class Task { title string }
34986 class Saw { note string }
34987
34988 rule dispatch when Task as task => {
34989 invoke Child { task { title task.title } } as child
34990 after child reaches "never_declared" as m {
34991 record Saw { note m.note }
34992 }
34993 }
34994}
34995
34996workflow Child {
34997 input task Task
34998 output result R
34999 class Task { title string }
35000 class R { title string }
35001 class P { note string }
35002
35003 rule go when Task as task => {
35004 emit milestone "actually_declared" of P { note task.title }
35005 complete result { title task.title }
35006 }
35007}
35008"#;
35009 let compiled = compile_program_with_root(source, Some("Parent"));
35010 assert!(
35011 compiled.diagnostics.iter().any(|d| d.message.contains(
35012 "reaches milestone `never_declared` that workflow `Child` does not declare"
35013 )),
35014 "{:?}",
35015 compiled.diagnostics
35016 );
35017 }
35018
35019 #[test]
35020 fn emit_milestone_rejects_unknown_payload_class() {
35021 let source = r#"
35022workflow Child {
35023 input task Task
35024 output result R
35025 class Task { title string }
35026 class R { title string }
35027
35028 rule go when Task as task => {
35029 emit milestone "m1" of Nonexistent { note task.title }
35030 complete result { title task.title }
35031 }
35032}
35033"#;
35034 let compiled = compile_program(source);
35035 assert!(
35036 compiled.diagnostics.iter().any(|d| d
35037 .message
35038 .contains("emits milestone `m1` with unknown payload class `Nonexistent`")),
35039 "{:?}",
35040 compiled.diagnostics
35041 );
35042 }
35043
35044 #[test]
35045 fn milestone_reaches_accepts_declared_milestone() {
35046 let source = r#"
35049workflow Parent {
35050 input task Task
35051 class Task { title string }
35052 class Saw { note string }
35053
35054 rule dispatch when Task as task => {
35055 invoke Child { task { title task.title } } as child
35056 after child reaches "halfway" as m {
35057 record Saw { note m.note }
35058 }
35059 }
35060}
35061
35062workflow Child {
35063 input task Task
35064 output result R
35065 class Task { title string }
35066 class R { title string }
35067 class P { note string }
35068
35069 rule go when Task as task => {
35070 emit milestone "halfway" of P { note task.title }
35071 complete result { title task.title }
35072 }
35073}
35074"#;
35075 let compiled = compile_program_with_root(source, Some("Parent"));
35076 assert!(
35077 !compiled
35078 .diagnostics
35079 .iter()
35080 .any(|d| d.message.contains("reaches milestone")
35081 || d.message.contains("unknown payload class")),
35082 "{:?}",
35083 compiled.diagnostics
35084 );
35085 }
35086
35087 #[test]
35088 fn recurring_clock_source_requires_missed() {
35089 let source = r#"
35090workflow NeedsMissed
35091
35092signal triage.tick {
35093 scheduled_at time
35094}
35095
35096source clock as daily {
35097 every weekday at 09:00
35098 timezone "UTC"
35099
35100 observe as tick
35101 emit triage.tick {
35102 scheduled_at tick.scheduled_at
35103 }
35104}
35105"#;
35106 let compiled = compile_program(source);
35107 assert!(
35108 compiled.diagnostics.iter().any(|diagnostic| diagnostic
35109 .message
35110 .contains("must declare a `missed` policy")),
35111 "{:?}",
35112 compiled.diagnostics
35113 );
35114 }
35115
35116 #[test]
35117 fn calendar_clock_source_requires_timezone() {
35118 let source = r#"
35119workflow NeedsTimezone
35120
35121signal triage.tick {
35122 scheduled_at time
35123}
35124
35125source clock as daily {
35126 every weekday at 09:00
35127 missed skip
35128
35129 observe as tick
35130 emit triage.tick {
35131 scheduled_at tick.scheduled_at
35132 }
35133}
35134"#;
35135 let compiled = compile_program(source);
35136 assert!(
35137 compiled
35138 .diagnostics
35139 .iter()
35140 .any(|diagnostic| diagnostic.message.contains("should declare a `timezone`")),
35141 "{:?}",
35142 compiled.diagnostics
35143 );
35144 }
35145
35146 #[test]
35147 fn generic_source_block_lowers_to_signal_source() {
35148 let source = r#"
35149workflow Ingress
35150
35151signal deploy.finished {
35152 service string
35153}
35154
35155source webhook as deploys {
35156 observe as obs
35157 emit deploy.finished {
35158 service obs.service
35159 }
35160}
35161"#;
35162 let compiled = compile_program(source);
35163 assert_eq!(compiled.diagnostics, Vec::new());
35164 let ir = compiled.ir.expect("program compiles");
35165 assert_eq!(ir.sources.len(), 1);
35166 let decl = &ir.sources[0];
35167 assert!(!decl.is_clock);
35168 assert_eq!(decl.provider, "webhook");
35169 assert!(decl.recurrence.is_none());
35170 assert_eq!(decl.emit_signal, "deploy.finished");
35171 }
35172
35173 #[test]
35174 fn complete_field_reads_are_collected_per_field() {
35175 let source = r#"
35179@tool
35180workflow Producer {
35181 input request Req
35182 output result R
35183 class Req { id string }
35184 class A { x string }
35185 class B { y string }
35186 class R { id string note string }
35187
35188 rule combine
35189 when A as a
35190 when B as b
35191 => {
35192 complete result {
35193 id a.x
35194 note b.y
35195 }
35196 }
35197}
35198"#;
35199 let compiled = compile_program(source);
35200 let ir = compiled.ir.expect("program compiles");
35201 let rule = ir
35202 .rules
35203 .iter()
35204 .find(|r| r.name == "combine")
35205 .expect("combine rule");
35206 let per_field = rule
35207 .metadata
35208 .complete_field_reads
35209 .get("result")
35210 .expect("result has per-field reads");
35211 assert_eq!(
35212 per_field.get("id"),
35213 Some(&BTreeSet::from(["a".to_owned()])),
35214 "id references only a: {per_field:?}"
35215 );
35216 assert_eq!(
35217 per_field.get("note"),
35218 Some(&BTreeSet::from(["b".to_owned()])),
35219 "note references only b: {per_field:?}"
35220 );
35221 }
35222
35223 #[test]
35224 fn milestone_field_reads_are_collected_per_field() {
35225 let source = r#"
35228workflow Child {
35229 input request Req
35230 output result R
35231 class Req { id string }
35232 class A { x string }
35233 class B { y string }
35234 class R { ok bool }
35235 class Progress { hot string cold string }
35236
35237 rule report
35238 when A as a
35239 when B as b
35240 => {
35241 emit milestone "halfway" of Progress {
35242 hot a.x
35243 cold b.y
35244 }
35245 complete result { ok true }
35246 }
35247}
35248"#;
35249 let compiled = compile_program(source);
35250 let ir = compiled.ir.expect("program compiles");
35251 let rule = ir
35252 .rules
35253 .iter()
35254 .find(|r| r.name == "report")
35255 .expect("report rule");
35256 let per_field = rule
35257 .metadata
35258 .milestone_field_reads
35259 .get("halfway")
35260 .expect("milestone has per-field reads");
35261 assert_eq!(
35262 per_field.get("hot"),
35263 Some(&BTreeSet::from(["a".to_owned()])),
35264 "hot references only a: {per_field:?}"
35265 );
35266 assert_eq!(
35267 per_field.get("cold"),
35268 Some(&BTreeSet::from(["b".to_owned()])),
35269 "cold references only b: {per_field:?}"
35270 );
35271 }
35272}