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.
- {codebeacon-0.6.1 → codebeacon-0.6.2}/PKG-INFO +1 -1
- codebeacon-0.6.2/codebeacon/__init__.py +1 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/common/safety.py +33 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/common/types.py +2 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/export/obsidian.py +12 -3
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/queries/angular.scm +18 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/queries/nestjs.scm +18 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/services.py +61 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/graph/build.py +73 -1
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/graph/cluster.py +40 -12
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/wiki/generator.py +10 -2
- {codebeacon-0.6.1 → codebeacon-0.6.2}/pyproject.toml +1 -1
- codebeacon-0.6.2/tests/test_graphify_parity_fixes.py +335 -0
- codebeacon-0.6.1/codebeacon/__init__.py +0 -1
- {codebeacon-0.6.1 → codebeacon-0.6.2}/.cursorrules +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/.github/CODEOWNERS +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/.github/dependabot.yml +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/.github/workflows/ci.yml +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/.github/workflows/release.yml +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/.gitignore +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/AGENTS.md +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/CLAUDE.md +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/LICENSE +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/README.de.md +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/README.es.md +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/README.fr.md +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/README.ja.md +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/README.ko.md +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/README.md +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/README.pt-BR.md +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/README.zh-CN.md +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/__main__.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/affected.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/cache.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/cli.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/common/__init__.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/common/filters.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/common/symbols.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/config.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/contextmap/__init__.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/contextmap/generator.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/diagnostics.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/discover/__init__.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/discover/detector.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/discover/ignore.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/discover/scanner.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/export/__init__.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/export/callflow_html.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/export/hooks.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/export/mcp.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/export/merge.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/export/tree_html.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/__init__.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/base.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/components.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/dependencies.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/dotnet.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/entities.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/queries/README.md +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/queries/actix.scm +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/queries/aspnet.scm +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/queries/django.scm +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/queries/express.scm +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/queries/fastapi.scm +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/queries/flask.scm +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/queries/gin.scm +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/queries/ktor.scm +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/queries/laravel.scm +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/queries/rails.scm +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/queries/react.scm +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/queries/spring_boot.scm +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/queries/svelte.scm +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/queries/tauri.scm +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/queries/vapor.scm +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/queries/vue.scm +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/routes.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/extract/semantic.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/graph/__init__.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/graph/analyze.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/graph/enrich.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/graph/write.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/knowledge/__init__.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/knowledge/generator.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/pipeline.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/plugins/__init__.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/plugins/githooks.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/plugins/skills.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/semantic_pipeline.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/skill/SKILL.md +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/wave.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/wiki/__init__.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/wiki/index.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon/wiki/templates.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/codebeacon.yaml.example +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/docs/TRANSLATION_STATUS.md +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/public-plan.md +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/skill/install.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/__init__.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/conftest.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/actix/main.rs +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/angular/app.component.ts +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/aspnet/UserController.cs +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/django/views.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/express/userRouter.js +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/fastapi/main.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/flask/app.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/gin/main.go +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/integration_workspace/api-python/pyproject.toml +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/integration_workspace/api-python/src/__init__.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/integration_workspace/api-python/src/main.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/integration_workspace/api-python/src/services.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/integration_workspace/web/package.json +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/integration_workspace/web/src/UserPage.tsx +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/ktor/UserRoutes.kt +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/laravel/UserController.php +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/nestjs/user.controller.ts +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/rails/users_controller.rb +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/react/UserPage.tsx +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/spring_boot/UserController.java +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/sveltekit/+page.svelte +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/vapor/routes.swift +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/fixtures/vue/UserList.vue +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/integration/__init__.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/integration/test_full_pipeline.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_affected.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_affected_wiki.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_audit_bugfixes.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_cli_dispatch.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_contextmap_paths.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_dependencies.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_diagnostics.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_discover.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_dotnet.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_entities.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_filters.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_graph.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_knowledge.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_known_bugs.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_mcp_and_semantic.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_optional_grammars.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_pipeline_module.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_plugins.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_resolve.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_routes.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_safety_and_writes.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_scanner_sensitive.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_semantic.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_semantic_hardening.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_semantic_stats.py +0 -0
- {codebeacon-0.6.1 → codebeacon-0.6.2}/tests/test_services.py +0 -0
- {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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
58
|
-
|
|
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.
|
|
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" }
|