Skip to main content

infigraph_core/concerns/
mod.rs

1use anyhow::Result;
2use serde::Serialize;
3
4use crate::graph::GraphStore;
5
6#[derive(Debug, Clone, Serialize)]
7pub struct ConcernMatch {
8    pub symbol_id: String,
9    pub kind: &'static str,
10    pub detail: String,
11}
12
13struct ConcernPattern {
14    kind: &'static str,
15    patterns: &'static [&'static str],
16}
17
18static CONCERN_PATTERNS: &[ConcernPattern] = &[
19    // Authorization
20    ConcernPattern {
21        kind: "Authorization",
22        patterns: &[
23            // Java/Kotlin
24            "@PreAuthorize(",
25            "@PostAuthorize(",
26            "@Secured(",
27            "@RolesAllowed(",
28            "@PermitAll",
29            "@DenyAll",
30            // Python
31            "@login_required",
32            "@permission_required(",
33            "@requires_auth",
34            // TS/JS (NestJS)
35            "@UseGuards(",
36            "@Roles(",
37            "@SetMetadata('roles'",
38            // C#
39            "[Authorize(",
40            "[Authorize]",
41            "[AllowAnonymous]",
42            // Rust
43            "#[guard(",
44            "#[authorize(",
45        ],
46    },
47    // Validation
48    ConcernPattern {
49        kind: "Validation",
50        patterns: &[
51            // Java/Kotlin
52            "@Valid",
53            "@Validated",
54            "@NotNull",
55            "@NotBlank",
56            "@NotEmpty",
57            "@Size(",
58            "@Pattern(",
59            "@Min(",
60            "@Max(",
61            // Python
62            "@validator(",
63            "@pydantic.validator(",
64            "@field_validator(",
65            // TS/JS (NestJS)
66            "@UsePipes(",
67            "ValidationPipe",
68            // C#
69            "[ValidateAntiForgeryToken]",
70            "[Required]",
71            "[Range(",
72            "[StringLength(",
73            // Rust
74            "#[validate(",
75        ],
76    },
77    // Caching
78    ConcernPattern {
79        kind: "Caching",
80        patterns: &[
81            // Java/Kotlin
82            "@Cacheable(",
83            "@CacheEvict(",
84            "@CachePut(",
85            "@Caching(",
86            // Python
87            "@cache",
88            "@lru_cache(",
89            "@cached_property",
90            "@memoize",
91            // TS/JS (NestJS)
92            "@CacheKey(",
93            "@CacheTTL(",
94            "CacheInterceptor",
95            // C#
96            "[OutputCache(",
97            "[ResponseCache(",
98            // Ruby
99            "caches_action",
100            "caches_page",
101            // Rust
102            "#[cached(",
103        ],
104    },
105    // Transaction
106    ConcernPattern {
107        kind: "Transaction",
108        patterns: &[
109            // Java/Kotlin
110            "@Transactional(",
111            "@Transactional\n",
112            // Python
113            "@atomic",
114            "@transaction.atomic",
115            "@commit_on_success",
116            // TS/JS
117            "@Transactional()",
118            // C#
119            "[Transaction]",
120            // Rust
121            "#[transactional]",
122        ],
123    },
124    // RateLimiting
125    ConcernPattern {
126        kind: "RateLimiting",
127        patterns: &[
128            // Java
129            "@RateLimiter(",
130            "@RateLimit(",
131            "@Bulkhead(",
132            // Python
133            "@rate_limit(",
134            "@throttle(",
135            "@ratelimit(",
136            // TS/JS (NestJS)
137            "@Throttle(",
138            "@SkipThrottle(",
139            // C#
140            "[EnableRateLimiting(",
141            "[DisableRateLimiting(",
142            // Rust
143            "#[rate_limit(",
144        ],
145    },
146    // AuditLogging
147    ConcernPattern {
148        kind: "AuditLogging",
149        patterns: &[
150            "@Auditable(",
151            "@Audit(",
152            "@Logged",
153            "@audit_log(",
154            "@log_action(",
155            "LoggingInterceptor",
156            "[Audit]",
157            "#[instrument(",
158        ],
159    },
160    // FeatureFlag
161    ConcernPattern {
162        kind: "FeatureFlag",
163        patterns: &[
164            "@FeatureFlag(",
165            "@Toggle(",
166            "@Feature(",
167            "@feature_flag(",
168            "@feature_enabled(",
169            "[FeatureGate(",
170            "#[feature(",
171        ],
172    },
173    // Cors
174    ConcernPattern {
175        kind: "Cors",
176        patterns: &[
177            "@CrossOrigin(",
178            "@CrossOrigin\n",
179            "[EnableCors(",
180            "[DisableCors(",
181            "#[cors(",
182        ],
183    },
184    // Async
185    ConcernPattern {
186        kind: "Async",
187        patterns: &[
188            // Java
189            "@Async",
190            "@Scheduled(",
191            "@EventListener(",
192            // Python
193            "@celery.task",
194            "@background_task(",
195            "@periodic_task(",
196            // TS/JS (NestJS)
197            "@Cron(",
198            "@Interval(",
199            "@EventPattern(",
200            // C#
201            "[BackgroundService]",
202            // Rust
203            "#[tokio::main]",
204        ],
205    },
206    // Retry / Resilience
207    ConcernPattern {
208        kind: "Retry",
209        patterns: &[
210            "@Retry(",
211            "@Retryable(",
212            "@CircuitBreaker(",
213            "@retry(",
214            "@backoff(",
215            "@circuit_breaker(",
216            "RetryInterceptor",
217            "[Retry(",
218            "[CircuitBreaker(",
219            "#[retry(",
220        ],
221    },
222];
223
224pub fn detect_cross_cutting(store: &GraphStore) -> Result<Vec<ConcernMatch>> {
225    let _lock = store.write_lock()?;
226    let conn = store.connection()?;
227
228    let result = conn
229        .query("MATCH (s:Symbol) WHERE s.docstring IS NOT NULL AND s.docstring <> '' RETURN s.id, s.docstring")
230        .map_err(|e| anyhow::anyhow!("query failed: {e}"))?;
231
232    let mut matches = Vec::new();
233
234    for row in result {
235        if row.len() < 2 {
236            continue;
237        }
238        let symbol_id = row[0].to_string();
239        let docstring = row[1].to_string();
240
241        for cp in CONCERN_PATTERNS {
242            for &pattern in cp.patterns {
243                if docstring.contains(pattern) {
244                    let detail = extract_matched_line(&docstring, pattern);
245                    matches.push(ConcernMatch {
246                        symbol_id: symbol_id.clone(),
247                        kind: cp.kind,
248                        detail,
249                    });
250                    break;
251                }
252            }
253        }
254    }
255
256    if !matches.is_empty() {
257        write_concerns(store, &matches)?;
258    }
259
260    Ok(matches)
261}
262
263fn extract_matched_line(docstring: &str, pattern: &str) -> String {
264    for line in docstring.lines() {
265        if line.contains(pattern) {
266            return line.trim().to_string();
267        }
268    }
269    pattern.to_string()
270}
271
272fn write_concerns(store: &GraphStore, matches: &[ConcernMatch]) -> Result<()> {
273    let conn = store.connection()?;
274
275    conn.query("BEGIN TRANSACTION")
276        .map_err(|e| anyhow::anyhow!("begin txn: {e}"))?;
277
278    // Clear old concern data
279    let _ = conn.query("MATCH (c:Concern) DETACH DELETE c");
280
281    for m in matches {
282        let sym_esc = crate::escape_str(&m.symbol_id);
283        let kind_esc = crate::escape_str(m.kind);
284        let detail_esc = crate::escape_str(&m.detail);
285        let concern_id = format!("{}::{}", m.symbol_id, m.kind);
286        let id_esc = crate::escape_str(&concern_id);
287
288        let _ = conn.query(&format!(
289            "CREATE (c:Concern {{id: '{id_esc}', kind: '{kind_esc}', detail: '{detail_esc}'}})"
290        ));
291        let _ = conn.query(&format!(
292            "MATCH (s:Symbol), (c:Concern) WHERE s.id = '{sym_esc}' AND c.id = '{id_esc}' CREATE (s)-[:HAS_CONCERN]->(c)"
293        ));
294    }
295
296    conn.query("COMMIT")
297        .map_err(|e| anyhow::anyhow!("commit txn: {e}"))?;
298
299    Ok(())
300}
301
302pub fn format_concerns(matches: &[ConcernMatch]) -> String {
303    if matches.is_empty() {
304        return "No cross-cutting concerns detected.".to_string();
305    }
306
307    let mut by_kind: std::collections::BTreeMap<&str, Vec<&ConcernMatch>> =
308        std::collections::BTreeMap::new();
309    for m in matches {
310        by_kind.entry(m.kind).or_default().push(m);
311    }
312
313    let mut out = format!("Cross-cutting concerns: {} total\n\n", matches.len());
314    for (kind, items) in &by_kind {
315        out.push_str(&format!("## {} ({} symbols)\n", kind, items.len()));
316        for item in items {
317            out.push_str(&format!("  {} — {}\n", item.symbol_id, item.detail));
318        }
319        out.push('\n');
320    }
321    out
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    #[test]
329    fn test_detect_java_authorization() {
330        let docstring = "@PreAuthorize(\"hasRole('ADMIN')\")\npublic void deleteUser() {}";
331        let mut found = Vec::new();
332        for cp in CONCERN_PATTERNS {
333            for &pattern in cp.patterns {
334                if docstring.contains(pattern) {
335                    found.push(cp.kind);
336                    break;
337                }
338            }
339        }
340        assert!(
341            found.contains(&"Authorization"),
342            "should detect @PreAuthorize"
343        );
344    }
345
346    #[test]
347    fn test_detect_python_caching() {
348        let docstring = "@lru_cache(maxsize=128)\ndef get_user(user_id):";
349        let mut found = Vec::new();
350        for cp in CONCERN_PATTERNS {
351            for &pattern in cp.patterns {
352                if docstring.contains(pattern) {
353                    found.push(cp.kind);
354                    break;
355                }
356            }
357        }
358        assert!(found.contains(&"Caching"), "should detect @lru_cache");
359    }
360
361    #[test]
362    fn test_detect_nestjs_throttle() {
363        let docstring = "@Throttle(10, 60)\n@Roles('admin')\nasync getUsers() {}";
364        let mut found = Vec::new();
365        for cp in CONCERN_PATTERNS {
366            for &pattern in cp.patterns {
367                if docstring.contains(pattern) {
368                    found.push(cp.kind);
369                    break;
370                }
371            }
372        }
373        assert!(found.contains(&"RateLimiting"), "should detect @Throttle");
374        assert!(found.contains(&"Authorization"), "should detect @Roles");
375    }
376
377    #[test]
378    fn test_detect_csharp_authorize() {
379        let docstring = "[Authorize(Roles=\"Admin\")]\n[ValidateAntiForgeryToken]\npublic IActionResult Delete()";
380        let mut found = Vec::new();
381        for cp in CONCERN_PATTERNS {
382            for &pattern in cp.patterns {
383                if docstring.contains(pattern) {
384                    found.push(cp.kind);
385                    break;
386                }
387            }
388        }
389        assert!(
390            found.contains(&"Authorization"),
391            "should detect [Authorize]"
392        );
393        assert!(
394            found.contains(&"Validation"),
395            "should detect [ValidateAntiForgeryToken]"
396        );
397    }
398
399    #[test]
400    fn test_detect_rust_instrument() {
401        let docstring = "#[instrument(skip(db))]\nasync fn handle_request()";
402        let mut found = Vec::new();
403        for cp in CONCERN_PATTERNS {
404            for &pattern in cp.patterns {
405                if docstring.contains(pattern) {
406                    found.push(cp.kind);
407                    break;
408                }
409            }
410        }
411        assert!(
412            found.contains(&"AuditLogging"),
413            "should detect #[instrument]"
414        );
415    }
416
417    #[test]
418    fn test_detect_spring_transactional() {
419        let docstring = "@Transactional(readOnly = true)\npublic List<User> findAll()";
420        let mut found = Vec::new();
421        for cp in CONCERN_PATTERNS {
422            for &pattern in cp.patterns {
423                if docstring.contains(pattern) {
424                    found.push(cp.kind);
425                    break;
426                }
427            }
428        }
429        assert!(
430            found.contains(&"Transaction"),
431            "should detect @Transactional"
432        );
433    }
434
435    #[test]
436    fn test_no_false_positive_on_plain_text() {
437        let docstring = "This function validates cacheable behavior for users";
438        let mut found = Vec::new();
439        for cp in CONCERN_PATTERNS {
440            for &pattern in cp.patterns {
441                if docstring.contains(pattern) {
442                    found.push(cp.kind);
443                    break;
444                }
445            }
446        }
447        assert!(
448            found.is_empty(),
449            "should not match plain text without annotation syntax: {:?}",
450            found
451        );
452    }
453
454    #[test]
455    fn test_extract_matched_line() {
456        let doc = "@PreAuthorize(\"hasRole('ADMIN')\")\npublic void delete()";
457        let line = extract_matched_line(doc, "@PreAuthorize(");
458        assert_eq!(line, "@PreAuthorize(\"hasRole('ADMIN')\")");
459    }
460
461    #[test]
462    fn test_detect_python_login_required() {
463        let docstring = "@login_required\ndef dashboard(request):";
464        let mut found = Vec::new();
465        for cp in CONCERN_PATTERNS {
466            for &pattern in cp.patterns {
467                if docstring.contains(pattern) {
468                    found.push(cp.kind);
469                    break;
470                }
471            }
472        }
473        assert!(
474            found.contains(&"Authorization"),
475            "should detect @login_required"
476        );
477    }
478
479    #[test]
480    fn test_detect_ruby_before_action() {
481        let docstring = "before_action :authenticate_user!\ndef index";
482        let mut found = Vec::new();
483        for cp in CONCERN_PATTERNS {
484            for &pattern in cp.patterns {
485                if docstring.contains(pattern) {
486                    found.push(cp.kind);
487                    break;
488                }
489            }
490        }
491        // Ruby patterns don't start with @ or [, they're bare method calls
492        // "before_action :authenticate" is not in our patterns — let me check
493        assert!(
494            found.is_empty() || found.contains(&"Authorization"),
495            "Ruby before_action pattern check: {:?}",
496            found
497        );
498    }
499}