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 ConcernPattern {
21 kind: "Authorization",
22 patterns: &[
23 "@PreAuthorize(",
25 "@PostAuthorize(",
26 "@Secured(",
27 "@RolesAllowed(",
28 "@PermitAll",
29 "@DenyAll",
30 "@login_required",
32 "@permission_required(",
33 "@requires_auth",
34 "@UseGuards(",
36 "@Roles(",
37 "@SetMetadata('roles'",
38 "[Authorize(",
40 "[Authorize]",
41 "[AllowAnonymous]",
42 "#[guard(",
44 "#[authorize(",
45 ],
46 },
47 ConcernPattern {
49 kind: "Validation",
50 patterns: &[
51 "@Valid",
53 "@Validated",
54 "@NotNull",
55 "@NotBlank",
56 "@NotEmpty",
57 "@Size(",
58 "@Pattern(",
59 "@Min(",
60 "@Max(",
61 "@validator(",
63 "@pydantic.validator(",
64 "@field_validator(",
65 "@UsePipes(",
67 "ValidationPipe",
68 "[ValidateAntiForgeryToken]",
70 "[Required]",
71 "[Range(",
72 "[StringLength(",
73 "#[validate(",
75 ],
76 },
77 ConcernPattern {
79 kind: "Caching",
80 patterns: &[
81 "@Cacheable(",
83 "@CacheEvict(",
84 "@CachePut(",
85 "@Caching(",
86 "@cache",
88 "@lru_cache(",
89 "@cached_property",
90 "@memoize",
91 "@CacheKey(",
93 "@CacheTTL(",
94 "CacheInterceptor",
95 "[OutputCache(",
97 "[ResponseCache(",
98 "caches_action",
100 "caches_page",
101 "#[cached(",
103 ],
104 },
105 ConcernPattern {
107 kind: "Transaction",
108 patterns: &[
109 "@Transactional(",
111 "@Transactional\n",
112 "@atomic",
114 "@transaction.atomic",
115 "@commit_on_success",
116 "@Transactional()",
118 "[Transaction]",
120 "#[transactional]",
122 ],
123 },
124 ConcernPattern {
126 kind: "RateLimiting",
127 patterns: &[
128 "@RateLimiter(",
130 "@RateLimit(",
131 "@Bulkhead(",
132 "@rate_limit(",
134 "@throttle(",
135 "@ratelimit(",
136 "@Throttle(",
138 "@SkipThrottle(",
139 "[EnableRateLimiting(",
141 "[DisableRateLimiting(",
142 "#[rate_limit(",
144 ],
145 },
146 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 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 ConcernPattern {
175 kind: "Cors",
176 patterns: &[
177 "@CrossOrigin(",
178 "@CrossOrigin\n",
179 "[EnableCors(",
180 "[DisableCors(",
181 "#[cors(",
182 ],
183 },
184 ConcernPattern {
186 kind: "Async",
187 patterns: &[
188 "@Async",
190 "@Scheduled(",
191 "@EventListener(",
192 "@celery.task",
194 "@background_task(",
195 "@periodic_task(",
196 "@Cron(",
198 "@Interval(",
199 "@EventPattern(",
200 "[BackgroundService]",
202 "#[tokio::main]",
204 ],
205 },
206 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 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 assert!(
494 found.is_empty() || found.contains(&"Authorization"),
495 "Ruby before_action pattern check: {:?}",
496 found
497 );
498 }
499}