codebeacon 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. codebeacon/__init__.py +1 -0
  2. codebeacon/__main__.py +3 -0
  3. codebeacon/cache.py +136 -0
  4. codebeacon/cli.py +391 -0
  5. codebeacon/common/__init__.py +0 -0
  6. codebeacon/common/filters.py +170 -0
  7. codebeacon/common/symbols.py +121 -0
  8. codebeacon/common/types.py +98 -0
  9. codebeacon/config.py +144 -0
  10. codebeacon/contextmap/__init__.py +0 -0
  11. codebeacon/contextmap/generator.py +602 -0
  12. codebeacon/discover/__init__.py +0 -0
  13. codebeacon/discover/detector.py +388 -0
  14. codebeacon/discover/scanner.py +192 -0
  15. codebeacon/export/__init__.py +0 -0
  16. codebeacon/export/mcp.py +515 -0
  17. codebeacon/export/obsidian.py +812 -0
  18. codebeacon/extract/__init__.py +22 -0
  19. codebeacon/extract/base.py +372 -0
  20. codebeacon/extract/components.py +357 -0
  21. codebeacon/extract/dependencies.py +140 -0
  22. codebeacon/extract/entities.py +575 -0
  23. codebeacon/extract/queries/README.md +116 -0
  24. codebeacon/extract/queries/actix.scm +115 -0
  25. codebeacon/extract/queries/angular.scm +155 -0
  26. codebeacon/extract/queries/aspnet.scm +159 -0
  27. codebeacon/extract/queries/django.scm +122 -0
  28. codebeacon/extract/queries/express.scm +124 -0
  29. codebeacon/extract/queries/fastapi.scm +152 -0
  30. codebeacon/extract/queries/flask.scm +120 -0
  31. codebeacon/extract/queries/gin.scm +142 -0
  32. codebeacon/extract/queries/ktor.scm +144 -0
  33. codebeacon/extract/queries/laravel.scm +172 -0
  34. codebeacon/extract/queries/nestjs.scm +183 -0
  35. codebeacon/extract/queries/rails.scm +114 -0
  36. codebeacon/extract/queries/react.scm +111 -0
  37. codebeacon/extract/queries/spring_boot.scm +204 -0
  38. codebeacon/extract/queries/svelte.scm +73 -0
  39. codebeacon/extract/queries/vapor.scm +130 -0
  40. codebeacon/extract/queries/vue.scm +123 -0
  41. codebeacon/extract/routes.py +910 -0
  42. codebeacon/extract/semantic.py +280 -0
  43. codebeacon/extract/services.py +597 -0
  44. codebeacon/graph/__init__.py +1 -0
  45. codebeacon/graph/analyze.py +281 -0
  46. codebeacon/graph/build.py +320 -0
  47. codebeacon/graph/cluster.py +160 -0
  48. codebeacon/graph/enrich.py +206 -0
  49. codebeacon/skill/SKILL.md +127 -0
  50. codebeacon/wave.py +292 -0
  51. codebeacon/wiki/__init__.py +0 -0
  52. codebeacon/wiki/generator.py +376 -0
  53. codebeacon/wiki/index.py +95 -0
  54. codebeacon/wiki/templates.py +467 -0
  55. codebeacon-0.1.2.dist-info/METADATA +319 -0
  56. codebeacon-0.1.2.dist-info/RECORD +59 -0
  57. codebeacon-0.1.2.dist-info/WHEEL +4 -0
  58. codebeacon-0.1.2.dist-info/entry_points.txt +2 -0
  59. codebeacon-0.1.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,597 @@
