codebeacon 0.6.1__tar.gz → 0.6.2__tar.gz

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 (151) hide show
  1. {codebeacon-0.6.1 → codebeacon-0.6.2}/PKG-INFO +1 -1
  2. codebeacon-0.6.2/codebeacon/__init__.py +1 -0
  3. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/common/safety.py +33 -0
  4. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/common/types.py +2 -0
  5. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/export/obsidian.py +12 -3
  6. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/queries/angular.scm +18 -0
  7. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/queries/nestjs.scm +18 -0
  8. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/services.py +61 -0
  9. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/graph/build.py +73 -1
  10. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/graph/cluster.py +40 -12
  11. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/wiki/generator.py +10 -2
  12. {codebeacon-0.6.1 → codebeacon-0.6.2}/pyproject.toml +1 -1
  13. codebeacon-0.6.2/tests/test_graphify_parity_fixes.py +335 -0
  14. codebeacon-0.6.1/codebeacon/__init__.py +0 -1
  15. {codebeacon-0.6.1 → codebeacon-0.6.2}/.cursorrules +0 -0
  16. {codebeacon-0.6.1 → codebeacon-0.6.2}/.github/CODEOWNERS +0 -0
  17. {codebeacon-0.6.1 → codebeacon-0.6.2}/.github/dependabot.yml +0 -0
  18. {codebeacon-0.6.1 → codebeacon-0.6.2}/.github/workflows/ci.yml +0 -0
  19. {codebeacon-0.6.1 → codebeacon-0.6.2}/.github/workflows/release.yml +0 -0
  20. {codebeacon-0.6.1 → codebeacon-0.6.2}/.gitignore +0 -0
  21. {codebeacon-0.6.1 → codebeacon-0.6.2}/AGENTS.md +0 -0
  22. {codebeacon-0.6.1 → codebeacon-0.6.2}/CLAUDE.md +0 -0
  23. {codebeacon-0.6.1 → codebeacon-0.6.2}/LICENSE +0 -0
  24. {codebeacon-0.6.1 → codebeacon-0.6.2}/README.de.md +0 -0
  25. {codebeacon-0.6.1 → codebeacon-0.6.2}/README.es.md +0 -0
  26. {codebeacon-0.6.1 → codebeacon-0.6.2}/README.fr.md +0 -0
  27. {codebeacon-0.6.1 → codebeacon-0.6.2}/README.ja.md +0 -0
  28. {codebeacon-0.6.1 → codebeacon-0.6.2}/README.ko.md +0 -0
  29. {codebeacon-0.6.1 → codebeacon-0.6.2}/README.md +0 -0
  30. {codebeacon-0.6.1 → codebeacon-0.6.2}/README.pt-BR.md +0 -0
  31. {codebeacon-0.6.1 → codebeacon-0.6.2}/README.zh-CN.md +0 -0
  32. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/__main__.py +0 -0
  33. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/affected.py +0 -0
  34. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/cache.py +0 -0
  35. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/cli.py +0 -0
  36. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/common/__init__.py +0 -0
  37. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/common/filters.py +0 -0
  38. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/common/symbols.py +0 -0
  39. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/config.py +0 -0
  40. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/contextmap/__init__.py +0 -0
  41. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/contextmap/generator.py +0 -0
  42. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/diagnostics.py +0 -0
  43. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/discover/__init__.py +0 -0
  44. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/discover/detector.py +0 -0
  45. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/discover/ignore.py +0 -0
  46. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/discover/scanner.py +0 -0
  47. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/export/__init__.py +0 -0
  48. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/export/callflow_html.py +0 -0
  49. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/export/hooks.py +0 -0
  50. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/export/mcp.py +0 -0
  51. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/export/merge.py +0 -0
  52. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/export/tree_html.py +0 -0
  53. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/__init__.py +0 -0
  54. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/base.py +0 -0
  55. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/components.py +0 -0
  56. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/dependencies.py +0 -0
  57. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/dotnet.py +0 -0
  58. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/entities.py +0 -0
  59. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/queries/README.md +0 -0
  60. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/queries/actix.scm +0 -0
  61. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/queries/aspnet.scm +0 -0
  62. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/queries/django.scm +0 -0
  63. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/queries/express.scm +0 -0
  64. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/queries/fastapi.scm +0 -0
  65. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/queries/flask.scm +0 -0
  66. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/queries/gin.scm +0 -0
  67. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/queries/ktor.scm +0 -0
  68. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/queries/laravel.scm +0 -0
  69. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/queries/rails.scm +0 -0
  70. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/queries/react.scm +0 -0
  71. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/queries/spring_boot.scm +0 -0
  72. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/queries/svelte.scm +0 -0
  73. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/queries/tauri.scm +0 -0
  74. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/queries/vapor.scm +0 -0
  75. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/queries/vue.scm +0 -0
  76. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/routes.py +0 -0
  77. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/semantic.py +0 -0
  78. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/graph/__init__.py +0 -0
  79. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/graph/analyze.py +0 -0
  80. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/graph/enrich.py +0 -0
  81. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/graph/write.py +0 -0
  82. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/knowledge/__init__.py +0 -0
  83. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/knowledge/generator.py +0 -0
  84. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/pipeline.py +0 -0
  85. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/plugins/__init__.py +0 -0
  86. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/plugins/githooks.py +0 -0
  87. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/plugins/skills.py +0 -0
  88. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/semantic_pipeline.py +0 -0
  89. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/skill/SKILL.md +0 -0
  90. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/wave.py +0 -0
  91. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/wiki/__init__.py +0 -0
  92. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/wiki/index.py +0 -0
  93. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/wiki/templates.py +0 -0
  94. {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon.yaml.example +0 -0
  95. {codebeacon-0.6.1 → codebeacon-0.6.2}/docs/TRANSLATION_STATUS.md +0 -0
  96. {codebeacon-0.6.1 → codebeacon-0.6.2}/public-plan.md +0 -0
  97. {codebeacon-0.6.1 → codebeacon-0.6.2}/skill/install.py +0 -0
  98. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/__init__.py +0 -0
  99. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/conftest.py +0 -0
  100. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/actix/main.rs +0 -0
  101. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/angular/app.component.ts +0 -0
  102. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/aspnet/UserController.cs +0 -0
  103. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/django/views.py +0 -0
  104. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/express/userRouter.js +0 -0
  105. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/fastapi/main.py +0 -0
  106. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/flask/app.py +0 -0
  107. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/gin/main.go +0 -0
  108. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/integration_workspace/api-python/pyproject.toml +0 -0
  109. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/integration_workspace/api-python/src/__init__.py +0 -0
  110. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/integration_workspace/api-python/src/main.py +0 -0
  111. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/integration_workspace/api-python/src/services.py +0 -0
  112. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/integration_workspace/web/package.json +0 -0
  113. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/integration_workspace/web/src/UserPage.tsx +0 -0
  114. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/ktor/UserRoutes.kt +0 -0
  115. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/laravel/UserController.php +0 -0
  116. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/nestjs/user.controller.ts +0 -0
  117. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/rails/users_controller.rb +0 -0
  118. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/react/UserPage.tsx +0 -0
  119. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/spring_boot/UserController.java +0 -0
  120. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/sveltekit/+page.svelte +0 -0
  121. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/vapor/routes.swift +0 -0
  122. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/vue/UserList.vue +0 -0
  123. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/integration/__init__.py +0 -0
  124. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/integration/test_full_pipeline.py +0 -0
  125. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_affected.py +0 -0
  126. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_affected_wiki.py +0 -0
  127. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_audit_bugfixes.py +0 -0
  128. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_cli_dispatch.py +0 -0
  129. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_contextmap_paths.py +0 -0
  130. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_dependencies.py +0 -0
  131. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_diagnostics.py +0 -0
  132. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_discover.py +0 -0
  133. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_dotnet.py +0 -0
  134. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_entities.py +0 -0
  135. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_filters.py +0 -0
  136. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_graph.py +0 -0
  137. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_knowledge.py +0 -0
  138. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_known_bugs.py +0 -0
  139. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_mcp_and_semantic.py +0 -0
  140. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_optional_grammars.py +0 -0
  141. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_pipeline_module.py +0 -0
  142. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_plugins.py +0 -0
  143. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_resolve.py +0 -0
  144. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_routes.py +0 -0
  145. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_safety_and_writes.py +0 -0
  146. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_scanner_sensitive.py +0 -0
  147. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_semantic.py +0 -0
  148. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_semantic_hardening.py +0 -0
  149. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_semantic_stats.py +0 -0
  150. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_services.py +0 -0
  151. {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_wiki.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codebeacon
3
- Version: 0.6.1
3
+ Version: 0.6.2
4
4
  Summary: Source code AST analysis tool for AI context generation — unified multi-framework knowledge graph
5
5
  Project-URL: Homepage, https://github.com/codebeacon/codebeacon
6
6
  Project-URL: Repository, https://github.com/codebeacon/codebeacon
@@ -0,0 +1 @@
1
+ __version__ = "0.6.2"
@@ -18,6 +18,7 @@ Three concerns:
18
18
 
19
19
  from __future__ import annotations
20
20
 
21
+ import hashlib
21
22
  import re
22
23
  import subprocess
23
24
  from pathlib import Path
@@ -69,6 +70,38 @@ def escape_frontmatter_value(value: object) -> str:
69
70
  return text
70
71
 
71
72
 
73
+ def cap_filename(name: str, limit: int = 200) -> str:
74
+ """Cap a filename stem to ``limit`` UTF-8 *bytes*, collision-safely.
75
+
76
+ A single path component is capped at 255 bytes by every mainstream
77
+ filesystem, so a node label long enough to overflow that — roughly 85 CJK
78
+ characters at 3 bytes each, or 255 ASCII — makes ``Path.write_text`` raise
79
+ ``OSError`` (ENAMETOOLONG) and aborts the *entire* obsidian / wiki export,
80
+ not just the one note. We cap to 200 bytes to leave headroom for the
81
+ trailing ``.md`` and any ``_N`` dedup suffix the caller appends.
82
+
83
+ Two properties matter:
84
+
85
+ * The budget is counted in **bytes**, not characters, so multi-byte scripts
86
+ (CJK, emoji) cannot slip past a character-count guard.
87
+ * When truncation actually happens we append ``_<sha1[:8]>`` of the original
88
+ name, so two labels sharing a long common prefix
89
+ (``"z"*250 + "_ALPHA"`` vs ``"z"*250 + "_BETA"``) still resolve to
90
+ distinct files instead of silently colliding.
91
+
92
+ Truncation slices the UTF-8 byte string and decodes with ``"ignore"`` so a
93
+ multi-byte character straddling the cut is dropped rather than producing
94
+ mojibake. Short names are returned untouched. Mirrors graphify 690b4e5.
95
+ """
96
+ encoded = name.encode("utf-8")
97
+ if len(encoded) <= limit:
98
+ return name
99
+ digest = hashlib.sha1(name.encode("utf-8")).hexdigest()[:8]
100
+ keep = max(limit - 9, 0) # room for "_" + 8 hex chars
101
+ truncated = encoded[:keep].decode("utf-8", "ignore")
102
+ return f"{truncated}_{digest}"
103
+
104
+
72
105
  def git_head(repo_path: str | Path) -> str:
73
106
  """Return the full HEAD commit SHA for ``repo_path``, or "" if not a repo.
74
107
 
@@ -62,6 +62,8 @@ class ServiceInfo:
62
62
  methods: list = field(default_factory=list) # list[str] - method names
63
63
  dependencies: list = field(default_factory=list) # list[str] - injected type names (unresolved)
64
64
  annotations: list = field(default_factory=list) # list[str] - @Service, @Injectable, etc.
65
+ implements: list = field(default_factory=list) # list[str] - implemented interface names
66
+ extends: list = field(default_factory=list) # list[str] - superclass names
65
67
 
66
68
 
67
69
  @dataclass(slots=True)
@@ -33,7 +33,7 @@ from typing import Any
33
33
 
34
34
  import networkx as nx
35
35
 
36
- from codebeacon.common.safety import escape_frontmatter_value, sanitize_label
36
+ from codebeacon.common.safety import cap_filename, escape_frontmatter_value, sanitize_label
37
37
 
38
38
 
39
39
  # ── Regexes ────────────────────────────────────────────────────────────────────
@@ -402,9 +402,18 @@ def _reverse_relation(relation: str) -> str:
402
402
 
403
403
 
404
404
  def _safe_note_name(label: str) -> str:
405
- """Convert node label to a safe filename stem (no path separators)."""
405
+ """Convert node label to a safe filename stem (no path separators).
406
+
407
+ The byte cap (via ``cap_filename``) prevents a very long label — or an 85+
408
+ character CJK class name — from overflowing the 255-byte filesystem limit
409
+ and crashing ``_step1_generate_notes`` with ENAMETOOLONG. Because every
410
+ wikilink target is generated through this same function, the note's
411
+ filename and the links pointing at it stay byte-for-byte consistent even
412
+ after truncation.
413
+ """
406
414
  # Replace characters that confuse Obsidian wikilinks
407
- return re.sub(r'[/\\#^|[\]]', "_", label).strip()
415
+ cleaned = re.sub(r'[/\\#^|[\]]', "_", label).strip()
416
+ return cap_filename(cleaned)
408
417
 
409
418
 
410
419
  # ── Step 2: Fix broken wikilinks ──────────────────────────────────────────────
@@ -191,3 +191,21 @@
191
191
  (import_statement
192
192
  source: (string) @import.path
193
193
  ) @import.decl
194
+
195
+ ; ── Class heritage: extends / implements ─────────────────────────────────────
196
+ ; Feeds SymbolTable._implements_map so an interface-typed dependency resolves to
197
+ ; the @Injectable class that implements it. One match is emitted per heritage
198
+ ; type (multiple implements → multiple matches); the interpreter folds them
199
+ ; back per class. Validated against tree-sitter-typescript:
200
+ ; class_heritage → extends_clause(value:) + implements_clause(type_identifier)
201
+ (class_declaration
202
+ name: (type_identifier) @service.heritage_class
203
+ (class_heritage
204
+ (extends_clause
205
+ value: (identifier) @service.extends
206
+ )?
207
+ (implements_clause
208
+ (type_identifier) @service.implements
209
+ )?
210
+ )
211
+ ) @service.with_heritage
@@ -181,3 +181,21 @@
181
181
  (import_statement
182
182
  source: (string) @import.path
183
183
  ) @import.decl
184
+
185
+ ; ── Class heritage: extends / implements ─────────────────────────────────────
186
+ ; Feeds SymbolTable._implements_map so an interface-typed provider resolves to
187
+ ; the @Injectable class that implements it. One match is emitted per heritage
188
+ ; type (multiple implements → multiple matches); the interpreter folds them
189
+ ; back per class. Validated against tree-sitter-typescript:
190
+ ; class_heritage → extends_clause(value:) + implements_clause(type_identifier)
191
+ (class_declaration
192
+ name: (type_identifier) @service.heritage_class
193
+ (class_heritage
194
+ (extends_clause
195
+ value: (identifier) @service.extends
196
+ )?
197
+ (implements_clause
198
+ (type_identifier) @service.implements
199
+ )?
200
+ )
201
+ ) @service.with_heritage
@@ -137,6 +137,38 @@ def _nid(file_path: str, name: str) -> str:
137
137
  return f"{file_path}::{name}"
138
138
 
139
139
 
140
+ def _collect_heritage(matches: list) -> dict[str, tuple[list[str], list[str]]]:
141
+ """Collect ``class_name → (extends, implements)`` from heritage captures.
142
+
143
+ The ``@service.with_heritage`` query pattern (TS frameworks) emits one match
144
+ per heritage type, so a class with ``implements IFoo, OnInit`` produces two
145
+ matches that both carry ``@service.heritage_class``. We fold them back into
146
+ per-class lists, de-duplicating while preserving declaration order. Feeds the
147
+ structured ``ServiceInfo.extends`` / ``.implements`` fields that
148
+ ``SymbolTable`` reads for interface→implementation DI resolution.
149
+ """
150
+ extends: dict[str, list[str]] = {}
151
+ implements: dict[str, list[str]] = {}
152
+ for _idx, caps in matches:
153
+ if "service.heritage_class" not in caps:
154
+ continue
155
+ cname = node_text(caps["service.heritage_class"][0])
156
+ for node in caps.get("service.extends", []):
157
+ val = node_text(node)
158
+ bucket = extends.setdefault(cname, [])
159
+ if val and val not in bucket:
160
+ bucket.append(val)
161
+ for node in caps.get("service.implements", []):
162
+ val = node_text(node)
163
+ bucket = implements.setdefault(cname, [])
164
+ if val and val not in bucket:
165
+ bucket.append(val)
166
+ return {
167
+ cname: (extends.get(cname, []), implements.get(cname, []))
168
+ for cname in set(extends) | set(implements)
169
+ }
170
+
171
+
140
172
  def _interpret_noop(
141
173
  file_path: str, matches: list, framework: str,
142
174
  ) -> tuple[list[ServiceInfo], list[UnresolvedRef]]:
@@ -183,6 +215,11 @@ def _interpret_spring_boot(
183
215
  if start <= cls.start_byte <= end:
184
216
  if iface not in info.annotations:
185
217
  info.annotations.append(f"implements:{iface}")
218
+ # Structured field too: SymbolTable reads metadata["implements"]
219
+ # (not the annotation string) to drive interface→impl DI
220
+ # resolution. The annotation is kept for the wiki display.
221
+ if iface not in info.implements:
222
+ info.implements.append(iface)
186
223
  break
187
224
 
188
225
  # @Autowired field injection
@@ -304,6 +341,16 @@ def _interpret_nestjs(
304
341
  framework="nestjs",
305
342
  ))
306
343
 
344
+ # Attach class heritage (extends / implements) so interface-typed providers
345
+ # resolve to their implementing service via SymbolTable._implements_map.
346
+ heritage = _collect_heritage(matches)
347
+ for svc in services.values():
348
+ ext, impl = heritage.get(svc.name, ([], []))
349
+ if ext:
350
+ svc.extends = ext
351
+ if impl:
352
+ svc.implements = impl
353
+
307
354
  return list(services.values()), unresolved
308
355
 
309
356
 
@@ -475,6 +522,10 @@ def _interpret_aspnet(
475
522
  )
476
523
  if iface:
477
524
  svc.annotations.append(f"implements:{iface}")
525
+ # Structured field drives interface→impl DI resolution; the C#
526
+ # base_list's first identifier is conventionally the interface
527
+ # (IFoo) when a class registers via AddScoped<IFoo, FooImpl>().
528
+ svc.implements.append(iface)
478
529
  services.append(svc)
479
530
 
480
531
  if "di.generic_registration" in caps and "di.service_type" in caps and "di.impl_type" in caps:
@@ -635,6 +686,16 @@ def _interpret_angular(
635
686
  framework="angular",
636
687
  ))
637
688
 
689
+ # Attach class heritage (extends / implements) so interface-typed providers
690
+ # resolve to their implementing service via SymbolTable._implements_map.
691
+ heritage = _collect_heritage(matches)
692
+ for svc in services.values():
693
+ ext, impl = heritage.get(svc.name, ([], []))
694
+ if ext:
695
+ svc.extends = ext
696
+ if impl:
697
+ svc.implements = impl
698
+
638
699
  return list(services.values()), unresolved
639
700
 
640
701
 
@@ -62,6 +62,18 @@ def build_graph(
62
62
  service_roots, claimed_ids,
63
63
  )
64
64
 
65
+ # Rewrite file-path-keyed UnresolvedRef sources onto their final node IDs.
66
+ # The per-file extractors mint ``UnresolvedRef.source_node_id`` as
67
+ # ``f"{file_path}::{name}"`` (see extract/services.py::_nid), but graph nodes
68
+ # are keyed ``project::name``. Left unrewritten, the resolved injects edge
69
+ # carries a source the graph has never heard of and gets silently dropped by
70
+ # ``_build_nx_graph``. Spring/NestJS survive that drop because they *also*
71
+ # stage the dependency on ``svc.dependencies`` (which _ingest_wave re-emits
72
+ # with the correct id), but FastAPI ``Depends()``, Laravel ``bind()`` and
73
+ # ASP.NET ``AddScoped<>`` only ever emit the file-path form — so their DI
74
+ # edges vanished entirely until this remap.
75
+ _remap_unresolved_sources(all_nodes, all_unresolved)
76
+
65
77
  # Cross-file declaration merge. When the same symbol appears in multiple
66
78
  # files (Swift `extension Foo`, C# partial classes, Ruby reopened classes),
67
79
  # the per-file extractor emits one Node per file. NetworkX collapses them
@@ -142,6 +154,8 @@ def _ingest_wave(
142
154
  "methods": svc.methods,
143
155
  "dependencies": svc.dependencies,
144
156
  "annotations": svc.annotations,
157
+ "implements": svc.implements,
158
+ "extends": svc.extends,
145
159
  "framework": svc.framework,
146
160
  "project": project_name,
147
161
  },
@@ -258,6 +272,64 @@ def _disambiguate_decl(
258
272
  return disambiguated, f"{name} ({hint})"
259
273
 
260
274
 
275
+ # ── UnresolvedRef source remap ────────────────────────────────────────────────
276
+
277
+ def _bare_name(node_id: str) -> str:
278
+ """Recover a declaration's plain name from its graph node ID.
279
+
280
+ Node IDs are ``project::Name`` or, for directory-disambiguated collisions,
281
+ ``project::hint/Name`` — so the trailing path segment after the last ``::``
282
+ and ``/`` is the original symbol name. Route IDs carry extra ``::`` segments
283
+ but are never DI sources, so the (harmless) value they yield never matches a
284
+ real ``ref_name``.
285
+ """
286
+ return node_id.rsplit("::", 1)[-1].rsplit("/", 1)[-1]
287
+
288
+
289
+ def _remap_unresolved_sources(
290
+ all_nodes: list[Node], all_unresolved: list[UnresolvedRef]
291
+ ) -> None:
292
+ """Point each UnresolvedRef at a real graph node ID, in place.
293
+
294
+ Two lookup tables are built from the finalized node set:
295
+
296
+ * ``by_file_name`` — ``(source_file, bare_name) → node_id``. This is the
297
+ exact case: the injecting class is declared in the same file the
298
+ extractor stamped onto the ref (Spring, NestJS, FastAPI).
299
+ * ``by_name`` — ``bare_name → node_id``. The fallback for binding-style DI
300
+ where the ref's file is the *registration site* (a Laravel ServiceProvider
301
+ or an ASP.NET ``Startup``) but the intended source node is the
302
+ implementation class declared elsewhere.
303
+
304
+ A ref whose ``source_node_id`` already names a live node (the
305
+ ``project::Name`` form that ``_ingest_wave`` emits from ``svc.dependencies``)
306
+ is left untouched, so this never disturbs edges that already resolve. A ref
307
+ that matches nothing is also left as-is and will drop exactly as it did
308
+ before — no regression, only recovery.
309
+ """
310
+ node_ids = {n.id for n in all_nodes}
311
+ by_file_name: dict[tuple[str, str], str] = {}
312
+ by_name: dict[str, list[str]] = {}
313
+ for n in all_nodes:
314
+ bare = _bare_name(n.id)
315
+ by_file_name.setdefault((n.source_file, bare), n.id)
316
+ by_name.setdefault(bare, []).append(n.id)
317
+
318
+ for ref in all_unresolved:
319
+ sid = ref.source_node_id
320
+ if sid in node_ids:
321
+ continue # already a valid graph node id
322
+ file_part, sep, name_part = sid.rpartition("::")
323
+ if not sep or not name_part:
324
+ continue
325
+ new_id = by_file_name.get((file_part, name_part))
326
+ if new_id is None:
327
+ candidates = by_name.get(name_part)
328
+ new_id = candidates[0] if candidates else None
329
+ if new_id is not None:
330
+ ref.source_node_id = new_id
331
+
332
+
261
333
  # ── Cross-file declaration merge ──────────────────────────────────────────────
262
334
 
263
335
  def _merge_cross_file_decls(nodes: list[Node]) -> list[Node]:
@@ -271,7 +343,7 @@ def _merge_cross_file_decls(nodes: list[Node]) -> list[Node]:
271
343
  """
272
344
  _LIST_KEYS = (
273
345
  "fields", "methods", "dependencies", "annotations",
274
- "relations", "props", "hooks",
346
+ "relations", "props", "hooks", "implements", "extends",
275
347
  )
276
348
  first: dict[str, Node] = {}
277
349
  for node in nodes:
@@ -24,25 +24,53 @@ def cluster(G: nx.DiGraph) -> dict[str, int]:
24
24
  """Detect communities in the graph.
25
25
 
26
26
  Returns:
27
- node_id → community_id mapping (community IDs are consecutive integers
28
- starting from 0).
27
+ node_id → community_id mapping. Community IDs are consecutive integers
28
+ starting from 0, assigned by a *stable total order* (see
29
+ :func:`_relabel_stable`) so that an identical graph always yields an
30
+ identical mapping regardless of which backend ran or the order it
31
+ happened to enumerate communities in.
29
32
  """
30
33
  if G.number_of_nodes() == 0:
31
34
  return {}
32
35
 
33
- result = _try_graspologic(G)
34
- if result is not None:
35
- return result
36
+ for backend in (_try_graspologic, _try_leidenalg, _try_louvain):
37
+ result = backend(G)
38
+ if result is not None:
39
+ return _relabel_stable(result)
36
40
 
37
- result = _try_leidenalg(G)
38
- if result is not None:
39
- return result
41
+ return _relabel_stable(_connected_components(G))
40
42
 
41
- result = _try_louvain(G)
42
- if result is not None:
43
- return result
44
43
 
45
- return _connected_components(G)
44
+ def _relabel_stable(communities: dict[str, int]) -> dict[str, int]:
45
+ """Reassign community IDs deterministically from the raw partition.
46
+
47
+ The Leiden / Louvain / connected-component backends each label communities
48
+ in whatever order they happen to enumerate them — an order that is *not*
49
+ stable across runs when several communities share the same size. Two scans
50
+ of an unchanged graph could therefore hand the same grouping different
51
+ integer IDs, churning 77–88% of ``beacon.json`` lines on a no-op rescan and
52
+ drowning real diffs (graphify measured exactly this before f5f3a1c).
53
+
54
+ We break that by imposing a total order on the communities themselves:
55
+ largest first, ties broken by the lexicographically-sorted tuple of member
56
+ node IDs. Equal groupings are bytewise-identical inputs to the sort, so they
57
+ always receive the same ID. The ``str()`` coercion keeps the comparison
58
+ well-defined even if a backend hands back non-string node keys.
59
+ """
60
+ members: dict[int, list[str]] = {}
61
+ for node, cid in communities.items():
62
+ members.setdefault(cid, []).append(node)
63
+
64
+ ordered = sorted(
65
+ members.values(),
66
+ key=lambda nodes: (-len(nodes), tuple(sorted(map(str, nodes)))),
67
+ )
68
+
69
+ relabeled: dict[str, int] = {}
70
+ for new_cid, nodes in enumerate(ordered):
71
+ for node in nodes:
72
+ relabeled[node] = new_cid
73
+ return relabeled
46
74
 
47
75
 
48
76
  def apply_communities(G: nx.DiGraph, communities: dict[str, int]) -> None:
@@ -27,6 +27,7 @@ from typing import Any
27
27
 
28
28
  import networkx as nx
29
29
 
30
+ from codebeacon.common.safety import cap_filename
30
31
  from codebeacon.wiki import templates
31
32
 
32
33
 
@@ -54,8 +55,15 @@ def _is_controller(label: str, annotations: list[str]) -> bool:
54
55
 
55
56
 
56
57
  def _safe_filename(label: str) -> str:
57
- """Strip characters that are unsafe in filenames."""
58
- return "".join(c if c.isalnum() or c in "-_." else "_" for c in label)
58
+ """Strip filename-unsafe characters, then cap the stem to a safe byte length.
59
+
60
+ The byte cap stops a pathologically long class label (or an 85+ character
61
+ CJK name) from overflowing the 255-byte filesystem limit and crashing the
62
+ whole wiki write with ENAMETOOLONG. ``cap_filename`` keeps long-prefix
63
+ labels distinct via a hash suffix, so two capped names never collide.
64
+ """
65
+ cleaned = "".join(c if c.isalnum() or c in "-_." else "_" for c in label)
66
+ return cap_filename(cleaned)
59
67
 
60
68
 
61
69
  def node_to_wiki_path(G: nx.DiGraph, node_id: str) -> str | None:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "codebeacon"
7
- version = "0.6.1"
7
+ version = "0.6.2"
8
8
  description = "Source code AST analysis tool for AI context generation — unified multi-framework knowledge graph"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }