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