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,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