Skip to main content

whipplescript_parser/
syntax.rs

1//! The lexer and the recursive-descent parser: tokens, clause bags, and the `Parser` impl that builds the AST.
2//!
3//! Moved verbatim out of `lib.rs`; `use super::*` keeps the IR types and
4//! helpers it already resolved against in scope.
5
6use super::*;
7/// Stage marker retained for the CLI scaffold.
8pub fn parser_stage() -> &'static str {
9    whipplescript_core::IMPLEMENTATION_STAGE
10}
11
12#[derive(Clone, Debug, Eq, PartialEq)]
13pub(crate) struct Lexed {
14    pub(crate) tokens: Vec<Token>,
15    diagnostics: Vec<Diagnostic>,
16    comments: Vec<Comment>,
17}
18
19#[derive(Clone, Debug, Eq, PartialEq)]
20pub(crate) struct Token {
21    kind: TokenKind,
22    span: SourceSpan,
23}
24
25#[derive(Clone, Debug, Eq, PartialEq)]
26pub(crate) enum TokenKind {
27    Ident(String),
28    String(String),
29    Number(String),
30    Arrow,
31    ThinArrow,
32    Symbol(char),
33}
34
35impl TokenKind {
36    fn label(&self) -> String {
37        match self {
38            Self::Ident(value) => format!("identifier `{value}`"),
39            Self::String(_) => "string literal".to_owned(),
40            Self::Number(_) => "number literal".to_owned(),
41            Self::Arrow => "`=>`".to_owned(),
42            Self::ThinArrow => "`->`".to_owned(),
43            Self::Symbol(value) => format!("`{value}`"),
44        }
45    }
46}
47
48pub(crate) fn lex(source: &str) -> Lexed {
49    let bytes = source.as_bytes();
50    let mut tokens = Vec::new();
51    let mut diagnostics = Vec::new();
52    let mut comments = Vec::new();
53    let mut index = 0;
54
55    while index < bytes.len() {
56        let byte = bytes[index];
57        if byte.is_ascii_whitespace() {
58            index += 1;
59            continue;
60        }
61
62        if byte == b'#' {
63            let end = skip_line(bytes, index + 1);
64            comments.push(Comment {
65                marker: CommentMarker::Hash,
66                text: source[index + 1..end].trim().to_owned(),
67                span: SourceSpan { start: index, end },
68            });
69            index = end;
70            continue;
71        }
72
73        if byte == b'/' && bytes.get(index + 1) == Some(&b'/') {
74            let end = skip_line(bytes, index + 2);
75            comments.push(Comment {
76                marker: CommentMarker::Slash,
77                text: source[index + 2..end].trim().to_owned(),
78                span: SourceSpan { start: index, end },
79            });
80            index = end;
81            continue;
82        }
83
84        if is_ident_start(byte) {
85            let start = index;
86            index += 1;
87            while index < bytes.len() && is_ident_continue(bytes[index]) {
88                index += 1;
89            }
90            tokens.push(Token {
91                kind: TokenKind::Ident(source[start..index].to_owned()),
92                span: SourceSpan { start, end: index },
93            });
94            continue;
95        }
96
97        if byte.is_ascii_digit() {
98            let start = index;
99            index += 1;
100            while index < bytes.len() && bytes[index].is_ascii_digit() {
101                index += 1;
102            }
103            tokens.push(Token {
104                kind: TokenKind::Number(source[start..index].to_owned()),
105                span: SourceSpan { start, end: index },
106            });
107            continue;
108        }
109
110        if byte == b'"' {
111            let (token, next, diagnostic) = lex_string(source, index);
112            tokens.push(token);
113            if let Some(diagnostic) = diagnostic {
114                diagnostics.push(diagnostic);
115            }
116            index = next;
117            continue;
118        }
119
120        if byte == b'=' && bytes.get(index + 1) == Some(&b'>') {
121            tokens.push(Token {
122                kind: TokenKind::Arrow,
123                span: SourceSpan {
124                    start: index,
125                    end: index + 2,
126                },
127            });
128            index += 2;
129            continue;
130        }
131
132        if byte == b'=' && bytes.get(index + 1) == Some(&b'=') {
133            index += 2;
134            continue;
135        }
136
137        if byte == b'!' && bytes.get(index + 1) == Some(&b'=') {
138            index += 2;
139            continue;
140        }
141
142        if matches!(byte, b'<' | b'>') && bytes.get(index + 1) == Some(&b'=') {
143            index += 2;
144            continue;
145        }
146
147        if matches!(byte, b'&' | b'|') && bytes.get(index + 1) == Some(&byte) {
148            index += 2;
149            continue;
150        }
151
152        if byte == b'-' && bytes.get(index + 1) == Some(&b'>') {
153            tokens.push(Token {
154                kind: TokenKind::ThinArrow,
155                span: SourceSpan {
156                    start: index,
157                    end: index + 2,
158                },
159            });
160            index += 2;
161            continue;
162        }
163
164        // Arithmetic operators appear inside guard and field-value
165        // expressions, which are re-parsed from raw source slices; the
166        // file-level lexer only needs to step over them.
167        if matches!(byte, b'*' | b'/' | b'-') {
168            index += 1;
169            continue;
170        }
171
172        if b"{}[]()<>,?|.+!:@".contains(&byte) {
173            tokens.push(Token {
174                kind: TokenKind::Symbol(byte as char),
175                span: SourceSpan {
176                    start: index,
177                    end: index + 1,
178                },
179            });
180            index += 1;
181            continue;
182        }
183
184        diagnostics.push(Diagnostic {
185            related: Vec::new(),
186            span: SourceSpan {
187                start: index,
188                end: index + 1,
189            },
190            message: format!("unexpected character `{}`", byte as char),
191            suggestion: None,
192        });
193        index += 1;
194    }
195
196    Lexed {
197        tokens,
198        diagnostics,
199        comments,
200    }
201}
202
203/// Extract the comments from a source program, in source order. Comments are not
204/// part of the token stream or AST; this is the entry point tooling (`whip fmt`,
205/// the LSP) uses to preserve them.
206pub fn lex_comments(source: &str) -> Vec<Comment> {
207    lex(source).comments
208}
209
210/// Byte-span regions of string literals and comments in `source`. A tool that
211/// edits identifier occurrences (e.g. `whip lsp` rename) consults these to avoid
212/// touching text inside a prompt string or a comment — only code identifiers are
213/// real references.
214pub fn string_and_comment_spans(source: &str) -> Vec<SourceSpan> {
215    let lexed = lex(source);
216    let mut spans: Vec<SourceSpan> = lexed
217        .tokens
218        .iter()
219        .filter(|token| matches!(token.kind, TokenKind::String(_)))
220        .map(|token| token.span)
221        .collect();
222    spans.extend(lexed.comments.iter().map(|comment| comment.span));
223    spans
224}
225
226pub(crate) fn skip_line(bytes: &[u8], mut index: usize) -> usize {
227    while index < bytes.len() && bytes[index] != b'\n' {
228        index += 1;
229    }
230    index
231}
232
233pub(crate) fn is_ident_start(byte: u8) -> bool {
234    byte.is_ascii_alphabetic() || byte == b'_'
235}
236
237pub(crate) fn is_ident_continue(byte: u8) -> bool {
238    is_ident_start(byte) || byte.is_ascii_digit() || byte == b'-'
239}
240
241pub(crate) fn lex_string(source: &str, start: usize) -> (Token, usize, Option<Diagnostic>) {
242    let bytes = source.as_bytes();
243    let triple = bytes.get(start..start + 3) == Some(b"\"\"\"");
244    let content_start = if triple { start + 3 } else { start + 1 };
245    let mut index = content_start;
246
247    while index < bytes.len() {
248        if triple && bytes.get(index..index + 3) == Some(b"\"\"\"") {
249            let end = index + 3;
250            return (
251                Token {
252                    kind: TokenKind::String(source[content_start..index].to_owned()),
253                    span: SourceSpan { start, end },
254                },
255                end,
256                None,
257            );
258        }
259
260        if !triple && bytes[index] == b'"' {
261            let end = index + 1;
262            return (
263                Token {
264                    kind: TokenKind::String(source[content_start..index].to_owned()),
265                    span: SourceSpan { start, end },
266                },
267                end,
268                None,
269            );
270        }
271
272        if !triple && bytes[index] == b'\\' && index + 1 < bytes.len() {
273            index += 2;
274        } else {
275            index += 1;
276        }
277    }
278
279    (
280        Token {
281            kind: TokenKind::String(source[content_start..].to_owned()),
282            span: SourceSpan {
283                start,
284                end: source.len(),
285            },
286        },
287        source.len(),
288        Some(Diagnostic {
289            related: Vec::new(),
290            span: SourceSpan {
291                start,
292                end: source.len(),
293            },
294            message: "unterminated string literal".to_owned(),
295            suggestion: Some("close the string literal".to_owned()),
296        }),
297    )
298}
299
300/// The value kind of a `declaration_block` clause (Shape 1, DR-0011 amended
301/// 2026-07-08 with `Duration`/`Glob`/`Schema`/`Scalar`/`Flag`). The order-free
302/// analog of `body::SlotKind` for top-level declarations. A `Flag` clause is a
303/// bare presence clause carrying no value.
304#[derive(Clone, Copy, Debug)]
305#[allow(dead_code)]
306pub(crate) enum ClauseKind {
307    Identifier,
308    Expression,
309    Duration,
310    Glob,
311    Schema,
312    Scalar,
313    Flag,
314}
315
316/// The typed AST node a migrated declaration lowers to — the one hand-written
317/// seam of the otherwise data-driven Shape 1 pipeline. `effect_operation`
318/// lowers to a uniform node, but the seven decls each build a distinct typed
319/// node, so a future dispatch slice switches on this. Unused in D2.0 beyond
320/// being carried in the spec.
321#[derive(Clone, Copy, Debug)]
322#[allow(dead_code)]
323pub(crate) enum DeclAstKind {
324    Tracker,
325    Channel,
326    Counter,
327    Lease,
328    Ledger,
329    MemoryPool,
330    FileStore,
331    Stream,
332    Credential,
333}
334
335/// One order-free clause of a `declaration_block` construct: a named value
336/// (`words` = the build-time-split clause-name tokens; single-word names are a
337/// one-element slice), an optional `connective` consumed before the value
338/// (`Some("by")` for ledger `partition by`; shares Shape 2's vocabulary plus
339/// `by`), a value `kind`, whether it is a `[ ... ]` `list`, and the
340/// `unknown_hint` shown when a sibling clause name is not recognized. The
341/// order-free analog of `body::EffectSlotSpec`.
342///
343/// `required`/`missing_summary` are NOT carried here: required-ness is a
344/// validation concern, not a parse concern. For the std decls it is enforced
345/// by the typed-node builder (`item_from_decl_ast`, the hand-written seam,
346/// which also owns the bespoke domain guidance); the manifest still declares
347/// `required`/`missing_summary` as the third-party contract validated by the
348/// CLI manifest validator.
349#[derive(Clone, Copy, Debug)]
350pub(crate) struct ClauseSpec {
351    pub(crate) name: &'static str,
352    pub(crate) words: &'static [&'static str],
353    pub(crate) connective: Option<&'static str>,
354    pub(crate) kind: ClauseKind,
355    pub(crate) list: bool,
356    unknown_hint: &'static str,
357}
358
359/// The full grammar of one `declaration_block` construct (Shape 1). `keyword`
360/// is the full head phrase (`"memory pool"`/`"file store"`); `keyword_words`
361/// is its whitespace-split tokens (head-word dispatch reads `keyword_words[0]`).
362/// `ast_kind` is the hand-written builder seam.
363#[derive(Clone, Copy, Debug)]
364#[allow(dead_code)]
365pub(crate) struct DeclarationBlockSpec {
366    pub(crate) keyword: &'static str,
367    pub(crate) keyword_words: &'static [&'static str],
368    ast_kind: DeclAstKind,
369    pub(crate) clauses: &'static [ClauseSpec],
370}
371
372// The table itself is generated at build time from the grammar-only manifests
373// (std/grammars/*.json) by build.rs, mirroring `EFFECT_OPERATION_GRAMMAR`: each
374// declaration_block construct's DR-0011 `grammar` object transcribes into one
375// `DeclarationBlockSpec` row, so the manifests are the single source of parse
376// grammar and the table can never drift from them. D2.0 builds the table and
377// unit-tests it; nothing dispatches through it yet.
378include!(concat!(env!("OUT_DIR"), "/declaration_block_grammar.rs"));
379
380/// The parsed value of one matched `declaration_block` clause. A `Scalar` clause
381/// is literal-polymorphic: `cap`/`slots`/`context limit` carry a `Number`, while
382/// file-store `root` and channel `destination` carry `Str` — the grammar marks
383/// both `scalar` and the per-decl builder casts to the field's width/shape.
384/// `Missing` records a clause whose name matched but whose value failed to parse,
385/// so the first-word span is still captured (file-store `root_span` is set on the
386/// clause keyword regardless of the value's fate).
387#[derive(Clone, Debug)]
388pub(crate) enum ClauseValue {
389    Ident(Ident),
390    Idents(Vec<Ident>),
391    Duration(u64),
392    Number(u32),
393    Str(StringLiteral),
394    Globs(Vec<String>),
395    Flag,
396    Missing,
397}
398
399/// The order-free accumulator a generic `parse_declaration_block` fills as it
400/// reads a decl's brace block, keyed by the spec clause `name`; the per-decl
401/// typed-node builder reads it by name. Each record also carries the FIRST
402/// clause-name-word token span (file-store/memory-pool serialize these into the
403/// AST for `whip fmt`). Last write wins, matching the hand parsers' field
404/// overwrite. The Shape-1 analog of the ordered field vector Shape 2 builds.
405pub(crate) struct ClauseBag {
406    records: Vec<(&'static str, SourceSpan, ClauseValue)>,
407}
408
409impl ClauseBag {
410    fn new() -> Self {
411        ClauseBag {
412            records: Vec::new(),
413        }
414    }
415
416    fn record(&mut self, name: &'static str, first_word_span: SourceSpan, value: ClauseValue) {
417        self.records.push((name, first_word_span, value));
418    }
419
420    fn get(&self, name: &str) -> Option<&(&'static str, SourceSpan, ClauseValue)> {
421        self.records
422            .iter()
423            .rev()
424            .find(|(clause, _, _)| *clause == name)
425    }
426
427    fn ident(&self, name: &str) -> Option<Ident> {
428        match self.get(name) {
429            Some((_, _, ClauseValue::Ident(ident))) => Some(ident.clone()),
430            _ => None,
431        }
432    }
433
434    fn idents(&self, name: &str) -> Option<Vec<Ident>> {
435        match self.get(name) {
436            Some((_, _, ClauseValue::Idents(idents))) => Some(idents.clone()),
437            _ => None,
438        }
439    }
440
441    fn duration(&self, name: &str) -> Option<u64> {
442        match self.get(name) {
443            Some((_, _, ClauseValue::Duration(seconds))) => Some(*seconds),
444            _ => None,
445        }
446    }
447
448    fn number(&self, name: &str) -> Option<u32> {
449        match self.get(name) {
450            Some((_, _, ClauseValue::Number(value))) => Some(*value),
451            _ => None,
452        }
453    }
454
455    fn text(&self, name: &str) -> Option<String> {
456        self.text_literal(name).map(|literal| literal.value)
457    }
458
459    fn text_literal(&self, name: &str) -> Option<StringLiteral> {
460        match self.get(name) {
461            Some((_, _, ClauseValue::Str(literal))) => Some(literal.clone()),
462            _ => None,
463        }
464    }
465
466    fn globs(&self, name: &str) -> Vec<String> {
467        match self.get(name) {
468            Some((_, _, ClauseValue::Globs(values))) => values.clone(),
469            _ => Vec::new(),
470        }
471    }
472
473    fn flag(&self, name: &str) -> bool {
474        matches!(self.get(name), Some((_, _, ClauseValue::Flag)))
475    }
476
477    fn span(&self, name: &str) -> Option<SourceSpan> {
478        self.get(name).map(|(_, span, _)| *span)
479    }
480}
481
482pub(crate) struct Parser<'a> {
483    pub(crate) source: &'a str,
484    pub(crate) tokens: Vec<Token>,
485    pub(crate) pos: usize,
486    pub(crate) diagnostics: Vec<Diagnostic>,
487    /// S7 inline contract payloads: classes synthesized from `output result {
488    /// … }`-style blocks, appended to the item list after the parse loop.
489    pub(crate) pending_contract_classes: Vec<ClassDecl>,
490}
491
492pub(crate) struct ParsedWorkflow {
493    decl: WorkflowDecl,
494    explicit_body: bool,
495}
496
497impl Parser<'_> {
498    fn parse_program(&mut self) -> Program {
499        let mut workflow = None;
500        let mut workflow_tags = Vec::new();
501        let mut workflow_description = None;
502        let mut explicit_workflow_body = false;
503        let mut workflows = Vec::new();
504        let mut patterns = Vec::new();
505        let mut items = Vec::new();
506        let mut pending_tags = Vec::new();
507        let mut pending_description = None;
508
509        while !self.is_at_end() {
510            if self.at_symbol('@') {
511                if let Some(tag) = self.parse_tag() {
512                    pending_tags.push(tag);
513                }
514            } else if self.at_ident("description") {
515                self.parse_pending_description(&mut pending_description);
516            } else if self.at_ident("workflow") {
517                if let Some(parsed_workflow) = self.parse_workflow(
518                    std::mem::take(&mut pending_tags),
519                    pending_description.take(),
520                ) {
521                    if parsed_workflow.explicit_body {
522                        workflows.push(parsed_workflow.decl);
523                    } else {
524                        if workflow.is_some() {
525                            self.diagnostics.push(Diagnostic { related: Vec::new(),
526                                span: parsed_workflow.decl.name.span,
527                                message: "multiple implicit workflow headers are not supported"
528                                    .to_owned(),
529                                suggestion: Some(
530                                    "use explicit `workflow Name { ... }` declarations with `--root`"
531                                        .to_owned(),
532                                ),
533                            });
534                        }
535                        workflow_tags = parsed_workflow.decl.tags;
536                        workflow_description = parsed_workflow.decl.description;
537                        // A header-form workflow carries no block, so its only
538                        // items are the compact-signature contracts (if any);
539                        // those are top-level for a single-workflow program.
540                        items.extend(parsed_workflow.decl.items);
541                        workflow = Some(parsed_workflow.decl.name);
542                        explicit_workflow_body = false;
543                    }
544                }
545            } else if self.at_ident("pattern") {
546                self.reject_pending_tags(&mut pending_tags, "pattern");
547                self.reject_pending_description(&mut pending_description, "pattern");
548                if let Some(pattern) = self.parse_pattern() {
549                    patterns.push(pattern);
550                }
551            } else if let Some(item) =
552                self.parse_declaration_item(&mut pending_tags, &mut pending_description)
553            {
554                items.push(item);
555            } else if self.reject_gherkin_misuse() {
556                continue;
557            } else {
558                if self.is_at_end() {
559                    break;
560                }
561                self.unexpected("top-level declaration");
562                if !self.is_at_end() {
563                    self.advance();
564                }
565            }
566        }
567
568        // S7: classes synthesized from inline contract payloads join the item
569        // list like ordinary declarations.
570        items.extend(
571            std::mem::take(&mut self.pending_contract_classes)
572                .into_iter()
573                .map(Item::Class),
574        );
575
576        Program {
577            workflow,
578            workflow_tags,
579            workflow_description,
580            explicit_workflow_body,
581            workflows,
582            patterns,
583            items,
584        }
585    }
586
587    fn parse_workflow(
588        &mut self,
589        tags: Vec<TagDecl>,
590        description: Option<StringLiteral>,
591    ) -> Option<ParsedWorkflow> {
592        let start = self.expect_keyword("workflow")?.span.start;
593        let name = self.expect_ident("workflow name")?;
594        let mut explicit_body = false;
595        let mut items = Vec::new();
596        let mut end = name.span.end;
597        // Optional compact contract signature: `Name(in: T, ...) -> Out [! Fail]`.
598        // Desugars to the same `input`/`output`/`failure` contract decls as the
599        // keyword form, with the output named `result` and the failure `error`
600        // (the conventional names). Both forms are legal; `whip fmt` re-emits the
601        // keyword lines (one canonical stored shape).
602        if self.at_symbol('(') {
603            if let Some((contracts, signature_end)) = self.parse_compact_contract_signature() {
604                end = signature_end;
605                items.extend(contracts.into_iter().map(Item::WorkflowContract));
606            }
607        }
608        if self.at_symbol('{') {
609            explicit_body = true;
610            self.expect_symbol('{')?;
611            let mut pending_tags = Vec::new();
612            let mut pending_description = None;
613            while !self.is_at_end() && !self.at_symbol('}') {
614                if self.at_symbol('@') {
615                    if let Some(tag) = self.parse_tag() {
616                        pending_tags.push(tag);
617                    }
618                    continue;
619                }
620                if self.at_ident("description") {
621                    self.parse_pending_description(&mut pending_description);
622                    continue;
623                }
624                if self.at_ident("workflow") || self.at_ident("pattern") {
625                    self.reject_pending_tags(&mut pending_tags, "workflow body declaration");
626                    self.reject_pending_description(
627                        &mut pending_description,
628                        "workflow body declaration",
629                    );
630                    self.unexpected("workflow body declaration");
631                    self.advance();
632                    continue;
633                }
634                if let Some(item) =
635                    self.parse_declaration_item(&mut pending_tags, &mut pending_description)
636                {
637                    items.push(item);
638                } else if self.reject_gherkin_misuse() {
639                    continue;
640                } else {
641                    if self.is_at_end() {
642                        break;
643                    }
644                    self.reject_pending_tags(&mut pending_tags, "workflow body declaration");
645                    self.reject_pending_description(
646                        &mut pending_description,
647                        "workflow body declaration",
648                    );
649                    self.unexpected("workflow body declaration");
650                    if !self.is_at_end() {
651                        self.advance();
652                    }
653                }
654            }
655            if let Some(close) = self.expect_symbol('}') {
656                end = close.span.end;
657            }
658        }
659        // S7: classes synthesized from this workflow's inline contract payloads
660        // stay in ITS scope (braced-workflow schemas are workflow-scoped), so
661        // two workflows can both write `output result { … }` without their
662        // `output.result` classes colliding.
663        items.extend(
664            std::mem::take(&mut self.pending_contract_classes)
665                .into_iter()
666                .map(Item::Class),
667        );
668        Some(ParsedWorkflow {
669            decl: WorkflowDecl {
670                name,
671                tags,
672                description,
673                items,
674                span: SourceSpan { start, end },
675            },
676            explicit_body,
677        })
678    }
679
680    /// Parses a compact contract signature `(name: Type, ...) -> Output [! Failure]`
681    /// into the same contract decls the keyword form produces. The output binding
682    /// is named `result` and the failure `error` — the conventional names used by
683    /// `complete result` / `fail error`. Returns the contracts and the signature's
684    /// end offset (so the workflow span covers it).
685    fn parse_compact_contract_signature(&mut self) -> Option<(Vec<WorkflowContractDecl>, usize)> {
686        self.expect_symbol('(')?;
687        let mut contracts = Vec::new();
688        while !self.is_at_end() && !self.at_symbol(')') {
689            let name = self.expect_ident("workflow input name")?;
690            self.expect_symbol(':')?;
691            let ty = self.parse_type()?;
692            let span = name.span.join(ty.span());
693            contracts.push(WorkflowContractDecl {
694                kind: WorkflowContractKind::Input,
695                name,
696                ty,
697                span,
698            });
699            if self.at_symbol(',') {
700                self.advance();
701            } else if !self.at_symbol(')') {
702                self.unexpected("`,` or `)`");
703                while !self.is_at_end() && !self.at_symbol(')') && !self.at_symbol(',') {
704                    self.advance();
705                }
706            }
707        }
708        self.expect_symbol(')')?;
709        self.expect_thin_arrow()?;
710        let output_ty = self.parse_type()?;
711        let output_span = output_ty.span();
712        let mut end = output_span.end;
713        contracts.push(WorkflowContractDecl {
714            kind: WorkflowContractKind::Output,
715            name: Ident {
716                name: "result".to_owned(),
717                span: output_span,
718            },
719            ty: output_ty,
720            span: output_span,
721        });
722        if self.at_symbol('!') {
723            self.advance();
724            let failure_ty = self.parse_type()?;
725            let failure_span = failure_ty.span();
726            end = failure_span.end;
727            contracts.push(WorkflowContractDecl {
728                kind: WorkflowContractKind::Failure,
729                name: Ident {
730                    name: "error".to_owned(),
731                    span: failure_span,
732                },
733                ty: failure_ty,
734                span: failure_span,
735            });
736        }
737        Some((contracts, end))
738    }
739
740    fn parse_tag(&mut self) -> Option<TagDecl> {
741        let at = self.expect_symbol('@')?;
742        let name_start = at.span.end;
743        let mut name_end = name_start;
744        for (offset, ch) in self.source[name_start..].char_indices() {
745            if ch.is_whitespace() {
746                break;
747            }
748            name_end = name_start + offset + ch.len_utf8();
749        }
750        let name = self.source[name_start..name_end].to_owned();
751        while !self.is_at_end() && self.peek().is_some_and(|token| token.span.start < name_end) {
752            self.advance();
753        }
754        let span = SourceSpan {
755            start: at.span.start,
756            end: name_end,
757        };
758        if name.is_empty() {
759            self.diagnostics.push(Diagnostic {
760                related: Vec::new(),
761                span,
762                message: "tag is missing a name".to_owned(),
763                suggestion: Some("write a tag such as `@fixture`".to_owned()),
764            });
765            return None;
766        }
767        if !name
768            .chars()
769            .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | ':' | '.'))
770        {
771            self.diagnostics.push(Diagnostic {
772                related: Vec::new(),
773                span,
774                message: format!("tag `@{name}` contains unsupported characters"),
775                suggestion: Some(
776                    "use letters, digits, `_`, `-`, `.`, or `:` in tag names".to_owned(),
777                ),
778            });
779            return None;
780        }
781        Some(TagDecl { name, span })
782    }
783
784    fn reject_pending_tags(&mut self, pending_tags: &mut Vec<TagDecl>, target: &str) {
785        for tag in pending_tags.drain(..) {
786            self.diagnostics.push(Diagnostic {
787                related: Vec::new(),
788                span: tag.span,
789                message: format!("tag `@{}` cannot be attached to {target}", tag.name),
790                suggestion: Some(
791                    "place tags on workflows, matrices, assertions, or rules".to_owned(),
792                ),
793            });
794        }
795    }
796
797    fn parse_pending_description(&mut self, pending_description: &mut Option<StringLiteral>) {
798        let Some(description) = self.parse_description() else {
799            return;
800        };
801        if let Some(previous) = pending_description.replace(description) {
802            self.diagnostics.push(Diagnostic { related: Vec::new(),
803                span: previous.span,
804                message: "description is not attached to a declaration".to_owned(),
805                suggestion: Some(
806                    "place only one `description \"...\"` immediately before the target declaration"
807                        .to_owned(),
808                ),
809            });
810        }
811    }
812
813    fn parse_description(&mut self) -> Option<StringLiteral> {
814        let description = self.expect_keyword("description")?;
815        let Some(value) = self.expect_string("description string") else {
816            return Some(StringLiteral {
817                value: String::new(),
818                span: description.span,
819            });
820        };
821        Some(value)
822    }
823
824    fn reject_pending_description(
825        &mut self,
826        pending_description: &mut Option<StringLiteral>,
827        target: &str,
828    ) {
829        if let Some(description) = pending_description.take() {
830            self.diagnostics.push(Diagnostic {
831                related: Vec::new(),
832                span: description.span,
833                message: format!("description cannot be attached to {target}"),
834                suggestion: Some(
835                    "place descriptions on workflows, matrices, assertions, or rules".to_owned(),
836                ),
837            });
838        }
839    }
840
841    fn reject_gherkin_misuse(&mut self) -> bool {
842        let Some(token) = self.peek() else {
843            return false;
844        };
845        let TokenKind::Ident(keyword) = &token.kind else {
846            return false;
847        };
848        if !is_gherkin_keyword(keyword) {
849            return false;
850        }
851        let span = token.span;
852        self.diagnostics.push(Diagnostic { related: Vec::new(),
853            span,
854            message: format!(
855                "Gherkin keyword `{keyword}` is not WhippleScript workflow syntax"
856            ),
857            suggestion: Some(
858                "use `workflow`, `table`, `rule ... when ... => { ... }`, and `assert` instead of free-text Given/When/Then steps"
859                    .to_owned(),
860            ),
861        });
862        self.advance_to_line_end(span.start);
863        true
864    }
865
866    fn advance_to_line_end(&mut self, line_start: usize) {
867        let line_end = self.source[line_start..]
868            .find('\n')
869            .map(|offset| line_start + offset)
870            .unwrap_or(self.source.len());
871        while self.peek().is_some_and(|token| token.span.start < line_end) {
872            self.advance();
873        }
874    }
875
876    fn parse_declaration_item(
877        &mut self,
878        pending_tags: &mut Vec<TagDecl>,
879        pending_description: &mut Option<StringLiteral>,
880    ) -> Option<Item> {
881        // Data-driven `declaration_block` dispatch (Shape 1), the first check —
882        // the analog of `body.rs`'s `effect_operation_spec` hook. Head-word peek;
883        // the five real exceptions (harness/agent/signal/source/coerce) and all
884        // core decls are absent from the grammar table, so table membership is
885        // the partition. Every declaration-family construct parses through
886        // `parse_declaration_block` + its typed-node builder — no hand parsers.
887        if let Some(spec) = self.declaration_block_spec_at() {
888            self.reject_pending_tags(pending_tags, spec.keyword);
889            self.reject_pending_description(pending_description, spec.keyword);
890            return self.parse_declaration_block(spec);
891        }
892        if self.at_ident("include") {
893            self.reject_pending_tags(pending_tags, "include");
894            self.reject_pending_description(pending_description, "include");
895            self.parse_include().map(Item::Include)
896        } else if self.at_ident("use") {
897            self.reject_pending_tags(pending_tags, "use");
898            self.reject_pending_description(pending_description, "use");
899            self.parse_use().map(Item::Use)
900        } else if self.at_ident("pattern") {
901            self.reject_pending_tags(pending_tags, "pattern");
902            self.reject_pending_description(pending_description, "pattern");
903            self.parse_pattern().map(Item::Pattern)
904        } else if self.at_ident("apply") {
905            self.reject_pending_tags(pending_tags, "apply");
906            self.reject_pending_description(pending_description, "apply");
907            self.parse_apply().map(Item::Apply)
908        } else if self.at_ident("input") || self.at_ident("output") || self.at_ident("failure") {
909            self.reject_pending_tags(pending_tags, "workflow contract");
910            self.reject_pending_description(pending_description, "workflow contract");
911            self.parse_workflow_contract().map(Item::WorkflowContract)
912        } else if self.at_ident("flow") {
913            // R2 (language-refinement campaign): the `flow` declaration was
914            // REMOVED — sequential pipelines are written as a rule chaining
915            // steps with `then <binding> <- <effect>`.
916            let span = self
917                .peek()
918                .map(|token| token.span)
919                .unwrap_or(SourceSpan { start: 0, end: 0 });
920            self.diagnostics.push(Diagnostic {
921                related: Vec::new(),
922                span,
923                message: "the `flow` declaration was removed".to_owned(),
924                suggestion: Some(
925                    "write a `rule` and chain sequential steps with `then <binding> <- <effect>`"
926                        .to_owned(),
927                ),
928            });
929            // Recovery: swallow the whole flow declaration (headers + balanced
930            // body) so the body's statements don't each re-error as bogus
931            // top-level declarations.
932            let mut depth = 0usize;
933            while !self.is_at_end() {
934                let token = self.advance();
935                match &token.kind {
936                    TokenKind::Symbol('{') => depth += 1,
937                    TokenKind::Symbol('}') => {
938                        depth = depth.saturating_sub(1);
939                        if depth == 0 {
940                            break;
941                        }
942                    }
943                    _ => {}
944                }
945            }
946            None
947        } else if self.at_ident("action") {
948            self.reject_pending_tags(pending_tags, "action");
949            self.reject_pending_description(pending_description, "action");
950            self.parse_action().map(Item::Action)
951        } else if self.at_ident("harness") {
952            self.reject_pending_tags(pending_tags, "harness");
953            self.reject_pending_description(pending_description, "harness");
954            self.parse_harness().map(Item::Harness)
955        } else if self.at_ident("agent") {
956            self.reject_pending_tags(pending_tags, "agent");
957            self.reject_pending_description(pending_description, "agent");
958            self.parse_agent().map(Item::Agent)
959        } else if self.at_ident("enum") {
960            self.reject_pending_tags(pending_tags, "enum");
961            self.reject_pending_description(pending_description, "enum");
962            self.parse_enum().map(Item::Enum)
963        } else if self.at_ident("signal") {
964            self.reject_pending_tags(pending_tags, "signal");
965            self.reject_pending_description(pending_description, "signal");
966            self.parse_event().map(Item::Event)
967        } else if self.at_ident("gauge") {
968            self.reject_pending_tags(pending_tags, "gauge");
969            self.reject_pending_description(pending_description, "gauge");
970            self.parse_gauge().map(Item::Gauge)
971        } else if self.at_ident("campaign") {
972            self.reject_pending_tags(pending_tags, "campaign");
973            self.reject_pending_description(pending_description, "campaign");
974            self.parse_campaign().map(Item::Campaign)
975        } else if self.at_ident("mark") {
976            self.reject_pending_tags(pending_tags, "mark");
977            self.reject_pending_description(pending_description, "mark");
978            self.parse_mark().map(Item::Mark)
979        } else if self.at_ident("source") {
980            self.reject_pending_tags(pending_tags, "source");
981            self.reject_pending_description(pending_description, "source");
982            self.parse_source()
983                .map(|source| Item::Source(Box::new(source)))
984        } else if self.at_ident("test") {
985            self.reject_pending_tags(pending_tags, "test");
986            self.reject_pending_description(pending_description, "test");
987            self.parse_test().map(Item::Test)
988        } else if self.at_ident("class") {
989            self.reject_pending_tags(pending_tags, "class");
990            self.reject_pending_description(pending_description, "class");
991            self.parse_class().map(Item::Class)
992        } else if self.at_ident("table") {
993            self.parse_table(std::mem::take(pending_tags), pending_description.take())
994                .map(Item::Table)
995        } else if self.at_ident("coerce") {
996            self.reject_pending_tags(pending_tags, "coerce");
997            self.reject_pending_description(pending_description, "coerce");
998            self.parse_coerce().map(Item::Coerce)
999        } else if self.at_ident("assert") {
1000            self.parse_assert(std::mem::take(pending_tags), pending_description.take())
1001                .map(Item::Assert)
1002        } else if self.at_ident("rule") {
1003            self.parse_rule(std::mem::take(pending_tags), pending_description.take())
1004                .map(Item::Rule)
1005        } else {
1006            None
1007        }
1008    }
1009
1010    /// Peek (without consuming) the `declaration_block` grammar whose keyword
1011    /// head word matches the current token. Head-word dispatch only
1012    /// (`keyword_words[0]`, NOT a 2-token peek — a 2-token peek mis-routes a
1013    /// malformed `file <x>` to the wrong diagnostic; tail validation belongs in
1014    /// the per-decl parser). The exact analog of `body::effect_operation_spec`.
1015    pub(crate) fn declaration_block_spec_at(&self) -> Option<&'static DeclarationBlockSpec> {
1016        let head = match self.peek().map(|token| &token.kind) {
1017            Some(TokenKind::Ident(value)) => value.as_str(),
1018            _ => return None,
1019        };
1020        DECLARATION_BLOCK_GRAMMAR
1021            .iter()
1022            .find(|spec| spec.keyword_words.first() == Some(&head))
1023    }
1024
1025    /// The single generic top-level `declaration_block` parser (Shape 1) — the
1026    /// analog of `body::parse_effect_operation`. The head word is already matched
1027    /// by `declaration_block_spec_at`; this consumes the keyword (validating any
1028    /// tail word, e.g. `store` after `file`), the name, and an order-free brace
1029    /// block of clauses into a `ClauseBag`, then hands off to the per-decl typed
1030    /// node builder (`item_from_decl_ast`). Unknown clauses emit the spec's
1031    /// `unknown_hint` and resynchronize (file-store precedent).
1032    fn parse_declaration_block(&mut self, spec: &'static DeclarationBlockSpec) -> Option<Item> {
1033        let head = *spec.keyword_words.first()?;
1034        let start = self.expect_keyword(head)?.span.start;
1035        for tail in &spec.keyword_words[1..] {
1036            if !self.consume_ident(tail) {
1037                self.expected(format!("`{tail}` after `{head}`"));
1038                return None;
1039            }
1040        }
1041        let name = self.expect_ident(&format!("{} name", spec.keyword))?;
1042        // Surface-defaults batch (R4 S1/S2): the block is optional. A bare
1043        // declaration (`tracker backlog`) parses with an empty clause bag;
1044        // constructs whose clauses are all optional/defaulted accept it, and
1045        // ones with required clauses report their own missing-field
1046        // diagnostics (better than "expected `{`").
1047        if !self.at_symbol('{') {
1048            let span = SourceSpan {
1049                start,
1050                end: name.span.end,
1051            };
1052            let bag = ClauseBag::new();
1053            return self.item_from_decl_ast(spec.ast_kind, &bag, name, span);
1054        }
1055        self.expect_symbol('{')?;
1056        let field_label = format!("{} field", spec.keyword);
1057        let mut bag = ClauseBag::new();
1058        while !self.is_at_end() && !self.at_symbol('}') {
1059            let Some(field) = self.expect_ident(&field_label) else {
1060                self.synchronize_to_block_item();
1061                continue;
1062            };
1063            // Greedy multi-word clause-name match: `field` is the first word;
1064            // extend with follow words while some clause name is a longer prefix
1065            // match (file-store `allow read`/`allow write`, memory `context limit`).
1066            let first_word_span = field.span;
1067            let mut words = vec![field.name];
1068            let matched = loop {
1069                let depth = words.len();
1070                if let Some(clause) = spec.clauses.iter().find(|clause| {
1071                    clause.words.len() == depth
1072                        && clause
1073                            .words
1074                            .iter()
1075                            .zip(&words)
1076                            .all(|(a, b)| *a == b.as_str())
1077                }) {
1078                    break Some(clause);
1079                }
1080                let extendable = spec.clauses.iter().any(|clause| {
1081                    clause.words.len() > depth
1082                        && clause
1083                            .words
1084                            .iter()
1085                            .zip(&words)
1086                            .all(|(a, b)| *a == b.as_str())
1087                });
1088                if !extendable {
1089                    break None;
1090                }
1091                let Some(next) = self.expect_ident("clause name") else {
1092                    break None;
1093                };
1094                words.push(next.name);
1095            };
1096            let Some(clause) = matched else {
1097                let hint = spec.clauses.first().map(|clause| clause.unknown_hint);
1098                self.diagnostics.push(Diagnostic {
1099                    related: Vec::new(),
1100                    span: first_word_span,
1101                    message: format!("unknown {} field `{}`", spec.keyword, words.join(" ")),
1102                    suggestion: hint.map(str::to_owned),
1103                });
1104                self.synchronize_to_block_item();
1105                continue;
1106            };
1107            // Clause connective (ledger `partition by`): mandatory (M2) — a
1108            // missing connective is a parse error, like a Shape 2 slot connective.
1109            if let Some(connective) = clause.connective {
1110                if !self.consume_ident(connective) {
1111                    self.diagnostics.push(Diagnostic {
1112                        related: Vec::new(),
1113                        span: first_word_span,
1114                        message: format!("expected `{connective}` after `{}`", clause.name),
1115                        suggestion: Some(format!("write `{} {connective} <field>`", clause.name)),
1116                    });
1117                    self.synchronize_to_block_item();
1118                    continue;
1119                }
1120            }
1121            let value = self.parse_clause_value(clause);
1122            bag.record(clause.name, first_word_span, value);
1123        }
1124        let close = self.expect_symbol('}')?;
1125        let span = SourceSpan {
1126            start,
1127            end: close.span.end,
1128        };
1129        self.item_from_decl_ast(spec.ast_kind, &bag, name, span)
1130    }
1131
1132    /// Parse one clause's value by its `ClauseKind`, recording `Missing` on a
1133    /// failed parse so the clause's first-word span is still captured. A `Scalar`
1134    /// is literal-polymorphic (number or string); the builder casts.
1135    fn parse_clause_value(&mut self, clause: &ClauseSpec) -> ClauseValue {
1136        match clause.kind {
1137            // DR-0011 vocabulary amendment (DR-0052 grammar pass,
1138            // 2026-07-31): `list` extends to `identifier` clauses — a
1139            // bracketed bare-ident list (`members [worker, reviewer]`),
1140            // parsed by the same list parser the agent `tools` grant uses.
1141            ClauseKind::Identifier if clause.list => self
1142                .parse_ident_list()
1143                .map_or(ClauseValue::Missing, |(idents, _)| {
1144                    ClauseValue::Idents(idents)
1145                }),
1146            ClauseKind::Identifier | ClauseKind::Schema => self
1147                .expect_ident(&format!("{} value", clause.name))
1148                .map_or(ClauseValue::Missing, ClauseValue::Ident),
1149            ClauseKind::Duration => self
1150                .parse_decl_duration_seconds(&format!("{} duration", clause.name))
1151                .map_or(ClauseValue::Missing, ClauseValue::Duration),
1152            ClauseKind::Scalar => match self.peek().map(|token| &token.kind) {
1153                Some(TokenKind::String(_)) => self
1154                    .expect_string(&format!("{} value", clause.name))
1155                    .map_or(ClauseValue::Missing, ClauseValue::Str),
1156                _ => self
1157                    .expect_u32(&format!("{} value", clause.name))
1158                    .map_or(ClauseValue::Missing, |(value, _)| {
1159                        ClauseValue::Number(value)
1160                    }),
1161            },
1162            ClauseKind::Glob if clause.list => {
1163                // Route glob lists through the shared list parser UNCHANGED — it
1164                // keeps its "skill string" element label (avoids file-store
1165                // negative-fixture churn).
1166                let globs = self
1167                    .parse_string_list()
1168                    .map(|(literals, _)| literals.into_iter().map(|l| l.value).collect())
1169                    .unwrap_or_default();
1170                ClauseValue::Globs(globs)
1171            }
1172            ClauseKind::Glob => self
1173                .expect_string(&format!("{} value", clause.name))
1174                .map_or(ClauseValue::Missing, ClauseValue::Str),
1175            ClauseKind::Flag => ClauseValue::Flag,
1176            // No migrated decl carries an expression clause; recorded inert.
1177            ClauseKind::Expression => ClauseValue::Missing,
1178        }
1179    }
1180
1181    /// The one hand-written seam of the data-driven pipeline: build the distinct
1182    /// typed AST node each `declaration_block` lowers to from the order-free
1183    /// `ClauseBag`, reproducing each hand parser's required-field check, bespoke
1184    /// missing-field diagnostic, and field-width casts exactly (so success `.ir`
1185    /// and coord IR fields are byte-identical).
1186    fn item_from_decl_ast(
1187        &mut self,
1188        ast_kind: DeclAstKind,
1189        bag: &ClauseBag,
1190        name: Ident,
1191        span: SourceSpan,
1192    ) -> Option<Item> {
1193        match ast_kind {
1194            DeclAstKind::Tracker => {
1195                // S1: `tracker <name>` bare — provider defaults to `builtin`,
1196                // today's only provider.
1197                let provider = bag.ident("provider").unwrap_or_else(|| Ident {
1198                    name: "builtin".to_owned(),
1199                    span,
1200                });
1201                Some(Item::Tracker(TrackerDecl {
1202                    name,
1203                    provider,
1204                    span,
1205                }))
1206            }
1207            DeclAstKind::Channel => {
1208                // S2: `channel <name>` bare — provider defaults to `local`.
1209                let provider = bag.ident("provider").unwrap_or_else(|| Ident {
1210                    name: "local".to_owned(),
1211                    span,
1212                });
1213                Some(Item::Channel(ChannelDecl {
1214                    name,
1215                    provider,
1216                    workspace: bag.ident("workspace"),
1217                    destination: bag.text_literal("destination"),
1218                    span,
1219                }))
1220            }
1221            DeclAstKind::Credential => {
1222                let Some(kind) = bag.ident("kind") else {
1223                    self.diagnostics.push(Diagnostic {
1224                        related: Vec::new(),
1225                        span,
1226                        message: format!("credential `{}` must declare its kind", name.name),
1227                        suggestion: Some(
1228                            "add `kind <kind>` inside the credential block (bearer | basic | \
1229                             raw | hmac_sha256 | ed25519 | aws_sigv4 | jwt_rs256)"
1230                                .to_owned(),
1231                        ),
1232                    });
1233                    return None;
1234                };
1235                Some(Item::Credential(CredentialDecl { name, kind, span }))
1236            }
1237            DeclAstKind::Stream => {
1238                let Some(members) = bag.idents("members").filter(|idents| !idents.is_empty())
1239                else {
1240                    self.diagnostics.push(Diagnostic {
1241                        related: Vec::new(),
1242                        span,
1243                        message: format!("stream `{}` must declare its members", name.name),
1244                        suggestion: Some(
1245                            "a stream is a declared collaboration: name its member \
1246                             agents with `members [<agent>, ...]`"
1247                                .to_owned(),
1248                        ),
1249                    });
1250                    return None;
1251                };
1252                Some(Item::Stream(StreamDecl {
1253                    name,
1254                    members,
1255                    staleness_seconds: bag.duration("staleness"),
1256                    span,
1257                }))
1258            }
1259            DeclAstKind::Counter => {
1260                let key_type = bag.ident("key");
1261                let cap = bag.number("cap").map(i64::from);
1262                // `reset`: identifier + membership check; an invalid period keeps
1263                // the value (matching the hand parser) but emits the enum diagnostic.
1264                let reset = bag.ident("reset").map(|period| {
1265                    if !matches!(
1266                        period.name.as_str(),
1267                        "hourly" | "daily" | "weekly" | "monthly"
1268                    ) {
1269                        self.diagnostics.push(Diagnostic {
1270                            related: Vec::new(),
1271                            span: period.span,
1272                            message: format!("unknown reset period `{}`", period.name),
1273                            suggestion: Some(
1274                                "use `hourly`, `daily`, `weekly`, or `monthly`".to_owned(),
1275                            ),
1276                        });
1277                    }
1278                    period.name
1279                });
1280                let shared = bag.flag("shared");
1281                let (Some(key_type), Some(cap), Some(reset)) = (key_type, cap, reset) else {
1282                    self.diagnostics.push(Diagnostic {
1283                        related: Vec::new(),
1284                        span,
1285                        message: format!(
1286                            "counter `{}` must declare `key`, `cap`, and `reset`",
1287                            name.name
1288                        ),
1289                        suggestion: Some(
1290                            "every counter is bounded: declare all three fields".to_owned(),
1291                        ),
1292                    });
1293                    return None;
1294                };
1295                Some(Item::Counter(CounterDecl {
1296                    name,
1297                    key_type,
1298                    cap,
1299                    reset,
1300                    timezone: bag.text("timezone"),
1301                    shared,
1302                    span,
1303                }))
1304            }
1305            DeclAstKind::Lease => {
1306                let key_type = bag.ident("key");
1307                let slots = bag.number("slots").unwrap_or(1);
1308                let ttl_seconds = bag.duration("ttl");
1309                let shared = bag.flag("shared");
1310                let (Some(key_type), Some(ttl_seconds)) = (key_type, ttl_seconds) else {
1311                    self.diagnostics.push(Diagnostic {
1312                        related: Vec::new(),
1313                        span,
1314                        message: format!(
1315                            "lease `{}` must declare a `key` type and a `ttl` backstop",
1316                            name.name
1317                        ),
1318                        suggestion: Some(
1319                            "every lease is bounded: declare `key <Type>` and `ttl <duration>`"
1320                                .to_owned(),
1321                        ),
1322                    });
1323                    return None;
1324                };
1325                Some(Item::Lease(LeaseDecl {
1326                    name,
1327                    key_type,
1328                    slots,
1329                    ttl_seconds,
1330                    shared,
1331                    span,
1332                }))
1333            }
1334            DeclAstKind::Ledger => {
1335                let entry_schema = bag.ident("entry");
1336                let partition_field = bag.ident("partition");
1337                let retain_seconds = bag.duration("retain");
1338                let shared = bag.flag("shared");
1339                let (Some(entry_schema), Some(partition_field), Some(retain_seconds)) =
1340                    (entry_schema, partition_field, retain_seconds)
1341                else {
1342                    self.diagnostics.push(Diagnostic {
1343                        related: Vec::new(),
1344                        span,
1345                        message: format!(
1346                            "ledger `{}` must declare `entry`, `partition by`, and `retain`",
1347                            name.name
1348                        ),
1349                        suggestion: Some(
1350                            "every ledger is bounded and partitioned: declare all three fields"
1351                                .to_owned(),
1352                        ),
1353                    });
1354                    return None;
1355                };
1356                Some(Item::Ledger(LedgerDecl {
1357                    name,
1358                    entry_schema,
1359                    partition_field,
1360                    retain_seconds,
1361                    shared,
1362                    span,
1363                }))
1364            }
1365            DeclAstKind::FileStore => {
1366                let root_span = bag.span("root");
1367                let read_span = bag.span("allow read");
1368                let write_span = bag.span("allow write");
1369                let provider_span = bag.span("provider");
1370                let read_globs = bag.globs("allow read");
1371                let write_globs = bag.globs("allow write");
1372                let provider = bag.ident("provider");
1373                let Some(root) = bag.text("root") else {
1374                    self.diagnostics.push(Diagnostic {
1375                        related: Vec::new(),
1376                        span,
1377                        message: format!("file store `{}` is missing a root", name.name),
1378                        suggestion: Some(
1379                            "add `root \"<dir>\"` inside the file store block".to_owned(),
1380                        ),
1381                    });
1382                    return None;
1383                };
1384                Some(Item::FileStore(FileStoreDecl {
1385                    name,
1386                    root,
1387                    read_globs,
1388                    write_globs,
1389                    provider,
1390                    root_span,
1391                    read_span,
1392                    write_span,
1393                    provider_span,
1394                    span,
1395                }))
1396            }
1397            DeclAstKind::MemoryPool => {
1398                let context_limit = bag.number("context limit").map(u64::from);
1399                // The span serializes only alongside a value (hand parser sets it
1400                // inside the successful-parse branch).
1401                let context_limit_span = context_limit.and(bag.span("context limit"));
1402                Some(Item::MemoryPool(MemoryPoolDecl {
1403                    name,
1404                    context_limit,
1405                    context_limit_span,
1406                    span,
1407                }))
1408            }
1409        }
1410    }
1411
1412    fn parse_pattern(&mut self) -> Option<PatternDecl> {
1413        let start = self.expect_keyword("pattern")?.span.start;
1414        let name = self.expect_ident("pattern name")?;
1415        let type_params = self.parse_type_param_list().unwrap_or_default();
1416        let open = self.expect_symbol('{')?;
1417        let mut items = Vec::new();
1418        let mut pending_tags = Vec::new();
1419        let mut pending_description = None;
1420        while !self.is_at_end() && !self.at_symbol('}') {
1421            if self.at_symbol('@') {
1422                if let Some(tag) = self.parse_tag() {
1423                    pending_tags.push(tag);
1424                }
1425                continue;
1426            }
1427            if self.at_ident("description") {
1428                self.parse_pending_description(&mut pending_description);
1429                continue;
1430            }
1431            if self.at_ident("workflow") || self.at_ident("pattern") {
1432                self.reject_pending_tags(&mut pending_tags, "pattern body declaration");
1433                self.reject_pending_description(
1434                    &mut pending_description,
1435                    "pattern body declaration",
1436                );
1437                self.unexpected("pattern body declaration");
1438                self.advance();
1439                continue;
1440            }
1441            if let Some(item) =
1442                self.parse_declaration_item(&mut pending_tags, &mut pending_description)
1443            {
1444                items.push(item);
1445            } else if self.reject_gherkin_misuse() {
1446                continue;
1447            } else {
1448                if self.is_at_end() {
1449                    break;
1450                }
1451                self.reject_pending_tags(&mut pending_tags, "pattern body declaration");
1452                self.reject_pending_description(
1453                    &mut pending_description,
1454                    "pattern body declaration",
1455                );
1456                self.unexpected("pattern body declaration");
1457                self.advance();
1458            }
1459        }
1460        let end = self
1461            .expect_symbol('}')
1462            .map(|token| token.span.end)
1463            .unwrap_or(open.span.end);
1464        Some(PatternDecl {
1465            name,
1466            type_params,
1467            items,
1468            span: SourceSpan { start, end },
1469        })
1470    }
1471
1472    fn parse_type_param_list(&mut self) -> Option<Vec<Ident>> {
1473        if !self.at_symbol('<') {
1474            return Some(Vec::new());
1475        }
1476        self.expect_symbol('<')?;
1477        let mut params = Vec::new();
1478        while !self.is_at_end() && !self.at_symbol('>') {
1479            params.push(self.expect_ident("type parameter")?);
1480            if self.at_symbol(',') {
1481                self.advance();
1482            } else if !self.at_symbol('>') {
1483                self.unexpected("`,` or `>`");
1484                while !self.is_at_end() && !self.at_symbol('>') && !self.at_symbol(',') {
1485                    self.advance();
1486                }
1487            }
1488        }
1489        self.expect_symbol('>')?;
1490        Some(params)
1491    }
1492
1493    fn parse_type_arg_list(&mut self) -> Option<Vec<TypeSyntax>> {
1494        if !self.at_symbol('<') {
1495            return Some(Vec::new());
1496        }
1497        self.expect_symbol('<')?;
1498        let mut args = Vec::new();
1499        while !self.is_at_end() && !self.at_symbol('>') {
1500            args.push(self.parse_type()?);
1501            if self.at_symbol(',') {
1502                self.advance();
1503            } else if !self.at_symbol('>') {
1504                self.unexpected("`,` or `>`");
1505                while !self.is_at_end() && !self.at_symbol('>') && !self.at_symbol(',') {
1506                    self.advance();
1507                }
1508            }
1509        }
1510        self.expect_symbol('>')?;
1511        Some(args)
1512    }
1513
1514    fn parse_apply(&mut self) -> Option<ApplyDecl> {
1515        let start = self.expect_keyword("apply")?.span.start;
1516        let pattern = self.expect_ident("pattern name")?;
1517        let type_args = self.parse_type_arg_list().unwrap_or_default();
1518        self.expect_keyword("as")?;
1519        let alias = self.expect_ident("pattern application alias")?;
1520        let body = self.parse_block_source()?;
1521        let span = SourceSpan {
1522            start,
1523            end: body.span.end,
1524        };
1525        Some(ApplyDecl {
1526            pattern,
1527            type_args,
1528            alias,
1529            body,
1530            span,
1531        })
1532    }
1533
1534    fn parse_include(&mut self) -> Option<IncludeDecl> {
1535        self.expect_keyword("include")?;
1536        Some(IncludeDecl {
1537            path: self.expect_string("include path")?,
1538        })
1539    }
1540
1541    fn parse_workflow_contract(&mut self) -> Option<WorkflowContractDecl> {
1542        let keyword = self.advance().clone();
1543        let kind = match &keyword.kind {
1544            TokenKind::Ident(value) if value == "input" => WorkflowContractKind::Input,
1545            TokenKind::Ident(value) if value == "output" => WorkflowContractKind::Output,
1546            TokenKind::Ident(value) if value == "failure" => WorkflowContractKind::Failure,
1547            _ => return None,
1548        };
1549        let name = self.expect_ident("workflow contract name")?;
1550        // S7 (surface-defaults batch): an inline payload block synthesizes a
1551        // hygienic anonymous class (the `decide` precedent) — `output result {
1552        // message string }` declares the class `output.result` implicitly. The
1553        // dotted name cannot collide with a user class (identifiers cannot
1554        // contain `.`).
1555        if self.at_symbol('{') {
1556            self.advance();
1557            let mut fields = Vec::new();
1558            while !self.is_at_end() && !self.at_symbol('}') {
1559                let Some(field_name) = self.expect_ident("contract payload field name") else {
1560                    self.synchronize_to_block_item();
1561                    continue;
1562                };
1563                let Some(field_ty) = self.parse_type() else {
1564                    self.synchronize_to_block_item();
1565                    continue;
1566                };
1567                let field_span = field_name.span.join(field_ty.span());
1568                fields.push(ClassField {
1569                    name: field_name,
1570                    ty: field_ty,
1571                    is_key: false,
1572                    presence_condition: None,
1573                    span: field_span,
1574                });
1575            }
1576            let close_span = self.peek().map(|token| token.span);
1577            self.expect_symbol('}')?;
1578            let contract_keyword = match kind {
1579                WorkflowContractKind::Input => "input",
1580                WorkflowContractKind::Output => "output",
1581                WorkflowContractKind::Failure => "failure",
1582            };
1583            let class_name = format!("{contract_keyword}.{}", name.name);
1584            let end = close_span.map(|span| span.end).unwrap_or(name.span.end);
1585            let span = SourceSpan {
1586                start: keyword.span.start,
1587                end,
1588            };
1589            self.pending_contract_classes.push(ClassDecl {
1590                name: Ident {
1591                    name: class_name.clone(),
1592                    span,
1593                },
1594                fields,
1595                span,
1596            });
1597            return Some(WorkflowContractDecl {
1598                kind,
1599                name,
1600                ty: TypeSyntax::Ref {
1601                    name: Ident {
1602                        name: class_name,
1603                        span,
1604                    },
1605                },
1606                span,
1607            });
1608        }
1609        let ty = self.parse_type()?;
1610        let span = keyword.span.join(ty.span());
1611        Some(WorkflowContractDecl {
1612            kind,
1613            name,
1614            ty,
1615            span,
1616        })
1617    }
1618
1619    fn parse_use(&mut self) -> Option<UseDecl> {
1620        self.expect_keyword("use")?;
1621        if self.at_ident("plugin") || self.at_ident("skill") {
1622            let removed_kind = self.advance().clone();
1623            let removed_label = match &removed_kind.kind {
1624                TokenKind::Ident(value) => value.as_str(),
1625                _ => "",
1626            };
1627            self.diagnostics.push(Diagnostic { related: Vec::new(),
1628                span: removed_kind.span,
1629                message: format!("`use {removed_label}` is no longer supported"),
1630                suggestion: Some(
1631                    "write `use std.memory` for package libraries; attach skills with `agent { skills [...] }`"
1632                        .to_owned(),
1633                ),
1634            });
1635        }
1636        Some(UseDecl {
1637            name: self.expect_use_name("package library name")?,
1638        })
1639    }
1640
1641    /// Parses `<n><unit>` durations at declaration level (`ttl 10m`,
1642    /// `retain 90d`) — the lexer splits them into a number and a unit ident.
1643    fn parse_decl_duration_seconds(&mut self, label: &str) -> Option<u64> {
1644        let (value, span) = self.expect_u32(label)?;
1645        let unit = self.expect_ident(label)?;
1646        match body::parse_short_duration_seconds(&format!("{value}{}", unit.name)) {
1647            Some(seconds) if seconds > 0 => Some(seconds),
1648            _ => {
1649                self.diagnostics.push(Diagnostic {
1650                    related: Vec::new(),
1651                    span: span.join(unit.span),
1652                    message: format!("invalid duration `{value}{}`", unit.name),
1653                    suggestion: Some("use `<n><unit>` with unit s, m, h, or d".to_owned()),
1654                });
1655                None
1656            }
1657        }
1658    }
1659
1660    fn parse_harness(&mut self) -> Option<HarnessDecl> {
1661        let start = self.expect_keyword("harness")?.span.start;
1662        let name = self.expect_ident("harness name")?;
1663        self.expect_symbol(':')?;
1664        let kind = self.expect_ident("harness kind")?;
1665        let span = SourceSpan {
1666            start,
1667            end: kind.span.end,
1668        };
1669        Some(HarnessDecl { name, kind, span })
1670    }
1671
1672    fn parse_agent(&mut self) -> Option<AgentDecl> {
1673        let start = self.expect_keyword("agent")?.span.start;
1674        let name = self.expect_ident("agent name")?;
1675        let harness = if self.at_ident("using") {
1676            self.advance();
1677            Some(self.expect_ident("harness name")?)
1678        } else {
1679            None
1680        };
1681        let delegated_to = if harness.is_none() && self.at_ident("delegated") {
1682            self.advance();
1683            self.expect_keyword("to")?;
1684            Some(self.expect_ident("delegate provider")?)
1685        } else {
1686            None
1687        };
1688        // A bare declaration (`agent researcher`) is valid: every field has a
1689        // default (managed provider, least-authority profile, capacity 1).
1690        if !self.at_symbol('{') {
1691            let end = delegated_to
1692                .as_ref()
1693                .map(|ident| ident.span.end)
1694                .or_else(|| harness.as_ref().map(|ident| ident.span.end))
1695                .unwrap_or(name.span.end);
1696            return Some(AgentDecl {
1697                name,
1698                harness,
1699                delegated_to,
1700                fields: Vec::new(),
1701                span: SourceSpan { start, end },
1702            });
1703        }
1704        let open = self.expect_symbol('{')?;
1705        let mut fields = Vec::new();
1706
1707        while !self.is_at_end() && !self.at_symbol('}') {
1708            let Some(field_name) = self.expect_ident("agent field") else {
1709                self.synchronize_to_block_item();
1710                continue;
1711            };
1712
1713            match field_name.name.as_str() {
1714                "provider" => {
1715                    if let Some(provider) = self.expect_ident("provider name") {
1716                        fields.push(AgentField::Provider(provider));
1717                    } else {
1718                        self.synchronize_to_block_item();
1719                    }
1720                }
1721                "profile" => {
1722                    if let Some(value) = self.expect_string("profile string") {
1723                        fields.push(AgentField::Profile(value));
1724                    } else {
1725                        self.synchronize_to_block_item();
1726                    }
1727                }
1728                "capacity" => {
1729                    if let Some((value, span)) = self.expect_u32("capacity value") {
1730                        fields.push(AgentField::Capacity(value, span));
1731                    } else {
1732                        self.synchronize_to_block_item();
1733                    }
1734                }
1735                "skills" => {
1736                    if let Some((skills, span)) = self.parse_string_list() {
1737                        fields.push(AgentField::Skills(skills, span));
1738                    } else {
1739                        self.synchronize_to_block_item();
1740                    }
1741                }
1742                "capabilities" => {
1743                    if let Some((capabilities, span)) = self.parse_string_list() {
1744                        fields.push(AgentField::Capabilities(capabilities, span));
1745                    } else {
1746                        self.synchronize_to_block_item();
1747                    }
1748                }
1749                "requires" => {
1750                    if let Some((classes, span)) = self.parse_feature_class_list() {
1751                        fields.push(AgentField::Requires(classes, span));
1752                    } else {
1753                        self.synchronize_to_block_item();
1754                    }
1755                }
1756                "tools" => {
1757                    if let Some((tools, span)) = self.parse_ident_list() {
1758                        fields.push(AgentField::Tools(tools, span));
1759                    } else {
1760                        self.synchronize_to_block_item();
1761                    }
1762                }
1763                "compaction" => {
1764                    if let Some(strategy) = self.expect_ident("compaction strategy") {
1765                        fields.push(AgentField::Compaction(strategy));
1766                    } else {
1767                        self.synchronize_to_block_item();
1768                    }
1769                }
1770                "thread" => {
1771                    if let Some(mode) = self.expect_ident("thread mode") {
1772                        fields.push(AgentField::Thread(mode));
1773                    } else {
1774                        self.synchronize_to_block_item();
1775                    }
1776                }
1777                "settings" => {
1778                    if let Some(sources) = self.expect_ident("settings source") {
1779                        fields.push(AgentField::Settings(sources));
1780                    } else {
1781                        self.synchronize_to_block_item();
1782                    }
1783                }
1784                _ => {
1785                    let span = field_name.span;
1786                    fields.push(AgentField::Unknown {
1787                        name: field_name,
1788                        span,
1789                    });
1790                    self.synchronize_to_block_item();
1791                }
1792            }
1793        }
1794
1795        let end = self
1796            .expect_symbol('}')
1797            .map(|token| token.span.end)
1798            .unwrap_or(open.span.end);
1799
1800        Some(AgentDecl {
1801            name,
1802            harness,
1803            delegated_to,
1804            fields,
1805            span: SourceSpan { start, end },
1806        })
1807    }
1808
1809    fn parse_enum(&mut self) -> Option<EnumDecl> {
1810        let start = self.expect_keyword("enum")?.span.start;
1811        let name = self.expect_ident("enum name")?;
1812        let open = self.expect_symbol('{')?;
1813        let mut variants = Vec::new();
1814
1815        let mut previous_variant_end: Option<usize> = None;
1816        while !self.is_at_end() && !self.at_symbol('}') {
1817            let Some(variant) = self.expect_ident("enum variant") else {
1818                self.synchronize_to_block_item();
1819                continue;
1820            };
1821            // One variant per line: two variants sharing a line is almost
1822            // always pasted prose or a forgotten `#` — and the stray words
1823            // would otherwise become variants that pollute the domain (and
1824            // reach coerce output schemas).
1825            if let Some(previous_end) = previous_variant_end {
1826                let line_of = |offset: usize| {
1827                    self.source[..offset.min(self.source.len())]
1828                        .bytes()
1829                        .filter(|byte| *byte == b'\n')
1830                        .count()
1831                };
1832                if line_of(previous_end) == line_of(variant.span.start) {
1833                    self.diagnostics.push(Diagnostic {
1834                        related: Vec::new(),
1835                        span: variant.span,
1836                        message: format!(
1837                            "enum `{}` declares variant `{}` on the same line as the previous variant",
1838                            name.name, variant.name
1839                        ),
1840                        suggestion: Some("write one enum variant per line".to_owned()),
1841                    });
1842                }
1843            }
1844            // A brace body makes this a data-carrying variant; the body
1845            // reuses the class field grammar (sum types, spec/sum-types.md).
1846            let mut fields = Vec::new();
1847            let mut end = variant.span.end;
1848            if self.at_symbol('{') {
1849                self.expect_symbol('{');
1850                while !self.is_at_end() && !self.at_symbol('}') {
1851                    let Some(field_name) = self.expect_ident("variant field name") else {
1852                        self.synchronize_to_block_item();
1853                        continue;
1854                    };
1855                    let Some(ty) = self.parse_type() else {
1856                        self.synchronize_to_block_item();
1857                        continue;
1858                    };
1859                    fields.push(ClassField {
1860                        span: field_name.span.join(ty.span()),
1861                        name: field_name,
1862                        ty,
1863                        is_key: false,
1864                        presence_condition: None,
1865                    });
1866                }
1867                if let Some(close) = self.expect_symbol('}') {
1868                    end = close.span.end;
1869                }
1870            }
1871            let span = SourceSpan {
1872                start: variant.span.start,
1873                end,
1874            };
1875            previous_variant_end = Some(end);
1876            variants.push(EnumVariantDecl {
1877                name: variant,
1878                fields,
1879                span,
1880            });
1881        }
1882
1883        let end = self
1884            .expect_symbol('}')
1885            .map(|token| token.span.end)
1886            .unwrap_or(open.span.end);
1887
1888        Some(EnumDecl {
1889            name,
1890            variants,
1891            span: SourceSpan { start, end },
1892        })
1893    }
1894
1895    fn parse_event(&mut self) -> Option<EventDecl> {
1896        let start = self.expect_keyword("signal")?.span.start;
1897        // Dotted lowercase name (`deploy.finished`), matching the `when fact`
1898        // convention and distinct from PascalCase classes.
1899        let first = self.expect_ident("signal name")?;
1900        let mut name = first.name.clone();
1901        let mut name_span = first.span;
1902        while self.at_symbol('.') {
1903            self.expect_symbol('.');
1904            let segment = self.expect_ident("signal name segment")?;
1905            name.push('.');
1906            name.push_str(&segment.name);
1907            name_span = name_span.join(segment.span);
1908        }
1909        if !name.contains('.')
1910            || name
1911                .split('.')
1912                .any(|segment| segment.chars().next().is_some_and(char::is_uppercase))
1913        {
1914            self.diagnostics.push(Diagnostic {
1915                related: Vec::new(),
1916                span: name_span,
1917                message: format!("signal name `{name}` must be dotted lowercase"),
1918                suggestion: Some(
1919                    "use a dotted lowercase name such as `deploy.finished`".to_owned(),
1920                ),
1921            });
1922        }
1923        let open = self.expect_symbol('{')?;
1924        let mut fields = Vec::new();
1925        while !self.is_at_end() && !self.at_symbol('}') {
1926            let Some(field_name) = self.expect_ident("signal field name") else {
1927                self.synchronize_to_block_item();
1928                continue;
1929            };
1930            let Some(ty) = self.parse_type() else {
1931                self.synchronize_to_block_item();
1932                continue;
1933            };
1934            let presence_condition = self.parse_field_presence_condition();
1935            fields.push(ClassField {
1936                span: field_name.span.join(ty.span()),
1937                name: field_name,
1938                ty,
1939                is_key: false,
1940                presence_condition,
1941            });
1942        }
1943        let end = self
1944            .expect_symbol('}')
1945            .map(|token| token.span.end)
1946            .unwrap_or(open.span.end);
1947        Some(EventDecl {
1948            name,
1949            name_span,
1950            fields,
1951            span: SourceSpan { start, end },
1952        })
1953    }
1954
1955    /// A dotted name (`summarize.extract`, `std.spend`) as one string + span.
1956    fn parse_dotted_name_spanned(&mut self, label: &str) -> Option<(String, SourceSpan)> {
1957        let first = self.expect_ident(label)?;
1958        let mut name = first.name.clone();
1959        let mut span = first.span;
1960        while self.at_symbol('.') {
1961            self.expect_symbol('.');
1962            let segment = self.expect_ident(label)?;
1963            name.push('.');
1964            name.push_str(&segment.name);
1965            span = span.join(segment.span);
1966        }
1967        Some((name, span))
1968    }
1969
1970    fn parse_gauge_ref(&mut self, label: &str) -> Option<GaugeRef> {
1971        let (name, span) = self.parse_dotted_name_spanned(label)?;
1972        Some(GaugeRef { name, span })
1973    }
1974
1975    /// A comma-separated gauge-reference list (`ascend a, std.spend`).
1976    fn parse_gauge_ref_list(&mut self, label: &str, into: &mut Vec<GaugeRef>) -> Option<()> {
1977        into.push(self.parse_gauge_ref(label)?);
1978        while self.at_symbol(',') {
1979            self.expect_symbol(',');
1980            into.push(self.parse_gauge_ref(label)?);
1981        }
1982        Some(())
1983    }
1984
1985    /// A numeric literal as exact source text: `800` or `0.9`. The
1986    /// declaration lexer emits digits-only Number tokens, so a fraction is
1987    /// Number `.` Number.
1988    fn parse_decl_number_text(&mut self, label: &str) -> Option<(String, SourceSpan)> {
1989        let token = self.peek()?;
1990        let TokenKind::Number(whole) = token.kind.clone() else {
1991            self.expected(label);
1992            return None;
1993        };
1994        let mut span = token.span;
1995        let mut text = whole;
1996        self.advance();
1997        if self.at_symbol('.') {
1998            self.expect_symbol('.');
1999            let token = self.peek()?;
2000            let TokenKind::Number(fraction) = token.kind.clone() else {
2001                self.expected(format!("{label} fraction digits"));
2002                return None;
2003            };
2004            text.push('.');
2005            text.push_str(&fraction);
2006            span = span.join(token.span);
2007            self.advance();
2008        }
2009        Some((text, span))
2010    }
2011
2012    /// Bar direction: `at least` (true) / `at most` (false). The declaration
2013    /// tokenizer steps over `>=`/`<=` silently (the `is` precedent), so a
2014    /// user writing an operator gets a targeted diagnostic instead of a
2015    /// direction-less bar: the raw source gap before the next token is
2016    /// inspected for the dropped operator.
2017    fn parse_bar_direction(&mut self, label: &str) -> Option<bool> {
2018        if self.consume_ident("at") {
2019            if self.consume_ident("least") {
2020                return Some(true);
2021            }
2022            if self.consume_ident("most") {
2023                return Some(false);
2024            }
2025            self.expected(format!("`least` or `most` after `at` in {label}"));
2026            return None;
2027        }
2028        let gap_start = self.last_span_end();
2029        let gap_end = self
2030            .peek()
2031            .map(|token| token.span.start)
2032            .unwrap_or(gap_start);
2033        let gap = &self.source[gap_start..gap_end.max(gap_start)];
2034        let suggestion = if gap.contains(">=") {
2035            Some("write `at least` (the declaration grammar uses words, not `>=`)".to_owned())
2036        } else if gap.contains("<=") {
2037            Some("write `at most` (the declaration grammar uses words, not `<=`)".to_owned())
2038        } else {
2039            Some("write `at least <n>` or `at most <n>`".to_owned())
2040        };
2041        self.diagnostics.push(Diagnostic {
2042            related: Vec::new(),
2043            span: SourceSpan {
2044                start: gap_start,
2045                end: gap_end.max(gap_start),
2046            },
2047            message: format!("expected `at least` or `at most` in {label}"),
2048            suggestion,
2049        });
2050        None
2051    }
2052
2053    /// `mark "<name>" after <site>`.
2054    fn parse_mark(&mut self) -> Option<MarkDecl> {
2055        let start = self.expect_keyword("mark")?.span.start;
2056        let name = self.expect_string("mark name")?;
2057        if !self.consume_ident("after") {
2058            self.expected("`after` and a committing site in the mark declaration");
2059            return None;
2060        }
2061        let (site, site_span) = self.parse_dotted_name_spanned("mark site")?;
2062        Some(MarkDecl {
2063            name,
2064            site,
2065            site_span,
2066            span: SourceSpan {
2067                start,
2068                end: site_span.end,
2069            },
2070        })
2071    }
2072
2073    /// `gauge <name> [on <site>] { judge via <form> [expect <bar>]
2074    /// [inputs <gauges>] }`.
2075    fn parse_gauge(&mut self) -> Option<GaugeDecl> {
2076        let start = self.expect_keyword("gauge")?.span.start;
2077        let name = self.expect_ident("gauge name")?;
2078        let (site, site_span) = if self.consume_ident("on") {
2079            let (site, span) = self.parse_dotted_name_spanned("gauge site")?;
2080            (Some(site), Some(span))
2081        } else {
2082            (None, None)
2083        };
2084        let open = self.expect_symbol('{')?;
2085        let mut judge: Option<GaugeJudge> = None;
2086        let mut expect: Option<GaugeBar> = None;
2087        let mut inputs: Vec<GaugeRef> = Vec::new();
2088        while !self.is_at_end() && !self.at_symbol('}') {
2089            if self.at_ident("judge") {
2090                let keyword = self.advance().clone();
2091                if !self.consume_ident("via") {
2092                    self.expected("`via` after `judge`");
2093                    self.synchronize_to_block_item();
2094                    continue;
2095                }
2096                let form = if self.consume_ident("coerce") {
2097                    self.expect_ident("coerce judge name").and_then(|name| {
2098                        let mut args = Vec::new();
2099                        if self.at_symbol('(') {
2100                            self.expect_symbol('(');
2101                            loop {
2102                                let (path, _) = self.parse_dotted_name_spanned("judge argument")?;
2103                                args.push(path);
2104                                if !self.at_symbol(',') {
2105                                    break;
2106                                }
2107                                self.expect_symbol(',')?;
2108                            }
2109                            self.expect_symbol(')')?;
2110                        }
2111                        Some(GaugeJudge::Coerce(name, args))
2112                    })
2113                } else if self.consume_ident("prompt") {
2114                    self.expect_string("prompt judge template")
2115                        .map(GaugeJudge::Prompt)
2116                } else if self.consume_ident("exec") {
2117                    self.expect_string("exec judge command")
2118                        .map(GaugeJudge::Exec)
2119                } else if self.consume_ident("labels") {
2120                    self.expect_string("labels source").map(GaugeJudge::Labels)
2121                } else {
2122                    self.diagnostics.push(Diagnostic {
2123                        related: Vec::new(),
2124                        span: keyword.span,
2125                        message: "unknown judge form".to_owned(),
2126                        suggestion: Some(
2127                            "judge forms are `coerce <Name>`, `prompt \"<template>\"`, \
2128                             `exec \"<command>\"`, and `labels \"<source>\"`"
2129                                .to_owned(),
2130                        ),
2131                    });
2132                    None
2133                };
2134                let Some(form) = form else {
2135                    self.synchronize_to_block_item();
2136                    continue;
2137                };
2138                if judge.is_some() {
2139                    self.diagnostics.push(Diagnostic {
2140                        related: Vec::new(),
2141                        span: keyword.span,
2142                        message: "gauge declares more than one judge".to_owned(),
2143                        suggestion: Some("a gauge has exactly one judge".to_owned()),
2144                    });
2145                } else {
2146                    judge = Some(form);
2147                }
2148            } else if self.at_ident("expect") {
2149                let keyword = self.advance().clone();
2150                let subject_ident = match self.expect_ident("bar subject") {
2151                    Some(ident) => ident,
2152                    None => {
2153                        self.synchronize_to_block_item();
2154                        continue;
2155                    }
2156                };
2157                let subject = if subject_ident.name == "P" && self.at_symbol('(') {
2158                    self.expect_symbol('(');
2159                    let Some(field) = self.expect_ident("chance bar field") else {
2160                        self.synchronize_to_block_item();
2161                        continue;
2162                    };
2163                    if self.expect_symbol(')').is_none() {
2164                        self.synchronize_to_block_item();
2165                        continue;
2166                    }
2167                    GaugeBarSubject::Chance { field }
2168                } else {
2169                    let stat = &subject_ident.name;
2170                    let is_quantile = stat.len() > 1
2171                        && stat.starts_with('p')
2172                        && stat[1..].chars().all(|ch| ch.is_ascii_digit());
2173                    if stat != "mean" && !is_quantile {
2174                        self.diagnostics.push(Diagnostic {
2175                            related: Vec::new(),
2176                            span: subject_ident.span,
2177                            message: format!("unknown bar statistic `{stat}`"),
2178                            suggestion: Some(
2179                                "bars are chance-shaped (`P(<field>)`) or stat-shaped \
2180                                 (`mean`, `p10`, `p90`, ...)"
2181                                    .to_owned(),
2182                            ),
2183                        });
2184                    }
2185                    GaugeBarSubject::Stat {
2186                        stat: subject_ident,
2187                    }
2188                };
2189                let Some(at_least) = self.parse_bar_direction("the gauge bar") else {
2190                    self.synchronize_to_block_item();
2191                    continue;
2192                };
2193                let Some((threshold, threshold_span)) =
2194                    self.parse_decl_number_text("bar threshold")
2195                else {
2196                    self.synchronize_to_block_item();
2197                    continue;
2198                };
2199                if expect.is_some() {
2200                    self.diagnostics.push(Diagnostic {
2201                        related: Vec::new(),
2202                        span: keyword.span,
2203                        message: "gauge declares more than one bar".to_owned(),
2204                        suggestion: Some("a gauge has at most one `expect` bar".to_owned()),
2205                    });
2206                } else {
2207                    expect = Some(GaugeBar {
2208                        subject,
2209                        at_least,
2210                        threshold,
2211                        span: keyword.span.join(threshold_span),
2212                    });
2213                }
2214            } else if self.at_ident("inputs") {
2215                self.advance();
2216                if self
2217                    .parse_gauge_ref_list("input gauge name", &mut inputs)
2218                    .is_none()
2219                {
2220                    self.synchronize_to_block_item();
2221                }
2222            } else {
2223                let span = self.peek().map(|token| token.span).unwrap_or(open.span);
2224                self.diagnostics.push(Diagnostic {
2225                    related: Vec::new(),
2226                    span,
2227                    message: "unknown gauge clause".to_owned(),
2228                    suggestion: Some(
2229                        "gauge clauses are `judge via`, `expect`, and `inputs`".to_owned(),
2230                    ),
2231                });
2232                self.synchronize_to_block_item();
2233            }
2234        }
2235        let end = self
2236            .expect_symbol('}')
2237            .map(|token| token.span.end)
2238            .unwrap_or(open.span.end);
2239        let Some(judge) = judge else {
2240            self.diagnostics.push(Diagnostic {
2241                related: Vec::new(),
2242                span: name.span,
2243                message: format!("gauge `{}` declares no judge", name.name),
2244                suggestion: Some(
2245                    "add `judge via coerce <Name>`, `judge via prompt \"<template>\"`, \
2246                     `judge via exec \"<command>\"`, or `judge via labels \"<source>\"`"
2247                        .to_owned(),
2248                ),
2249            });
2250            return None;
2251        };
2252        Some(GaugeDecl {
2253            name,
2254            site,
2255            site_span,
2256            judge,
2257            expect,
2258            inputs,
2259            span: SourceSpan { start, end },
2260        })
2261    }
2262
2263    /// `campaign <name> { ascend … [reach …] [guard …] [sacrifice …] }`.
2264    fn parse_campaign(&mut self) -> Option<CampaignDecl> {
2265        let start = self.expect_keyword("campaign")?.span.start;
2266        let name = self.expect_ident("campaign name")?;
2267        let open = self.expect_symbol('{')?;
2268        let mut ascend: Vec<GaugeRef> = Vec::new();
2269        let mut reach: Vec<CampaignReach> = Vec::new();
2270        let mut guard: Vec<CampaignGuard> = Vec::new();
2271        let mut sacrifice: Vec<GaugeRef> = Vec::new();
2272        let mut proposer_redacted = false;
2273        while !self.is_at_end() && !self.at_symbol('}') {
2274            if self.at_ident("ascend") {
2275                self.advance();
2276                if self
2277                    .parse_gauge_ref_list("ascend gauge name", &mut ascend)
2278                    .is_none()
2279                {
2280                    self.synchronize_to_block_item();
2281                }
2282            } else if self.at_ident("reach") {
2283                let keyword = self.advance().clone();
2284                let Some(gauge) = self.parse_gauge_ref("reach gauge name") else {
2285                    self.synchronize_to_block_item();
2286                    continue;
2287                };
2288                let Some(at_least) = self.parse_bar_direction("the reach target") else {
2289                    self.synchronize_to_block_item();
2290                    continue;
2291                };
2292                let Some((threshold, threshold_span)) =
2293                    self.parse_decl_number_text("reach threshold")
2294                else {
2295                    self.synchronize_to_block_item();
2296                    continue;
2297                };
2298                // A trailing duration unit (`800ms`) lexes as a separate
2299                // ident; a clause keyword never collides with the unit set.
2300                let unit = if self
2301                    .peek()
2302                    .map(|token| {
2303                        matches!(&token.kind, TokenKind::Ident(name)
2304                            if matches!(name.as_str(), "ms" | "s" | "m" | "h" | "d"))
2305                    })
2306                    .unwrap_or(false)
2307                {
2308                    self.expect_ident("unit").map(|ident| ident.name)
2309                } else {
2310                    None
2311                };
2312                reach.push(CampaignReach {
2313                    gauge,
2314                    at_least,
2315                    threshold,
2316                    unit,
2317                    span: keyword.span.join(threshold_span),
2318                });
2319            } else if self.at_ident("guard") {
2320                let keyword = self.advance().clone();
2321                let Some(gauge) = self.parse_gauge_ref("guard gauge name") else {
2322                    self.synchronize_to_block_item();
2323                    continue;
2324                };
2325                if !self.consume_ident("within") {
2326                    self.expected("`within` after the guarded gauge");
2327                    self.synchronize_to_block_item();
2328                    continue;
2329                }
2330                let Some((band_percent, band_span)) = self.parse_decl_number_text("guard band")
2331                else {
2332                    self.synchronize_to_block_item();
2333                    continue;
2334                };
2335                if !self.consume_ident("percent") {
2336                    self.expected("`percent` after the guard band");
2337                    self.synchronize_to_block_item();
2338                    continue;
2339                }
2340                guard.push(CampaignGuard {
2341                    gauge,
2342                    band_percent,
2343                    span: keyword.span.join(band_span),
2344                });
2345            } else if self.at_ident("sacrifice") {
2346                self.advance();
2347                if self
2348                    .parse_gauge_ref_list("sacrifice gauge name", &mut sacrifice)
2349                    .is_none()
2350                {
2351                    self.synchronize_to_block_item();
2352                }
2353            } else if self.at_ident("proposer") {
2354                self.advance();
2355                if self.consume_ident("redacted") {
2356                    proposer_redacted = true;
2357                } else {
2358                    self.expected("`redacted` after `proposer`");
2359                    self.synchronize_to_block_item();
2360                }
2361            } else {
2362                let span = self.peek().map(|token| token.span).unwrap_or(open.span);
2363                self.diagnostics.push(Diagnostic {
2364                    related: Vec::new(),
2365                    span,
2366                    message: "unknown campaign clause".to_owned(),
2367                    suggestion: Some(
2368                        "campaign clauses are `ascend`, `reach`, `guard`, `sacrifice`, \
2369                         and `proposer redacted`"
2370                            .to_owned(),
2371                    ),
2372                });
2373                self.synchronize_to_block_item();
2374            }
2375        }
2376        let end = self
2377            .expect_symbol('}')
2378            .map(|token| token.span.end)
2379            .unwrap_or(open.span.end);
2380        if ascend.is_empty() && reach.is_empty() {
2381            self.diagnostics.push(Diagnostic {
2382                related: Vec::new(),
2383                span: name.span,
2384                message: format!("campaign `{}` names nothing to improve", name.name),
2385                suggestion: Some("add an `ascend` or `reach` clause".to_owned()),
2386            });
2387        }
2388        Some(CampaignDecl {
2389            name,
2390            ascend,
2391            reach,
2392            guard,
2393            sacrifice,
2394            proposer_redacted,
2395            span: SourceSpan { start, end },
2396        })
2397    }
2398
2399    fn last_span_end(&self) -> usize {
2400        self.pos
2401            .checked_sub(1)
2402            .and_then(|index| self.tokens.get(index))
2403            .map(|token| token.span.end)
2404            .unwrap_or(0)
2405    }
2406
2407    fn parse_dotted_name(&mut self, label: &str) -> Option<String> {
2408        let first = self.expect_ident(label)?;
2409        let mut name = first.name.clone();
2410        while self.at_symbol('.') {
2411            self.advance();
2412            let segment = self.expect_ident(label)?;
2413            name.push('.');
2414            name.push_str(&segment.name);
2415        }
2416        Some(name)
2417    }
2418
2419    /// Capture the source text of an expression from the current token to end of
2420    /// line (the `assert`/guard idiom), advancing past the consumed tokens.
2421    fn capture_expr_to_line_end(&mut self) -> (String, SourceSpan) {
2422        let start = self
2423            .peek()
2424            .map(|token| token.span.start)
2425            .unwrap_or(self.source.len());
2426        let line_end = self.source[start..]
2427            .find('\n')
2428            .map(|offset| start + offset)
2429            .unwrap_or(self.source.len());
2430        let mut end = start;
2431        while !self.is_at_end() {
2432            let Some(token) = self.peek() else { break };
2433            if token.span.start >= line_end {
2434                break;
2435            }
2436            let token_end = token.span.end.min(line_end);
2437            self.advance();
2438            end = token_end;
2439        }
2440        let span = SourceSpan { start, end };
2441        trimmed_source_text(self.source_text(span), span)
2442    }
2443
2444    /// Capture source text up to (but not including) a terminator identifier or a
2445    /// closing brace — used for a predicate bounded by `is`.
2446    fn capture_expr_until_ident(&mut self, terminator: &str) -> (String, SourceSpan) {
2447        let start = self
2448            .peek()
2449            .map(|token| token.span.start)
2450            .unwrap_or(self.source.len());
2451        let mut end = start;
2452        while !self.is_at_end() && !self.at_ident(terminator) && !self.at_symbol('}') {
2453            let Some(token) = self.peek() else { break };
2454            let token_end = token.span.end;
2455            self.advance();
2456            end = token_end;
2457        }
2458        let span = SourceSpan { start, end };
2459        trimmed_source_text(self.source_text(span), span)
2460    }
2461
2462    fn parse_test(&mut self) -> Option<TestDecl> {
2463        let start = self.expect_keyword("test")?.span.start;
2464        let name = self.expect_string("test name")?;
2465        let open = self.expect_symbol('{')?;
2466        let mut workflow = None;
2467        let mut clauses = Vec::new();
2468        while !self.is_at_end() && !self.at_symbol('}') {
2469            if self.at_ident("workflow") {
2470                self.advance();
2471                match self.expect_ident("workflow name") {
2472                    Some(name) => {
2473                        if workflow.is_some() {
2474                            self.diagnostics.push(Diagnostic {
2475                                related: Vec::new(),
2476                                span: name.span,
2477                                message: "a test scenario binds at most one `workflow`".to_owned(),
2478                                suggestion: Some(
2479                                    "remove the extra `workflow <Name>` header".to_owned(),
2480                                ),
2481                            });
2482                        }
2483                        workflow = Some(name);
2484                    }
2485                    None => self.synchronize_to_block_item(),
2486                }
2487            } else if self.at_ident("given") {
2488                match self.parse_given() {
2489                    Some(clause) => clauses.push(TestClause::Given(clause)),
2490                    None => self.synchronize_to_block_item(),
2491                }
2492            } else if self.at_ident("stub") {
2493                match self.parse_stub() {
2494                    Some(clause) => clauses.push(TestClause::Stub(clause)),
2495                    None => self.synchronize_to_block_item(),
2496                }
2497            } else if self.at_ident("run") {
2498                match self.parse_run() {
2499                    Some(clause) => clauses.push(TestClause::Run(clause)),
2500                    None => self.synchronize_to_block_item(),
2501                }
2502            } else if self.at_ident("expect") {
2503                match self.parse_expect() {
2504                    Some(clause) => clauses.push(TestClause::Expect(clause)),
2505                    None => self.synchronize_to_block_item(),
2506                }
2507            } else {
2508                self.unexpected("a test clause (`workflow`, `given`, `stub`, `run`, or `expect`)");
2509                self.synchronize_to_block_item();
2510            }
2511        }
2512        let end = self
2513            .expect_symbol('}')
2514            .map(|token| token.span.end)
2515            .unwrap_or(open.span.end);
2516        Some(TestDecl {
2517            name,
2518            workflow,
2519            clauses,
2520            span: SourceSpan { start, end },
2521        })
2522    }
2523
2524    fn parse_test_record(&mut self) -> Option<(Vec<TestField>, usize)> {
2525        let open = self.expect_symbol('{')?;
2526        let mut fields = Vec::new();
2527        while !self.is_at_end() && !self.at_symbol('}') {
2528            let Some(name) = self.expect_ident("test field name") else {
2529                self.synchronize_to_block_item();
2530                continue;
2531            };
2532            let (value, value_span) = self.capture_expr_to_line_end();
2533            fields.push(TestField {
2534                span: name.span.join(value_span),
2535                name,
2536                value,
2537            });
2538        }
2539        let end = self
2540            .expect_symbol('}')
2541            .map(|token| token.span.end)
2542            .unwrap_or(open.span.end);
2543        Some((fields, end))
2544    }
2545
2546    fn parse_given(&mut self) -> Option<GivenClause> {
2547        let start = self.expect_keyword("given")?.span.start;
2548        if self.consume_ident("input") {
2549            let (fields, end) = self.parse_test_record()?;
2550            Some(GivenClause::Input {
2551                fields,
2552                span: SourceSpan { start, end },
2553            })
2554        } else if self.consume_ident("fact") {
2555            let ty = self.expect_ident("fact type")?;
2556            let (fields, end) = self.parse_test_record()?;
2557            Some(GivenClause::Fact {
2558                ty,
2559                fields,
2560                span: SourceSpan { start, end },
2561            })
2562        } else if self.consume_ident("signal") {
2563            let name = self.parse_dotted_name("signal name")?;
2564            let (fields, end) = self.parse_test_record()?;
2565            Some(GivenClause::Signal {
2566                name,
2567                fields,
2568                span: SourceSpan { start, end },
2569            })
2570        } else if self.consume_ident("clock") {
2571            if !self.consume_ident("at") {
2572                self.expected("`at <timestamp>` after `given clock`");
2573            }
2574            let at = self.expect_string("clock timestamp")?;
2575            let end = at.span.end;
2576            Some(GivenClause::Clock {
2577                at,
2578                span: SourceSpan { start, end },
2579            })
2580        } else if self.consume_ident("tracker") {
2581            let tracker = self.parse_dotted_name("tracker name")?;
2582            if !self.consume_ident("issue") {
2583                self.expected("`issue { … }` after `given tracker <name>`");
2584            }
2585            let (fields, end) = self.parse_test_record()?;
2586            Some(GivenClause::Tracker {
2587                tracker,
2588                fields,
2589                span: SourceSpan { start, end },
2590            })
2591        } else if self.consume_ident("file") {
2592            let store = self.parse_dotted_name("file store name")?;
2593            if !self.consume_ident("at") {
2594                self.expected("`at <path> \"<content>\"` after `given file <store>`");
2595            }
2596            let path = self.expect_string("file path")?;
2597            let content = self.expect_string("file content")?;
2598            let end = content.span.end;
2599            Some(GivenClause::File {
2600                store,
2601                path,
2602                content,
2603                span: SourceSpan { start, end },
2604            })
2605        } else {
2606            self.unexpected(
2607                "`input`, `fact`, `signal`, `clock`, `tracker`, or `file` after `given`",
2608            );
2609            None
2610        }
2611    }
2612
2613    fn parse_stub(&mut self) -> Option<StubClause> {
2614        let start = self.expect_keyword("stub")?.span.start;
2615        // Surface path: dotted-name segments up to the outcome, all on the `stub`
2616        // line. The trailing segment (before a `{`, string, or end-of-line) is the
2617        // outcome; the rest is the surface. v0 keeps this lexical: at least one
2618        // surface segment + one outcome.
2619        let line_end = self.source[start..]
2620            .find('\n')
2621            .map(|offset| start + offset)
2622            .unwrap_or(self.source.len());
2623        let mut segments = Vec::new();
2624        while matches!(
2625            self.peek().map(|token| &token.kind),
2626            Some(TokenKind::Ident(_))
2627        ) && self.peek().is_some_and(|token| token.span.start < line_end)
2628        {
2629            match self.parse_dotted_name("stub surface") {
2630                Some(segment) => segments.push(segment),
2631                None => break,
2632            }
2633        }
2634        if segments.len() < 2 {
2635            self.diagnostics.push(Diagnostic {
2636                related: Vec::new(),
2637                span: SourceSpan {
2638                    start,
2639                    end: self.last_span_end(),
2640                },
2641                message: "stub needs a surface and an outcome (e.g. `stub agent triager succeeds`)"
2642                    .to_owned(),
2643                suggestion: Some("write `stub <surface...> <outcome> [payload]`".to_owned()),
2644            });
2645            return None;
2646        }
2647        let outcome = segments.pop().expect("outcome present");
2648        let surface = segments;
2649        let payload = if self.at_symbol('{') {
2650            let (fields, _) = self.parse_test_record()?;
2651            Some(StubPayload::Record(fields))
2652        } else if matches!(
2653            self.peek().map(|token| &token.kind),
2654            Some(TokenKind::String(_))
2655        ) {
2656            Some(StubPayload::Message(self.expect_string("stub message")?))
2657        } else {
2658            None
2659        };
2660        let end = self.last_span_end();
2661        Some(StubClause {
2662            surface,
2663            outcome,
2664            payload,
2665            span: SourceSpan { start, end },
2666        })
2667    }
2668
2669    fn parse_run(&mut self) -> Option<RunClause> {
2670        let start = self.expect_keyword("run")?.span.start;
2671        let kind = if self.consume_ident("until") {
2672            if self.consume_ident("idle") {
2673                RunKind::UntilIdle
2674            } else if self.consume_ident("workflow") {
2675                if self.consume_ident("completed") {
2676                    RunKind::UntilWorkflowCompleted
2677                } else if self.consume_ident("failed") {
2678                    RunKind::UntilWorkflowFailed
2679                } else {
2680                    self.expected("`completed` or `failed` after `workflow`");
2681                    return None;
2682                }
2683            } else {
2684                self.expected("`idle` or `workflow completed|failed` after `until`");
2685                return None;
2686            }
2687        } else if self.consume_ident("for") {
2688            let (steps, _) = self.expect_u32("step count")?;
2689            if !self.consume_ident("steps") {
2690                self.expected("`steps` after the step count");
2691            }
2692            RunKind::ForSteps(steps)
2693        } else {
2694            self.expected("`until ...` or `for <N> steps` after `run`");
2695            return None;
2696        };
2697        let end = self.last_span_end();
2698        Some(RunClause {
2699            kind,
2700            span: SourceSpan { start, end },
2701        })
2702    }
2703
2704    fn parse_expect(&mut self) -> Option<ExpectClause> {
2705        let start = self.expect_keyword("expect")?.span.start;
2706        let target = if self.consume_ident("workflow") {
2707            if self.consume_ident("completed") {
2708                ExpectTarget::WorkflowCompleted
2709            } else if self.consume_ident("failed") {
2710                let failure = if self.consume_ident("with") {
2711                    self.expect_ident("failure type")
2712                } else {
2713                    None
2714                };
2715                ExpectTarget::WorkflowFailed { failure }
2716            } else {
2717                self.expected("`completed` or `failed` after `workflow`");
2718                return None;
2719            }
2720        } else if self.consume_ident("rule") {
2721            let name = self.expect_ident("rule name")?;
2722            let status = if self.consume_ident("fired") {
2723                if matches!(
2724                    self.peek().map(|token| &token.kind),
2725                    Some(TokenKind::Number(_))
2726                ) {
2727                    let (count, _) = self.expect_u32("fired count")?;
2728                    if !self.consume_ident("times") {
2729                        self.expected("`times` after the fired count");
2730                    }
2731                    RuleStatus::FiredTimes(count)
2732                } else {
2733                    RuleStatus::Fired
2734                }
2735            } else if self.consume_ident("did") {
2736                if !self.consume_ident("not") {
2737                    self.expected("`not` in `did not fire`");
2738                }
2739                if !self.consume_ident("fire") {
2740                    self.expected("`fire` in `did not fire`");
2741                }
2742                RuleStatus::DidNotFire
2743            } else {
2744                self.expected("`fired`, `fired <N> times`, or `did not fire`");
2745                return None;
2746            };
2747            ExpectTarget::Rule { name, status }
2748        } else if self.consume_ident("effect") {
2749            let name = self.parse_dotted_name("effect name")?;
2750            let status = if self.consume_ident("requested") {
2751                EffectStatus::Requested
2752            } else if self.consume_ident("completed") {
2753                EffectStatus::Completed
2754            } else if self.consume_ident("failed") {
2755                EffectStatus::Failed
2756            } else {
2757                self.expected("`requested`, `completed`, or `failed` after the effect name");
2758                return None;
2759            };
2760            ExpectTarget::Effect { name, status }
2761        } else if self.consume_ident("diagnostic") {
2762            let code = self.parse_dotted_name("diagnostic code")?;
2763            ExpectTarget::Diagnostic { code }
2764        } else if self.consume_ident("no") {
2765            let name = self.parse_dotted_name("forbidden effect name")?;
2766            ExpectTarget::NoEffect { name }
2767        } else {
2768            let noun = self.parse_dotted_name("projection noun")?;
2769            let kind = self.parse_proj_query_kind()?;
2770            let end = self.last_span_end();
2771            ExpectTarget::Projection(ProjQuery {
2772                noun,
2773                kind,
2774                span: SourceSpan { start, end },
2775            })
2776        };
2777        let end = self.last_span_end();
2778        Some(ExpectClause {
2779            target,
2780            span: SourceSpan { start, end },
2781        })
2782    }
2783
2784    fn parse_proj_query_kind(&mut self) -> Option<ProjQueryKind> {
2785        if self.consume_ident("exists") {
2786            return Some(ProjQueryKind::Exists);
2787        }
2788        if self.consume_ident("count") {
2789            if !self.consume_ident("where") {
2790                self.expected("`where <predicate> is <N>` after `count`");
2791                return None;
2792            }
2793            let (predicate, _) = self.capture_expr_until_ident("is");
2794            if !self.consume_ident("is") {
2795                self.expected("`is <N>` after the count predicate");
2796                return None;
2797            }
2798            let (count, _) = self.expect_u32("count value")?;
2799            return Some(ProjQueryKind::Count { predicate, count });
2800        }
2801        if self.consume_ident("where") {
2802            let (predicate, _) = self.capture_expr_to_line_end();
2803            return Some(ProjQueryKind::Where { predicate });
2804        }
2805        self.expected("`exists`, `count where ... is <N>`, or `where ...`");
2806        None
2807    }
2808
2809    fn parse_source(&mut self) -> Option<SourceDecl> {
2810        let start = self.expect_keyword("source")?.span.start;
2811        let provider = self.expect_ident("source provider")?;
2812        let is_clock = provider.name == "clock";
2813        if !self.consume_ident("as") {
2814            self.expected("`as <name>` after the source provider");
2815            return None;
2816        }
2817        let name = self.expect_ident("source name")?;
2818        let open = self.expect_symbol('{')?;
2819
2820        let mut recurrence: Option<Recurrence> = None;
2821        let mut timezone: Option<StringLiteral> = None;
2822        let mut missed: Option<MissedPolicy> = None;
2823        let mut path: Option<StringLiteral> = None;
2824        let mut watch: Option<StringLiteral> = None;
2825        let mut url: Option<StringLiteral> = None;
2826        let mut dedup: Option<SourceValue> = None;
2827        let mut observe_binding: Option<Ident> = None;
2828        let mut emit: Option<SourceEmit> = None;
2829
2830        while !self.is_at_end() && !self.at_symbol('}') {
2831            if self.at_ident("every") || self.at_ident("at") {
2832                if let Some(parsed) = self.parse_recurrence() {
2833                    recurrence = Some(parsed);
2834                } else {
2835                    self.synchronize_to_block_item();
2836                }
2837            } else if self.at_ident("timezone") {
2838                self.advance();
2839                timezone = self.expect_string("timezone string");
2840            } else if self.at_ident("path") {
2841                self.advance();
2842                path = self.expect_string("path string");
2843            } else if self.at_ident("watch") {
2844                self.advance();
2845                watch = self.expect_string("watch glob string");
2846            } else if self.at_ident("url") {
2847                self.advance();
2848                url = self.expect_string("url string");
2849            } else if self.at_ident("dedup") {
2850                self.advance();
2851                dedup = self.parse_source_value();
2852            } else if self.at_ident("missed") {
2853                missed = self.parse_missed_policy();
2854            } else if self.at_ident("observe") {
2855                self.advance();
2856                if !self.consume_ident("as") {
2857                    self.expected("`as <binding>` after `observe`");
2858                }
2859                observe_binding = self.expect_ident("observe binding");
2860            } else if self.at_ident("emit") {
2861                emit = self.parse_source_emit();
2862            } else {
2863                self.unexpected(
2864                    "a source clause (`every`/`at`, `timezone`, `path`, `watch`, `url`, `dedup`, `missed`, `observe`, `emit`)",
2865                );
2866                self.synchronize_to_block_item();
2867            }
2868        }
2869        let end = self
2870            .expect_symbol('}')
2871            .map(|token| token.span.end)
2872            .unwrap_or(open.span.end);
2873        let span = SourceSpan { start, end };
2874
2875        let observe_binding = match observe_binding {
2876            Some(binding) => binding,
2877            None => {
2878                self.diagnostics.push(Diagnostic {
2879                    related: Vec::new(),
2880                    span,
2881                    message: format!("source `{}` must declare `observe as <binding>`", name.name),
2882                    suggestion: Some("add `observe as tick`".to_owned()),
2883                });
2884                return None;
2885            }
2886        };
2887        let emit = match emit {
2888            Some(emit) => emit,
2889            None => {
2890                self.diagnostics.push(Diagnostic {
2891                    related: Vec::new(),
2892                    span,
2893                    message: format!(
2894                        "source `{}` must declare `emit <signal> {{ ... }}`",
2895                        name.name
2896                    ),
2897                    suggestion: Some("add `emit triage.tick { ... }`".to_owned()),
2898                });
2899                return None;
2900            }
2901        };
2902
2903        let clock = if is_clock {
2904            let recurrence = match recurrence {
2905                Some(recurrence) => recurrence,
2906                None => {
2907                    self.diagnostics.push(Diagnostic {
2908                        related: Vec::new(),
2909                        span,
2910                        message: format!("clock source `{}` must declare a recurrence", name.name),
2911                        suggestion: Some(
2912                            "add `every weekday at 09:00`, `every 5m`, or `at 09:00`".to_owned(),
2913                        ),
2914                    });
2915                    return None;
2916                }
2917            };
2918            Some(ClockPolicy {
2919                recurrence,
2920                timezone,
2921                missed,
2922                span,
2923            })
2924        } else {
2925            if recurrence.is_some() || timezone.is_some() || missed.is_some() {
2926                self.diagnostics.push(Diagnostic {
2927                    related: Vec::new(),
2928                    span,
2929                    message: format!(
2930                        "source `{}` uses clock-only clauses but its provider is `{}`, not `clock`",
2931                        name.name, provider.name
2932                    ),
2933                    suggestion: Some(
2934                        "use `source clock as ...` for recurrence, timezone, or missed clauses"
2935                            .to_owned(),
2936                    ),
2937                });
2938            }
2939            None
2940        };
2941
2942        Some(SourceDecl {
2943            name,
2944            provider,
2945            clock,
2946            path,
2947            watch,
2948            url,
2949            dedup,
2950            observe_binding,
2951            emit,
2952            span,
2953        })
2954    }
2955
2956    fn parse_recurrence(&mut self) -> Option<Recurrence> {
2957        if self.at_ident("at") {
2958            let at = self.expect_keyword("at")?;
2959            let time = self.parse_time_of_day()?;
2960            return Some(Recurrence::At {
2961                span: at.span.join(time.span),
2962                time,
2963            });
2964        }
2965        let every = self.expect_keyword("every")?;
2966        if matches!(
2967            self.peek().map(|token| &token.kind),
2968            Some(TokenKind::Number(_))
2969        ) {
2970            let (value, _) = self.expect_u32("recurrence interval")?;
2971            let unit = self.expect_ident("duration unit (`s`, `m`, `h`, or `d`)")?;
2972            let seconds = match unit.name.as_str() {
2973                "s" => value as u64,
2974                "m" => value as u64 * 60,
2975                "h" => value as u64 * 3_600,
2976                "d" => value as u64 * 86_400,
2977                other => {
2978                    self.diagnostics.push(Diagnostic {
2979                        related: Vec::new(),
2980                        span: unit.span,
2981                        message: format!("unknown duration unit `{other}`"),
2982                        suggestion: Some("use `s`, `m`, `h`, or `d`".to_owned()),
2983                    });
2984                    return None;
2985                }
2986            };
2987            return Some(Recurrence::EveryDuration {
2988                seconds,
2989                source: format!("{value}{}", unit.name),
2990                span: every.span.join(unit.span),
2991            });
2992        }
2993        let pattern_ident =
2994            self.expect_ident("calendar pattern (`day`, `weekday`, or a weekday)")?;
2995        let pattern = match pattern_ident.name.as_str() {
2996            "day" => CalendarPattern::Day,
2997            "weekday" => CalendarPattern::Weekday,
2998            "monday" => CalendarPattern::Weekly(Weekday::Monday),
2999            "tuesday" => CalendarPattern::Weekly(Weekday::Tuesday),
3000            "wednesday" => CalendarPattern::Weekly(Weekday::Wednesday),
3001            "thursday" => CalendarPattern::Weekly(Weekday::Thursday),
3002            "friday" => CalendarPattern::Weekly(Weekday::Friday),
3003            "saturday" => CalendarPattern::Weekly(Weekday::Saturday),
3004            "sunday" => CalendarPattern::Weekly(Weekday::Sunday),
3005            other => {
3006                self.diagnostics.push(Diagnostic {
3007                    related: Vec::new(),
3008                    span: pattern_ident.span,
3009                    message: format!("unknown calendar pattern `{other}`"),
3010                    suggestion: Some(
3011                        "use `day`, `weekday`, or a weekday such as `monday`".to_owned(),
3012                    ),
3013                });
3014                return None;
3015            }
3016        };
3017        if !self.consume_ident("at") {
3018            self.expected("`at <hh:mm>` after the calendar pattern");
3019            return None;
3020        }
3021        let time = self.parse_time_of_day()?;
3022        Some(Recurrence::EveryCalendar {
3023            pattern,
3024            span: every.span.join(time.span),
3025            time,
3026        })
3027    }
3028
3029    fn parse_time_of_day(&mut self) -> Option<TimeOfDay> {
3030        let (hour, hour_span) = self.expect_u32("hour")?;
3031        self.expect_symbol(':')?;
3032        let (minute, minute_span) = self.expect_u32("minute")?;
3033        if hour > 23 || minute > 59 {
3034            self.diagnostics.push(Diagnostic {
3035                related: Vec::new(),
3036                span: hour_span.join(minute_span),
3037                message: format!("invalid time of day `{hour:02}:{minute:02}`"),
3038                suggestion: Some("use a 24-hour `hh:mm` such as `09:00`".to_owned()),
3039            });
3040            return None;
3041        }
3042        Some(TimeOfDay {
3043            hour: hour as u8,
3044            minute: minute as u8,
3045            span: hour_span.join(minute_span),
3046        })
3047    }
3048
3049    fn parse_missed_policy(&mut self) -> Option<MissedPolicy> {
3050        self.expect_keyword("missed")?;
3051        if self.consume_ident("skip") {
3052            return Some(MissedPolicy::Skip);
3053        }
3054        if self.consume_ident("coalesce") {
3055            return Some(MissedPolicy::Coalesce);
3056        }
3057        if self.consume_ident("catch_up") {
3058            if !self.consume_ident("limit") {
3059                self.expected("`limit <N>` after `catch_up`");
3060                return None;
3061            }
3062            let (limit, _) = self.expect_u32("catch_up limit")?;
3063            return Some(MissedPolicy::CatchUp { limit });
3064        }
3065        self.expected("`skip`, `coalesce`, or `catch_up limit <N>`");
3066        None
3067    }
3068
3069    fn parse_source_emit(&mut self) -> Option<SourceEmit> {
3070        let emit = self.expect_keyword("emit")?;
3071        let first = self.expect_ident("emit signal name")?;
3072        let mut signal = first.name.clone();
3073        let mut signal_span = first.span;
3074        while self.at_symbol('.') {
3075            self.advance();
3076            let segment = self.expect_ident("signal name segment")?;
3077            signal.push('.');
3078            signal.push_str(&segment.name);
3079            signal_span = signal_span.join(segment.span);
3080        }
3081        let from = if self.consume_ident("from") {
3082            Some(self.expect_ident("binding name after `from`")?)
3083        } else {
3084            None
3085        };
3086        if from.is_some() && !self.at_symbol('{') {
3087            let end = from.as_ref().map(|ident| ident.span.end).unwrap_or(0);
3088            return Some(SourceEmit {
3089                signal,
3090                signal_span,
3091                from,
3092                fields: Vec::new(),
3093                span: SourceSpan {
3094                    start: emit.span.start,
3095                    end,
3096                },
3097            });
3098        }
3099        let open = self.expect_symbol('{')?;
3100        let mut fields = Vec::new();
3101        while !self.is_at_end() && !self.at_symbol('}') {
3102            let Some(field_name) = self.expect_ident("emit field name") else {
3103                self.synchronize_to_block_item();
3104                continue;
3105            };
3106            let Some(value) = self.parse_source_value() else {
3107                self.synchronize_to_block_item();
3108                continue;
3109            };
3110            let value_span = match &value {
3111                SourceValue::Path { span, .. } => *span,
3112                SourceValue::String(literal) => literal.span,
3113                SourceValue::Number(_, span) => *span,
3114            };
3115            fields.push(SourceEmitField {
3116                span: field_name.span.join(value_span),
3117                name: field_name,
3118                value,
3119            });
3120        }
3121        let end = self
3122            .expect_symbol('}')
3123            .map(|token| token.span.end)
3124            .unwrap_or(open.span.end);
3125        Some(SourceEmit {
3126            signal,
3127            signal_span,
3128            from,
3129            fields,
3130            span: SourceSpan {
3131                start: emit.span.start,
3132                end,
3133            },
3134        })
3135    }
3136
3137    fn parse_source_value(&mut self) -> Option<SourceValue> {
3138        match self.peek().map(|token| &token.kind) {
3139            Some(TokenKind::String(_)) => self.expect_string("value").map(SourceValue::String),
3140            Some(TokenKind::Number(_)) => {
3141                let token = self.advance().clone();
3142                if let TokenKind::Number(value) = token.kind {
3143                    Some(SourceValue::Number(value, token.span))
3144                } else {
3145                    None
3146                }
3147            }
3148            Some(TokenKind::Ident(_)) => {
3149                let binding = self.expect_ident("value path")?;
3150                let mut segments = Vec::new();
3151                let mut span = binding.span;
3152                while self.at_symbol('.') {
3153                    self.advance();
3154                    let segment = self.expect_ident("path segment")?;
3155                    span = span.join(segment.span);
3156                    segments.push(segment);
3157                }
3158                Some(SourceValue::Path {
3159                    binding,
3160                    segments,
3161                    span,
3162                })
3163            }
3164            _ => {
3165                self.expected("a value (observation path, string, or number)");
3166                None
3167            }
3168        }
3169    }
3170
3171    fn parse_class(&mut self) -> Option<ClassDecl> {
3172        let start = self.expect_keyword("class")?.span.start;
3173        let name = self.expect_ident("class name")?;
3174        let open = self.expect_symbol('{')?;
3175        let mut fields = Vec::new();
3176
3177        while !self.is_at_end() && !self.at_symbol('}') {
3178            let Some(field_name) = self.expect_ident("class field name") else {
3179                self.synchronize_to_block_item();
3180                continue;
3181            };
3182            let Some(ty) = self.parse_type() else {
3183                self.synchronize_to_block_item();
3184                continue;
3185            };
3186            // `@key`: mark this field as the class's natural key (import per-row
3187            // idempotency, spec/std-library/files.md).
3188            let mut is_key = false;
3189            if self.at_symbol('@') {
3190                if let Some(tag) = self.parse_tag() {
3191                    if tag.name == "key" {
3192                        is_key = true;
3193                    } else {
3194                        self.diagnostics.push(Diagnostic {
3195                            related: Vec::new(),
3196                            span: tag.span,
3197                            message: format!("unknown field tag `@{}`", tag.name),
3198                            suggestion: Some(
3199                                "the only field tag is `@key` (the class natural key)".to_owned(),
3200                            ),
3201                        });
3202                    }
3203                }
3204            }
3205            let presence_condition = self.parse_field_presence_condition();
3206            let span = field_name.span.join(ty.span());
3207            fields.push(ClassField {
3208                span,
3209                name: field_name,
3210                ty,
3211                is_key,
3212                presence_condition,
3213            });
3214        }
3215
3216        let end = self
3217            .expect_symbol('}')
3218            .map(|token| token.span.end)
3219            .unwrap_or(open.span.end);
3220
3221        Some(ClassDecl {
3222            name,
3223            fields,
3224            span: SourceSpan { start, end },
3225        })
3226    }
3227
3228    fn parse_table(
3229        &mut self,
3230        tags: Vec<TagDecl>,
3231        description: Option<StringLiteral>,
3232    ) -> Option<TableDecl> {
3233        let start = self.expect_keyword("table")?.span.start;
3234        let name = self.expect_ident("table name")?;
3235        self.expect_keyword("as")?;
3236        let schema = self.expect_ident("table row class")?;
3237        let open = self.expect_symbol('[')?;
3238        let mut rows = Vec::new();
3239
3240        while !self.is_at_end() && !self.at_symbol(']') {
3241            if self.at_symbol(',') {
3242                self.advance();
3243                continue;
3244            }
3245            if !self.at_symbol('{') {
3246                self.unexpected("table row `{ ... }`");
3247                self.synchronize_to_table_row();
3248                continue;
3249            }
3250            if let Some(row) = self.parse_table_row() {
3251                rows.push(row);
3252            }
3253            if self.at_symbol(',') {
3254                self.advance();
3255            }
3256        }
3257
3258        let end = self
3259            .expect_symbol(']')
3260            .map(|token| token.span.end)
3261            .unwrap_or(open.span.end);
3262        Some(TableDecl {
3263            name,
3264            tags,
3265            description,
3266            schema,
3267            rows,
3268            span: SourceSpan { start, end },
3269        })
3270    }
3271
3272    fn parse_table_row(&mut self) -> Option<TableRow> {
3273        let open = self.expect_symbol('{')?;
3274        let body_start = open.span.end;
3275        let mut depth = 1usize;
3276        let mut body_end = body_start;
3277        let mut close_end = open.span.end;
3278
3279        while !self.is_at_end() {
3280            let token = self.advance().clone();
3281            match token.kind {
3282                TokenKind::Symbol('{') => {
3283                    depth += 1;
3284                    body_end = token.span.end;
3285                }
3286                TokenKind::Symbol('}') => {
3287                    depth -= 1;
3288                    if depth == 0 {
3289                        body_end = token.span.start;
3290                        close_end = token.span.end;
3291                        break;
3292                    }
3293                    body_end = token.span.end;
3294                }
3295                _ => body_end = token.span.end,
3296            }
3297        }
3298
3299        if depth != 0 {
3300            self.diagnostics.push(Diagnostic {
3301                related: Vec::new(),
3302                span: SourceSpan {
3303                    start: open.span.start,
3304                    end: body_end,
3305                },
3306                message: "unterminated table row".to_owned(),
3307                suggestion: Some("close the table row with `}`".to_owned()),
3308            });
3309            return None;
3310        }
3311
3312        let body_span = SourceSpan {
3313            start: body_start,
3314            end: body_end,
3315        };
3316        let (text, span) = trimmed_source_text(self.source_text(body_span), body_span);
3317        Some(TableRow {
3318            body: BlockSource { text, span },
3319            span: SourceSpan {
3320                start: open.span.start,
3321                end: close_end,
3322            },
3323        })
3324    }
3325
3326    fn parse_coerce(&mut self) -> Option<CoerceDecl> {
3327        let start = self.expect_keyword("coerce")?.span.start;
3328        let name = self.expect_ident("coerce name")?;
3329        let params = self.parse_param_list()?;
3330        self.expect_thin_arrow()?;
3331        let output = self.parse_type()?;
3332        // Block-less prompt-only form: `coerce f(a) -> T """…"""` — sugar for
3333        // a block whose sole clause is the prompt. Desugared here to the same
3334        // body text (`prompt <raw string>`), so lowering, prompt extraction,
3335        // and fingerprints are byte-for-byte the block form's.
3336        if !self.at_symbol('{') {
3337            if let Some(TokenKind::String(_)) = self.peek().map(|token| &token.kind) {
3338                let token = self.advance().clone();
3339                let raw = self
3340                    .source_text(SourceSpan {
3341                        start: token.span.start,
3342                        end: token.span.end,
3343                    })
3344                    .to_owned();
3345                let body = BlockSource {
3346                    text: format!("prompt {raw}"),
3347                    span: token.span,
3348                };
3349                let span = SourceSpan {
3350                    start,
3351                    end: body.span.end,
3352                };
3353                return Some(CoerceDecl {
3354                    name,
3355                    params,
3356                    output,
3357                    body,
3358                    span,
3359                });
3360            }
3361        }
3362        let body = self.parse_block_source()?;
3363        let span = SourceSpan {
3364            start,
3365            end: body.span.end,
3366        };
3367        Some(CoerceDecl {
3368            name,
3369            params,
3370            output,
3371            body,
3372            span,
3373        })
3374    }
3375
3376    fn parse_param_list(&mut self) -> Option<Vec<ParamDecl>> {
3377        self.expect_symbol('(')?;
3378        let mut params = Vec::new();
3379
3380        while !self.is_at_end() && !self.at_symbol(')') {
3381            let name = self.expect_ident("parameter name")?;
3382            let ty = self.parse_type()?;
3383            params.push(ParamDecl {
3384                span: name.span.join(ty.span()),
3385                name,
3386                ty,
3387            });
3388
3389            if self.at_symbol(',') {
3390                self.advance();
3391            } else if !self.at_symbol(')') {
3392                self.unexpected("`,` or `)`");
3393                while !self.is_at_end() && !self.at_symbol(')') && !self.at_symbol(',') {
3394                    self.advance();
3395                }
3396            }
3397        }
3398
3399        self.expect_symbol(')')?;
3400        Some(params)
3401    }
3402
3403    /// `action <name>(<param: type>, …) { <effect chain> }` (DR-0023). The body
3404    /// is captured as a block source; expansion at call sites is a later slice.
3405    fn parse_action(&mut self) -> Option<ActionDecl> {
3406        let start = self.expect_keyword("action")?.span.start;
3407        let name = self.expect_ident("action name")?;
3408        self.expect_symbol('(')?;
3409        let mut params = Vec::new();
3410        while !self.is_at_end() && !self.at_symbol(')') {
3411            let param_name = self.expect_ident("action parameter name")?;
3412            let ty = self.parse_type()?;
3413            let span = param_name.span.join(ty.span());
3414            params.push(ActionParam {
3415                name: param_name,
3416                ty,
3417                span,
3418            });
3419            if self.at_symbol(',') {
3420                self.advance();
3421            }
3422        }
3423        self.expect_symbol(')')?;
3424        let body = self.parse_block_source()?;
3425        let span = SourceSpan {
3426            start,
3427            end: body.span.end,
3428        };
3429        Some(ActionDecl {
3430            name,
3431            params,
3432            body,
3433            span,
3434        })
3435    }
3436
3437    fn parse_rule(
3438        &mut self,
3439        tags: Vec<TagDecl>,
3440        description: Option<StringLiteral>,
3441    ) -> Option<RuleDecl> {
3442        let start = self.expect_keyword("rule")?.span.start;
3443        let name = self.expect_ident("rule name")?;
3444        let mut whens = Vec::new();
3445
3446        while !self.is_at_end() && !self.at_arrow() {
3447            if self.at_ident("when") {
3448                whens.extend(self.parse_when_clauses()?);
3449            } else if self.at_ident("with") {
3450                let span = self
3451                    .peek()
3452                    .map(|token| token.span)
3453                    .unwrap_or(SourceSpan { start, end: start });
3454                self.diagnostics.push(Diagnostic {
3455                    related: Vec::new(),
3456                    span,
3457                    message: "`with` is not a rule readiness clause".to_owned(),
3458                    suggestion: Some("use `when` for rule conditions".to_owned()),
3459                });
3460                self.advance();
3461            } else {
3462                self.unexpected("`when` clause or `=>`");
3463                self.advance();
3464            }
3465        }
3466
3467        self.expect_arrow()?;
3468        let body = self.parse_block_source()?;
3469        let span = SourceSpan {
3470            start,
3471            end: body.span.end,
3472        };
3473        Some(RuleDecl {
3474            name,
3475            tags,
3476            description,
3477            whens,
3478            body,
3479            span,
3480        })
3481    }
3482
3483    fn parse_when_clauses(&mut self) -> Option<Vec<WhenClause>> {
3484        let when = self.expect_keyword("when")?;
3485        if self.at_symbol('{') {
3486            return self.parse_grouped_when_clauses(when.span);
3487        }
3488
3489        Some(vec![self.parse_when_clause_after_keyword(when.span)?])
3490    }
3491
3492    fn parse_assert(
3493        &mut self,
3494        tags: Vec<TagDecl>,
3495        description: Option<StringLiteral>,
3496    ) -> Option<AssertDecl> {
3497        let assert = self.expect_keyword("assert")?;
3498        let expr_start = assert.span.end;
3499        let line_end = self.source[expr_start..]
3500            .find('\n')
3501            .map(|offset| expr_start + offset)
3502            .unwrap_or(self.source.len());
3503        let mut expr_end = line_end;
3504
3505        while !self.is_at_end() && self.peek()?.span.start < line_end {
3506            expr_end = self.peek()?.span.end.min(line_end);
3507            self.advance();
3508        }
3509        expr_end = Self::extend_span_over_skipped_operators(self.source, expr_end, line_end);
3510
3511        let span = SourceSpan {
3512            start: expr_start,
3513            end: expr_end,
3514        };
3515        let (expr, span) = trimmed_source_text(self.source_text(span), span);
3516        Some(AssertDecl {
3517            tags,
3518            description,
3519            expr,
3520            span,
3521        })
3522    }
3523
3524    fn parse_when_clause_after_keyword(&mut self, when: SourceSpan) -> Option<WhenClause> {
3525        self.parse_when_clause_with_stop(when, false)
3526    }
3527
3528    /// Flow headers terminate at the body `{`; rule headers at `=>`.
3529    fn parse_when_clause_with_stop(
3530        &mut self,
3531        when: SourceSpan,
3532        stop_at_brace: bool,
3533    ) -> Option<WhenClause> {
3534        let text_start = when.end;
3535        let mut text_end = text_start;
3536
3537        while !(self.is_at_end()
3538            || self.at_arrow()
3539            || self.at_ident("when")
3540            || self.at_ident("rule")
3541            || stop_at_brace && self.at_symbol('{'))
3542        {
3543            text_end = self.peek()?.span.end;
3544            self.advance();
3545        }
3546        let limit = self
3547            .peek()
3548            .map(|token| token.span.start)
3549            .unwrap_or(self.source.len());
3550        text_end = Self::extend_span_over_skipped_operators(self.source, text_end, limit);
3551
3552        let span = SourceSpan {
3553            start: text_start,
3554            end: text_end,
3555        };
3556        let (text, span) = trimmed_source_text(self.source_text(span), span);
3557        Some(WhenClause { text, span })
3558    }
3559
3560    /// The file-level lexer steps over expression operators (`==`, `!=`,
3561    /// `<=`, `>=`, `&&`, `||`, `*`, `/`, `-`) without emitting tokens —
3562    /// expressions are re-parsed from raw source slices. A raw capture that
3563    /// walks TOKEN spans therefore stops short when the clause ends in a
3564    /// dangling operator, silently truncating `assert a ==` to `assert a`
3565    /// (which then mis-diagnoses as a non-boolean expression instead of a
3566    /// syntax error). Extend `end` across same-line trailing operator bytes
3567    /// so the expression parser sees the dangling operator. `=>`/`->` are
3568    /// real tokens and `//` starts a comment; neither is consumed here.
3569    fn extend_span_over_skipped_operators(source: &str, mut end: usize, limit: usize) -> usize {
3570        let bytes = source.as_bytes();
3571        loop {
3572            let mut cursor = end;
3573            while cursor < limit && bytes[cursor].is_ascii_whitespace() && bytes[cursor] != b'\n' {
3574                cursor += 1;
3575            }
3576            let width = match (bytes.get(cursor), bytes.get(cursor + 1)) {
3577                _ if cursor >= limit => break,
3578                (Some(b'='), Some(b'='))
3579                | (Some(b'!'), Some(b'='))
3580                | (Some(b'<'), Some(b'='))
3581                | (Some(b'>'), Some(b'='))
3582                | (Some(b'&'), Some(b'&'))
3583                | (Some(b'|'), Some(b'|')) => 2,
3584                (Some(b'/'), Some(b'/')) => break,
3585                (Some(b'-'), Some(b'>')) => break,
3586                (Some(b'*' | b'/' | b'-'), _) => 1,
3587                _ => break,
3588            };
3589            if cursor + width > limit {
3590                break;
3591            }
3592            end = cursor + width;
3593        }
3594        end
3595    }
3596
3597    fn parse_grouped_when_clauses(&mut self, when: SourceSpan) -> Option<Vec<WhenClause>> {
3598        let open = self.expect_symbol('{')?;
3599        let body_start = open.span.end;
3600        let mut depth = 1usize;
3601        let mut body_end = body_start;
3602        let mut close_end = open.span.end;
3603
3604        while !self.is_at_end() {
3605            let token = self.advance().clone();
3606            match token.kind {
3607                TokenKind::Symbol('{') => {
3608                    depth += 1;
3609                    body_end = token.span.end;
3610                }
3611                TokenKind::Symbol('}') => {
3612                    depth -= 1;
3613                    if depth == 0 {
3614                        body_end = token.span.start;
3615                        close_end = token.span.end;
3616                        break;
3617                    }
3618                    body_end = token.span.end;
3619                }
3620                _ => body_end = token.span.end,
3621            }
3622        }
3623
3624        if depth != 0 {
3625            self.diagnostics.push(Diagnostic {
3626                related: Vec::new(),
3627                span: SourceSpan {
3628                    start: when.start,
3629                    end: body_end,
3630                },
3631                message: "unterminated grouped `when` block".to_owned(),
3632                suggestion: Some("close the grouped readiness block with `}`".to_owned()),
3633            });
3634            return Some(Vec::new());
3635        }
3636
3637        let body_span = SourceSpan {
3638            start: body_start,
3639            end: body_end,
3640        };
3641        let mut clauses = Vec::new();
3642        let mut offset = 0usize;
3643        for line in self.source_text(body_span).split_inclusive('\n') {
3644            let line_without_newline = line.trim_end_matches('\n');
3645            let line_start = body_span.start + offset;
3646            offset += line.len();
3647            let leading = line_without_newline.len() - line_without_newline.trim_start().len();
3648            let trailing = line_without_newline.len() - line_without_newline.trim_end().len();
3649            let trimmed_start = line_start + leading;
3650            let trimmed_end = line_start + line_without_newline.len().saturating_sub(trailing);
3651            if trimmed_start >= trimmed_end {
3652                continue;
3653            }
3654            clauses.push(WhenClause {
3655                text: self.source[trimmed_start..trimmed_end].to_owned(),
3656                span: SourceSpan {
3657                    start: trimmed_start,
3658                    end: trimmed_end,
3659                },
3660            });
3661        }
3662
3663        if clauses.is_empty() {
3664            self.diagnostics.push(Diagnostic {
3665                related: Vec::new(),
3666                span: SourceSpan {
3667                    start: when.start,
3668                    end: close_end,
3669                },
3670                message: "grouped `when` block has no readiness clauses".to_owned(),
3671                suggestion: Some(
3672                    "add one condition per line, such as `started` or `Class as binding`"
3673                        .to_owned(),
3674                ),
3675            });
3676        }
3677
3678        Some(clauses)
3679    }
3680
3681    fn parse_block_source(&mut self) -> Option<BlockSource> {
3682        let open = self.expect_symbol('{')?;
3683        let body_start = open.span.end;
3684        let mut depth = 1usize;
3685        let mut body_end = body_start;
3686
3687        while !self.is_at_end() {
3688            let token = self.advance().clone();
3689            match token.kind {
3690                TokenKind::Symbol('{') => {
3691                    depth += 1;
3692                    body_end = token.span.end;
3693                }
3694                TokenKind::Symbol('}') => {
3695                    depth -= 1;
3696                    if depth == 0 {
3697                        body_end = token.span.start;
3698                        return Some(BlockSource {
3699                            text: self
3700                                .source_text(SourceSpan {
3701                                    start: body_start,
3702                                    end: body_end,
3703                                })
3704                                .trim()
3705                                .to_owned(),
3706                            span: SourceSpan {
3707                                start: open.span.start,
3708                                end: token.span.end,
3709                            },
3710                        });
3711                    }
3712                    body_end = token.span.end;
3713                }
3714                _ => {
3715                    body_end = token.span.end;
3716                }
3717            }
3718        }
3719
3720        self.diagnostics.push(Diagnostic {
3721            related: Vec::new(),
3722            span: SourceSpan {
3723                start: open.span.start,
3724                end: body_end,
3725            },
3726            message: "unterminated block".to_owned(),
3727            suggestion: Some("add a closing `}`".to_owned()),
3728        });
3729        Some(BlockSource {
3730            text: self
3731                .source_text(SourceSpan {
3732                    start: body_start,
3733                    end: body_end,
3734                })
3735                .trim()
3736                .to_owned(),
3737            span: SourceSpan {
3738                start: open.span.start,
3739                end: body_end,
3740            },
3741        })
3742    }
3743
3744    fn parse_type(&mut self) -> Option<TypeSyntax> {
3745        let first = self.parse_type_atom()?;
3746        let first = self.parse_type_suffixes(first);
3747
3748        if !self.at_symbol('|') {
3749            return Some(first);
3750        }
3751
3752        let start = first.span().start;
3753        let mut end = first.span().end;
3754        let mut variants = vec![first];
3755
3756        while self.at_symbol('|') {
3757            self.advance();
3758            let variant = self.parse_type_atom()?;
3759            let variant = self.parse_type_suffixes(variant);
3760            end = variant.span().end;
3761            variants.push(variant);
3762        }
3763
3764        Some(TypeSyntax::Union {
3765            variants,
3766            span: SourceSpan { start, end },
3767        })
3768    }
3769
3770    fn parse_type_atom(&mut self) -> Option<TypeSyntax> {
3771        Some(if self.at_ident("AgentRef") {
3772            let agent_ref = self.advance().clone();
3773            self.expect_symbol('<')?;
3774            let mut agents = Vec::new();
3775            while !self.is_at_end() && !self.at_symbol('>') {
3776                if self.at_symbol('|') {
3777                    self.advance();
3778                    continue;
3779                }
3780                let Some(agent) = self.expect_ident("agent reference") else {
3781                    break;
3782                };
3783                agents.push(agent);
3784            }
3785            let close = self.expect_symbol('>')?;
3786            TypeSyntax::AgentRef {
3787                agents,
3788                span: agent_ref.span.join(close.span),
3789            }
3790        } else if self.at_ident("map") {
3791            let map = self.advance().clone();
3792            self.expect_symbol('<')?;
3793            let inner = self.parse_type()?;
3794            let close = self.expect_symbol('>')?;
3795            TypeSyntax::Map {
3796                span: map.span.join(close.span),
3797                inner: Box::new(inner),
3798            }
3799        } else if matches!(
3800            self.peek().map(|token| &token.kind),
3801            Some(TokenKind::String(_))
3802        ) {
3803            let literal = self.expect_string("literal type")?;
3804            TypeSyntax::LiteralString {
3805                value: literal.value,
3806                span: literal.span,
3807            }
3808        } else {
3809            let ident = self.expect_ident("type name")?;
3810            if is_primitive_type(&ident.name) {
3811                TypeSyntax::Primitive {
3812                    name: ident.name,
3813                    span: ident.span,
3814                }
3815            } else {
3816                TypeSyntax::Ref { name: ident }
3817            }
3818        })
3819    }
3820
3821    fn parse_type_suffixes(&mut self, mut ty: TypeSyntax) -> TypeSyntax {
3822        loop {
3823            if self.at_symbol('?') {
3824                let question = self.advance().clone();
3825                ty = TypeSyntax::Optional {
3826                    span: ty.span().join(question.span),
3827                    inner: Box::new(ty),
3828                };
3829            } else if self.at_symbol('[') {
3830                self.advance();
3831                let Some(close) = self.expect_symbol(']') else {
3832                    return ty;
3833                };
3834                ty = TypeSyntax::Array {
3835                    span: ty.span().join(close.span),
3836                    inner: Box::new(ty),
3837                };
3838            } else {
3839                return ty;
3840            }
3841        }
3842    }
3843
3844    fn parse_string_list(&mut self) -> Option<(Vec<StringLiteral>, SourceSpan)> {
3845        let open = self.expect_symbol('[')?;
3846        let mut values = Vec::new();
3847
3848        while !self.is_at_end() && !self.at_symbol(']') {
3849            values.push(self.expect_string("skill string")?);
3850            if self.at_symbol(',') {
3851                self.advance();
3852            } else if !self.at_symbol(']') {
3853                self.unexpected("`,` or `]`");
3854                self.synchronize_to_block_item();
3855                break;
3856            }
3857        }
3858
3859        let close = self.expect_symbol(']')?;
3860        Some((values, open.span.join(close.span)))
3861    }
3862
3863    /// Parse a bracketed list of identifiers, e.g. `[WordCount, OpenPr]`. Used for
3864    /// the agent `tools` grant, whose entries reference declared workflows by name.
3865    fn parse_ident_list(&mut self) -> Option<(Vec<Ident>, SourceSpan)> {
3866        let open = self.expect_symbol('[')?;
3867        let mut values = Vec::new();
3868
3869        while !self.is_at_end() && !self.at_symbol(']') {
3870            values.push(self.expect_ident("tool workflow name")?);
3871            if self.at_symbol(',') {
3872                self.advance();
3873            } else if !self.at_symbol(']') {
3874                self.unexpected("`,` or `]`");
3875                self.synchronize_to_block_item();
3876                break;
3877            }
3878        }
3879
3880        let close = self.expect_symbol(']')?;
3881        Some((values, open.span.join(close.span)))
3882    }
3883
3884    /// Parse a bracketed list of dotted feature-class names, e.g.
3885    /// `[session.resume, turn.cancel]` (agent `requires`, DR-0015 taxonomy).
3886    /// Each entry is one or more identifiers joined by `.`; taxonomy
3887    /// membership is validated at lowering.
3888    fn parse_feature_class_list(&mut self) -> Option<(Vec<Ident>, SourceSpan)> {
3889        let open = self.expect_symbol('[')?;
3890        let mut values = Vec::new();
3891
3892        while !self.is_at_end() && !self.at_symbol(']') {
3893            let head = self.expect_ident("feature class")?;
3894            let mut name = head.name.clone();
3895            let mut span = head.span;
3896            while self.at_symbol('.') {
3897                self.advance();
3898                let part = self.expect_ident("feature class segment")?;
3899                name.push('.');
3900                name.push_str(&part.name);
3901                span = span.join(part.span);
3902            }
3903            values.push(Ident { name, span });
3904            if self.at_symbol(',') {
3905                self.advance();
3906            } else if !self.at_symbol(']') {
3907                self.unexpected("`,` or `]`");
3908                self.synchronize_to_block_item();
3909                break;
3910            }
3911        }
3912
3913        let close = self.expect_symbol(']')?;
3914        Some((values, open.span.join(close.span)))
3915    }
3916
3917    fn expect_keyword(&mut self, keyword: &str) -> Option<Token> {
3918        if self.at_ident(keyword) {
3919            Some(self.advance().clone())
3920        } else {
3921            self.expected(format!("`{keyword}`"));
3922            None
3923        }
3924    }
3925
3926    fn expect_ident(&mut self, label: &str) -> Option<Ident> {
3927        let token = self.peek()?;
3928        if let TokenKind::Ident(name) = &token.kind {
3929            let ident = Ident {
3930                name: name.clone(),
3931                span: token.span,
3932            };
3933            self.advance();
3934            Some(ident)
3935        } else {
3936            self.expected(label);
3937            None
3938        }
3939    }
3940
3941    /// Family B: an optional `when <discriminant> is "<literal>"` suffix on a
3942    /// schema/signal field — the field is present only when the literal-union
3943    /// discriminant field equals the literal. `is` is used instead of `==` to stay
3944    /// within the declaration tokenizer; the meaning is equality
3945    /// (spec/decision-records/discriminated-families-design.md §5.7).
3946    fn parse_field_presence_condition(&mut self) -> Option<(String, String)> {
3947        if !self.at_ident("when") {
3948            return None;
3949        }
3950        self.advance(); // `when`
3951        let disc = self.expect_ident("discriminant field name after `when`")?;
3952        if self.at_ident("is") {
3953            self.advance();
3954        } else {
3955            self.expected("`is` after the discriminant field");
3956            return None;
3957        }
3958        let literal = self.expect_string("discriminant literal value")?;
3959        Some((disc.name, literal.value))
3960    }
3961
3962    fn expect_string(&mut self, label: &str) -> Option<StringLiteral> {
3963        let token = self.peek()?;
3964        if let TokenKind::String(value) = &token.kind {
3965            let literal = StringLiteral {
3966                value: value.clone(),
3967                span: token.span,
3968            };
3969            self.advance();
3970            Some(literal)
3971        } else {
3972            self.expected(label);
3973            None
3974        }
3975    }
3976
3977    fn expect_use_name(&mut self, label: &str) -> Option<StringLiteral> {
3978        let token = self.peek()?;
3979        match &token.kind {
3980            // A package name may be a dotted path (`std.messaging`, `std.coord`)
3981            // or a bare ident (`memory`); a string literal is also accepted.
3982            TokenKind::Ident(value) => {
3983                let mut name = value.clone();
3984                let mut span = token.span;
3985                self.advance();
3986                while self.at_symbol('.') {
3987                    self.expect_symbol('.');
3988                    let Some(segment) = self.expect_ident("package name segment") else {
3989                        break;
3990                    };
3991                    name.push('.');
3992                    name.push_str(&segment.name);
3993                    span = span.join(segment.span);
3994                }
3995                Some(StringLiteral { value: name, span })
3996            }
3997            TokenKind::String(value) => {
3998                let literal = StringLiteral {
3999                    value: value.clone(),
4000                    span: token.span,
4001                };
4002                self.advance();
4003                Some(literal)
4004            }
4005            _ => {
4006                self.expected(label);
4007                None
4008            }
4009        }
4010    }
4011
4012    fn expect_u32(&mut self, label: &str) -> Option<(u32, SourceSpan)> {
4013        let token = self.peek()?;
4014        if let TokenKind::Number(value) = &token.kind {
4015            let span = token.span;
4016            let parsed = value.parse::<u32>();
4017            self.advance();
4018            match parsed {
4019                Ok(value) => Some((value, span)),
4020                Err(_) => {
4021                    self.diagnostics.push(Diagnostic {
4022                        related: Vec::new(),
4023                        span,
4024                        message: format!("{label} must fit in u32"),
4025                        suggestion: Some("use a non-negative integer such as `1`".to_owned()),
4026                    });
4027                    None
4028                }
4029            }
4030        } else {
4031            self.expected(label);
4032            None
4033        }
4034    }
4035
4036    fn expect_symbol(&mut self, symbol: char) -> Option<Token> {
4037        if self.at_symbol(symbol) {
4038            Some(self.advance().clone())
4039        } else {
4040            self.expected(format!("`{symbol}`"));
4041            None
4042        }
4043    }
4044
4045    fn expect_arrow(&mut self) -> Option<Token> {
4046        if self.at_arrow() {
4047            Some(self.advance().clone())
4048        } else {
4049            self.expected("`=>`");
4050            None
4051        }
4052    }
4053
4054    fn expect_thin_arrow(&mut self) -> Option<Token> {
4055        if self.at_thin_arrow() {
4056            Some(self.advance().clone())
4057        } else {
4058            self.expected("`->`");
4059            None
4060        }
4061    }
4062
4063    fn at_ident(&self, expected: &str) -> bool {
4064        matches!(self.peek().map(|token| &token.kind), Some(TokenKind::Ident(value)) if value == expected)
4065    }
4066
4067    fn consume_ident(&mut self, expected: &str) -> bool {
4068        if self.at_ident(expected) {
4069            self.advance();
4070            true
4071        } else {
4072            false
4073        }
4074    }
4075
4076    fn at_symbol(&self, expected: char) -> bool {
4077        matches!(self.peek().map(|token| &token.kind), Some(TokenKind::Symbol(value)) if *value == expected)
4078    }
4079
4080    fn at_arrow(&self) -> bool {
4081        matches!(self.peek().map(|token| &token.kind), Some(TokenKind::Arrow))
4082    }
4083
4084    fn at_thin_arrow(&self) -> bool {
4085        matches!(
4086            self.peek().map(|token| &token.kind),
4087            Some(TokenKind::ThinArrow)
4088        )
4089    }
4090
4091    fn peek(&self) -> Option<&Token> {
4092        self.tokens.get(self.pos)
4093    }
4094
4095    fn advance(&mut self) -> &Token {
4096        let index = self.pos;
4097        self.pos += 1;
4098        &self.tokens[index]
4099    }
4100
4101    fn is_at_end(&self) -> bool {
4102        self.pos >= self.tokens.len()
4103    }
4104
4105    fn expected(&mut self, expected: impl fmt::Display) {
4106        let expected = expected.to_string();
4107        let (span, found) = match self.peek() {
4108            Some(token) => (token.span, token.kind.label()),
4109            None => (
4110                SourceSpan {
4111                    start: self.source.len(),
4112                    end: self.source.len(),
4113                },
4114                "end of file".to_owned(),
4115            ),
4116        };
4117        self.diagnostics.push(Diagnostic {
4118            related: Vec::new(),
4119            span,
4120            message: format!("expected {expected}, found {found}"),
4121            suggestion: suggestion_for_expected(&expected),
4122        });
4123    }
4124
4125    fn unexpected(&mut self, expected: impl fmt::Display) {
4126        let Some(token) = self.peek() else {
4127            self.expected(expected);
4128            return;
4129        };
4130        let expected = expected.to_string();
4131        self.diagnostics.push(Diagnostic {
4132            related: Vec::new(),
4133            span: token.span,
4134            message: format!("expected {expected}, found {}", token.kind.label()),
4135            suggestion: suggestion_for_expected(&expected),
4136        });
4137    }
4138
4139    fn synchronize_to_block_item(&mut self) {
4140        while !self.is_at_end() {
4141            if self.at_symbol('}')
4142                || self.at_ident("profile")
4143                || self.at_ident("provider")
4144                || self.at_ident("capacity")
4145                || self.at_ident("skills")
4146                || self.at_ident("capabilities")
4147                || self.at_ident("tools")
4148                || self.at_ident("compaction")
4149                || self.at_ident("settings")
4150            {
4151                return;
4152            }
4153            self.advance();
4154        }
4155    }
4156
4157    fn synchronize_to_table_row(&mut self) {
4158        while !self.is_at_end() {
4159            if self.at_symbol('{') || self.at_symbol(']') {
4160                return;
4161            }
4162            self.advance();
4163        }
4164    }
4165
4166    fn source_text(&self, span: SourceSpan) -> &str {
4167        &self.source[span.start..span.end]
4168    }
4169}
4170
4171pub(crate) fn trimmed_source_text(source: &str, span: SourceSpan) -> (String, SourceSpan) {
4172    let leading = source.len() - source.trim_start().len();
4173    let trailing = source.len() - source.trim_end().len();
4174    let end = source.len().saturating_sub(trailing);
4175    if leading > end {
4176        return (
4177            String::new(),
4178            SourceSpan {
4179                start: span.end,
4180                end: span.end,
4181            },
4182        );
4183    }
4184    (
4185        source[leading..end].to_owned(),
4186        SourceSpan {
4187            start: span.start + leading,
4188            end: span.start + end,
4189        },
4190    )
4191}
4192
4193pub(crate) fn is_primitive_type(name: &str) -> bool {
4194    matches!(
4195        name,
4196        "string"
4197            | "int"
4198            | "float"
4199            | "bool"
4200            | "null"
4201            | "duration"
4202            | "time"
4203            | "image"
4204            | "audio"
4205            | "pdf"
4206            | "video"
4207            | "secret"
4208    )
4209}
4210
4211pub(crate) fn is_gherkin_keyword(keyword: &str) -> bool {
4212    matches!(
4213        keyword,
4214        "Feature"
4215            | "Rule"
4216            | "Background"
4217            | "Scenario"
4218            | "ScenarioOutline"
4219            | "Scenario-Outline"
4220            | "Examples"
4221            | "Given"
4222            | "When"
4223            | "Then"
4224            | "And"
4225            | "But"
4226    )
4227}
4228
4229pub(crate) fn suggestion_for_expected(expected: &str) -> Option<String> {
4230    match expected {
4231        "`{`" => Some("add a `{ ... }` block".to_owned()),
4232        "`=>`" => Some("add `=> { ... }` after the rule conditions".to_owned()),
4233        "`->`" => Some("add `-> OutputType` before the coerce prompt block".to_owned()),
4234        "profile string" => Some("write `profile \"profile-name\"`".to_owned()),
4235        "capacity value" => Some("write `capacity 1`".to_owned()),
4236        "package library name" => Some("write a package library name, such as `memory`".to_owned()),
4237        "type name" => Some("write a primitive type or schema name".to_owned()),
4238        _ => None,
4239    }
4240}
4241
4242/// Parses a source file into a recoverable AST plus diagnostics.
4243pub fn parse_program(source: &str) -> ParseOutput {
4244    let lexed = lex(source);
4245    let mut parser = Parser {
4246        source,
4247        tokens: lexed.tokens,
4248        pos: 0,
4249        diagnostics: lexed.diagnostics,
4250        pending_contract_classes: Vec::new(),
4251    };
4252
4253    let program = parser.parse_program();
4254    ParseOutput {
4255        program,
4256        diagnostics: parser.diagnostics,
4257    }
4258}