1
+ """Service / DI extraction for all supported frameworks.
2
+
3
+ Public API:
4
+ extract_services(file_path, framework) -> tuple[list[ServiceInfo], list[UnresolvedRef]]
5
+
6
+ Design:
7
+ - Run the framework's .scm query once per file
8
+ - Collect service classes/functions + DI dependencies
9
+ - DI dependencies are returned as UnresolvedRef (resolved later in Pass 2)
10
+ - UnresolvedRef.source_node_id uses f"{file_path}::{class_name}" format
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+
16
+ from codebeacon.common.types import ServiceInfo, UnresolvedRef
17
+ from codebeacon.extract.base import (
18
+ extract_sfc_sections,
19
+ load_query_file,
20
+ node_text,
21
+ parse_file,
22
+ parse_sfc_script,
23
+ run_query,
24
+ )
25
+
26
+
27
+ # ── Framework → query file stem ───────────────────────────────────────────────
28
+
29
+ _FW_TO_QUERY: dict[str, str] = {
30
+ "spring-boot": "spring_boot",
31
+ "express": "express",
32
+ "koa": "express",
33
+ "fastify": "express",
34
+ "nestjs": "nestjs",
35
+ "nextjs": "react",
36
+ "react": "react",
37
+ "fastapi": "fastapi",
38
+ "django": "django",
39
+ "flask": "flask",
40
+ "gin": "gin",
41
+ "echo": "gin",
42
+ "fiber": "gin",
43
+ "go": "gin",
44
+ "rails": "rails",
45
+ "laravel": "laravel",
46
+ "aspnet": "aspnet",
47
+ "actix": "actix",
48
+ "axum": "actix",
49
+ "rust": "actix",
50
+ "vapor": "vapor",
51
+ "ktor": "ktor",
52
+ "vue": "vue",
53
+ "nuxt": "vue",
54
+ "sveltekit": "svelte",
55
+ "angular": "angular",
56
+ }
57
+
58
+
59
+ # ── Public function ───────────────────────────────────────────────────────────
60
+
61
+ def extract_services(
62
+ file_path: str,
63
+ framework: str,
64
+ ) -> tuple[list[ServiceInfo], list[UnresolvedRef]]:
65
+ """Extract service classes and DI dependencies from *file_path*."""
66
+ fw = framework.lower()
67
+ query_name = _FW_TO_QUERY.get(fw)
68
+ if not query_name:
69
+ return [], []
70
+
71
+ query_src = load_query_file(query_name)
72
+ if not query_src:
73
+ return [], []
74
+
75
+ # SFC dispatch
76
+ ext = Path(file_path).suffix.lower()
77
+ if ext in (".vue", ".svelte"):
78
+ sfc = extract_sfc_sections(file_path)
79
+ if sfc is None:
80
+ return [], []
81
+ parsed = parse_sfc_script(sfc)
82
+ else:
83
+ parsed = parse_file(file_path)
84
+
85
+ if parsed is None:
86
+ return [], []
87
+ root, lang = parsed
88
+
89
+ from codebeacon.extract.base import is_grammar_allowed
90
+ if not is_grammar_allowed(query_name, lang):
91
+ return [], []
92
+
93
+ try:
94
+ matches = run_query(lang, query_src, root)
95
+ except Exception:
96
+ return [], []
97
+
98
+ _interpreters = {
99
+ "spring_boot": _interpret_spring_boot,
100
+ "express": _interpret_express,
101
+ "nestjs": _interpret_nestjs,
102
+ "fastapi": _interpret_fastapi,
103
+ "django": _interpret_noop,
104
+ "flask": _interpret_noop,
105
+ "gin": _interpret_gin,
106
+ "rails": _interpret_rails,
107
+ "laravel": _interpret_laravel,
108
+ "aspnet": _interpret_aspnet,
109
+ "actix": _interpret_actix,
110
+ "vapor": _interpret_vapor,
111
+ "ktor": _interpret_ktor,
112
+ "react": _interpret_noop,
113
+ "vue": _interpret_noop,
114
+ "svelte": _interpret_noop,
115
+ "angular": _interpret_angular,
116
+ }
117
+
118
+ interpreter = _interpreters.get(query_name, _interpret_noop)
119
+ try:
120
+ return interpreter(file_path, matches, fw)
121
+ except Exception:
122
+ return [], []
123
+
124
+
125
+ # ── Helpers ───────────────────────────────────────────────────────────────────
126
+
127
+ def _nid(file_path: str, name: str) -> str:
128
+ """Build a stable node ID for UnresolvedRef.source_node_id."""
129
+ return f"{file_path}::{name}"
130
+
131
+
132
+ def _interpret_noop(
133
+ file_path: str, matches: list, framework: str,
134
+ ) -> tuple[list[ServiceInfo], list[UnresolvedRef]]:
135
+ return [], []
136
+
137
+
138
+ # ── Per-framework interpreters ────────────────────────────────────────────────
139
+
140
+ def _interpret_spring_boot(
141
+ file_path: str, matches: list, framework: str,
142
+ ) -> tuple[list[ServiceInfo], list[UnresolvedRef]]:
143
+ """Spring Boot: @Service/@Component/@Repository + @Autowired / constructor injection."""
144
+ services: dict[int, ServiceInfo] = {} # class start_byte → ServiceInfo
145
+ unresolved: list[UnresolvedRef] = []
146
+ # class byte ranges for DI correlation
147
+ class_ranges: dict[int, tuple[int, int, str]] = {} # start → (start, end, class_name)
148
+
149
+ for _idx, caps in matches:
150
+ # @Service / @Component / @Repository class
151
+ if "service.class" in caps and "service.class_name" in caps:
152
+ cls = caps["service.class"][0]
153
+ name = node_text(caps["service.class_name"][0])
154
+ ann = node_text(caps["service.annotation"][0]) if "service.annotation" in caps else ""
155
+ key = cls.start_byte
156
+ if key not in services:
157
+ services[key] = ServiceInfo(
158
+ name=name,
159
+ class_name=name,
160
+ source_file=file_path,
161
+ line=cls.start_point[0] + 1,
162
+ framework="spring-boot",
163
+ annotations=[ann] if ann else [],
164
+ )
165
+ class_ranges[key] = (cls.start_byte, cls.end_byte, name)
166
+ elif ann and ann not in services[key].annotations:
167
+ services[key].annotations.append(ann)
168
+
169
+ # Implemented interfaces
170
+ if "service.with_interface" in caps and "service.interface" in caps:
171
+ cls = caps["service.with_interface"][0]
172
+ iface = node_text(caps["service.interface"][0])
173
+ for key, info in services.items():
174
+ start, end, _ = class_ranges.get(key, (0, 0, ""))
175
+ if start <= cls.start_byte <= end:
176
+ if iface not in info.annotations:
177
+ info.annotations.append(f"implements:{iface}")
178
+ break
179
+
180
+ # @Autowired field injection
181
+ if "di.autowired_field" in caps and "di.field_type" in caps:
182
+ field_node = caps["di.autowired_field"][0]
183
+ dep_type = node_text(caps["di.field_type"][0])
184
+ # Find enclosing class
185
+ for key, (start, end, cls_name) in class_ranges.items():
186
+ if start <= field_node.start_byte <= end:
187
+ if dep_type not in services[key].dependencies:
188
+ services[key].dependencies.append(dep_type)
189
+ unresolved.append(UnresolvedRef(
190
+ source_node_id=_nid(file_path, cls_name),
191
+ ref_type="autowired",
192
+ ref_name=dep_type,
193
+ framework="spring-boot",
194
+ ))
195
+ break
196
+
197
+ # Constructor injection
198
+ if "di.constructor" in caps and "di.ctor_param_type" in caps:
199
+ ctor_node = caps["di.constructor"][0]
200
+ for param_type_node in caps["di.ctor_param_type"]:
201
+ dep_type = node_text(param_type_node)
202
+ for key, (start, end, cls_name) in class_ranges.items():
203
+ if start <= ctor_node.start_byte <= end:
204
+ if dep_type not in services[key].dependencies:
205
+ services[key].dependencies.append(dep_type)
206
+ unresolved.append(UnresolvedRef(
207
+ source_node_id=_nid(file_path, cls_name),
208
+ ref_type="autowired",
209
+ ref_name=dep_type,
210
+ framework="spring-boot",
211
+ ))
212
+ break
213
+
214
+ return list(services.values()), unresolved
215
+
216
+
217
+ def _interpret_express(
218
+ file_path: str, matches: list, framework: str,
219
+ ) -> tuple[list[ServiceInfo], list[UnresolvedRef]]:
220
+ """Express/Koa/Fastify: exported classes as services (no DI framework)."""
221
+ services: list[ServiceInfo] = []
222
+ seen: set[str] = set()
223
+
224
+ for _idx, caps in matches:
225
+ if "service.name" in caps:
226
+ name = node_text(caps["service.name"][0])
227
+ if name in seen:
228
+ continue
229
+ seen.add(name)
230
+ node = caps.get("service.export_class", caps.get("service.class", [None]))[0]
231
+ line = node.start_point[0] + 1 if node else 1
232
+ services.append(ServiceInfo(
233
+ name=name,
234
+ class_name=name,
235
+ source_file=file_path,
236
+ line=line,
237
+ framework=framework,
238
+ ))
239
+ return services, []
240
+
241
+
242
+ def _interpret_nestjs(
243
+ file_path: str, matches: list, framework: str,
244
+ ) -> tuple[list[ServiceInfo], list[UnresolvedRef]]:
245
+ """NestJS: @Injectable + constructor injection.
246
+
247
+ Injectable decorator is sibling of class_declaration in export_statement.
248
+ Constructor DI is matched separately via service.constructor_di pattern.
249
+ Uses byte-position matching to correlate DI with enclosing class.
250
+ """
251
+ services: dict[str, ServiceInfo] = {} # class_name → ServiceInfo
252
+ # Track byte ranges for each service's enclosing export_statement
253
+ svc_ranges: dict[str, tuple[int, int]] = {} # class_name → (start, end)
254
+ unresolved: list[UnresolvedRef] = []
255
+
256
+ # Pass 1: collect @Injectable classes
257
+ for _idx, caps in matches:
258
+ for key in ("service.injectable", "service.injectable_noexport"):
259
+ if key in caps and "service.class_name" in caps:
260
+ name = node_text(caps["service.class_name"][0])
261
+ if name not in services:
262
+ cls = caps[key][0]
263
+ services[name] = ServiceInfo(
264
+ name=name,
265
+ class_name=name,
266
+ source_file=file_path,
267
+ line=cls.start_point[0] + 1,
268
+ framework="nestjs",
269
+ annotations=["Injectable"],
270
+ )
271
+ svc_ranges[name] = (cls.start_byte, cls.end_byte)
272
+ break
273
+
274
+ # Pass 2: collect constructor DI, matching to enclosing class by position
275
+ for _idx, caps in matches:
276
+ if "service.constructor_di" in caps and "service.inject_type" in caps:
277
+ ctor_node = caps["service.constructor_di"][0]
278
+ ctor_start = ctor_node.start_byte
279
+ # Find enclosing service by byte range
280
+ enclosing_name = ""
281
+ for name, (start, end) in svc_ranges.items():
282
+ if start <= ctor_start <= end:
283
+ enclosing_name = name
284
+ break
285
+ if not enclosing_name:
286
+ continue
287
+ svc = services[enclosing_name]
288
+ for dep_node in caps["service.inject_type"]:
289
+ dep = node_text(dep_node)
290
+ if dep not in svc.dependencies:
291
+ svc.dependencies.append(dep)
292
+ unresolved.append(UnresolvedRef(
293
+ source_node_id=_nid(file_path, enclosing_name),
294
+ ref_type="inject",
295
+ ref_name=dep,
296
+ framework="nestjs",
297
+ ))
298
+
299
+ return list(services.values()), unresolved
300
+
301
+
302
+ def _interpret_fastapi(
303
+ file_path: str, matches: list, framework: str,
304
+ ) -> tuple[list[ServiceInfo], list[UnresolvedRef]]:
305
+ """FastAPI: Depends() function-based DI."""
306
+ services: list[ServiceInfo] = []
307
+ unresolved: list[UnresolvedRef] = []
308
+ seen_funcs: set[str] = set()
309
+
310
+ for _idx, caps in matches:
311
+ # Functions that accept typed parameters (potential services)
312
+ if "service.function" in caps and "service.func_name" in caps:
313
+ name = node_text(caps["service.func_name"][0])
314
+ if name in seen_funcs:
315
+ continue
316
+ seen_funcs.add(name)
317
+ node = caps["service.function"][0]
318
+ services.append(ServiceInfo(
319
+ name=name,
320
+ class_name=name,
321
+ source_file=file_path,
322
+ line=node.start_point[0] + 1,
323
+ framework="fastapi",
324
+ ))
325
+
326
+ # Depends(func) calls
327
+ if "service.depends" in caps and "service.depends_func" in caps:
328
+ dep_func = node_text(caps["service.depends_func"][0])
329
+ # Find enclosing function for the unresolved ref
330
+ depends_node = caps["service.depends"][0]
331
+ enclosing = ""
332
+ for svc in services:
333
+ if svc.name in seen_funcs:
334
+ enclosing = svc.name
335
+ if dep_func not in (s.name for s in services):
336
+ unresolved.append(UnresolvedRef(
337
+ source_node_id=_nid(file_path, enclosing or "unknown"),
338
+ ref_type="depends",
339
+ ref_name=dep_func,
340
+ framework="fastapi",
341
+ ))
342
+
343
+ return services, unresolved
344
+
345
+
346
+ def _interpret_gin(
347
+ file_path: str, matches: list, framework: str,
348
+ ) -> tuple[list[ServiceInfo], list[UnresolvedRef]]:
349
+ """Go: service structs with embedded field types as dependencies."""
350
+ services: list[ServiceInfo] = []
351
+ seen: set[str] = set()
352
+
353
+ for _idx, caps in matches:
354
+ if ("service.struct" in caps or "service.struct_plain" in caps) and "service.struct_name" in caps:
355
+ name = node_text(caps["service.struct_name"][0])
356
+ if name in seen:
357
+ continue
358
+ seen.add(name)
359
+ deps = [node_text(n) for n in caps.get("service.field_type", [])]
360
+ node = caps.get("service.struct", caps.get("service.struct_plain", [None]))[0]
361
+ line = node.start_point[0] + 1 if node else 1
362
+ services.append(ServiceInfo(
363
+ name=name,
364
+ class_name=name,
365
+ source_file=file_path,
366
+ line=line,
367
+ framework=framework,
368
+ dependencies=deps,
369
+ ))
370
+ return services, []
371
+
372
+
373
+ def _interpret_rails(
374
+ file_path: str, matches: list, framework: str,
375
+ ) -> tuple[list[ServiceInfo], list[UnresolvedRef]]:
376
+ """Rails: plain Ruby classes as services."""
377
+ services: list[ServiceInfo] = []
378
+ seen: set[str] = set()
379
+
380
+ for _idx, caps in matches:
381
+ if "service.class" in caps and "service.class_name" in caps:
382
+ name = node_text(caps["service.class_name"][0])
383
+ if name in seen:
384
+ continue
385
+ seen.add(name)
386
+ node = caps["service.class"][0]
387
+ services.append(ServiceInfo(
388
+ name=name,
389
+ class_name=name,
390
+ source_file=file_path,
391
+ line=node.start_point[0] + 1,
392
+ framework="rails",
393
+ ))
394
+ return services, []
395
+
396
+
397
+ def _interpret_laravel(
398
+ file_path: str, matches: list, framework: str,
399
+ ) -> tuple[list[ServiceInfo], list[UnresolvedRef]]:
400
+ """Laravel: service classes + $this->app->bind() DI bindings."""
401
+ services: list[ServiceInfo] = []
402
+ unresolved: list[UnresolvedRef] = []
403
+ seen: set[str] = set()
404
+
405
+ for _idx, caps in matches:
406
+ if "service.class" in caps and "service.class_name" in caps:
407
+ name = node_text(caps["service.class_name"][0])
408
+ if name in seen:
409
+ continue
410
+ seen.add(name)
411
+ node = caps["service.class"][0]
412
+ services.append(ServiceInfo(
413
+ name=name,
414
+ class_name=name,
415
+ source_file=file_path,
416
+ line=node.start_point[0] + 1,
417
+ framework="laravel",
418
+ ))
419
+
420
+ if "di.binding" in caps and "di.interface" in caps and "di.implementation" in caps:
421
+ iface = node_text(caps["di.interface"][0])
422
+ impl = node_text(caps["di.implementation"][0])
423
+ unresolved.append(UnresolvedRef(
424
+ source_node_id=_nid(file_path, impl),
425
+ ref_type="bind",
426
+ ref_name=iface,
427
+ framework="laravel",
428
+ ))
429
+
430
+ return services, unresolved
431
+
432
+
433
+ def _interpret_aspnet(
434
+ file_path: str, matches: list, framework: str,
435
+ ) -> tuple[list[ServiceInfo], list[UnresolvedRef]]:
436
+ """ASP.NET: service classes with interfaces + AddScoped<IFoo, FooImpl>() DI."""
437
+ services: list[ServiceInfo] = []
438
+ unresolved: list[UnresolvedRef] = []
439
+ seen: set[str] = set()
440
+
441
+ for _idx, caps in matches:
442
+ if "service.class" in caps and "service.class_name" in caps:
443
+ name = node_text(caps["service.class_name"][0])
444
+ if name in seen:
445
+ continue
446
+ seen.add(name)
447
+ iface = node_text(caps["service.interface"][0]) if "service.interface" in caps else ""
448
+ node = caps["service.class"][0]
449
+ svc = ServiceInfo(
450
+ name=name,
451
+ class_name=name,
452
+ source_file=file_path,
453
+ line=node.start_point[0] + 1,
454
+ framework="aspnet",
455
+ )
456
+ if iface:
457
+ svc.annotations.append(f"implements:{iface}")
458
+ services.append(svc)
459
+
460
+ if "di.generic_registration" in caps and "di.service_type" in caps and "di.impl_type" in caps:
461
+ iface = node_text(caps["di.service_type"][0])
462
+ impl = node_text(caps["di.impl_type"][0])
463
+ unresolved.append(UnresolvedRef(
464
+ source_node_id=_nid(file_path, impl),
465
+ ref_type="bind",
466
+ ref_name=iface,
467
+ framework="aspnet",
468
+ ))
469
+
470
+ return services, unresolved
471
+
472
+
473
+ def _interpret_actix(
474
+ file_path: str, matches: list, framework: str,
475
+ ) -> tuple[list[ServiceInfo], list[UnresolvedRef]]:
476
+ """Actix/Axum: AppState and other service structs."""
477
+ services: list[ServiceInfo] = []
478
+ seen: set[str] = set()
479
+
480
+ for _idx, caps in matches:
481
+ if "service.struct" in caps and "service.struct_name" in caps:
482
+ name = node_text(caps["service.struct_name"][0])
483
+ if name in seen:
484
+ continue
485
+ seen.add(name)
486
+ node = caps["service.struct"][0]
487
+ services.append(ServiceInfo(
488
+ name=name,
489
+ class_name=name,
490
+ source_file=file_path,
491
+ line=node.start_point[0] + 1,
492
+ framework=framework,
493
+ ))
494
+ return services, []
495
+
496
+
497
+ def _interpret_vapor(
498
+ file_path: str, matches: list, framework: str,
499
+ ) -> tuple[list[ServiceInfo], list[UnresolvedRef]]:
500
+ """Vapor: route configuration functions as services."""
501
+ services: list[ServiceInfo] = []
502
+ seen: set[str] = set()
503
+
504
+ for _idx, caps in matches:
505
+ if "service.func" in caps and "service.func_name" in caps:
506
+ name = node_text(caps["service.func_name"][0])
507
+ if name in seen:
508
+ continue
509
+ seen.add(name)
510
+ node = caps["service.func"][0]
511
+ services.append(ServiceInfo(
512
+ name=name,
513
+ class_name=name,
514
+ source_file=file_path,
515
+ line=node.start_point[0] + 1,
516
+ framework="vapor",
517
+ ))
518
+ return services, []
519
+
520
+
521
+ def _interpret_ktor(
522
+ file_path: str, matches: list, framework: str,
523
+ ) -> tuple[list[ServiceInfo], list[UnresolvedRef]]:
524
+ """Ktor: Koin DI single{}/factory{} + regular Kotlin classes."""
525
+ services: list[ServiceInfo] = []
526
+ unresolved: list[UnresolvedRef] = []
527
+ seen: set[str] = set()
528
+
529
+ for _idx, caps in matches:
530
+ # Koin: single { UserService(get()) }
531
+ if "service.koin_binding" in caps and "service.koin_type" in caps:
532
+ name = node_text(caps["service.koin_type"][0])
533
+ if name not in seen:
534
+ seen.add(name)
535
+ node = caps["service.koin_binding"][0]
536
+ services.append(ServiceInfo(
537
+ name=name,
538
+ class_name=name,
539
+ source_file=file_path,
540
+ line=node.start_point[0] + 1,
541
+ framework="ktor",
542
+ annotations=["koin"],
543
+ ))
544
+
545
+ # Regular class
546
+ if "service.class" in caps and "service.class_name" in caps:
547
+ name = node_text(caps["service.class_name"][0])
548
+ if name not in seen:
549
+ seen.add(name)
550
+ node = caps["service.class"][0]
551
+ services.append(ServiceInfo(
552
+ name=name,
553
+ class_name=name,
554
+ source_file=file_path,
555
+ line=node.start_point[0] + 1,
556
+ framework="ktor",
557
+ ))
558
+
559
+ return services, unresolved
560
+
561
+
562
+ def _interpret_angular(
563
+ file_path: str, matches: list, framework: str,
564
+ ) -> tuple[list[ServiceInfo], list[UnresolvedRef]]:
565
+ """Angular: @Injectable + constructor DI."""
566
+ services: dict[int, ServiceInfo] = {}
567
+ unresolved: list[UnresolvedRef] = []
568
+
569
+ for _idx, caps in matches:
570
+ if "service.injectable" in caps and "service.class_name" in caps:
571
+ cls = caps["service.injectable"][0]
572
+ name = node_text(caps["service.class_name"][0])
573
+ services[cls.start_byte] = ServiceInfo(
574
+ name=name,
575
+ class_name=name,
576
+ source_file=file_path,
577
+ line=cls.start_point[0] + 1,
578
+ framework="angular",
579
+ annotations=["Injectable"],
580
+ )
581
+
582
+ if "service.constructor_di" in caps and "service.inject_type" in caps:
583
+ for dep_node in caps["service.inject_type"]:
584
+ dep = node_text(dep_node)
585
+ # Assign to closest service by position
586
+ for svc in services.values():
587
+ if dep not in svc.dependencies:
588
+ svc.dependencies.append(dep)
589
+ unresolved.append(UnresolvedRef(
590
+ source_node_id=_nid(file_path, svc.name),
591
+ ref_type="inject",
592
+ ref_name=dep,
593
+ framework="angular",
594
+ ))
595
+ break # assign to first service (constructor_di is inside a class)
596
+
597
+ return list(services.values()), unresolved
@@ -0,0 +1 @@
1
+ """Graph construction, enrichment, clustering, and analysis."""