1use crate::ast::{Ast, Expr, ExprId, FnBody, Span, Stmt};
4use crate::diagnostic::Diagnostic;
5use crate::lexer::{Interner, Lexer, Symbol};
6use crate::parser;
7use serde_json::{Map, Value};
8use std::sync::Arc;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub(crate) enum Global {
13 Agent,
14 Parallel,
15 Pipeline,
16 Phase,
17 Log,
18 Args,
19 Cwd,
20 Math,
21 Json,
22 ObjectNamespace,
23 ArrayNamespace,
24 DateNamespace,
27 NumberCast,
28 StringCast,
29 BooleanCast,
30 ParseInt,
31 ParseFloat,
32 IsNaN,
33}
34
35fn global_for(name: &str) -> Option<Global> {
36 Some(match name {
37 "agent" => Global::Agent,
38 "parallel" => Global::Parallel,
39 "pipeline" => Global::Pipeline,
40 "phase" => Global::Phase,
41 "log" => Global::Log,
42 "args" => Global::Args,
43 "cwd" => Global::Cwd,
44 "Math" => Global::Math,
45 "JSON" => Global::Json,
46 "Object" => Global::ObjectNamespace,
47 "Array" => Global::ArrayNamespace,
48 "Date" => Global::DateNamespace,
49 "Number" => Global::NumberCast,
50 "String" => Global::StringCast,
51 "Boolean" => Global::BooleanCast,
52 "parseInt" => Global::ParseInt,
53 "parseFloat" => Global::ParseFloat,
54 "isNaN" => Global::IsNaN,
55 _ => return None,
56 })
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Default)]
61pub struct Meta {
62 pub name: String,
63 pub description: String,
64 pub phases: Vec<String>,
66}
67
68pub struct Script {
74 pub(crate) ast: Ast,
75 pub(crate) body: Span,
76 pub(crate) interner: Interner,
77 pub(crate) globals: Vec<Option<Global>>,
80 meta: Meta,
81 source: Arc<str>,
82 phase_titles: Vec<String>,
83 estimated_agents: Option<u32>,
84}
85
86impl std::fmt::Debug for Script {
87 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88 f.debug_struct("Script")
89 .field("meta", &self.meta)
90 .field("statements", &self.body.len)
91 .finish()
92 }
93}
94
95impl Script {
96 pub fn parse(source: &str) -> Result<Script, Diagnostic> {
99 let mut interner = Interner::default();
100 let tokens = Lexer::tokenize(source, &mut interner)?;
101 let parsed = parser::parse(&tokens, &mut interner)?;
102
103 let meta = match parsed.meta {
104 Some(expr) => read_meta(&parsed.ast, &interner, expr)?,
105 None => {
106 return Err(
107 Diagnostic::new(1, 1, "this script has no `meta` block").with_help(
108 "start the script with \
109 `export const meta = { name: '...', description: '...' }`",
110 ),
111 );
112 }
113 };
114
115 let globals = (0..interner.len())
116 .map(|symbol| global_for(interner.resolve(symbol as Symbol)))
117 .collect();
118
119 let mut script = Script {
120 ast: parsed.ast,
121 body: parsed.body,
122 interner,
123 globals,
124 meta,
125 source: Arc::from(source),
126 phase_titles: Vec::new(),
127 estimated_agents: None,
128 };
129 script.phase_titles = script.collect_phase_titles();
130 script.estimated_agents = script.count_agents();
131 Ok(script)
132 }
133
134 pub fn meta(&self) -> &Meta {
135 &self.meta
136 }
137
138 pub fn source(&self) -> &str {
139 &self.source
140 }
141
142 pub fn declared_phases(&self) -> &[String] {
147 &self.phase_titles
148 }
149
150 pub fn estimated_agents(&self) -> Option<u32> {
158 self.estimated_agents
159 }
160
161 fn symbol_is(&self, symbol: Symbol, global: Global) -> bool {
162 self.globals
163 .get(symbol as usize)
164 .copied()
165 .flatten()
166 .is_some_and(|found| found == global)
167 }
168
169 fn collect_phase_titles(&self) -> Vec<String> {
170 let mut titles = self.meta.phases.clone();
171 for expr in &self.ast.exprs {
172 let Expr::Call { callee, args } = expr else {
173 continue;
174 };
175 let Expr::Name(symbol) = self.ast.expr(*callee) else {
176 continue;
177 };
178 if !self.symbol_is(*symbol, Global::Phase) {
179 continue;
180 }
181 let Some(first) = self.ast.exprs_in(*args).first() else {
182 continue;
183 };
184 if let Expr::Text(title) = self.ast.expr(*first)
185 && !titles.iter().any(|known| known == title.as_ref())
186 {
187 titles.push(title.to_string());
188 }
189 }
190 titles
191 }
192
193 fn count_agents(&self) -> Option<u32> {
195 let mut count = 0u32;
196 self.walk_statements(self.body, &mut count)?;
197 Some(count)
198 }
199
200 fn walk_statements(&self, span: Span, count: &mut u32) -> Option<()> {
201 for id in self.ast.stmts_in(span) {
202 match self.ast.stmt(*id) {
203 Stmt::Declare { value, .. } => self.walk_expr(*value, count)?,
204 Stmt::Assign { target, value } => {
205 self.walk_expr(*target, count)?;
206 self.walk_expr(*value, count)?;
207 }
208 Stmt::Expr(value) => self.walk_expr(*value, count)?,
209 Stmt::If {
210 test,
211 consequent,
212 alternate,
213 } => {
214 self.walk_expr(*test, count)?;
215 self.walk_statements(*consequent, count)?;
218 self.walk_statements(*alternate, count)?;
219 }
220 Stmt::ForOf { iterable, body, .. } => {
223 self.walk_expr(*iterable, count)?;
224 if self.contains_agent_call(*body) {
225 return None;
226 }
227 }
228 Stmt::While { test, body } => {
229 self.walk_expr(*test, count)?;
230 if self.contains_agent_call(*body) {
231 return None;
232 }
233 }
234 Stmt::Return(Some(value)) => self.walk_expr(*value, count)?,
235 Stmt::Return(None) | Stmt::Break | Stmt::Continue => {}
236 }
237 }
238 Some(())
239 }
240
241 fn walk_expr(&self, id: ExprId, count: &mut u32) -> Option<()> {
242 match self.ast.expr(id) {
243 Expr::Null | Expr::Bool(_) | Expr::Number(_) | Expr::Text(_) | Expr::Name(_) => {}
244 Expr::Template(span) => {
245 for part in self.ast.parts_in(*span) {
246 if let crate::ast::TemplatePart::Expr(expr) = part {
247 self.walk_expr(*expr, count)?;
248 }
249 }
250 }
251 Expr::Array(span) => {
252 for item in self.ast.exprs_in(*span) {
253 self.walk_expr(*item, count)?;
254 }
255 }
256 Expr::Object(span) => {
257 for field in self.ast.fields_in(*span) {
258 self.walk_expr(field.value, count)?;
259 }
260 }
261 Expr::Member { object, .. } => self.walk_expr(*object, count)?,
262 Expr::Index { object, index } => {
263 self.walk_expr(*object, count)?;
264 self.walk_expr(*index, count)?;
265 }
266 Expr::Call { callee, args } => {
267 if let Expr::Name(symbol) = self.ast.expr(*callee) {
268 if self.symbol_is(*symbol, Global::Agent) {
269 *count += 1;
270 }
271 if self.symbol_is(*symbol, Global::Parallel)
274 && let Some(first) = self.ast.exprs_in(*args).first()
275 && let Expr::Array(items) = self.ast.expr(*first)
276 {
277 for item in self.ast.exprs_in(*items) {
278 match self.ast.expr(*item) {
279 Expr::Arrow(function) => {
280 let def = self.ast.function(*function)?;
281 match &def.body {
282 FnBody::Expr(value) => self.walk_expr(*value, count)?,
283 FnBody::Block(block) => {
284 self.walk_statements(*block, count)?
285 }
286 }
287 }
288 _ => self.walk_expr(*item, count)?,
289 }
290 }
291 return Some(());
292 }
293 }
294 self.walk_expr(*callee, count)?;
295 for arg in self.ast.exprs_in(*args) {
296 self.walk_expr(*arg, count)?;
297 }
298 }
299 Expr::Arrow(function) => {
301 let def = self.ast.function(*function)?;
302 let reachable = match &def.body {
303 FnBody::Expr(value) => self.expr_contains_agent_call(*value),
304 FnBody::Block(block) => self.contains_agent_call(*block),
305 };
306 if reachable {
307 return None;
308 }
309 }
310 Expr::Await(value)
311 | Expr::Unary { operand: value, .. }
312 | Expr::New { callee: value } => self.walk_expr(*value, count)?,
313 Expr::Binary { left, right, .. } | Expr::Logical { left, right, .. } => {
314 self.walk_expr(*left, count)?;
315 self.walk_expr(*right, count)?;
316 }
317 Expr::Conditional {
318 test,
319 consequent,
320 alternate,
321 } => {
322 self.walk_expr(*test, count)?;
323 self.walk_expr(*consequent, count)?;
324 self.walk_expr(*alternate, count)?;
325 }
326 }
327 Some(())
328 }
329
330 fn contains_agent_call(&self, span: Span) -> bool {
331 self.ast
332 .stmts_in(span)
333 .iter()
334 .any(|id| match self.ast.stmt(*id) {
335 Stmt::Declare { value, .. } | Stmt::Expr(value) | Stmt::Return(Some(value)) => {
336 self.expr_contains_agent_call(*value)
337 }
338 Stmt::Assign { target, value } => {
339 self.expr_contains_agent_call(*target) || self.expr_contains_agent_call(*value)
340 }
341 Stmt::If {
342 test,
343 consequent,
344 alternate,
345 } => {
346 self.expr_contains_agent_call(*test)
347 || self.contains_agent_call(*consequent)
348 || self.contains_agent_call(*alternate)
349 }
350 Stmt::ForOf { iterable, body, .. } => {
351 self.expr_contains_agent_call(*iterable) || self.contains_agent_call(*body)
352 }
353 Stmt::While { test, body } => {
354 self.expr_contains_agent_call(*test) || self.contains_agent_call(*body)
355 }
356 Stmt::Return(None) | Stmt::Break | Stmt::Continue => false,
357 })
358 }
359
360 fn expr_contains_agent_call(&self, id: ExprId) -> bool {
361 let mut found = false;
362 let mut pending = vec![id];
363 while let Some(current) = pending.pop() {
366 match self.ast.expr(current) {
367 Expr::Call { callee, args } => {
368 if let Expr::Name(symbol) = self.ast.expr(*callee)
369 && self.symbol_is(*symbol, Global::Agent)
370 {
371 found = true;
372 break;
373 }
374 pending.push(*callee);
375 pending.extend_from_slice(self.ast.exprs_in(*args));
376 }
377 Expr::Template(span) => {
378 for part in self.ast.parts_in(*span) {
379 if let crate::ast::TemplatePart::Expr(expr) = part {
380 pending.push(*expr);
381 }
382 }
383 }
384 Expr::Array(span) => pending.extend_from_slice(self.ast.exprs_in(*span)),
385 Expr::Object(span) => {
386 pending.extend(self.ast.fields_in(*span).iter().map(|field| field.value));
387 }
388 Expr::Member { object, .. } => pending.push(*object),
389 Expr::Index { object, index } => {
390 pending.push(*object);
391 pending.push(*index);
392 }
393 Expr::Arrow(function) => {
394 if let Some(def) = self.ast.function(*function) {
395 match &def.body {
396 FnBody::Expr(value) => pending.push(*value),
397 FnBody::Block(block) => {
398 if self.contains_agent_call(*block) {
399 found = true;
400 break;
401 }
402 }
403 }
404 }
405 }
406 Expr::Await(value)
407 | Expr::Unary { operand: value, .. }
408 | Expr::New { callee: value } => pending.push(*value),
409 Expr::Binary { left, right, .. } | Expr::Logical { left, right, .. } => {
410 pending.push(*left);
411 pending.push(*right);
412 }
413 Expr::Conditional {
414 test,
415 consequent,
416 alternate,
417 } => {
418 pending.push(*test);
419 pending.push(*consequent);
420 pending.push(*alternate);
421 }
422 Expr::Null | Expr::Bool(_) | Expr::Number(_) | Expr::Text(_) | Expr::Name(_) => {}
423 }
424 }
425 found
426 }
427}
428
429fn read_meta(ast: &Ast, interner: &Interner, expr: ExprId) -> Result<Meta, Diagnostic> {
431 let pos = ast.expr_position(expr);
432 let value = literal_value(ast, interner, expr).ok_or_else(|| {
433 Diagnostic::new(
434 pos.line,
435 pos.column,
436 "`meta` may contain only literal values",
437 )
438 .with_help("remove any variable, function call, or expression from the `meta` block")
439 })?;
440 let Value::Object(fields) = value else {
441 return Err(Diagnostic::new(
442 pos.line,
443 pos.column,
444 "`meta` must be an object literal",
445 ));
446 };
447
448 let name = fields
449 .get("name")
450 .and_then(Value::as_str)
451 .map(str::trim)
452 .filter(|name| !name.is_empty())
453 .ok_or_else(|| {
454 Diagnostic::new(
455 pos.line,
456 pos.column,
457 "`meta` needs a non-empty `name` string",
458 )
459 .with_help("the name becomes the slash command when the workflow is saved")
460 })?
461 .to_string();
462 let description = fields
463 .get("description")
464 .and_then(Value::as_str)
465 .map(str::trim)
466 .filter(|text| !text.is_empty())
467 .ok_or_else(|| {
468 Diagnostic::new(
469 pos.line,
470 pos.column,
471 "`meta` needs a non-empty `description` string",
472 )
473 .with_help("one sentence saying what the workflow does")
474 })?
475 .to_string();
476
477 let mut phases = Vec::new();
478 if let Some(Value::Array(items)) = fields.get("phases") {
479 for item in items {
480 let title = match item {
481 Value::String(title) => title.clone(),
482 Value::Object(entry) => entry
483 .get("title")
484 .and_then(Value::as_str)
485 .unwrap_or_default()
486 .to_string(),
487 _ => String::new(),
488 };
489 if !title.is_empty() {
490 phases.push(title);
491 }
492 }
493 }
494
495 Ok(Meta {
496 name,
497 description,
498 phases,
499 })
500}
501
502fn literal_value(ast: &Ast, interner: &Interner, expr: ExprId) -> Option<Value> {
504 Some(match ast.expr(expr) {
505 Expr::Null => Value::Null,
506 Expr::Bool(value) => Value::Bool(*value),
507 Expr::Number(value) => {
508 serde_json::Number::from_f64(*value).map_or(Value::Null, Value::Number)
509 }
510 Expr::Text(text) => Value::String(text.to_string()),
511 Expr::Array(span) => Value::Array(
512 ast.exprs_in(*span)
513 .iter()
514 .map(|item| literal_value(ast, interner, *item))
515 .collect::<Option<Vec<_>>>()?,
516 ),
517 Expr::Object(span) => {
518 let mut map = Map::new();
519 for field in ast.fields_in(*span) {
520 map.insert(
521 interner.resolve(field.name).to_string(),
522 literal_value(ast, interner, field.value)?,
523 );
524 }
525 Value::Object(map)
526 }
527 _ => return None,
528 })
529}
530
531#[cfg(test)]
532mod tests {
533 use super::*;
534
535 const AUDIT: &str = r#"
536export const meta = {
537 name: 'audit-routes',
538 description: 'Audit every tool file for missing path checks',
539 phases: [{ title: 'Discover' }, { title: 'Audit' }],
540}
541
542phase('Discover')
543const found = await agent('List every .rs file under crates/kiss-coding/src/tools.', {
544 schema: {
545 type: 'object',
546 required: ['files'],
547 properties: { files: { type: 'array', items: { type: 'string' } } },
548 },
549})
550
551phase('Audit')
552const audits = await pipeline(found.files, file =>
553 agent(`Audit ${file} for missing path checks.`, { label: file }),
554)
555
556return audits.filter(Boolean)
557"#;
558
559 #[test]
560 fn the_reference_script_parses() {
561 let script = Script::parse(AUDIT).expect("audit script parses");
562 assert_eq!(script.meta().name, "audit-routes");
563 assert_eq!(
564 script.meta().description,
565 "Audit every tool file for missing path checks"
566 );
567 assert_eq!(script.declared_phases(), ["Discover", "Audit"]);
568 }
569
570 #[test]
571 fn a_data_dependent_fan_out_reports_an_unknown_agent_count() {
572 let script = Script::parse(AUDIT).expect("audit script parses");
573 assert_eq!(script.estimated_agents(), None);
576 }
577
578 #[test]
579 fn a_fixed_fan_out_reports_an_exact_agent_count() {
580 let script = Script::parse(
581 r#"
582export const meta = { name: 'three', description: 'Three fixed agents' }
583const results = await parallel([
584 () => agent('one'),
585 () => agent('two'),
586 () => agent('three'),
587])
588return results
589"#,
590 )
591 .expect("script parses");
592 assert_eq!(script.estimated_agents(), Some(3));
593 }
594
595 #[test]
596 fn a_sequential_script_reports_its_exact_count() {
597 let script = Script::parse(
598 r#"
599export const meta = { name: 'two', description: 'Two agents in order' }
600const first = await agent('one')
601const second = await agent(`two after ${first}`)
602return [first, second]
603"#,
604 )
605 .expect("script parses");
606 assert_eq!(script.estimated_agents(), Some(2));
607 }
608
609 #[test]
610 fn a_loop_that_starts_agents_is_unbounded() {
611 let script = Script::parse(
612 r#"
613export const meta = { name: 'loop', description: 'One agent per item' }
614const out = []
615for (const item of args) {
616 out.push(await agent(`check ${item}`))
617}
618return out
619"#,
620 )
621 .expect("script parses");
622 assert_eq!(script.estimated_agents(), None);
623 }
624
625 #[test]
626 fn phase_titles_not_declared_in_meta_are_still_listed() {
627 let script = Script::parse(
628 r#"
629export const meta = { name: 'p', description: 'Phases', phases: [{ title: 'First' }] }
630phase('First')
631const a = await agent('a')
632phase('Second')
633const b = await agent('b')
634return [a, b]
635"#,
636 )
637 .expect("script parses");
638 assert_eq!(script.declared_phases(), ["First", "Second"]);
639 }
640
641 #[test]
642 fn a_missing_meta_block_explains_what_to_write() {
643 let error = Script::parse("const a = await agent('x')\nreturn a\n").unwrap_err();
644 assert!(error.message.contains("no `meta` block"));
645 assert!(
646 error
647 .help
648 .is_some_and(|help| help.contains("export const meta"))
649 );
650 }
651
652 #[test]
653 fn meta_must_hold_only_literals() {
654 let error = Script::parse(
655 "export const meta = { name: 'x', description: makeDescription() }\nreturn 1\n",
656 )
657 .unwrap_err();
658 assert!(error.message.contains("only literal values"));
659 }
660
661 #[test]
662 fn meta_requires_a_name_and_a_description() {
663 let error = Script::parse("export const meta = { name: 'x' }\nreturn 1\n").unwrap_err();
664 assert!(error.message.contains("`description`"));
665
666 let error =
667 Script::parse("export const meta = { description: 'x' }\nreturn 1\n").unwrap_err();
668 assert!(error.message.contains("`name`"));
669 }
670
671 #[test]
672 fn unsupported_syntax_names_a_supported_alternative() {
673 let error = Script::parse(
674 "export const meta = { name: 'x', description: 'y' }\nfor (let i = 0; i < 3; i++) {}\n",
675 )
676 .unwrap_err();
677 assert_eq!(error.line, 2);
678 assert!(error.message.contains("for (const item of list)"));
679 assert!(
680 error
681 .help
682 .is_some_and(|help| help.contains("pipeline(list, item => agent(...))"))
683 );
684
685 let error = Script::parse(
686 "export const meta = { name: 'x', description: 'y' }\nimport fs from 'fs'\n",
687 )
688 .unwrap_err();
689 assert!(error.message.contains("`import` is not supported"));
690 assert!(
691 error
692 .help
693 .is_some_and(|help| help.contains("loads no modules"))
694 );
695 }
696
697 #[test]
698 fn deep_nesting_is_rejected_rather_than_overflowing_the_stack() {
699 let mut source =
700 String::from("export const meta = { name: 'x', description: 'y' }\nconst a = ");
701 source.push_str(&"(".repeat(200));
702 source.push('1');
703 source.push_str(&")".repeat(200));
704 let error = Script::parse(&source).unwrap_err();
705 assert!(error.message.contains("nests too deeply"));
706 }
707}
708
709#[cfg(test)]
710mod benchmarks {
711 use super::*;
712
713 fn representative_script() -> String {
715 let mut source = String::from(
716 "export const meta = {\n\
717 \x20 name: 'benchmark',\n\
718 \x20 description: 'A representative workflow',\n\
719 \x20 phases: [{ title: 'Discover' }, { title: 'Audit' }, { title: 'Report' }],\n\
720 }\n\n",
721 );
722 for round in 0..18 {
723 source.push_str(&format!(
724 "phase('Audit {round}')\n\
725 const found{round} = await agent(`list the files in group {round}`, {{\n\
726 \x20 schema: {{ type: 'object', required: ['files'] }},\n\
727 }})\n\
728 const audits{round} = await pipeline(\n\
729 \x20 found{round}.files,\n\
730 \x20 file => agent(`audit ${{file}} in round {round}`, {{ label: file }}),\n\
731 )\n\
732 if (audits{round}.length > 0) {{\n\
733 \x20 log(`round {round} produced ${{audits{round}.length}} findings`)\n\
734 }}\n\n"
735 ));
736 }
737 source.push_str("return 'done'\n");
738 source
739 }
740
741 #[test]
742 #[ignore = "release-mode performance benchmark"]
743 fn benchmark_performance_workflow_parse() {
744 let source = representative_script();
745 let lines = source.lines().count();
746 assert!(
747 (190..=230).contains(&lines),
748 "expected about 200 lines: {lines}"
749 );
750 kiss_bench::measure(
751 "workflow_script_parse",
752 21,
753 200,
754 "parse_200_line_script",
755 || Script::parse(&source).expect("the benchmark script parses"),
756 );
757 }
758}