agentstatedeveloper_core/adapter.rs
1use std::collections::{HashMap, HashSet};
2
3use crate::cross_service::DetectedEndpoint;
4use crate::dataflow::DetectedDataFlow;
5use crate::error::Result;
6use crate::schema::{Effect, SymbolKind};
7
8/// Parsed symbol returned by a language adapter. Carries enough info to
9/// build a full [`Symbol`](crate::schema::Symbol) node.
10#[derive(Debug, Clone)]
11pub struct ParsedSymbol {
12 pub qname: String,
13 pub kind: SymbolKind,
14 pub start_line: u32,
15 pub start_col: u32,
16 pub end_line: u32,
17 pub end_col: u32,
18 pub body: String,
19 pub signature: Option<String>,
20 pub doc: Option<String>,
21}
22
23/// A directed call edge from one qname to another, produced by a language
24/// adapter's intra-module call-graph extractor.
25#[derive(Debug, Clone, PartialEq, Eq, Hash)]
26pub struct CallEdge {
27 pub caller_qname: String,
28 pub callee_qname: String,
29}
30
31/// Workspace-wide context passed to adapters when resolving call edges
32/// across module boundaries.
33///
34/// ## Suffix-based lookup
35///
36/// Qnames include file-path prefixes (e.g., `Sources.Models.DriftCompiler.compile`).
37/// A call site using `DriftCompiler.compile(...)` only sees the type+method tail.
38/// After inserting all qnames, call [`build_suffix_index`] once so that
39/// [`find_by_suffix`] can resolve `"DriftCompiler.compile"` → the full qname in O(1).
40///
41/// ## Cross-file property map
42///
43/// Stored property declarations (`let name: TypeName` / `var name: TypeName`)
44/// are collected across ALL files in the current index run and stored in
45/// `properties` as `"EnclosingTypeSimpleName.propName" → "TypeSimpleName"`.
46/// Call adapters populate this via [`LanguageAdapter::extract_property_types`].
47/// Swift uses it to resolve `pool.resolve()` → `DriftSynthPool.resolve` when
48/// `pool: DriftSynthPool` is declared in a different file of the same project.
49#[derive(Debug, Clone, Default)]
50pub struct WorkspaceSymbols {
51 /// All qnames known to the indexer, flat.
52 pub qnames: HashSet<String>,
53 /// Map from qname to its kind, so adapters can distinguish
54 /// class-level methods from module-level functions during resolution.
55 pub kinds: HashMap<String, SymbolKind>,
56 /// suffix → Vec<full_qname>. Built by [`build_suffix_index`].
57 /// Each key is a 2-or-more component tail of a qname, e.g.
58 /// `"DriftCompiler.compile"` → `["Sources.Models.DriftCompiler.compile"]`.
59 pub suffix_index: HashMap<String, Vec<String>>,
60 /// Cross-file property map: `"EnclosingTypeSimple.propName"` →
61 /// `"TypeSimpleName"`. Populated by the index pipeline from ALL files
62 /// via [`LanguageAdapter::extract_property_types`] before call-graph
63 /// extraction runs. Allows resolving instance property calls when the
64 /// property declaration is in a different file than the method body.
65 pub properties: HashMap<String, String>,
66}
67
68impl WorkspaceSymbols {
69 /// Exact qname membership test.
70 pub fn contains(&self, qname: &str) -> bool {
71 self.qnames.contains(qname)
72 }
73
74 /// Build the suffix index from the current `qnames` set. Must be called
75 /// once after all qnames have been inserted, before any `find_by_suffix`
76 /// calls. Safe to call multiple times (rebuilds from scratch each time).
77 ///
78 /// For every qname with N dot-separated components, indexes all tails
79 /// of length 2..=N so that `"TypeName.method"` matches
80 /// `"file.path.TypeName.method"`.
81 pub fn build_suffix_index(&mut self) {
82 self.suffix_index.clear();
83 for qname in &self.qnames {
84 let parts: Vec<&str> = qname.split('.').collect();
85 // Only index tails with 2+ components (1-component is too ambiguous).
86 for start in 1..parts.len() {
87 let suffix = parts[start..].join(".");
88 self.suffix_index
89 .entry(suffix)
90 .or_default()
91 .push(qname.clone());
92 }
93 }
94 }
95
96 /// Look up a qname by suffix (e.g., `"DriftCompiler.compile"`).
97 ///
98 /// Returns the unique full qname when exactly one workspace qname ends
99 /// with `.<suffix>` or equals `suffix` exactly. Returns `None` when no
100 /// match is found or when the suffix is ambiguous (multiple matches).
101 ///
102 /// Requires [`build_suffix_index`] to have been called after the last
103 /// batch of qname inserts.
104 pub fn find_by_suffix<'a>(&'a self, suffix: &str) -> Option<&'a str> {
105 // Exact match first (no prefix stripping needed).
106 if let Some(q) = self.qnames.get(suffix) {
107 return Some(q.as_str());
108 }
109 let matches = self.suffix_index.get(suffix)?;
110 if matches.len() == 1 {
111 Some(matches[0].as_str())
112 } else {
113 // Ambiguous — don't guess.
114 None
115 }
116 }
117}
118
119/// Plan L t-006: one call site the static walker tried to resolve but
120/// couldn't. May be stdlib (`print`, `len`), third-party (an
121/// unindexed dependency), or genuinely dynamic. Aggregated by the
122/// indexer to expose the static-resolution coverage gap so agents
123/// don't assume missing edges are bugs.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct UnresolvedCall {
126 pub file: String,
127 pub line: u32,
128 /// The function-position text as it appears in source (e.g.
129 /// `requests.get`, `Foo.bar`, `local_var`).
130 pub callee_text: String,
131}
132
133/// Plan L t-005: one dynamic-dispatch call site detected by an adapter.
134/// `pattern` is a short label (`getattr` / `callback-arg` / `metaclass`);
135/// `snippet` is the source line trimmed for display in CLI warnings.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct DynamicDispatchHint {
138 pub file: String,
139 pub line: u32,
140 pub pattern: String,
141 pub snippet: String,
142}
143
144/// Language-specific parsing + effect inference. Implementations live in
145/// sibling crates: `agentstatedeveloper-python`, `-typescript`, etc.
146pub trait LanguageAdapter: Send + Sync {
147 /// Stable language identifier — "python", "typescript", ...
148 fn language(&self) -> &str;
149
150 /// File extensions this adapter handles, without the leading dot.
151 /// Used by the shared index pipeline for file dispatch.
152 /// Default: empty (adapter is never auto-selected by extension).
153 fn file_extensions(&self) -> &'static [&'static str] {
154 &[]
155 }
156
157 /// Parse a file and return declared symbols.
158 fn parse_symbols(&self, file: &str, source: &str) -> Result<Vec<ParsedSymbol>>;
159
160 /// Heuristic effect inference for a symbol. Returns an empty Vec when
161 /// the adapter has no opinion (author must declare explicitly).
162 fn infer_effects(&self, source: &str, symbol: &ParsedSymbol) -> Vec<Effect>;
163
164 /// Plan L t-005: report dynamic-dispatch sites in the file's source.
165 /// These are call patterns the static call-graph walker can't resolve
166 /// (e.g. `getattr(obj, attr_name)(args)`, callback-by-argument,
167 /// metaclass tricks). Returning a non-empty list signals to the
168 /// indexer that some call paths in this file won't show up in the
169 /// graph; the indexer surfaces a one-line warning so agents/humans
170 /// know the missing edges aren't a bug, they're a known limit.
171 ///
172 /// Default: empty. Adapters with no dynamic-dispatch story return
173 /// nothing.
174 fn scan_dynamic_dispatch(&self, _file: &str, _source: &str) -> Vec<DynamicDispatchHint> {
175 Vec::new()
176 }
177
178 /// Plan L t-006: report call sites in this file whose function
179 /// position couldn't be resolved to a workspace qname. Default:
180 /// empty. Adapters that can compute this cheaply override.
181 fn report_unresolved_calls(
182 &self,
183 _file: &str,
184 _source: &str,
185 _symbols: &[ParsedSymbol],
186 _workspace: &WorkspaceSymbols,
187 ) -> Vec<UnresolvedCall> {
188 Vec::new()
189 }
190
191 /// Extract call edges from a file's parsed symbols. Returned pairs are
192 /// (caller_qname, callee_qname). The adapter is free to use heuristics;
193 /// resolution quality is best-effort.
194 ///
195 /// `workspace` carries workspace-wide qname/kind context so adapters
196 /// can resolve cross-module calls (via imports). Adapters that only
197 /// care about intra-module edges may safely ignore it.
198 fn extract_call_edges(
199 &self,
200 _file: &str,
201 _source: &str,
202 _symbols: &[ParsedSymbol],
203 _workspace: &WorkspaceSymbols,
204 ) -> Vec<CallEdge> {
205 Vec::new()
206 }
207
208 /// Detect cross-service endpoints (HTTP routes/clients, pub-sub
209 /// emit/listen) in a file. Returns [`DetectedEndpoint`]s attributed to the
210 /// owning symbol by qname; the index pipeline enriches them with repo +
211 /// symbol identity. Whole-file signature (not per-symbol) because the
212 /// signal often lives just outside a symbol's body (e.g. a route decorator
213 /// above a `def`). Default: empty — adapters opt in.
214 ///
215 /// See [`crate::cross_service`] for the contract-keyed matching model.
216 fn infer_service_endpoints(
217 &self,
218 _file: &str,
219 _source: &str,
220 _symbols: &[ParsedSymbol],
221 ) -> Vec<DetectedEndpoint> {
222 Vec::new()
223 }
224
225 /// Project-level pass (Plan Q t-004b): rewrite endpoint contracts using
226 /// cross-file context — e.g. resolving nested router-mount prefix chains
227 /// (`app.include_router(api, prefix="/api")` → `api.include_router(calc,
228 /// prefix="/calc")` → `@calc.get("/x")` = `/api/calc/x`) that span files.
229 /// `files` is `(path, source)` for THIS adapter's language; `endpoints` is
230 /// the full project set — the adapter must only touch its own. Runs once
231 /// after per-file detection. Default: no-op.
232 fn resolve_endpoint_prefixes(
233 &self,
234 _files: &[(String, String)],
235 _endpoints: &mut Vec<crate::cross_service::ServiceEndpoint>,
236 ) {
237 }
238
239 /// Detect intra-process data-flow sites (`arg → param` at call sites) in a
240 /// file. Returns [`DetectedDataFlow`] naming caller/callee by qname plus the
241 /// argument and its position; the index pipeline resolves the callee's
242 /// parameter name and symbol identity. Default: empty — adapters opt in.
243 ///
244 /// See [`crate::dataflow`] for the model.
245 fn extract_dataflow(
246 &self,
247 _file: &str,
248 _source: &str,
249 _symbols: &[ParsedSymbol],
250 _workspace: &WorkspaceSymbols,
251 ) -> Vec<DetectedDataFlow> {
252 Vec::new()
253 }
254
255 /// Extract stored property declarations from a file's parsed symbols,
256 /// returning `"EnclosingTypeSimpleName.propertyName" → "TypeSimpleName"`.
257 ///
258 /// Called by the index pipeline for EVERY file in a run so that
259 /// `workspace.properties` accumulates a project-wide property map.
260 /// That map is then available to `extract_call_edges` to resolve
261 /// instance property calls across file boundaries.
262 ///
263 /// Default: returns an empty map (most adapters don't need this).
264 fn extract_property_types(&self, _symbols: &[ParsedSymbol]) -> HashMap<String, String> {
265 HashMap::new()
266 }
267}