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.
- codebeacon/__init__.py +1 -0
- codebeacon/__main__.py +3 -0
- codebeacon/cache.py +136 -0
- codebeacon/cli.py +391 -0
- codebeacon/common/__init__.py +0 -0
- codebeacon/common/filters.py +170 -0
- codebeacon/common/symbols.py +121 -0
- codebeacon/common/types.py +98 -0
- codebeacon/config.py +144 -0
- codebeacon/contextmap/__init__.py +0 -0
- codebeacon/contextmap/generator.py +602 -0
- codebeacon/discover/__init__.py +0 -0
- codebeacon/discover/detector.py +388 -0
- codebeacon/discover/scanner.py +192 -0
- codebeacon/export/__init__.py +0 -0
- codebeacon/export/mcp.py +515 -0
- codebeacon/export/obsidian.py +812 -0
- codebeacon/extract/__init__.py +22 -0
- codebeacon/extract/base.py +372 -0
- codebeacon/extract/components.py +357 -0
- codebeacon/extract/dependencies.py +140 -0
- codebeacon/extract/entities.py +575 -0
- codebeacon/extract/queries/README.md +116 -0
- codebeacon/extract/queries/actix.scm +115 -0
- codebeacon/extract/queries/angular.scm +155 -0
- codebeacon/extract/queries/aspnet.scm +159 -0
- codebeacon/extract/queries/django.scm +122 -0
- codebeacon/extract/queries/express.scm +124 -0
- codebeacon/extract/queries/fastapi.scm +152 -0
- codebeacon/extract/queries/flask.scm +120 -0
- codebeacon/extract/queries/gin.scm +142 -0
- codebeacon/extract/queries/ktor.scm +144 -0
- codebeacon/extract/queries/laravel.scm +172 -0
- codebeacon/extract/queries/nestjs.scm +183 -0
- codebeacon/extract/queries/rails.scm +114 -0
- codebeacon/extract/queries/react.scm +111 -0
- codebeacon/extract/queries/spring_boot.scm +204 -0
- codebeacon/extract/queries/svelte.scm +73 -0
- codebeacon/extract/queries/vapor.scm +130 -0
- codebeacon/extract/queries/vue.scm +123 -0
- codebeacon/extract/routes.py +910 -0
- codebeacon/extract/semantic.py +280 -0
- codebeacon/extract/services.py +597 -0
- codebeacon/graph/__init__.py +1 -0
- codebeacon/graph/analyze.py +281 -0
- codebeacon/graph/build.py +320 -0
- codebeacon/graph/cluster.py +160 -0
- codebeacon/graph/enrich.py +206 -0
- codebeacon/skill/SKILL.md +127 -0
- codebeacon/wave.py +292 -0
- codebeacon/wiki/__init__.py +0 -0
- codebeacon/wiki/generator.py +376 -0
- codebeacon/wiki/index.py +95 -0
- codebeacon/wiki/templates.py +467 -0
- codebeacon-0.1.2.dist-info/METADATA +319 -0
- codebeacon-0.1.2.dist-info/RECORD +59 -0
- codebeacon-0.1.2.dist-info/WHEEL +4 -0
- codebeacon-0.1.2.dist-info/entry_points.txt +2 -0
- codebeacon-0.1.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
"""Context Map generator: CLAUDE.md / .cursorrules / AGENTS.md.
|
|
2
|
+
|
|
3
|
+
Reads the knowledge graph and project metadata to produce AI assistant config
|
|
4
|
+
files that implement a 3-step lookup strategy.
|
|
5
|
+
|
|
6
|
+
Public API:
|
|
7
|
+
generate_context_map(G, output_dir, projects, obsidian_dir, targets)
|
|
8
|
+
→ list[str] (paths of files written)
|
|
9
|
+
|
|
10
|
+
Output files (written next to .codebeacon/):
|
|
11
|
+
CLAUDE.md ← Claude Code
|
|
12
|
+
.cursorrules ← Cursor IDE
|
|
13
|
+
AGENTS.md ← Codex / Copilot multi-agent
|
|
14
|
+
|
|
15
|
+
Lookup strategy encoded in each file:
|
|
16
|
+
Step 1 → .codebeacon/wiki/ (routes, controllers, services)
|
|
17
|
+
Step 2 → .codebeacon/obsidian/ (methods, fields, connections)
|
|
18
|
+
Step 3 → source files (only those found in Steps 1-2)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import datetime
|
|
24
|
+
from collections import defaultdict
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
import networkx as nx
|
|
29
|
+
|
|
30
|
+
from codebeacon.common.types import ProjectInfo
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ── Build-tool command tables ─────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
_BUILD_COMMANDS: dict[str, dict[str, str]] = {
|
|
36
|
+
"spring-boot": {
|
|
37
|
+
"install": "mvn clean install -DskipTests=true",
|
|
38
|
+
"build": "mvn clean package -DskipTests=true",
|
|
39
|
+
"run": "mvn spring-boot:run",
|
|
40
|
+
"test": "mvn test",
|
|
41
|
+
"test_single": "mvn test -Dtest=ClassName#methodName",
|
|
42
|
+
},
|
|
43
|
+
"ktor": {
|
|
44
|
+
"install": "./gradlew build",
|
|
45
|
+
"build": "./gradlew build",
|
|
46
|
+
"run": "./gradlew run",
|
|
47
|
+
"test": "./gradlew test",
|
|
48
|
+
"test_single": "./gradlew test --tests 'com.example.ClassName'",
|
|
49
|
+
},
|
|
50
|
+
"fastapi": {
|
|
51
|
+
"install": "pip install -r requirements.txt",
|
|
52
|
+
"build": "pip install -r requirements.txt",
|
|
53
|
+
"run": "uvicorn main:app --reload",
|
|
54
|
+
"test": "pytest",
|
|
55
|
+
"test_single": "pytest tests/test_foo.py::test_bar",
|
|
56
|
+
},
|
|
57
|
+
"django": {
|
|
58
|
+
"install": "pip install -r requirements.txt",
|
|
59
|
+
"build": "python manage.py collectstatic --noinput",
|
|
60
|
+
"run": "python manage.py runserver",
|
|
61
|
+
"test": "python manage.py test",
|
|
62
|
+
"test_single": "python manage.py test myapp.tests.MyTestCase",
|
|
63
|
+
},
|
|
64
|
+
"flask": {
|
|
65
|
+
"install": "pip install -r requirements.txt",
|
|
66
|
+
"build": "pip install -r requirements.txt",
|
|
67
|
+
"run": "flask run",
|
|
68
|
+
"test": "pytest",
|
|
69
|
+
"test_single": "pytest tests/test_foo.py::test_bar",
|
|
70
|
+
},
|
|
71
|
+
"express": {
|
|
72
|
+
"install": "npm install",
|
|
73
|
+
"build": "npm run build",
|
|
74
|
+
"run": "npm run dev",
|
|
75
|
+
"test": "npm test",
|
|
76
|
+
"test_single": "npm test -- --testNamePattern 'test name'",
|
|
77
|
+
},
|
|
78
|
+
"nestjs": {
|
|
79
|
+
"install": "npm install",
|
|
80
|
+
"build": "npm run build",
|
|
81
|
+
"run": "npm run start:dev",
|
|
82
|
+
"test": "npm test",
|
|
83
|
+
"test_single": "npm test -- --testNamePattern 'test name'",
|
|
84
|
+
},
|
|
85
|
+
"react": {
|
|
86
|
+
"install": "npm install",
|
|
87
|
+
"build": "npm run build",
|
|
88
|
+
"run": "npm run dev",
|
|
89
|
+
"test": "npm test",
|
|
90
|
+
"test_single": "npm test -- --testPathPattern TestFile",
|
|
91
|
+
},
|
|
92
|
+
"next": {
|
|
93
|
+
"install": "npm install",
|
|
94
|
+
"build": "npm run build",
|
|
95
|
+
"run": "npm run dev",
|
|
96
|
+
"test": "npm test",
|
|
97
|
+
"test_single": "npm test -- --testPathPattern TestFile",
|
|
98
|
+
},
|
|
99
|
+
"vue": {
|
|
100
|
+
"install": "npm install",
|
|
101
|
+
"build": "npm run build",
|
|
102
|
+
"run": "npm run dev",
|
|
103
|
+
"test": "npm run test:unit",
|
|
104
|
+
"test_single": "npm run test:unit -- TestFile",
|
|
105
|
+
},
|
|
106
|
+
"nuxt": {
|
|
107
|
+
"install": "npm install",
|
|
108
|
+
"build": "npm run build",
|
|
109
|
+
"run": "npm run dev",
|
|
110
|
+
"test": "npm run test",
|
|
111
|
+
"test_single": "npm run test -- TestFile",
|
|
112
|
+
},
|
|
113
|
+
"svelte": {
|
|
114
|
+
"install": "npm install",
|
|
115
|
+
"build": "npm run build",
|
|
116
|
+
"run": "npm run dev",
|
|
117
|
+
"test": "npm test",
|
|
118
|
+
"test_single": "npm test -- --testPathPattern TestFile",
|
|
119
|
+
},
|
|
120
|
+
"angular": {
|
|
121
|
+
"install": "npm install",
|
|
122
|
+
"build": "ng build",
|
|
123
|
+
"run": "ng serve",
|
|
124
|
+
"test": "ng test",
|
|
125
|
+
"test_single": "ng test --include **/foo.spec.ts",
|
|
126
|
+
},
|
|
127
|
+
"gin": {
|
|
128
|
+
"install": "go mod download",
|
|
129
|
+
"build": "go build ./...",
|
|
130
|
+
"run": "go run .",
|
|
131
|
+
"test": "go test ./...",
|
|
132
|
+
"test_single": "go test ./... -run TestFunctionName",
|
|
133
|
+
},
|
|
134
|
+
"echo": {
|
|
135
|
+
"install": "go mod download",
|
|
136
|
+
"build": "go build ./...",
|
|
137
|
+
"run": "go run .",
|
|
138
|
+
"test": "go test ./...",
|
|
139
|
+
"test_single": "go test ./... -run TestFunctionName",
|
|
140
|
+
},
|
|
141
|
+
"fiber": {
|
|
142
|
+
"install": "go mod download",
|
|
143
|
+
"build": "go build ./...",
|
|
144
|
+
"run": "go run .",
|
|
145
|
+
"test": "go test ./...",
|
|
146
|
+
"test_single": "go test ./... -run TestFunctionName",
|
|
147
|
+
},
|
|
148
|
+
"rails": {
|
|
149
|
+
"install": "bundle install",
|
|
150
|
+
"build": "bundle exec rails assets:precompile",
|
|
151
|
+
"run": "bundle exec rails server",
|
|
152
|
+
"test": "bundle exec rspec",
|
|
153
|
+
"test_single": "bundle exec rspec spec/models/user_spec.rb",
|
|
154
|
+
},
|
|
155
|
+
"laravel": {
|
|
156
|
+
"install": "composer install",
|
|
157
|
+
"build": "npm run build",
|
|
158
|
+
"run": "php artisan serve",
|
|
159
|
+
"test": "php artisan test",
|
|
160
|
+
"test_single": "php artisan test --filter TestClass",
|
|
161
|
+
},
|
|
162
|
+
"aspnet": {
|
|
163
|
+
"install": "dotnet restore",
|
|
164
|
+
"build": "dotnet build",
|
|
165
|
+
"run": "dotnet run",
|
|
166
|
+
"test": "dotnet test",
|
|
167
|
+
"test_single": "dotnet test --filter 'FullyQualifiedName~TestClass'",
|
|
168
|
+
},
|
|
169
|
+
"actix": {
|
|
170
|
+
"install": "cargo fetch",
|
|
171
|
+
"build": "cargo build --release",
|
|
172
|
+
"run": "cargo run",
|
|
173
|
+
"test": "cargo test",
|
|
174
|
+
"test_single": "cargo test test_function_name",
|
|
175
|
+
},
|
|
176
|
+
"axum": {
|
|
177
|
+
"install": "cargo fetch",
|
|
178
|
+
"build": "cargo build --release",
|
|
179
|
+
"run": "cargo run",
|
|
180
|
+
"test": "cargo test",
|
|
181
|
+
"test_single": "cargo test test_function_name",
|
|
182
|
+
},
|
|
183
|
+
"vapor": {
|
|
184
|
+
"install": "swift package resolve",
|
|
185
|
+
"build": "swift build",
|
|
186
|
+
"run": "swift run",
|
|
187
|
+
"test": "swift test",
|
|
188
|
+
"test_single": "swift test --filter TestClass/testMethod",
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
_FALLBACK_COMMANDS: dict[str, str] = {
|
|
193
|
+
"install": "# see project README",
|
|
194
|
+
"build": "# see project README",
|
|
195
|
+
"run": "# see project README",
|
|
196
|
+
"test": "# see project README",
|
|
197
|
+
"test_single": "# see project README",
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _get_commands(framework: str) -> dict[str, str]:
|
|
202
|
+
fw = framework.lower()
|
|
203
|
+
for key, cmds in _BUILD_COMMANDS.items():
|
|
204
|
+
if key in fw:
|
|
205
|
+
return cmds
|
|
206
|
+
return _FALLBACK_COMMANDS
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# ── Stats extraction ──────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
def _collect_stats(G: nx.DiGraph) -> dict[str, dict[str, int]]:
|
|
212
|
+
"""Return per-project counts: routes, services, entities, components."""
|
|
213
|
+
|
|
214
|
+
stats: dict[str, dict[str, int]] = defaultdict(lambda: {
|
|
215
|
+
"routes": 0, "services": 0, "entities": 0, "components": 0, "controllers": 0,
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
_CONTROLLER_SUFFIXES = ("Controller", "Router", "Handler", "Resource")
|
|
219
|
+
_CONTROLLER_ANNS = frozenset({
|
|
220
|
+
"@Controller", "@RestController", "[Controller]", "[ApiController]",
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
for node_id, data in G.nodes(data=True):
|
|
224
|
+
project = data.get("project", "")
|
|
225
|
+
if not project:
|
|
226
|
+
continue
|
|
227
|
+
ntype = data.get("type", "")
|
|
228
|
+
if ntype == "route":
|
|
229
|
+
stats[project]["routes"] += 1
|
|
230
|
+
elif ntype == "entity":
|
|
231
|
+
stats[project]["entities"] += 1
|
|
232
|
+
elif ntype == "component":
|
|
233
|
+
stats[project]["components"] += 1
|
|
234
|
+
elif ntype == "class":
|
|
235
|
+
anns = data.get("annotations", [])
|
|
236
|
+
label = data.get("label", "")
|
|
237
|
+
if any(a in _CONTROLLER_ANNS for a in anns) or label.endswith(_CONTROLLER_SUFFIXES):
|
|
238
|
+
stats[project]["controllers"] += 1
|
|
239
|
+
else:
|
|
240
|
+
stats[project]["services"] += 1
|
|
241
|
+
|
|
242
|
+
return dict(stats)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _hub_files(G: nx.DiGraph, top_n: int = 5) -> list[tuple[str, int]]:
|
|
246
|
+
"""Return (file_path, import_count) for the most-imported source files."""
|
|
247
|
+
counter: dict[str, int] = defaultdict(int)
|
|
248
|
+
for _, _, data in G.edges(data=True):
|
|
249
|
+
if data.get("relation") in ("imports", "imports_from"):
|
|
250
|
+
sf = data.get("source_file", "")
|
|
251
|
+
if sf:
|
|
252
|
+
counter[sf] += 1
|
|
253
|
+
ranked = sorted(counter.items(), key=lambda x: x[1], reverse=True)
|
|
254
|
+
return ranked[:top_n]
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ── Content builders ──────────────────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
def _build_content(
|
|
260
|
+
G: nx.DiGraph,
|
|
261
|
+
projects: list[ProjectInfo],
|
|
262
|
+
output_dir: Path,
|
|
263
|
+
obsidian_path: str,
|
|
264
|
+
stats: dict[str, dict[str, int]],
|
|
265
|
+
hub_files: list[tuple[str, int]],
|
|
266
|
+
tool: str, # "claude", "cursor", "agents"
|
|
267
|
+
) -> str:
|
|
268
|
+
today = datetime.date.today().isoformat()
|
|
269
|
+
codebeacon_dir = ".codebeacon" # relative to project root
|
|
270
|
+
|
|
271
|
+
# ── Header ──
|
|
272
|
+
if tool == "claude":
|
|
273
|
+
lines = [
|
|
274
|
+
"# CLAUDE.md",
|
|
275
|
+
"",
|
|
276
|
+
"## MANDATORY: Lookup Strategy",
|
|
277
|
+
"",
|
|
278
|
+
"> **Read these before ANY code exploration. No exceptions.**",
|
|
279
|
+
">",
|
|
280
|
+
"> Skipping these steps and reading source files directly is a rule violation.",
|
|
281
|
+
"",
|
|
282
|
+
]
|
|
283
|
+
elif tool == "cursor":
|
|
284
|
+
lines = [
|
|
285
|
+
"# Project Context",
|
|
286
|
+
"",
|
|
287
|
+
"## Lookup Strategy",
|
|
288
|
+
"",
|
|
289
|
+
"> Always follow this 3-step lookup before editing code.",
|
|
290
|
+
"",
|
|
291
|
+
]
|
|
292
|
+
else: # agents
|
|
293
|
+
lines = [
|
|
294
|
+
"# AGENTS.md",
|
|
295
|
+
"",
|
|
296
|
+
"## Context Lookup Protocol",
|
|
297
|
+
"",
|
|
298
|
+
"> All agents MUST follow this 3-step lookup before writing or modifying code.",
|
|
299
|
+
"",
|
|
300
|
+
]
|
|
301
|
+
|
|
302
|
+
# ── Step 1: wiki ──
|
|
303
|
+
lines += [
|
|
304
|
+
"### Step 1 → codebeacon wiki (routes, controllers, services, entities) — ALWAYS",
|
|
305
|
+
"```",
|
|
306
|
+
f"{codebeacon_dir}/wiki/index.md ← MUST read at session start",
|
|
307
|
+
f"{codebeacon_dir}/wiki/{{project}}/controllers/{{Name}}.md ← for controller logic",
|
|
308
|
+
f"{codebeacon_dir}/wiki/{{project}}/services/{{Name}}.md ← for service methods",
|
|
309
|
+
f"{codebeacon_dir}/wiki/{{project}}/entities/{{Name}}.md ← for data models",
|
|
310
|
+
f"{codebeacon_dir}/wiki/routes.md ← all API routes across projects",
|
|
311
|
+
"```",
|
|
312
|
+
"",
|
|
313
|
+
]
|
|
314
|
+
|
|
315
|
+
# ── Step 2: obsidian ──
|
|
316
|
+
lines += [
|
|
317
|
+
"### Step 2 → codebeacon obsidian (methods, fields, connections) — ALWAYS",
|
|
318
|
+
"**MUST read even if Step 1 found results.** Obsidian notes contain method lists,",
|
|
319
|
+
"field definitions, and class-level connections that wiki articles do not have.",
|
|
320
|
+
"",
|
|
321
|
+
f"Look up by class name — replace `{{project}}` with the relevant folder:",
|
|
322
|
+
"```",
|
|
323
|
+
f"{obsidian_path}/{{project}}/{{ClassName}}.md",
|
|
324
|
+
"```",
|
|
325
|
+
"",
|
|
326
|
+
]
|
|
327
|
+
|
|
328
|
+
# Project table
|
|
329
|
+
if projects:
|
|
330
|
+
lines += [
|
|
331
|
+
"| Project | Notes | Example |",
|
|
332
|
+
"| --- | --- | --- |",
|
|
333
|
+
]
|
|
334
|
+
for p in projects:
|
|
335
|
+
s = stats.get(p.name, {})
|
|
336
|
+
total = s.get("services", 0) + s.get("controllers", 0)
|
|
337
|
+
ent = s.get("entities", 0)
|
|
338
|
+
comp = s.get("components", 0)
|
|
339
|
+
# Example note name: pick first service or entity
|
|
340
|
+
example = _pick_example_note(G, p.name)
|
|
341
|
+
parts = []
|
|
342
|
+
if total:
|
|
343
|
+
parts.append(f"{total} services")
|
|
344
|
+
if ent:
|
|
345
|
+
parts.append(f"{ent} entities")
|
|
346
|
+
if comp:
|
|
347
|
+
parts.append(f"{comp} components")
|
|
348
|
+
note_summary = ", ".join(parts) if parts else "—"
|
|
349
|
+
lines.append(f"| {p.name} | {note_summary} | `{example}` |")
|
|
350
|
+
lines.append("")
|
|
351
|
+
|
|
352
|
+
# ── Step 3 ──
|
|
353
|
+
lines += [
|
|
354
|
+
"### Step 3 → source file (ONLY files identified in Steps 1-2)",
|
|
355
|
+
"Read only the specific source files whose paths were found in Steps 1-2.",
|
|
356
|
+
"No directory exploration, no Glob scans, no broad Grep searches.",
|
|
357
|
+
"",
|
|
358
|
+
]
|
|
359
|
+
|
|
360
|
+
if tool == "claude":
|
|
361
|
+
lines += [
|
|
362
|
+
"### Prohibited actions (before completing Steps 1-2)",
|
|
363
|
+
"- **DO NOT use Explore agent**",
|
|
364
|
+
"- **DO NOT use Glob for directory scans**",
|
|
365
|
+
"- **DO NOT use Grep for broad searches**",
|
|
366
|
+
"- **DO NOT Read source files directly without checking Steps 1-2 first**",
|
|
367
|
+
"",
|
|
368
|
+
"Proceed to Step 3 only when Steps 1-2 are insufficient.",
|
|
369
|
+
"",
|
|
370
|
+
]
|
|
371
|
+
|
|
372
|
+
lines.append("---")
|
|
373
|
+
lines.append("")
|
|
374
|
+
|
|
375
|
+
# ── Project stats table ──
|
|
376
|
+
lines += [
|
|
377
|
+
"## Projects",
|
|
378
|
+
"",
|
|
379
|
+
"| Project | Framework | Routes | Services | Entities | Components |",
|
|
380
|
+
"| --- | --- | --- | --- | --- | --- |",
|
|
381
|
+
]
|
|
382
|
+
for p in projects:
|
|
383
|
+
s = stats.get(p.name, {})
|
|
384
|
+
lines.append(
|
|
385
|
+
f"| {p.name} | {p.framework}"
|
|
386
|
+
f" | {s.get('routes', 0)}"
|
|
387
|
+
f" | {s.get('services', 0) + s.get('controllers', 0)}"
|
|
388
|
+
f" | {s.get('entities', 0)}"
|
|
389
|
+
f" | {s.get('components', 0)} |"
|
|
390
|
+
)
|
|
391
|
+
lines += ["", "---", ""]
|
|
392
|
+
|
|
393
|
+
# ── Common Commands ──
|
|
394
|
+
lines += ["## Common Commands", ""]
|
|
395
|
+
for p in projects:
|
|
396
|
+
cmds = _get_commands(p.framework)
|
|
397
|
+
lines += [f"### {p.name} ({p.framework})", "```bash"]
|
|
398
|
+
lines.append(f"{cmds['build']} # build")
|
|
399
|
+
lines.append(f"{cmds['run']} # run")
|
|
400
|
+
lines.append(f"{cmds['test']} # all tests")
|
|
401
|
+
if cmds.get("test_single") != cmds.get("test"):
|
|
402
|
+
lines.append(f"{cmds['test_single']} # single test")
|
|
403
|
+
lines += ["```", ""]
|
|
404
|
+
lines += ["---", ""]
|
|
405
|
+
|
|
406
|
+
# ── Architecture ──
|
|
407
|
+
lines += ["## Architecture", ""]
|
|
408
|
+
for p in projects:
|
|
409
|
+
s = stats.get(p.name, {})
|
|
410
|
+
arch_parts = [f"**{p.framework}**", f"{p.language}"]
|
|
411
|
+
lines.append(f"**{p.name}**: {' · '.join(arch_parts)}")
|
|
412
|
+
lines.append(f" Routes: {s.get('routes', 0)} | "
|
|
413
|
+
f"Services: {s.get('services', 0)} | "
|
|
414
|
+
f"Entities: {s.get('entities', 0)} | "
|
|
415
|
+
f"Components: {s.get('components', 0)}")
|
|
416
|
+
lines.append("")
|
|
417
|
+
|
|
418
|
+
# ── High-impact files ──
|
|
419
|
+
if hub_files:
|
|
420
|
+
lines += ["## High-Impact Files", "", "Changes here affect many other files:", ""]
|
|
421
|
+
for fp, cnt in hub_files:
|
|
422
|
+
lines.append(f"- `{fp}` (imported by {cnt} files)")
|
|
423
|
+
lines += ["", "---", ""]
|
|
424
|
+
|
|
425
|
+
# ── Footer ──
|
|
426
|
+
lines += [
|
|
427
|
+
f"_Generated by [codebeacon](https://github.com/codebeacon/codebeacon) · {today}_",
|
|
428
|
+
]
|
|
429
|
+
|
|
430
|
+
return "\n".join(lines) + "\n"
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _pick_example_note(G: nx.DiGraph, project: str) -> str:
|
|
434
|
+
"""Pick a representative note name for the project example column."""
|
|
435
|
+
for node_id, data in G.nodes(data=True):
|
|
436
|
+
if data.get("project") != project:
|
|
437
|
+
continue
|
|
438
|
+
if data.get("type") == "class":
|
|
439
|
+
label = data.get("label", "")
|
|
440
|
+
sf = data.get("source_file", "")
|
|
441
|
+
ext = Path(sf).suffix if sf else ""
|
|
442
|
+
if ext:
|
|
443
|
+
return f"{label}{ext}.md"
|
|
444
|
+
return f"{label}.md"
|
|
445
|
+
for node_id, data in G.nodes(data=True):
|
|
446
|
+
if data.get("project") != project:
|
|
447
|
+
continue
|
|
448
|
+
return data.get("label", "example") + ".md"
|
|
449
|
+
return "example.md"
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
# ── Merge helpers ─────────────────────────────────────────────────────────────
|
|
453
|
+
|
|
454
|
+
_BLOCK_START = "<!-- codebeacon:start -->"
|
|
455
|
+
_BLOCK_END = "<!-- codebeacon:end -->"
|
|
456
|
+
|
|
457
|
+
# Headings / patterns that are part of codebeacon output — used to strip
|
|
458
|
+
# legacy codebeacon content from files that pre-date the markers.
|
|
459
|
+
_LEGACY_PATTERNS = (
|
|
460
|
+
"## MANDATORY: Lookup Strategy",
|
|
461
|
+
"## Lookup Strategy",
|
|
462
|
+
"## Context Lookup Protocol",
|
|
463
|
+
"### Step 1 → codebeacon wiki",
|
|
464
|
+
"### Step 2 → codebeacon obsidian",
|
|
465
|
+
"### Step 3 → source file",
|
|
466
|
+
"### Prohibited actions",
|
|
467
|
+
"## Projects",
|
|
468
|
+
"## Common Commands",
|
|
469
|
+
"## Architecture",
|
|
470
|
+
"## High-Impact Files",
|
|
471
|
+
"_Generated by [codebeacon]",
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _strip_codebeacon_block(existing: str) -> str:
|
|
476
|
+
"""Remove a previously generated codebeacon block from *existing* text.
|
|
477
|
+
|
|
478
|
+
Handles two formats:
|
|
479
|
+
1. Marker-delimited blocks <!-- codebeacon:start --> … <!-- codebeacon:end -->
|
|
480
|
+
2. Legacy files (no markers): heuristically drops lines that belong to a
|
|
481
|
+
contiguous codebeacon section identified by _LEGACY_PATTERNS.
|
|
482
|
+
"""
|
|
483
|
+
# ── Format 1: marker-delimited ──
|
|
484
|
+
if _BLOCK_START in existing:
|
|
485
|
+
before = existing[:existing.index(_BLOCK_START)]
|
|
486
|
+
after_marker = existing[existing.index(_BLOCK_START) + len(_BLOCK_START):]
|
|
487
|
+
if _BLOCK_END in after_marker:
|
|
488
|
+
after = after_marker[after_marker.index(_BLOCK_END) + len(_BLOCK_END):]
|
|
489
|
+
else:
|
|
490
|
+
after = ""
|
|
491
|
+
return (before + after).strip()
|
|
492
|
+
|
|
493
|
+
# ── Format 2: legacy heuristic ──
|
|
494
|
+
lines = existing.splitlines()
|
|
495
|
+
cleaned: list[str] = []
|
|
496
|
+
inside = False
|
|
497
|
+
for line in lines:
|
|
498
|
+
stripped = line.strip()
|
|
499
|
+
if any(stripped.startswith(p) for p in _LEGACY_PATTERNS):
|
|
500
|
+
inside = True
|
|
501
|
+
if inside:
|
|
502
|
+
# Exit the codebeacon section when we hit a user heading that is
|
|
503
|
+
# NOT a codebeacon heading, after we've already entered one.
|
|
504
|
+
if stripped.startswith("#") and not any(
|
|
505
|
+
stripped.startswith(p) for p in _LEGACY_PATTERNS
|
|
506
|
+
):
|
|
507
|
+
inside = False
|
|
508
|
+
cleaned.append(line)
|
|
509
|
+
else:
|
|
510
|
+
cleaned.append(line)
|
|
511
|
+
|
|
512
|
+
return "\n".join(cleaned).strip()
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _merge_content(new_content: str, path: Path) -> str:
|
|
516
|
+
"""Return the final file text: codebeacon block on top, user content below.
|
|
517
|
+
|
|
518
|
+
- If the file does not exist → return new_content as-is (wrapped in markers).
|
|
519
|
+
- If it exists → strip any old codebeacon block, keep user content, prepend
|
|
520
|
+
the new block.
|
|
521
|
+
Duplicate detection uses the marker scheme so subsequent runs stay clean.
|
|
522
|
+
"""
|
|
523
|
+
wrapped = f"{_BLOCK_START}\n{new_content.rstrip()}\n{_BLOCK_END}\n"
|
|
524
|
+
|
|
525
|
+
if not path.exists():
|
|
526
|
+
return wrapped
|
|
527
|
+
|
|
528
|
+
existing = path.read_text(encoding="utf-8")
|
|
529
|
+
user_content = _strip_codebeacon_block(existing).strip()
|
|
530
|
+
|
|
531
|
+
if user_content:
|
|
532
|
+
return f"{wrapped}\n{user_content}\n"
|
|
533
|
+
return wrapped
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
# ── Public entry point ─────────────────────────────────────────────────────────
|
|
537
|
+
|
|
538
|
+
def generate_context_map(
|
|
539
|
+
G: nx.DiGraph,
|
|
540
|
+
output_dir: str | Path,
|
|
541
|
+
projects: list[ProjectInfo],
|
|
542
|
+
obsidian_dir: str | Path | None = None,
|
|
543
|
+
targets: list[str] | None = None,
|
|
544
|
+
) -> list[str]:
|
|
545
|
+
"""Generate CLAUDE.md, .cursorrules, and AGENTS.md context map files.
|
|
546
|
+
|
|
547
|
+
Files are written to the parent of output_dir (i.e. alongside .codebeacon/).
|
|
548
|
+
|
|
549
|
+
Args:
|
|
550
|
+
G: knowledge graph
|
|
551
|
+
output_dir: .codebeacon/ directory path
|
|
552
|
+
projects: list of ProjectInfo (name, path, framework, language)
|
|
553
|
+
obsidian_dir: custom obsidian vault path; defaults to output_dir/obsidian
|
|
554
|
+
targets: which files to generate; defaults to all three
|
|
555
|
+
|
|
556
|
+
Returns:
|
|
557
|
+
List of absolute paths of files written.
|
|
558
|
+
"""
|
|
559
|
+
if targets is None:
|
|
560
|
+
targets = ["CLAUDE.md", ".cursorrules", "AGENTS.md"]
|
|
561
|
+
|
|
562
|
+
output_path = Path(output_dir)
|
|
563
|
+
# Context map files live alongside .codebeacon/, not inside it
|
|
564
|
+
project_root = output_path.parent
|
|
565
|
+
|
|
566
|
+
# Obsidian path shown in docs — relative from project root if possible
|
|
567
|
+
if obsidian_dir:
|
|
568
|
+
obs_path = str(obsidian_dir)
|
|
569
|
+
else:
|
|
570
|
+
obs_abs = output_path / "obsidian"
|
|
571
|
+
try:
|
|
572
|
+
obs_path = str(obs_abs.relative_to(project_root))
|
|
573
|
+
except ValueError:
|
|
574
|
+
obs_path = str(obs_abs)
|
|
575
|
+
|
|
576
|
+
stats = _collect_stats(G)
|
|
577
|
+
hubs = _hub_files(G)
|
|
578
|
+
|
|
579
|
+
written: list[str] = []
|
|
580
|
+
|
|
581
|
+
# CLAUDE.md
|
|
582
|
+
if "CLAUDE.md" in targets:
|
|
583
|
+
content = _build_content(G, projects, output_path, obs_path, stats, hubs, tool="claude")
|
|
584
|
+
path = project_root / "CLAUDE.md"
|
|
585
|
+
path.write_text(_merge_content(content, path), encoding="utf-8")
|
|
586
|
+
written.append(str(path))
|
|
587
|
+
|
|
588
|
+
# .cursorrules
|
|
589
|
+
if ".cursorrules" in targets:
|
|
590
|
+
content = _build_content(G, projects, output_path, obs_path, stats, hubs, tool="cursor")
|
|
591
|
+
path = project_root / ".cursorrules"
|
|
592
|
+
path.write_text(_merge_content(content, path), encoding="utf-8")
|
|
593
|
+
written.append(str(path))
|
|
594
|
+
|
|
595
|
+
# AGENTS.md
|
|
596
|
+
if "AGENTS.md" in targets:
|
|
597
|
+
content = _build_content(G, projects, output_path, obs_path, stats, hubs, tool="agents")
|
|
598
|
+
path = project_root / "AGENTS.md"
|
|
599
|
+
path.write_text(_merge_content(content, path), encoding="utf-8")
|
|
600
|
+
written.append(str(path))
|
|
601
|
+
|
|
602
|
+
return written
|
|
File without changes
|