Skip to main content

comindly_core/resolve/
contracts.rs

1//! Contract graph: HTTP routes ↔ clients, queues/topics, buckets and tables, keyed so that
2//! providers and consumers meet across repos. Values resolve through constants, settings, env.
3
4use std::collections::HashMap;
5
6use sqlparser::ast::{ObjectName, Statement, TableFactor, TableWithJoins};
7use sqlparser::dialect::GenericDialect;
8use sqlparser::parser::Parser;
9
10use super::{Binder, Value};
11use crate::facts::{Args, ContractFact, FileFacts, ImportItem, TemplatePart, ValueExpr};
12use crate::model::{
13    EdgeKind, GlobalSymbolId, Language, Position, Range, RepoId, Symbol, SymbolKind,
14};
15
16const HTTP_VERBS: &[&str] = &["get", "post", "put", "patch", "delete", "head", "options"];
17const HTTP_CLIENTS: &[&str] = &[
18    "httpx.Client",
19    "httpx.AsyncClient",
20    "requests.Session",
21    "aiohttp.ClientSession",
22];
23const HTTP_MODULES: &[&str] = &["httpx", "requests", "axios", "ky", "got"];
24const ROUTERS: &[&str] = &[
25    "fastapi.APIRouter",
26    "fastapi.FastAPI",
27    "express",
28    "express.Router",
29];
30const DB_READS: &[&str] = &["get", "query", "select", "scalars"];
31const DB_WRITES: &[&str] = &["add", "merge", "delete", "insert", "update"];
32/// Calls whose value is their first argument's (`Path("/x")`, `str(x)`).
33const PASS_THROUGH: &[&str] = &["Path", "PurePath", "PurePosixPath", "str", "URL", "HttpUrl"];
34/// Calls building record-like values whose keyword arguments are attributes.
35const RECORDS: &[&str] = &["AttrDict", "dict", "SimpleNamespace", "Namespace"];
36
37/// The repo-like owner of contract nodes.
38pub const CONTRACTS: &str = "_contracts";
39
40#[derive(Clone)]
41enum Client {
42    Http,
43    Aws(String),
44}
45
46pub(super) struct Linked {
47    pub symbols: Vec<Symbol>,
48    pub edges: Vec<(String, String, EdgeKind)>,
49}
50
51pub(super) fn link(b: &Binder, facts: &[FileFacts], calls: &[(String, String)]) -> Linked {
52    let mut cx = Cx::new(b, facts);
53    cx.performing(facts, calls);
54    let mut out = Linked {
55        symbols: Vec::new(),
56        edges: Vec::new(),
57    };
58    let mut emit = |src: &str, key: String, kind: EdgeKind| {
59        // Contracts describe production topology; tests calling routes or fake buckets are noise.
60        if b.file_facts_of(src).is_some_and(|f| test_path(&f.path)) {
61            return;
62        }
63        out.edges
64            .push((src.to_string(), contract_id(&key).render(), kind));
65        if !out.symbols.iter().any(|s: &Symbol| s.name == key) {
66            out.symbols.push(contract_symbol(&key));
67        }
68    };
69
70    for f in facts {
71        for c in &f.contracts {
72            match c {
73                ContractFact::Table { class, name } => {
74                    emit(class, format!("table:{name}"), EdgeKind::Defines)
75                }
76                ContractFact::Route {
77                    handler,
78                    receiver,
79                    method,
80                    path,
81                    client,
82                    ..
83                } => {
84                    // Read the path where the handler lives (a class for Spring, else the file).
85                    let at = cx.b.parent.get(handler).unwrap_or(&f.file_id);
86                    let Some(p) = cx.value(path, at, 0) else {
87                        continue;
88                    };
89                    // File-routed handlers (Next.js) carry their full path and no router.
90                    let fulls = if receiver.is_empty() {
91                        vec![p]
92                    } else {
93                        let Some(router) = cx.var_of(receiver, &f.file_id, f) else {
94                            continue;
95                        };
96                        if !cx.routers.contains_key(&router) {
97                            continue;
98                        }
99                        cx.prefixes(&router, 0)
100                            .into_iter()
101                            .map(|pre| format!("{pre}{p}"))
102                            .collect()
103                    };
104                    let kind = if *client {
105                        EdgeKind::Consumes
106                    } else {
107                        EdgeKind::Serves
108                    };
109                    for full in fulls {
110                        emit(
111                            handler,
112                            format!("http:{method} {}", http_path(&full)),
113                            kind.clone(),
114                        );
115                    }
116                }
117                ContractFact::Handle {
118                    from,
119                    receiver,
120                    method,
121                    path,
122                    handler,
123                    ..
124                } => {
125                    let Some(p) = cx.value(path, from, 0) else {
126                        continue;
127                    };
128                    let p = format!("{}{p}", cx.group_prefix(from, receiver, 0));
129                    let src = match cx.b.resolve_path(handler, from, f, 0) {
130                        Some(Value::Sym(id)) if !handler.is_empty() => id,
131                        _ => from.clone(),
132                    };
133                    emit(
134                        &src,
135                        format!("http:{method} {}", http_path(&p)),
136                        EdgeKind::Serves,
137                    );
138                }
139                ContractFact::Op {
140                    from,
141                    receiver,
142                    method,
143                    args,
144                    ..
145                } => cx.op(f, from, receiver, method, args, &mut emit),
146                ContractFact::Construct { .. } | ContractFact::Infra { .. } => {}
147            }
148        }
149    }
150    cx.propagate(facts, &mut emit);
151    link_infra(b, facts, &mut out);
152    suffix_match(&mut out);
153    let mut seen = std::collections::HashSet::new();
154    out.edges
155        .retain(|(a, b, k)| seen.insert((a.clone(), b.clone(), k.as_str())));
156    out
157}
158
159/// IaC declarations and topic→queue subscriptions. A module's logical name (`inference-tasks`)
160/// joins the one contract of that kind the same repo's code uses whose name ends with it.
161fn link_infra(b: &Binder, facts: &[FileFacts], out: &mut Linked) {
162    let repo_of = |id: &str| b.file_facts_of(id).map(|f| f.repo.clone());
163    let mut used: Vec<(String, String)> = Vec::new(); // (repo, contract name)
164    let names: HashMap<String, String> = out
165        .symbols
166        .iter()
167        .map(|s| (s.id.render(), s.name.clone()))
168        .collect();
169    for (src, dst, _) in &out.edges {
170        if let (Some(r), Some(n)) = (repo_of(src), names.get(dst)) {
171            used.push((r, n.clone()));
172        }
173    }
174    // Full (non-logical) IaC names are candidates too: another repo may name this one's topic.
175    for f in facts {
176        for c in &f.contracts {
177            if let ContractFact::Infra {
178                declares,
179                subscribes,
180            } = c
181            {
182                for r in std::iter::once(declares)
183                    .chain(subscribes)
184                    .filter(|r| !r.logical)
185                {
186                    used.push((String::new(), format!("{}:{}", r.scheme, untier(&r.name))));
187                }
188            }
189        }
190    }
191    let key = |r: &crate::facts::InfraRef, repo: &str| -> String {
192        let full = format!("{}:{}", r.scheme, untier(&r.name));
193        if !r.logical {
194            return full;
195        }
196        let want = name_tokens(&untier(&r.name));
197        let own = name_tokens(repo);
198        let contains = |have: &[String], part: &[String]| {
199            !part.is_empty() && have.windows(part.len()).any(|w| w == part)
200        };
201        let mut hits: Vec<&String> = used
202            .iter()
203            .filter(|(_, n)| n.starts_with(&format!("{}:", r.scheme)))
204            .filter(|(rr, n)| {
205                let have = name_tokens(&n[r.scheme.len() + 1..]);
206                let ends = have.len() >= want.len() && have[have.len() - want.len()..] == want[..];
207                // Used by this repo's code, or a full name that carries this repo's name.
208                ends && (rr == repo || contains(&have, &own))
209            })
210            .map(|(_, n)| n)
211            .collect();
212        hits.sort();
213        hits.dedup();
214        match hits.as_slice() {
215            [one] => one.to_string(),
216            _ => full,
217        }
218    };
219    let add = |src: String, k: String, kind: EdgeKind, out: &mut Linked| {
220        let e = (src, contract_id(&k).render(), kind);
221        if !out.edges.contains(&e) {
222            out.edges.push(e);
223        }
224        if !out.symbols.iter().any(|s| s.name == k) {
225            out.symbols.push(contract_symbol(&k));
226        }
227    };
228    for f in facts {
229        for c in &f.contracts {
230            let ContractFact::Infra {
231                declares,
232                subscribes,
233            } = c
234            else {
235                continue;
236            };
237            let q = key(declares, &f.repo);
238            add(f.file_id.clone(), q.clone(), EdgeKind::Defines, out);
239            if let Some(t) = subscribes {
240                let t = key(t, &f.repo);
241                add(contract_id(&q).render(), t, EdgeKind::Subscribes, out);
242            }
243        }
244    }
245}
246
247/// A client sees paths below its configured base URL (often `/api/v1`), so a consumer-only
248/// HTTP contract links to the one provider route it is a segment-aligned suffix of.
249fn suffix_match(out: &mut Linked) {
250    let ids: HashMap<String, String> = out
251        .symbols
252        .iter()
253        .map(|s| (s.id.render(), s.name.clone()))
254        .collect();
255    let served: Vec<&String> = out
256        .edges
257        .iter()
258        .filter(|(_, _, k)| *k == EdgeKind::Serves)
259        .filter_map(|(_, d, _)| ids.get(d))
260        .collect();
261    let mut redirect: HashMap<String, String> = HashMap::new();
262    for (id, name) in &ids {
263        let Some((verb, path)) = name.strip_prefix("http:").and_then(|r| r.split_once(' ')) else {
264            continue;
265        };
266        if served.contains(&name) || path.matches('/').count() < 2 {
267            continue;
268        }
269        let cands: Vec<&&String> = served
270            .iter()
271            .filter(|p| {
272                p.strip_prefix("http:")
273                    .and_then(|r| r.split_once(' '))
274                    .is_some_and(|(v, pp)| v == verb && pp != path && pp.ends_with(path))
275            })
276            .collect();
277        let mut uniq: Vec<&String> = cands.into_iter().copied().collect();
278        uniq.sort();
279        uniq.dedup();
280        if let [target] = uniq.as_slice() {
281            redirect.insert(id.clone(), contract_id(target).render());
282        }
283    }
284    for e in &mut out.edges {
285        if let Some(t) = redirect.get(&e.1) {
286            e.1 = t.clone();
287        }
288    }
289    out.symbols
290        .retain(|s| !redirect.contains_key(&s.id.render()));
291}
292
293struct Cx<'b, 'a> {
294    b: &'b Binder<'a>,
295    values: HashMap<(&'a str, &'a str), &'a ValueExpr>,
296    /// Client registry: (scope, target name) → client kind (+ base URL value).
297    clients: HashMap<(String, String), Client>,
298    /// Router variable id → own prefix.
299    routers: HashMap<String, String>,
300    /// Router variable id → (parent app/router id, include prefix).
301    mounts: HashMap<String, Vec<(String, String)>>,
302    /// Classes/interfaces whose methods (transitively) perform HTTP, plus their supertypes.
303    http_types: std::collections::HashSet<String>,
304    /// Go route groups: (scope, variable) → (parent receiver, prefix).
305    groups: HashMap<(String, String), (Vec<String>, String)>,
306    /// Repo → (dotted config key, value), from YAML config files, in file order.
307    config: HashMap<&'a str, Vec<(&'a str, &'a str)>>,
308    /// Callable id → parameter names (receiver excluded).
309    params: HashMap<&'a str, &'a [String]>,
310    /// Callable id → what it does with its parameters (`send_message(QueueUrl=<param>)`).
311    summaries: std::cell::RefCell<HashMap<String, Vec<Summary>>>,
312}
313
314#[derive(Clone, Copy, PartialEq)]
315enum Scheme {
316    Sqs,
317    Sns,
318    S3,
319}
320
321/// A callable passes its parameter `param` (position `index`) to a contract operation.
322#[derive(Clone, PartialEq)]
323struct Summary {
324    param: String,
325    index: usize,
326    /// Attribute path read off the parameter (`config.queue` → `["queue"]`).
327    field: Vec<String>,
328    scheme: Scheme,
329    kind: EdgeKind,
330}
331
332/// A parameter origin: callable, parameter name, position, attribute path.
333type Origin = (String, String, usize, Vec<String>);
334
335/// A parameter's position, or `usize::MAX` when it is keyword-only (after a `*` marker).
336fn positional(names: &[String], p: &str) -> Option<usize> {
337    let i = names.iter().position(|n| n == p)?;
338    Some(if names[..i].iter().any(|n| n == "*") {
339        usize::MAX
340    } else {
341        i
342    })
343}
344
345fn scheme_key(s: Scheme, v: &str) -> String {
346    match s {
347        Scheme::Sqs => format!("sqs:{}", untier(&queue_name(v))),
348        Scheme::Sns => format!("sns:{}", untier(&arn_name(v))),
349        Scheme::S3 => format!("s3:{}", untier(&bucket(v))),
350    }
351}
352
353/// boto3 operations: the argument naming the resource (keyword, position) and the edge.
354fn aws_spec(svc: &str, method: &str) -> Option<(&'static [&'static str], usize, Scheme, EdgeKind)> {
355    const ANY: usize = usize::MAX;
356    Some(match (svc, method) {
357        ("sqs", "send_message" | "send_message_batch") => {
358            (&["QueueUrl"], ANY, Scheme::Sqs, EdgeKind::Publishes)
359        }
360        ("sqs", "receive_message") => (&["QueueUrl"], ANY, Scheme::Sqs, EdgeKind::Subscribes),
361        ("sns", "publish" | "publish_batch") => (
362            &["TopicArn", "TargetArn"],
363            ANY,
364            Scheme::Sns,
365            EdgeKind::Publishes,
366        ),
367        ("s3", "put_object" | "delete_object" | "copy_object") => {
368            (&["Bucket"], ANY, Scheme::S3, EdgeKind::Writes)
369        }
370        ("s3", "upload_file" | "upload_fileobj") => (&["Bucket"], 1, Scheme::S3, EdgeKind::Writes),
371        ("s3", "get_object" | "head_object" | "list_objects_v2") => {
372            (&["Bucket"], ANY, Scheme::S3, EdgeKind::Reads)
373        }
374        ("s3", "download_file" | "download_fileobj") => {
375            (&["Bucket"], 0, Scheme::S3, EdgeKind::Reads)
376        }
377        _ => return None,
378    })
379}
380
381/// The AWS service a call targets, from boto3's distinctive keyword names, when the client
382/// itself was built somewhere we cannot see (`client = await self._sqs()`).
383fn infer_aws(method: &str, has: impl Fn(&str) -> bool) -> Option<&'static str> {
384    ["sqs", "sns", "s3"]
385        .into_iter()
386        .find(|svc| aws_spec(svc, method).is_some_and(|(kws, _, _, _)| kws.iter().any(|k| has(k))))
387}
388
389/// Attribute names that denote the resource itself (`topic.arn`, `queue.url`).
390const RESOURCE_ATTRS: &[&str] = &["arn", "url", "name", "id"];
391
392impl<'b, 'a> Cx<'b, 'a> {
393    fn new(b: &'b Binder<'a>, facts: &'a [FileFacts]) -> Self {
394        let mut cx = Cx {
395            b,
396            values: HashMap::new(),
397            clients: HashMap::new(),
398            routers: HashMap::new(),
399            mounts: HashMap::new(),
400            http_types: std::collections::HashSet::new(),
401            groups: HashMap::new(),
402            params: HashMap::new(),
403            summaries: Default::default(),
404            config: HashMap::new(),
405        };
406        for f in facts
407            .iter()
408            .filter(|f| f.file_id.starts_with(crate::configvals::SCOPE))
409        {
410            for v in &f.values {
411                if let ValueExpr::Lit(lit) = &v.value {
412                    cx.config
413                        .entry(f.repo.as_str())
414                        .or_default()
415                        .push((v.name.as_str(), lit.as_str()));
416                }
417            }
418        }
419        for f in facts {
420            for p in &f.params {
421                cx.params.insert(p.func.as_str(), p.names.as_slice());
422            }
423        }
424        for f in facts {
425            for v in &f.values {
426                cx.values
427                    .insert((v.scope.as_str(), v.name.as_str()), &v.value);
428            }
429        }
430        for f in facts {
431            for c in &f.contracts {
432                let ContractFact::Construct {
433                    scope,
434                    target,
435                    ctor,
436                    args,
437                } = c
438                else {
439                    continue;
440                };
441                if f.language == Language::Go && ctor.last().is_some_and(|c| c == "Group") {
442                    let prefix = args
443                        .positional
444                        .first()
445                        .and_then(Option::as_ref)
446                        .and_then(|v| cx.value(v, scope, 0))
447                        .unwrap_or_default();
448                    let parent = ctor[..ctor.len() - 1].to_vec();
449                    if parent == [target.clone()] {
450                        continue;
451                    }
452                    cx.groups
453                        .insert((scope.clone(), target.clone()), (parent, prefix));
454                    continue;
455                }
456                let Some(ext) = cx.external(ctor, scope, f) else {
457                    continue;
458                };
459                if ROUTERS.contains(&ext.as_str()) {
460                    if let Some(var) = cx.var_of(std::slice::from_ref(target), scope, f) {
461                        let prefix = args
462                            .get("prefix", usize::MAX)
463                            .and_then(|v| cx.value(v, scope, 0))
464                            .unwrap_or_default();
465                        cx.routers.insert(var, prefix);
466                    }
467                } else if HTTP_CLIENTS.contains(&ext.as_str()) {
468                    cx.clients
469                        .insert((scope.clone(), target.clone()), Client::Http);
470                } else if ext == "boto3.client" || ext == "boto3.resource" {
471                    if let Some(svc) = args
472                        .get("service_name", 0)
473                        .and_then(|v| cx.value(v, scope, 0))
474                    {
475                        cx.clients
476                            .insert((scope.clone(), target.clone()), Client::Aws(svc));
477                    }
478                }
479            }
480        }
481        for f in facts {
482            for c in &f.contracts {
483                let ContractFact::Op {
484                    from,
485                    receiver,
486                    method,
487                    args,
488                    ..
489                } = c
490                else {
491                    continue;
492                };
493                // FastAPI `include_router(r, prefix=…)`, Express `use("/prefix", r)`.
494                let (router, prefix) = match method.as_str() {
495                    "include_router" => (
496                        args.positional.first(),
497                        args.get("prefix", usize::MAX)
498                            .and_then(|v| cx.value(v, from, 0)),
499                    ),
500                    "use" if args.positional.len() >= 2 => (
501                        args.positional.last(),
502                        args.positional
503                            .first()
504                            .and_then(Option::as_ref)
505                            .and_then(|v| cx.value(v, from, 0)),
506                    ),
507                    _ => continue,
508                };
509                let parent = cx.var_of(receiver, from, f);
510                let router = match router {
511                    Some(Some(ValueExpr::Name(p))) => cx.var_of(p, from, f),
512                    _ => None,
513                };
514                if let (Some(parent), Some(router)) = (parent, router) {
515                    cx.mounts
516                        .entry(router)
517                        .or_default()
518                        .push((parent, prefix.unwrap_or_default()));
519                }
520            }
521        }
522        cx
523    }
524
525    /// Mark types whose methods reach `fetch`/`axios` through the call graph (TS/JS wrappers).
526    fn performing(&mut self, facts: &[FileFacts], calls: &[(String, String)]) {
527        let mut doing: std::collections::HashSet<String> = std::collections::HashSet::new();
528        for f in facts
529            .iter()
530            .filter(|f| matches!(f.language, Language::TypeScript | Language::JavaScript))
531        {
532            for c in &f.contracts {
533                if let ContractFact::Op {
534                    from,
535                    receiver,
536                    method,
537                    ..
538                } = c
539                {
540                    if (receiver.is_empty() && method == "fetch")
541                        || self.direct_http(receiver, from, f)
542                    {
543                        doing.insert(from.clone());
544                    }
545                }
546            }
547        }
548        let mut callers: HashMap<&str, Vec<&str>> = HashMap::new();
549        for (src, dst) in calls {
550            callers.entry(dst.as_str()).or_default().push(src.as_str());
551        }
552        let mut queue: Vec<String> = doing.iter().cloned().collect();
553        while let Some(n) = queue.pop() {
554            for c in callers.get(n.as_str()).into_iter().flatten() {
555                if doing.len() < 100_000 && doing.insert(c.to_string()) {
556                    queue.push(c.to_string());
557                }
558            }
559        }
560        let mut types: Vec<String> = doing
561            .iter()
562            .filter_map(|m| self.b.parent.get(m))
563            .filter(|p| self.b.is_type(p))
564            .cloned()
565            .collect();
566        while let Some(t) = types.pop() {
567            if self.http_types.insert(t.clone()) {
568                types.extend(self.b.bases.borrow().get(&t).cloned().unwrap_or_default());
569            }
570        }
571    }
572
573    fn direct_http(&self, receiver: &[String], from: &str, f: &FileFacts) -> bool {
574        receiver.len() == 1
575            && self
576                .external(receiver, from, f)
577                .is_some_and(|m| HTTP_MODULES.contains(&m.as_str()))
578    }
579
580    /// Prefix of a Go route group variable, following nested groups.
581    fn group_prefix(&self, scope: &str, receiver: &[String], depth: u32) -> String {
582        let [name] = receiver else {
583            return String::new();
584        };
585        match self.groups.get(&(scope.to_string(), name.clone())) {
586            Some((parent, p)) if depth < 8 => {
587                format!("{}{p}", self.group_prefix(scope, parent, depth + 1))
588            }
589            _ => String::new(),
590        }
591    }
592
593    /// Full mount prefix of a router: parents' include prefixes, then its own prefix.
594    /// Every full mount prefix of a router (a router can be mounted in several places).
595    fn prefixes(&self, router: &str, depth: u32) -> Vec<String> {
596        let own = self.routers.get(router).cloned().unwrap_or_default();
597        match self.mounts.get(router) {
598            Some(ms) if depth < 8 => {
599                let mut out: Vec<String> = ms
600                    .iter()
601                    .flat_map(|(parent, p)| {
602                        self.prefixes(parent, depth + 1)
603                            .into_iter()
604                            .map(move |pp| format!("{pp}{p}"))
605                    })
606                    .map(|pre| format!("{pre}{own}"))
607                    .collect();
608                out.sort();
609                out.dedup();
610                out
611            }
612            _ => vec![own],
613        }
614    }
615
616    /// Dotted external name of a path whose head is imported from a third-party module.
617    fn external(&self, path: &[String], scope: &str, f: &FileFacts) -> Option<String> {
618        let (head, rest) = path.split_first()?;
619        let base = match self.b.resolve_name(head, scope, f, 0) {
620            Some(Value::Module { name, .. })
621                if !self
622                    .b
623                    .modules
624                    .contains_key(&(super::Family::Py, name.as_str())) =>
625            {
626                name
627            }
628            Some(_) => return None,
629            // `from fastapi import APIRouter`: bound by an import the corpus can't resolve.
630            None => f.imports.iter().find_map(|imp| {
631                imp.items.iter().find_map(|item| match item {
632                    // A default import names the module itself (`import axios from "axios"`).
633                    ImportItem::Name { name, alias }
634                        if imp.level == 0
635                            && name == "default"
636                            && alias.as_deref() == Some(head) =>
637                    {
638                        Some(imp.module.clone())
639                    }
640                    ImportItem::Name { name, alias }
641                        if imp.level == 0 && alias.as_deref().unwrap_or(name) == head =>
642                    {
643                        Some(format!("{}.{name}", imp.module))
644                    }
645                    ImportItem::Module { alias: Some(a) } if a == head => Some(imp.module.clone()),
646                    _ => None,
647                })
648            })?,
649        };
650        let mut parts = vec![base];
651        parts.extend(rest.iter().cloned());
652        Some(parts.join("."))
653    }
654
655    /// The module-variable symbol a path names (routers and apps are module variables).
656    fn var_of(&self, path: &[String], scope: &str, f: &FileFacts) -> Option<String> {
657        let found = match path {
658            [name] => self.b.module_member(&f.file_id, name, true, 0),
659            _ => self.b.resolve_path(path, scope, f, 0),
660        };
661        match found? {
662            Value::Sym(id) => Some(id),
663            _ => None,
664        }
665    }
666
667    fn client(&self, receiver: &[String], from: &str, f: &FileFacts) -> Option<Client> {
668        if let ([recv, attr], true) = (receiver, receiver.first().is_some_and(|r| r == "self")) {
669            let class = self.b.enclosing_class(from)?;
670            return self
671                .clients
672                .get(&(class, format!("{recv}.{attr}")))
673                .cloned();
674        }
675        if let [name] = receiver {
676            for s in self.b.scope_chain(from) {
677                if let Some(c) = self.clients.get(&(s, name.clone())) {
678                    return Some(c.clone());
679                }
680            }
681        }
682        // A module-level client imported from elsewhere.
683        let id = self.var_of(receiver, from, f)?;
684        let var = self.b.sym(&id)?;
685        let scope = self.b.parent.get(&id)?;
686        self.clients
687            .get(&(scope.clone(), var.name.clone()))
688            .cloned()
689    }
690
691    fn op(
692        &self,
693        f: &FileFacts,
694        from: &str,
695        receiver: &[String],
696        method: &str,
697        args: &Args,
698        emit: &mut impl FnMut(&str, String, EdgeKind),
699    ) {
700        if method == "text" && receiver.is_empty() {
701            if let Some(ValueExpr::Lit(sql)) = args.positional.first().and_then(Option::as_ref) {
702                for (table, write) in sql_tables(sql) {
703                    let kind = if write {
704                        EdgeKind::Writes
705                    } else {
706                        EdgeKind::Reads
707                    };
708                    emit(from, format!("table:{table}"), kind);
709                }
710            }
711            return;
712        }
713        // pypika: `Query.from_(t)` / `.join(t)` read, `Query.into(t)` / `Query.update(t)` write.
714        if matches!(method, "from_" | "join" | "into" | "update") {
715            if let Some(table) = args
716                .positional
717                .first()
718                .and_then(Option::as_ref)
719                .and_then(|v| self.pypika_table(v, from))
720            {
721                let kind = if matches!(method, "into" | "update") {
722                    EdgeKind::Writes
723                } else {
724                    EdgeKind::Reads
725                };
726                emit(from, format!("table:{table}"), kind);
727                return;
728            }
729        }
730        if let Some(table) = self.model_table(args, from, f) {
731            if DB_READS.contains(&method) {
732                emit(from, format!("table:{table}"), EdgeKind::Reads);
733                return;
734            }
735            if DB_WRITES.contains(&method) {
736                emit(from, format!("table:{table}"), EdgeKind::Writes);
737                return;
738            }
739        }
740        // Express: `router.get("/x", …, handler)` on an `express()` / `Router()` instance.
741        if HTTP_VERBS.contains(&method)
742            && matches!(f.language, Language::TypeScript | Language::JavaScript)
743            && args.positional.len() >= 2
744        {
745            if let Some(router) = self
746                .var_of(receiver, from, f)
747                .filter(|r| self.routers.contains_key(r))
748            {
749                let path = args.positional.first().and_then(Option::as_ref);
750                if let Some(p) = path.and_then(|p| self.value(p, from, 0)) {
751                    let handler = match args.positional.last() {
752                        Some(Some(ValueExpr::Name(h))) => {
753                            match self.b.resolve_path(h, from, f, 0) {
754                                Some(Value::Sym(id)) => id,
755                                _ => from.to_string(),
756                            }
757                        }
758                        _ => from.to_string(),
759                    };
760                    for pre in self.prefixes(&router, 0) {
761                        emit(
762                            &handler,
763                            format!(
764                                "http:{} {}",
765                                method.to_uppercase(),
766                                http_path(&format!("{pre}{p}"))
767                            ),
768                            EdgeKind::Serves,
769                        );
770                    }
771                }
772                return;
773            }
774        }
775        if receiver.is_empty() && method == "fetch" {
776            let url = args.positional.first().and_then(Option::as_ref);
777            let verb = match args.positional.get(1).and_then(Option::as_ref) {
778                Some(init) => self
779                    .field(init, from, "method", 0)
780                    .and_then(|(v, at)| self.value(&v, &at, 0))
781                    .unwrap_or_else(|| "GET".into()),
782                None => "GET".into(),
783            };
784            if let Some(url) = url.and_then(|u| self.value(u, from, 0)) {
785                emit(
786                    from,
787                    format!("http:{} {}", verb.to_uppercase(), http_path(&url)),
788                    EdgeKind::Consumes,
789                );
790            }
791            return;
792        }
793        if matches!(f.language, Language::Java | Language::Kotlin)
794            && receiver == [crate::lang::spring::WEBCLIENT]
795        {
796            let url = args.positional.first().and_then(Option::as_ref);
797            if let Some(url) = url.and_then(|u| self.value(u, from, 0)) {
798                emit(
799                    from,
800                    format!("http:{method} {}", http_path(&url)),
801                    EdgeKind::Consumes,
802                );
803            }
804            return;
805        }
806        if matches!(f.language, Language::Java | Language::Kotlin) {
807            if let Some((_, verb)) = crate::lang::spring::REST_TEMPLATE
808                .iter()
809                .find(|(m, _)| *m == method)
810            {
811                // `exchange(url, HttpMethod.GET, …)` names its verb.
812                let verb = match (verb.is_empty(), args.positional.get(1)) {
813                    (false, _) => Some(verb.to_string()),
814                    (true, Some(Some(ValueExpr::Name(p)))) => p.last().cloned(),
815                    _ => None,
816                };
817                let url = args.positional.first().and_then(Option::as_ref);
818                if let (Some(verb), Some(url)) = (verb, url.and_then(|u| self.value(u, from, 0))) {
819                    emit(
820                        from,
821                        format!("http:{} {}", verb.to_uppercase(), http_path(&url)),
822                        EdgeKind::Consumes,
823                    );
824                }
825                return;
826            }
827        }
828        if f.language == Language::Go {
829            if let Some((verb, url)) = go_request(receiver, method, args) {
830                if let Some(url) = self.value(url, from, 0) {
831                    emit(
832                        from,
833                        format!("http:{verb} {}", http_path(&url)),
834                        EdgeKind::Consumes,
835                    );
836                }
837                return;
838            }
839        }
840        if f.language == Language::Rust && HTTP_VERBS.contains(&method) {
841            // Only URL-shaped arguments: `map.get(key)` is not a request.
842            if let Some(url) = args
843                .positional
844                .first()
845                .and_then(Option::as_ref)
846                .and_then(|u| self.value(u, from, 0))
847                .filter(|u| u.starts_with("http") || u.starts_with('/'))
848            {
849                emit(
850                    from,
851                    format!("http:{} {}", method.to_uppercase(), http_path(&url)),
852                    EdgeKind::Consumes,
853                );
854            }
855            return;
856        }
857        let direct_http = self.direct_http(receiver, from, f);
858        let client = if direct_http {
859            Some(Client::Http)
860        } else {
861            self.client(receiver, from, f)
862                .or_else(|| self.wrapper(receiver, method, from, f))
863        };
864        match client {
865            Some(Client::Http) if HTTP_VERBS.contains(&method) || method == "request" => {
866                let (verb, url) = if method == "request" {
867                    (
868                        args.get("method", 0).and_then(|v| self.value(v, from, 0)),
869                        args.get("url", 1),
870                    )
871                } else {
872                    (Some(method.to_string()), args.get("url", 0))
873                };
874                if let (Some(verb), Some(url)) = (verb, url.and_then(|u| self.value(u, from, 0))) {
875                    emit(
876                        from,
877                        format!("http:{} {}", verb.to_uppercase(), http_path(&url)),
878                        EdgeKind::Consumes,
879                    );
880                }
881            }
882            Some(Client::Aws(svc)) => {
883                if let Some((key, kind)) = self.aws(&svc, method, args, from) {
884                    emit(from, key, kind);
885                }
886            }
887            None => {
888                if let Some(svc) = infer_aws(method, |k| self.kwarg(args, k, from).is_some()) {
889                    if let Some((key, kind)) = self.aws(svc, method, args, from) {
890                        emit(from, key, kind);
891                    }
892                }
893            }
894            _ => {}
895        }
896    }
897
898    /// A verb method on a class that owns an HTTP client (directly or via its bases) is an
899    /// HTTP call: shared service clients are recognised without per-wrapper rules.
900    fn wrapper(
901        &self,
902        receiver: &[String],
903        method: &str,
904        from: &str,
905        f: &FileFacts,
906    ) -> Option<Client> {
907        if !(HTTP_VERBS.contains(&method) || method == "request") || receiver.is_empty() {
908            return None;
909        }
910        let mut full = receiver.to_vec();
911        full.push(method.to_string());
912        if matches!(f.language, Language::TypeScript | Language::JavaScript) {
913            // The receiver's static type (often an interface) is implemented by an HTTP class.
914            let Value::Instance(t) = self.b.resolve_path(receiver, from, f, 0)? else {
915                return None;
916            };
917            return self.http_types.contains(&t).then_some(Client::Http);
918        }
919        let Value::Sym(m) = self.b.resolve_path(&full, from, f, 0)? else {
920            return None;
921        };
922        let owner = self.b.parent.get(&m)?;
923        self.owns_http(owner, 0).then_some(Client::Http)
924    }
925
926    fn owns_http(&self, class: &str, depth: u32) -> bool {
927        if depth > 8 {
928            return false;
929        }
930        let own = self.clients.iter().any(|((scope, target), c)| {
931            scope == class && target.starts_with("self.") && matches!(c, Client::Http)
932        });
933        own || self
934            .b
935            .bases
936            .borrow()
937            .get(class)
938            .is_some_and(|bs| bs.iter().any(|b| self.owns_http(b, depth + 1)))
939    }
940
941    /// The contract an AWS call names; a resource passed in as a parameter becomes a summary
942    /// of the enclosing callable instead, resolved later at its call sites.
943    fn aws(&self, svc: &str, method: &str, args: &Args, from: &str) -> Option<(String, EdgeKind)> {
944        let (kws, pos, scheme, kind) = aws_spec(svc, method)?;
945        let (expr, at) = kws
946            .iter()
947            .find_map(|k| self.kwarg(args, k, from))
948            .or_else(|| {
949                args.positional
950                    .get(pos)
951                    .and_then(Option::as_ref)
952                    .map(|e| (e.clone(), from.to_string()))
953            })?;
954        if let Some(v) = self.value(&expr, &at, 0) {
955            return Some((scheme_key(scheme, &v), kind));
956        }
957        if let Some((func, param, index, field)) = self.param_origin(&expr, &at, 0) {
958            self.summarise(
959                func,
960                Summary {
961                    param,
962                    index,
963                    field,
964                    scheme,
965                    kind,
966                },
967            );
968        }
969        None
970    }
971
972    /// A keyword argument, also when passed inside `**params` (`params = dict(TopicArn=…)`);
973    /// returns the expression and the scope to read it in.
974    fn kwarg(&self, args: &Args, key: &str, from: &str) -> Option<(ValueExpr, String)> {
975        if let Some((_, v)) = args.keywords.iter().find(|(n, _)| n == key) {
976            return Some((v.clone(), from.to_string()));
977        }
978        args.keywords
979            .iter()
980            .filter(|(n, _)| n == "**")
981            .find_map(|(_, v)| {
982                let (expr, at) = match v {
983                    ValueExpr::Name(p) => self.locate(p, from, 0)?,
984                    other => (other.clone(), from.to_string()),
985                };
986                let found = match &expr {
987                    ValueExpr::Call { kwargs, .. } => kwargs.iter().find(|(k, _)| k == key),
988                    ValueExpr::Record(pairs) => pairs.iter().find(|(k, _)| k == key),
989                    _ => None,
990                };
991                found.map(|(_, v)| (v.clone(), at))
992            })
993    }
994
995    fn summarise(&self, func: String, s: Summary) -> bool {
996        let mut all = self.summaries.borrow_mut();
997        let list = all.entry(func).or_default();
998        if list.contains(&s) {
999            return false;
1000        }
1001        list.push(s);
1002        true
1003    }
1004
1005    /// The callable parameter an expression stands for: a parameter (or an attribute of one) of
1006    /// an enclosing callable, or `self.attr` assigned from a constructor parameter.
1007    fn param_origin(&self, expr: &ValueExpr, from: &str, depth: u32) -> Option<Origin> {
1008        let ValueExpr::Name(path) = expr else {
1009            return None;
1010        };
1011        if let [recv, attr, rest @ ..] = path.as_slice() {
1012            if recv == "self" || recv == "this" {
1013                let class = self.b.enclosing_class(from)?;
1014                let (f, p, i, mut field) = self.attr_origin(&class, attr, depth)?;
1015                field.extend(rest.iter().cloned());
1016                return Some((f, p, i, field));
1017            }
1018        }
1019        let (head, rest) = path.split_first()?;
1020        let direct = self.b.scope_chain(from).into_iter().find_map(|s| {
1021            let i = positional(self.params.get(s.as_str())?, head)?;
1022            Some((s, head.clone(), i, rest.to_vec()))
1023        });
1024        if direct.is_some() || depth > 6 {
1025            return direct;
1026        }
1027        // A local assigned from a parameter (`arn = topic.arn`): follow it.
1028        let ValueExpr::Name(inner) = self.values.get(&(from, head.as_str())).copied()? else {
1029            return None;
1030        };
1031        let mut next = inner.clone();
1032        next.extend(rest.iter().cloned());
1033        self.param_origin(&ValueExpr::Name(next), from, depth + 1)
1034    }
1035
1036    fn attr_origin(&self, class: &str, attr: &str, depth: u32) -> Option<Origin> {
1037        if depth > 6 {
1038            return None;
1039        }
1040        if let Some(ValueExpr::Name(v)) = self.values.get(&(class, attr)).copied() {
1041            if let Some((q, rest)) = v.split_first() {
1042                let init = self.init_of(class, 0)?;
1043                let i = positional(self.params.get(init.as_str())?, q)?;
1044                return Some((init, q.clone(), i, rest.to_vec()));
1045            }
1046        }
1047        let bases = self
1048            .b
1049            .bases
1050            .borrow()
1051            .get(class)
1052            .cloned()
1053            .unwrap_or_default();
1054        bases
1055            .iter()
1056            .find_map(|b| self.attr_origin(b, attr, depth + 1))
1057    }
1058
1059    /// The constructor of a class (`__init__`), inherited if the class declares none.
1060    fn init_of(&self, class: &str, depth: u32) -> Option<String> {
1061        let own = format!("{}/__init__().", class.strip_suffix('#')?);
1062        if self.params.contains_key(own.as_str()) {
1063            return Some(own);
1064        }
1065        if depth > 6 {
1066            return None;
1067        }
1068        let bases = self
1069            .b
1070            .bases
1071            .borrow()
1072            .get(class)
1073            .cloned()
1074            .unwrap_or_default();
1075        bases.iter().find_map(|b| self.init_of(b, depth + 1))
1076    }
1077
1078    /// Resolve summaries at call sites: a call passing a known value emits the contract from the
1079    /// caller; one passing its own parameter summarises the caller in turn (bounded rounds).
1080    fn propagate(&self, facts: &[FileFacts], emit: &mut impl FnMut(&str, String, EdgeKind)) {
1081        for _ in 0..4 {
1082            let known = self.summaries.borrow().clone();
1083            if known.is_empty() {
1084                return;
1085            }
1086            let mut grew = false;
1087            for f in facts {
1088                for c in &f.calls {
1089                    let target = match self.b.resolve_path(&c.callee, &c.from, f, 0) {
1090                        Some(Value::Sym(id)) if self.b.is_type(&id) => self.init_of(&id, 0),
1091                        Some(Value::Sym(id)) => Some(id),
1092                        _ => None,
1093                    };
1094                    let Some(sums) = target.and_then(|t| known.get(&t)) else {
1095                        continue;
1096                    };
1097                    for s in sums {
1098                        let Some(arg) = c.args.get(&s.param, s.index) else {
1099                            continue;
1100                        };
1101                        let expr = match (arg, s.field.is_empty()) {
1102                            (_, true) => arg.clone(),
1103                            (ValueExpr::Name(p), false) => {
1104                                ValueExpr::Name(p.iter().chain(&s.field).cloned().collect())
1105                            }
1106                            _ => continue,
1107                        };
1108                        if let Some(v) = self.value(&expr, &c.from, 0) {
1109                            emit(&c.from, scheme_key(s.scheme, &v), s.kind.clone());
1110                        } else if let Some((func, param, index, field)) =
1111                            self.param_origin(&expr, &c.from, 0)
1112                        {
1113                            let next = Summary {
1114                                param,
1115                                index,
1116                                field,
1117                                ..s.clone()
1118                            };
1119                            grew |= self.summarise(func, next);
1120                        }
1121                    }
1122                }
1123            }
1124            if !grew {
1125                return;
1126            }
1127        }
1128    }
1129
1130    /// The table name of a pypika `Table("name", schema=…)` an expression evaluates to.
1131    fn pypika_table(&self, v: &ValueExpr, scope: &str) -> Option<String> {
1132        let (expr, at) = match v {
1133            ValueExpr::Name(p) => self.locate(p, scope, 0)?,
1134            other => (other.clone(), scope.to_string()),
1135        };
1136        let ValueExpr::Call { func, args, kwargs } = &expr else {
1137            return None;
1138        };
1139        if func.last().map(String::as_str) != Some("Table") {
1140            return None;
1141        }
1142        let name = kwargs
1143            .iter()
1144            .find(|(k, _)| k == "name")
1145            .map(|(_, v)| v)
1146            .or(args.first())?;
1147        self.value(name, &at, 0)
1148    }
1149
1150    /// Table of the ORM model an operation's first argument names or constructs.
1151    fn model_table(&self, args: &Args, from: &str, f: &FileFacts) -> Option<String> {
1152        let path = match args.positional.first() {
1153            Some(Some(ValueExpr::Name(p))) => p.clone(),
1154            _ => args.calls.iter().find(|(i, _)| *i == 0)?.1.clone(),
1155        };
1156        let Value::Sym(class) = self.b.resolve_path(&path, from, f, 0)? else {
1157            return None;
1158        };
1159        self.b.facts.values().find_map(|ff| {
1160            ff.contracts.iter().find_map(|c| match c {
1161                ContractFact::Table { class: c, name } if *c == class => Some(name.clone()),
1162                _ => None,
1163            })
1164        })
1165    }
1166
1167    /// The string a value expression evaluates to; unknown parts become `{}`.
1168    fn value(&self, v: &ValueExpr, scope: &str, depth: u32) -> Option<String> {
1169        if depth > 12 {
1170            return None;
1171        }
1172        match v {
1173            ValueExpr::Lit(s) => Some(s.clone()),
1174            ValueExpr::Env(k) => Some(format!("{{env:{k}}}")),
1175            ValueExpr::Template(parts) => Some(
1176                parts
1177                    .iter()
1178                    .map(|p| match p {
1179                        TemplatePart::Lit(s) => s.clone(),
1180                        TemplatePart::Expr(Some(path)) => self
1181                            .value(&ValueExpr::Name(path.clone()), scope, depth + 1)
1182                            .filter(|s| !s.contains("{env:"))
1183                            .unwrap_or_else(|| "{}".into()),
1184                        TemplatePart::Expr(None) => "{}".into(),
1185                    })
1186                    .collect(),
1187            ),
1188            ValueExpr::Name(path) => match self.locate(path, scope, depth + 1) {
1189                Some((expr, at)) => self.value(&expr, &at, depth + 1),
1190                None => self.config_value(path, scope).or_else(|| {
1191                    // `topic.arn`, `queue.url`: the resource the object names.
1192                    let (last, rest) = path.split_last()?;
1193                    (!rest.is_empty() && RESOURCE_ATTRS.contains(&last.as_str()))
1194                        .then(|| self.value(&ValueExpr::Name(rest.to_vec()), scope, depth + 1))
1195                        .flatten()
1196                }),
1197            },
1198            ValueExpr::Join { sep, parts } => {
1199                let vals: Vec<String> = parts
1200                    .iter()
1201                    .map(|p| {
1202                        self.value(p, scope, depth + 1)
1203                            .unwrap_or_else(|| "{}".into())
1204                    })
1205                    .collect();
1206                Some(if sep == "/" {
1207                    join_path(&vals)
1208                } else {
1209                    vals.concat()
1210                })
1211            }
1212            ValueExpr::Call { func, args, .. } => {
1213                let last = func.last()?.as_str();
1214                if PASS_THROUGH.contains(&last) {
1215                    self.value(args.first()?, scope, depth + 1)
1216                } else if *func == ["fmt", "Sprintf"] || *func == ["String", "format"] {
1217                    let fmt = self.value(args.first()?, scope, depth + 1)?;
1218                    Some(printf_placeholders(&fmt))
1219                } else {
1220                    None
1221                }
1222            }
1223            ValueExpr::Record(_) => None,
1224        }
1225    }
1226
1227    /// A settings path the code cannot resolve, read from the repo's YAML config: the path below
1228    /// a settings root (`config.task_queue.queue`), or a unique match of its last 2+ segments.
1229    fn config_value(&self, path: &[String], scope: &str) -> Option<String> {
1230        let repo = self.b.file_facts_of(scope)?.repo.as_str();
1231        let entries = self.config.get(repo)?;
1232        let segs: Vec<&str> = path.iter().map(String::as_str).collect();
1233        let root = segs.first()?.to_ascii_lowercase();
1234        if segs.len() >= 2
1235            && (root.contains("settings") || root.contains("config") || root == "cfg")
1236        {
1237            let key = segs[1..].join(".");
1238            if let Some((_, v)) = entries.iter().find(|(k, _)| *k == key) {
1239                return Some(v.to_string());
1240            }
1241        }
1242        for k in (2..=segs.len()).rev() {
1243            let suffix = segs[segs.len() - k..].join(".");
1244            let dotted = format!(".{suffix}");
1245            let hits: std::collections::BTreeSet<&str> = entries
1246                .iter()
1247                .filter(|(key, _)| *key == suffix || key.ends_with(&dotted))
1248                .map(|(_, v)| *v)
1249                .collect();
1250            match hits.len() {
1251                0 => continue,
1252                1 => return hits.into_iter().next().map(str::to_string),
1253                _ => return None,
1254            }
1255        }
1256        None
1257    }
1258
1259    /// The expression a dotted name denotes and the scope to read it in, following module
1260    /// constants, class attributes (incl. bases), instance types and record fields.
1261    fn locate(&self, path: &[String], scope: &str, depth: u32) -> Option<(ValueExpr, String)> {
1262        if depth > 12 {
1263            return None;
1264        }
1265        let f = self.b.file_facts_of(scope)?;
1266        let head = path.first()?;
1267        let mut found = None;
1268        for s in self.b.scope_chain(scope) {
1269            if let Some(v) = self.values.get(&(s.as_str(), head.as_str())) {
1270                found = Some(((*v).clone(), s, 1));
1271                break;
1272            }
1273        }
1274        if found.is_none() {
1275            for k in (1..=path.len()).rev() {
1276                let Some(v) = self.b.resolve_path(&path[..k], scope, f, 0) else {
1277                    continue;
1278                };
1279                let owner = match v {
1280                    Value::Instance(c) => Some(c),
1281                    Value::Sym(id) if self.b.is_type(&id) => Some(id),
1282                    Value::Sym(id) => {
1283                        // A module variable: the instance it holds (when an attribute follows),
1284                        // else its own value.
1285                        let var = self.b.sym(&id)?;
1286                        let parent = self.b.parent.get(&id)?.clone();
1287                        let instance = self
1288                            .b
1289                            .types
1290                            .get(&(parent.as_str(), var.name.as_str()))
1291                            .and_then(|src| self.b.type_of(src, &parent, 0))
1292                            .and_then(|v| match v {
1293                                Value::Instance(c) => Some(c),
1294                                _ => None,
1295                            });
1296                        if let (Some(c), Some(attr)) = (&instance, path.get(k)) {
1297                            if let Some((v, at)) = self.class_value(c, attr, 0) {
1298                                found = Some((v, at, k + 1));
1299                                break;
1300                            }
1301                        }
1302                        if let Some(v) = self.values.get(&(parent.as_str(), var.name.as_str())) {
1303                            found = Some(((*v).clone(), parent, k));
1304                            break;
1305                        }
1306                        instance
1307                    }
1308                    _ => None,
1309                };
1310                let (Some(class), Some(attr)) = (owner, path.get(k)) else {
1311                    continue;
1312                };
1313                if let Some((v, at)) = self.class_value(&class, attr, 0) {
1314                    found = Some((v, at, k + 1));
1315                    break;
1316                }
1317            }
1318        }
1319        let (mut expr, mut at, used) = found?;
1320        for name in &path[used..] {
1321            let (e, a) = self.field(&expr, &at, name, depth + 1)?;
1322            expr = e;
1323            at = a;
1324        }
1325        Some((expr, at))
1326    }
1327
1328    /// `X.name` where `X` evaluates to a record-like value.
1329    fn field(
1330        &self,
1331        v: &ValueExpr,
1332        scope: &str,
1333        name: &str,
1334        depth: u32,
1335    ) -> Option<(ValueExpr, String)> {
1336        match v {
1337            ValueExpr::Record(fields) => fields
1338                .iter()
1339                .find(|(k, _)| k == name)
1340                .map(|(_, v)| (v.clone(), scope.to_string())),
1341            ValueExpr::Call { func, kwargs, .. }
1342                if func.last().is_some_and(|f| RECORDS.contains(&f.as_str())) =>
1343            {
1344                kwargs
1345                    .iter()
1346                    .find(|(k, _)| k == name)
1347                    .map(|(_, v)| (v.clone(), scope.to_string()))
1348            }
1349            ValueExpr::Name(p) => {
1350                let mut full = p.clone();
1351                full.push(name.to_string());
1352                self.locate(&full, scope, depth + 1)
1353            }
1354            _ => None,
1355        }
1356    }
1357
1358    /// A class attribute's value expression, searching base classes.
1359    fn class_value(&self, class: &str, attr: &str, depth: u32) -> Option<(ValueExpr, String)> {
1360        if depth > 8 {
1361            return None;
1362        }
1363        if let Some(v) = self.values.get(&(class, attr)) {
1364            return Some(((*v).clone(), class.to_string()));
1365        }
1366        let bases = self
1367            .b
1368            .bases
1369            .borrow()
1370            .get(class)
1371            .cloned()
1372            .unwrap_or_default();
1373        bases
1374            .iter()
1375            .find_map(|b| self.class_value(b, attr, depth + 1))
1376    }
1377}
1378
1379/// Join path parts with single slashes, keeping a leading slash.
1380/// `http.Get(url)`, `http.Post(url, …)`, `http.NewRequest("GET", url, …)` and
1381/// `http.NewRequestWithContext(ctx, "GET", url, …)`: the verb and the URL argument.
1382fn go_request<'a>(
1383    receiver: &[String],
1384    method: &str,
1385    args: &'a Args,
1386) -> Option<(String, &'a ValueExpr)> {
1387    if receiver != ["http"] {
1388        return None;
1389    }
1390    let at = |i: usize| args.positional.get(i).and_then(Option::as_ref);
1391    let lit = |i: usize| match at(i) {
1392        Some(ValueExpr::Lit(v)) => Some(v.to_uppercase()),
1393        Some(ValueExpr::Name(p)) => p
1394            .last()
1395            .and_then(|m| m.strip_prefix("Method").map(str::to_uppercase)),
1396        _ => None,
1397    };
1398    match method {
1399        "Get" | "Head" => Some((method.to_uppercase(), at(0)?)),
1400        "Post" | "PostForm" => Some(("POST".into(), at(0)?)),
1401        "NewRequest" => Some((lit(0)?, at(1)?)),
1402        "NewRequestWithContext" => Some((lit(1)?, at(2)?)),
1403        _ => None,
1404    }
1405}
1406
1407fn test_path(path: &str) -> bool {
1408    let p = path.to_ascii_lowercase();
1409    let (dirs, file) = p.rsplit_once('/').unwrap_or(("", &p));
1410    let orig = path.rsplit('/').next().unwrap_or(path);
1411    dirs.split('/')
1412        .any(|d| matches!(d, "tests" | "test" | "__tests__" | "e2e" | "testing"))
1413        || file.starts_with("test_")
1414        || file == "conftest.py"
1415        || [
1416            "_test.py", "_test.go", "_test.rs", ".test.ts", ".test.tsx", ".test.js", ".spec.ts",
1417            ".spec.tsx", ".spec.js",
1418        ]
1419        .iter()
1420        .any(|s| file.ends_with(s))
1421        // JVM convention: `UserTest.java`, `UserTests.kt` (not `Latest.java`).
1422        || ["Test.java", "Tests.java", "Test.kt", "Tests.kt"].iter().any(|s| {
1423            orig.strip_suffix(s)
1424                .and_then(|stem| stem.chars().last())
1425                .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
1426        })
1427}
1428
1429/// `%s`, `%d`, `%v`, … become `{}` placeholders.
1430fn printf_placeholders(fmt: &str) -> String {
1431    let mut out = String::new();
1432    let mut it = fmt.chars().peekable();
1433    while let Some(c) = it.next() {
1434        if c != '%' {
1435            out.push(c);
1436            continue;
1437        }
1438        match it.peek() {
1439            Some('%') => {
1440                it.next();
1441                out.push('%');
1442            }
1443            Some(_) => {
1444                while it.next().is_some_and(|c| !c.is_ascii_alphabetic()) {}
1445                out.push_str("{}");
1446            }
1447            None => out.push('%'),
1448        }
1449    }
1450    out
1451}
1452
1453fn join_path(parts: &[String]) -> String {
1454    let mut out = String::new();
1455    for (i, p) in parts.iter().enumerate() {
1456        let p = if i == 0 {
1457            p.trim_end_matches('/')
1458        } else {
1459            p.trim_matches('/')
1460        };
1461        if p.is_empty() {
1462            continue;
1463        }
1464        if i > 0 {
1465            out.push('/');
1466        }
1467        out.push_str(p);
1468    }
1469    out
1470}
1471
1472/// `/v1/courses/{course_id}?x=1` → `/v1/courses/{}`; scheme and host dropped.
1473pub fn http_path(url: &str) -> String {
1474    let mut p = url;
1475    for scheme in ["https://", "http://"] {
1476        if let Some(rest) = p.strip_prefix(scheme) {
1477            p = rest.find('/').map_or("/", |i| &rest[i..]);
1478        }
1479    }
1480    // A leading unresolved base (`{}/x`, `{env:BASE}/x`) is a host, not a path segment.
1481    while p.starts_with('{') {
1482        p = p.find('}').map_or("", |i| &p[i + 1..]);
1483    }
1484    let p = p.split(['?', '#']).next().unwrap_or_default();
1485    let mut segs: Vec<String> = p
1486        .split('/')
1487        .filter(|s| !s.is_empty())
1488        .map(|s| {
1489            if (s.starts_with('{') && s.ends_with('}')) || s.starts_with(':') {
1490                "{}".to_string()
1491            } else {
1492                s.to_string()
1493            }
1494        })
1495        .collect();
1496    // `/docs${query}`: a placeholder glued to the last segment is a query string.
1497    if let Some(last) = segs.last_mut() {
1498        if last.len() > 2 && last.ends_with("{}") {
1499            last.truncate(last.len() - 2);
1500        }
1501    }
1502    format!("/{}", segs.join("/"))
1503}
1504
1505/// Deployment tiers are not part of a resource's identity: `acme-dev-jobs` and
1506/// `acme-${var.tier}-jobs` are the same queue, `acme-{}-jobs`.
1507pub fn untier(name: &str) -> String {
1508    const TIERS: &[&str] = &[
1509        "dev",
1510        "develop",
1511        "development",
1512        "stage",
1513        "staging",
1514        "stg",
1515        "prod",
1516        "production",
1517        "prd",
1518        "uat",
1519        "preprod",
1520        "sandbox",
1521    ];
1522    let mut out = String::new();
1523    let mut word = String::new();
1524    let flush = |w: &mut String, out: &mut String| {
1525        if TIERS.contains(&w.to_ascii_lowercase().as_str()) {
1526            out.push_str("{}");
1527        } else {
1528            out.push_str(w);
1529        }
1530        w.clear();
1531    };
1532    for c in name.chars() {
1533        if matches!(c, '-' | '_' | '.' | '/') {
1534            flush(&mut word, &mut out);
1535            out.push(c);
1536        } else {
1537            word.push(c);
1538        }
1539    }
1540    flush(&mut word, &mut out);
1541    out
1542}
1543
1544/// Tokens of a resource name, without trailing kind words (`-queue`, `-sns-topic.fifo`).
1545fn name_tokens(name: &str) -> Vec<String> {
1546    let mut t: Vec<String> = name
1547        .split(['-', '_', '.', ':', '/'])
1548        .filter(|w| !w.is_empty())
1549        .map(str::to_ascii_lowercase)
1550        .collect();
1551    while t.last().is_some_and(|w| {
1552        matches!(
1553            w.as_str(),
1554            "queue" | "topic" | "sns" | "sqs" | "fifo" | "bucket"
1555        )
1556    }) {
1557        t.pop();
1558    }
1559    t
1560}
1561
1562fn queue_name(url: &str) -> String {
1563    if url.starts_with("{env:") {
1564        return url.to_string();
1565    }
1566    url.trim_end_matches('/')
1567        .rsplit('/')
1568        .next()
1569        .unwrap_or(url)
1570        .to_string()
1571}
1572
1573fn arn_name(arn: &str) -> String {
1574    if arn.starts_with("{env:") {
1575        return arn.to_string();
1576    }
1577    arn.rsplit(':').next().unwrap_or(arn).to_string()
1578}
1579
1580fn bucket(b: &str) -> String {
1581    b.trim_start_matches("s3://")
1582        .split('/')
1583        .next()
1584        .unwrap_or(b)
1585        .to_string()
1586}
1587
1588/// Tables a SQL string reads or writes (`true` = write).
1589fn sql_tables(sql: &str) -> Vec<(String, bool)> {
1590    let Ok(stmts) = Parser::parse_sql(&GenericDialect {}, sql) else {
1591        return Vec::new();
1592    };
1593    let mut out = Vec::new();
1594    let name = |n: &ObjectName| {
1595        n.to_string()
1596            .rsplit('.')
1597            .next()
1598            .unwrap_or_default()
1599            .to_string()
1600    };
1601    let from = |twj: &[TableWithJoins], out: &mut Vec<(String, bool)>| {
1602        for t in twj {
1603            for f in std::iter::once(&t.relation).chain(t.joins.iter().map(|j| &j.relation)) {
1604                if let TableFactor::Table { name: n, .. } = f {
1605                    out.push((name(n), false));
1606                }
1607            }
1608        }
1609    };
1610    for s in stmts {
1611        match s {
1612            Statement::Query(q) => {
1613                if let sqlparser::ast::SetExpr::Select(sel) = q.body.as_ref() {
1614                    from(&sel.from, &mut out);
1615                }
1616            }
1617            Statement::Insert(i) => {
1618                let t = i.table.to_string();
1619                out.push((t.rsplit('.').next().unwrap_or(&t).to_string(), true));
1620            }
1621            Statement::Update(u) => {
1622                if let TableFactor::Table { name: n, .. } = &u.table.relation {
1623                    out.push((name(n), true));
1624                }
1625            }
1626            Statement::Delete(d) => {
1627                let tables = match &d.from {
1628                    sqlparser::ast::FromTable::WithFromKeyword(t)
1629                    | sqlparser::ast::FromTable::WithoutKeyword(t) => t,
1630                };
1631                for t in tables {
1632                    if let TableFactor::Table { name: n, .. } = &t.relation {
1633                        out.push((name(n), true));
1634                    }
1635                }
1636            }
1637            _ => {}
1638        }
1639    }
1640    out
1641}
1642
1643pub fn contract_id(key: &str) -> GlobalSymbolId {
1644    GlobalSymbolId {
1645        scheme: "comindly-contract".into(),
1646        package_manager: ".".into(),
1647        package: CONTRACTS.into(),
1648        version: ".".into(),
1649        descriptor: key.into(),
1650    }
1651}
1652
1653fn contract_symbol(key: &str) -> Symbol {
1654    let zero = Position { line: 0, column: 0 };
1655    Symbol {
1656        id: contract_id(key),
1657        name: key.to_string(),
1658        kind: SymbolKind::Contract,
1659        language: Language::Other("contract".into()),
1660        repo: RepoId(CONTRACTS.into()),
1661        file_path: String::new(),
1662        range: Range {
1663            start: zero,
1664            end: zero,
1665        },
1666        signature: None,
1667        docstring: None,
1668        fingerprint: None,
1669    }
1670}
1671
1672#[cfg(test)]
1673mod tests {
1674    use super::*;
1675
1676    #[test]
1677    fn keyword_only_params_have_no_position() {
1678        let names: Vec<String> = ["a", "*", "b"].iter().map(|s| s.to_string()).collect();
1679        assert_eq!(positional(&names, "a"), Some(0));
1680        assert_eq!(positional(&names, "b"), Some(usize::MAX));
1681    }
1682
1683    #[test]
1684    fn tiers_are_not_identity() {
1685        assert_eq!(
1686            untier("courses-dev-api-tasks-queue"),
1687            "courses-{}-api-tasks-queue"
1688        );
1689        assert_eq!(
1690            untier("courses-{}-api-tasks-queue"),
1691            "courses-{}-api-tasks-queue"
1692        );
1693        assert_eq!(untier("acme_prod.events.fifo"), "acme_{}.events.fifo");
1694        assert_eq!(untier("developers-feed"), "developers-feed");
1695        assert_eq!(
1696            name_tokens("courses-{}-x-analysis-events-queue")
1697                .last()
1698                .map(String::as_str),
1699            Some("events")
1700        );
1701    }
1702
1703    #[test]
1704    fn test_paths() {
1705        assert!(test_path("app/tests/e2e/test_x.py"));
1706        assert!(test_path("src/test/java/com/acme/UserTest.java"));
1707        assert!(test_path("web/src/api.spec.ts"));
1708        assert!(test_path("pkg/handler_test.go"));
1709        assert!(!test_path("app/latest_version.py"));
1710        assert!(!test_path("src/contest/routes.py"));
1711        assert!(!test_path("src/main/java/com/acme/Latest.java"));
1712        assert!(!test_path("src/contests.rs"));
1713        assert!(test_path("src/main/kotlin/UserServiceTest.kt"));
1714    }
1715
1716    #[test]
1717    fn framework_path_params_and_printf() {
1718        assert_eq!(http_path("/api/things/:id"), "/api/things/{}");
1719        assert_eq!(http_path("/api/x/{id:[0-9]+}"), "/api/x/{}");
1720        assert_eq!(printf_placeholders("%s/api/users/%d"), "{}/api/users/{}");
1721        assert_eq!(printf_placeholders("100%% %5.2f"), "100% {}");
1722        assert_eq!(
1723            http_path(&printf_placeholders("%s/api/users/%s")),
1724            "/api/users/{}"
1725        );
1726    }
1727
1728    #[test]
1729    fn http_paths_normalize() {
1730        assert_eq!(http_path("/v1/courses/{course_id}"), "/v1/courses/{}");
1731        assert_eq!(
1732            http_path("http://courses-svc/v1/courses/{}?x=1"),
1733            "/v1/courses/{}"
1734        );
1735        assert_eq!(http_path("{}/v1/a//b/"), "/v1/a/b");
1736        assert_eq!(http_path("{env:BASE}/x"), "/x");
1737        assert_eq!(http_path("/api/documents{}"), "/api/documents");
1738    }
1739
1740    #[test]
1741    fn sql_tables_by_statement() {
1742        assert_eq!(
1743            sql_tables("SELECT count(*) FROM courses"),
1744            vec![("courses".into(), false)]
1745        );
1746        assert_eq!(
1747            sql_tables("SELECT * FROM a JOIN b ON a.id = b.id"),
1748            vec![("a".into(), false), ("b".into(), false)]
1749        );
1750        assert_eq!(
1751            sql_tables("INSERT INTO courses (id) VALUES (1)"),
1752            vec![("courses".into(), true)]
1753        );
1754        assert_eq!(
1755            sql_tables("UPDATE x.courses SET a = 1"),
1756            vec![("courses".into(), true)]
1757        );
1758    }
1759}