Skip to main content

pecto_typescript/
flow.rs

1use crate::context::AnalysisContext;
2use crate::extractors::common::*;
3use pecto_core::model::*;
4
5const MAX_DEPTH: usize = 4;
6
7/// Extract request flows for TypeScript endpoints.
8pub fn extract_flows(spec: &mut ProjectSpec, ctx: &AnalysisContext) {
9    let mut flows = Vec::new();
10
11    for cap in &spec.capabilities {
12        for endpoint in &cap.endpoints {
13            let trigger = format!("{:?} {}", endpoint.method, endpoint.path);
14            let entry_point = format!("{}#{}", cap.source, cap.name);
15
16            let Some(file) = ctx.files.iter().find(|f| f.path == cap.source) else {
17                continue;
18            };
19
20            let root = file.tree.root_node();
21            let source = file.source.as_bytes();
22            let mut steps = Vec::new();
23
24            // Security step
25            if let Some(sec) = &endpoint.security
26                && sec.authentication.is_some()
27            {
28                steps.push(FlowStep {
29                    actor: cap.name.clone(),
30                    method: "guard".to_string(),
31                    kind: FlowStepKind::SecurityGuard,
32                    description: format!(
33                        "Auth: {}",
34                        sec.roles.first().map(|r| r.as_str()).unwrap_or("required")
35                    ),
36                    condition: None,
37                    children: Vec::new(),
38                });
39            }
40
41            // Trace method body via AST
42            let method_steps =
43                if let Some(method_body) = find_endpoint_method_body(&root, source, endpoint) {
44                    trace_method_body(&method_body, source, ctx, 0)
45                } else {
46                    // Fallback: text-based scanning
47                    let method_source = find_endpoint_source(&file.source, endpoint)
48                        .unwrap_or_else(|| file.source.clone());
49                    let mut fallback_steps = Vec::new();
50                    trace_source_text(&method_source, &mut fallback_steps);
51                    fallback_steps
52                };
53            steps.extend(method_steps);
54
55            // Return step
56            if let Some(b) = endpoint.behaviors.first() {
57                steps.push(FlowStep {
58                    actor: cap.name.clone(),
59                    method: "return".to_string(),
60                    kind: FlowStepKind::Return,
61                    description: format!("Return: {}", b.returns.status),
62                    condition: None,
63                    children: Vec::new(),
64                });
65            }
66
67            if steps.len() > 1 {
68                flows.push(RequestFlow {
69                    trigger,
70                    entry_point,
71                    steps,
72                });
73            }
74        }
75    }
76
77    spec.flows = flows;
78}
79
80// ==================== AST-based Tracing ====================
81
82/// Find the specific method body node for an endpoint using decorators.
83fn find_endpoint_method_body<'a>(
84    root: &'a tree_sitter::Node<'a>,
85    source: &[u8],
86    endpoint: &Endpoint,
87) -> Option<tree_sitter::Node<'a>> {
88    let method_lower = format!("{:?}", endpoint.method).to_lowercase();
89
90    for i in 0..root.named_child_count() {
91        let node = root.named_child(i).unwrap();
92
93        // NestJS class: look at class body children
94        if node.kind() == "class_declaration" || node.kind() == "export_statement" {
95            // May be wrapped in export_statement
96            let class_node = if node.kind() == "export_statement" {
97                let mut found = None;
98                for k in 0..node.named_child_count() {
99                    let c = node.named_child(k).unwrap();
100                    if c.kind() == "class_declaration" {
101                        found = Some(c);
102                        break;
103                    }
104                }
105                match found {
106                    Some(c) => c,
107                    None => continue,
108                }
109            } else {
110                node
111            };
112
113            if let Some(body) = class_node.child_by_field_name("body") {
114                // Track decorators seen before each method
115                let mut pending_decorators: Vec<String> = Vec::new();
116
117                // Use ALL children (named + unnamed) to catch decorators
118                for j in 0..body.child_count() {
119                    let member = body.child(j).unwrap();
120
121                    if member.kind() == "decorator" {
122                        let dec_text = node_text(&member, source).to_lowercase();
123                        pending_decorators.push(dec_text);
124                        continue;
125                    }
126
127                    if member.kind() == "method_definition" {
128                        // Also check decorators as direct children of method_definition
129                        let mut method_decorators = Vec::new();
130                        for k in 0..member.child_count() {
131                            let child = member.child(k).unwrap();
132                            if child.kind() == "decorator" {
133                                method_decorators.push(node_text(&child, source).to_lowercase());
134                            }
135                        }
136
137                        let all_decorators: Vec<&String> = pending_decorators
138                            .iter()
139                            .chain(method_decorators.iter())
140                            .collect();
141
142                        for dec_text in &all_decorators {
143                            let has_method = dec_text.contains(&format!("@{}(", method_lower))
144                                || dec_text.contains(&format!("@{}", method_lower));
145
146                            if !has_method {
147                                continue;
148                            }
149
150                            // Bare decorator: @Get() or @Get with no args
151                            let dec_is_bare = dec_text.contains("()")
152                                || dec_text.trim().ends_with(&format!("@{}", method_lower));
153
154                            // Does endpoint have path params like {id}?
155                            let has_path_params = endpoint.path.contains('{');
156
157                            let path_ok = if has_path_params {
158                                // Endpoint like /cats/{id} → decorator must have matching param
159                                // Find the {param} segment and check for both {param} and :param
160                                endpoint.path.rsplit('/').any(|seg| {
161                                    if seg.starts_with('{') && seg.ends_with('}') {
162                                        let colon = format!(":{}", &seg[1..seg.len() - 1]);
163                                        dec_text.contains(seg) || dec_text.contains(&colon)
164                                    } else {
165                                        false
166                                    }
167                                })
168                            } else {
169                                // Endpoint like /cats → decorator should be bare @Get()
170                                dec_is_bare
171                            };
172
173                            if path_ok {
174                                return member.child_by_field_name("body");
175                            }
176                        }
177
178                        pending_decorators.clear();
179                    } else if member.kind() != "comment" {
180                        pending_decorators.clear();
181                    }
182                }
183            }
184        }
185
186        // Next.js: export function GET
187        if node.kind() == "export_statement" {
188            for j in 0..node.named_child_count() {
189                let child = node.named_child(j).unwrap();
190                if child.kind() == "function_declaration" || child.kind() == "lexical_declaration" {
191                    let name = child
192                        .child_by_field_name("name")
193                        .map(|n| node_text(&n, source))
194                        .unwrap_or_default();
195                    let method_upper = format!("{:?}", endpoint.method);
196                    if name == method_upper {
197                        return child.child_by_field_name("body");
198                    }
199                }
200            }
201        }
202    }
203    None
204}
205
206/// Trace a method body node, resolving internal method calls.
207fn trace_method_body(
208    body: &tree_sitter::Node,
209    source: &[u8],
210    _ctx: &AnalysisContext,
211    depth: usize,
212) -> Vec<FlowStep> {
213    let class_body = find_enclosing_class_body(body);
214    let mut steps = Vec::new();
215    if depth >= MAX_DEPTH {
216        return steps;
217    }
218    trace_node_recursive(body, source, depth, &mut steps, class_body.as_ref());
219    steps
220}
221
222/// Walk up the tree to find the enclosing class body.
223fn find_enclosing_class_body<'a>(node: &'a tree_sitter::Node<'a>) -> Option<tree_sitter::Node<'a>> {
224    let mut current = node.parent();
225    while let Some(n) = current {
226        if n.kind() == "class_declaration" || n.kind() == "class" {
227            return n.child_by_field_name("body");
228        }
229        current = n.parent();
230    }
231    None
232}
233
234/// Find a method by name within a class body.
235fn find_method_in_class<'a>(
236    class_body: &'a tree_sitter::Node<'a>,
237    method_name: &str,
238    source: &[u8],
239) -> Option<tree_sitter::Node<'a>> {
240    for i in 0..class_body.named_child_count() {
241        let member = class_body.named_child(i).unwrap();
242        if member.kind() == "method_definition"
243            && let Some(name_node) = member.child_by_field_name("name")
244            && node_text(&name_node, source) == method_name
245        {
246            return member.child_by_field_name("body");
247        }
248    }
249    None
250}
251
252/// Recursively walk AST nodes, extracting flow steps.
253fn trace_node_recursive(
254    node: &tree_sitter::Node,
255    source: &[u8],
256    depth: usize,
257    steps: &mut Vec<FlowStep>,
258    class_body: Option<&tree_sitter::Node>,
259) {
260    match node.kind() {
261        "call_expression" => {
262            let text = node_text(node, source);
263
264            // Check for this.method() or this.service.method() calls
265            if let Some(func) = node.child_by_field_name("function")
266                && func.kind() == "member_expression"
267                && let Some(obj) = func.child_by_field_name("object")
268            {
269                let obj_text = node_text(&obj, source);
270
271                // this.method() → try to resolve as internal class method
272                if obj_text == "this"
273                    && let Some(prop) = func.child_by_field_name("property")
274                {
275                    let method_name = node_text(&prop, source);
276                    if let Some(cb) = class_body
277                        && depth < MAX_DEPTH
278                        && let Some(target_body) = find_method_in_class(cb, &method_name, source)
279                    {
280                        trace_node_recursive(&target_body, source, depth + 1, steps, Some(cb));
281                        return;
282                    }
283                }
284
285                // this.service.method() → service call
286                if obj.kind() == "member_expression"
287                    && let Some(inner_obj) = obj.child_by_field_name("object")
288                    && node_text(&inner_obj, source) == "this"
289                    && let Some(svc) = obj.child_by_field_name("property")
290                    && let Some(method) = func.child_by_field_name("property")
291                {
292                    let svc_name = node_text(&svc, source);
293                    let method_name = node_text(&method, source);
294                    if !is_excluded_ts_method(&method_name) {
295                        steps.push(FlowStep {
296                            actor: svc_name.clone(),
297                            method: method_name.clone(),
298                            kind: FlowStepKind::ServiceCall,
299                            description: format!("Call: {}.{}()", svc_name, method_name),
300                            condition: None,
301                            children: Vec::new(),
302                        });
303                        return;
304                    }
305                }
306            }
307
308            // Classify the call by text patterns
309            if let Some(step) = classify_method_call(&text) {
310                steps.push(step);
311                return;
312            }
313        }
314        "throw_statement" => {
315            let text = node_text(node, source);
316            let exception = text
317                .split("new ")
318                .nth(1)
319                .and_then(|s| s.split('(').next())
320                .unwrap_or("Error")
321                .trim();
322            steps.push(FlowStep {
323                actor: "".to_string(),
324                method: "throw".to_string(),
325                kind: FlowStepKind::ThrowException,
326                description: format!("throw {}", exception),
327                condition: None,
328                children: Vec::new(),
329            });
330            return;
331        }
332        "if_statement" => {
333            let condition_text = node
334                .child_by_field_name("condition")
335                .map(|c| node_text(&c, source))
336                .unwrap_or_default();
337
338            let mut if_children = Vec::new();
339            if let Some(consequence) = node.child_by_field_name("consequence") {
340                trace_node_recursive(
341                    &consequence,
342                    source,
343                    depth + 1,
344                    &mut if_children,
345                    class_body,
346                );
347            }
348
349            let mut else_children = Vec::new();
350            if let Some(alternative) = node.child_by_field_name("alternative") {
351                trace_node_recursive(
352                    &alternative,
353                    source,
354                    depth + 1,
355                    &mut else_children,
356                    class_body,
357                );
358            }
359
360            if !if_children.is_empty() || !else_children.is_empty() {
361                steps.push(FlowStep {
362                    actor: "".to_string(),
363                    method: "if".to_string(),
364                    kind: FlowStepKind::Condition,
365                    description: format!("if {}", condition_text),
366                    condition: Some(condition_text),
367                    children: if_children,
368                });
369                if !else_children.is_empty() {
370                    steps.push(FlowStep {
371                        actor: "".to_string(),
372                        method: "else".to_string(),
373                        kind: FlowStepKind::Condition,
374                        description: "else".to_string(),
375                        condition: Some("else".to_string()),
376                        children: else_children,
377                    });
378                }
379                return;
380            }
381        }
382        _ => {}
383    }
384
385    // Recurse into children
386    for i in 0..node.child_count() {
387        let child = node.child(i).unwrap();
388        trace_node_recursive(&child, source, depth, steps, class_body);
389    }
390}
391
392/// Classify a method call text into a FlowStep.
393fn classify_method_call(text: &str) -> Option<FlowStep> {
394    // DB write operations
395    if text.contains(".save(")
396        || text.contains(".insert(")
397        || text.contains(".update(")
398        || text.contains(".insertOne(")
399        || text.contains(".updateOne(")
400    {
401        let target = text.split('.').next().unwrap_or("").trim();
402        return Some(FlowStep {
403            actor: target.to_string(),
404            method: "save".to_string(),
405            kind: FlowStepKind::DbWrite,
406            description: format!("DB write: {}", target),
407            condition: None,
408            children: Vec::new(),
409        });
410    }
411
412    if text.contains(".create(") && !text.contains("createElement") {
413        let target = text.split('.').next().unwrap_or("").trim();
414        // Heuristic: .create() on a model/repository is a DB write
415        if !target.is_empty()
416            && target.chars().next().is_some_and(|c| c.is_lowercase())
417            && !matches!(target, "document" | "Object" | "Array")
418        {
419            return Some(FlowStep {
420                actor: target.to_string(),
421                method: "create".to_string(),
422                kind: FlowStepKind::DbWrite,
423                description: format!("DB write: {}.create()", target),
424                condition: None,
425                children: Vec::new(),
426            });
427        }
428    }
429
430    if text.contains(".delete(") || text.contains(".remove(") || text.contains(".deleteOne(") {
431        let target = text.split('.').next().unwrap_or("").trim();
432        return Some(FlowStep {
433            actor: target.to_string(),
434            method: "delete".to_string(),
435            kind: FlowStepKind::DbWrite,
436            description: format!("DB delete: {}", target),
437            condition: None,
438            children: Vec::new(),
439        });
440    }
441
442    // DB read operations
443    if text.contains(".find(")
444        || text.contains(".findOne(")
445        || text.contains(".findById(")
446        || text.contains(".findAll(")
447        || text.contains(".findOneBy(")
448        || text.contains(".query(")
449    {
450        let target = text.split('.').next().unwrap_or("").trim();
451        return Some(FlowStep {
452            actor: target.to_string(),
453            method: "find".to_string(),
454            kind: FlowStepKind::DbRead,
455            description: format!("DB read: {}", target),
456            condition: None,
457            children: Vec::new(),
458        });
459    }
460
461    // Event publishing
462    if text.contains(".emit(") || text.contains(".publish(") {
463        let event = text
464            .split(".emit(")
465            .nth(1)
466            .or_else(|| text.split(".publish(").nth(1))
467            .and_then(|s| s.split(')').next())
468            .unwrap_or("event");
469        return Some(FlowStep {
470            actor: "EventBus".to_string(),
471            method: "emit".to_string(),
472            kind: FlowStepKind::EventPublish,
473            description: format!("Emit: {}", event.chars().take(60).collect::<String>()),
474            condition: None,
475            children: Vec::new(),
476        });
477    }
478
479    // Service calls: object.method() where object is a lowercase field
480    if text.contains('.') && text.contains('(') {
481        let parts: Vec<&str> = text.splitn(2, '.').collect();
482        if parts.len() == 2 {
483            let target = parts[0].trim();
484            let method = parts[1].split('(').next().unwrap_or("").trim();
485
486            // this.service.method() → extract service.method
487            if target == "this" && parts[1].contains('.') {
488                let inner_parts: Vec<&str> = parts[1].splitn(2, '.').collect();
489                if inner_parts.len() == 2 {
490                    let service = inner_parts[0].trim();
491                    let svc_method = inner_parts[1].split('(').next().unwrap_or("").trim();
492                    if !service.is_empty()
493                        && service.chars().next().is_some_and(|c| c.is_lowercase())
494                        && !is_excluded_ts_method(svc_method)
495                    {
496                        return Some(FlowStep {
497                            actor: service.to_string(),
498                            method: svc_method.to_string(),
499                            kind: FlowStepKind::ServiceCall,
500                            description: format!("Call: {}.{}()", service, svc_method),
501                            condition: None,
502                            children: Vec::new(),
503                        });
504                    }
505                }
506            }
507
508            if !target.is_empty()
509                && target.chars().next().is_some_and(|c| c.is_lowercase())
510                && !is_excluded_ts_target(target)
511                && !is_excluded_ts_method(method)
512            {
513                return Some(FlowStep {
514                    actor: target.to_string(),
515                    method: method.to_string(),
516                    kind: FlowStepKind::ServiceCall,
517                    description: format!("Call: {}.{}()", target, method),
518                    condition: None,
519                    children: Vec::new(),
520                });
521            }
522        }
523    }
524
525    None
526}
527
528fn is_excluded_ts_target(target: &str) -> bool {
529    matches!(
530        target,
531        "this"
532            | "console"
533            | "Math"
534            | "JSON"
535            | "Object"
536            | "Array"
537            | "Promise"
538            | "Buffer"
539            | "Date"
540            | "RegExp"
541            | "Error"
542            | "process"
543            | "window"
544            | "document"
545            | "response"
546            | "res"
547            | "req"
548            | "request"
549    )
550}
551
552fn is_excluded_ts_method(method: &str) -> bool {
553    matches!(
554        method,
555        "toString"
556            | "valueOf"
557            | "map"
558            | "filter"
559            | "reduce"
560            | "forEach"
561            | "then"
562            | "catch"
563            | "finally"
564            | "push"
565            | "pop"
566            | "shift"
567            | "unshift"
568            | "join"
569            | "split"
570            | "slice"
571            | "splice"
572            | "concat"
573            | "includes"
574            | "indexOf"
575            | "keys"
576            | "values"
577            | "entries"
578            | "log"
579            | "warn"
580            | "error"
581            | "info"
582            | "debug"
583            | "status"
584            | "json"
585            | "send"
586            | "pipe"
587            | "subscribe"
588            | "toPromise"
589            | "bind"
590            | "apply"
591            | "call"
592    )
593}
594
595// ==================== Text-based Fallback ====================
596
597/// Find endpoint source text (for non-NestJS: Express, Next.js).
598fn find_endpoint_source(source: &str, endpoint: &Endpoint) -> Option<String> {
599    let method_lower = format!("{:?}", endpoint.method).to_lowercase();
600    let method_upper = format!("{:?}", endpoint.method);
601
602    let lines: Vec<&str> = source.lines().collect();
603
604    for (i, line) in lines.iter().enumerate() {
605        let trimmed = line.trim();
606
607        // Express: router.get('/path', ...) or app.get('/path', ...)
608        if trimmed.contains(&format!(".{}(", method_lower)) && trimmed.contains(&endpoint.path) {
609            return extract_brace_block(&lines, i);
610        }
611
612        // Next.js: export function GET
613        if trimmed.contains(&format!("function {}", method_upper))
614            || trimmed.contains(&format!("const {} ", method_upper))
615        {
616            return extract_brace_block(&lines, i);
617        }
618    }
619    None
620}
621
622fn extract_brace_block(lines: &[&str], start: usize) -> Option<String> {
623    let mut depth = 0;
624    let mut started = false;
625    let mut result = Vec::new();
626
627    for line in &lines[start..] {
628        for ch in line.chars() {
629            if ch == '{' {
630                depth += 1;
631                started = true;
632            } else if ch == '}' {
633                depth -= 1;
634            }
635        }
636        if started {
637            result.push(*line);
638        }
639        if started && depth == 0 {
640            return Some(result.join("\n"));
641        }
642    }
643    None
644}
645
646fn trace_source_text(source: &str, steps: &mut Vec<FlowStep>) {
647    for line in source.lines() {
648        let trimmed = line.trim();
649
650        if trimmed.contains(".save(") || trimmed.contains(".create(") {
651            steps.push(FlowStep {
652                actor: "Repository".to_string(),
653                method: "save".to_string(),
654                kind: FlowStepKind::DbWrite,
655                description: "DB write".to_string(),
656                condition: None,
657                children: Vec::new(),
658            });
659        } else if trimmed.contains(".find(") || trimmed.contains(".findOne(") {
660            steps.push(FlowStep {
661                actor: "Repository".to_string(),
662                method: "find".to_string(),
663                kind: FlowStepKind::DbRead,
664                description: "DB read".to_string(),
665                condition: None,
666                children: Vec::new(),
667            });
668        } else if trimmed.contains(".emit(") || trimmed.contains(".publish(") {
669            steps.push(FlowStep {
670                actor: "EventBus".to_string(),
671                method: "emit".to_string(),
672                kind: FlowStepKind::EventPublish,
673                description: "Emit event".to_string(),
674                condition: None,
675                children: Vec::new(),
676            });
677        }
678    }
679}