feed-the-machine 1.6.0 → 1.7.0
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.
- package/LICENSE +21 -21
- package/README.md +170 -170
- package/bin/brain.py +1340 -0
- package/bin/convert_claude_skills_to_codex.py +490 -0
- package/bin/generate-manifest.mjs +463 -463
- package/bin/harden_codex_skills.py +141 -0
- package/bin/install.mjs +491 -491
- package/bin/migrate-eng-buddy-data.py +875 -0
- package/bin/playbook_engine/__init__.py +1 -0
- package/bin/playbook_engine/conftest.py +8 -0
- package/bin/playbook_engine/extractor.py +33 -0
- package/bin/playbook_engine/manager.py +102 -0
- package/bin/playbook_engine/models.py +84 -0
- package/bin/playbook_engine/registry.py +35 -0
- package/bin/playbook_engine/test_extractor.py +72 -0
- package/bin/playbook_engine/test_integration.py +129 -0
- package/bin/playbook_engine/test_manager.py +85 -0
- package/bin/playbook_engine/test_models.py +166 -0
- package/bin/playbook_engine/test_registry.py +67 -0
- package/bin/playbook_engine/test_tracer.py +86 -0
- package/bin/playbook_engine/tracer.py +93 -0
- package/bin/tasks_db.py +456 -0
- package/docs/HOOKS.md +243 -243
- package/docs/INBOX.md +233 -233
- package/ftm/SKILL.md +125 -122
- package/ftm-audit/SKILL.md +623 -623
- package/ftm-audit/references/protocols/PROJECT-PATTERNS.md +91 -91
- package/ftm-audit/references/protocols/RUNTIME-WIRING.md +66 -66
- package/ftm-audit/references/protocols/WIRING-CONTRACTS.md +135 -135
- package/ftm-audit/references/strategies/AUTO-FIX-STRATEGIES.md +69 -69
- package/ftm-audit/references/templates/REPORT-FORMAT.md +96 -96
- package/ftm-audit/scripts/run-knip.sh +23 -23
- package/ftm-audit.yml +2 -2
- package/ftm-brainstorm/SKILL.md +1003 -498
- package/ftm-brainstorm/evals/evals.json +180 -100
- package/ftm-brainstorm/evals/promptfoo.yaml +109 -109
- package/ftm-brainstorm/references/agent-prompts.md +552 -224
- package/ftm-brainstorm/references/plan-template.md +209 -121
- package/ftm-brainstorm.yml +2 -2
- package/ftm-browse/SKILL.md +454 -454
- package/ftm-browse/daemon/browser-manager.ts +206 -206
- package/ftm-browse/daemon/bun.lock +30 -30
- package/ftm-browse/daemon/cli.ts +347 -347
- package/ftm-browse/daemon/commands.ts +410 -410
- package/ftm-browse/daemon/main.ts +357 -357
- package/ftm-browse/daemon/package.json +17 -17
- package/ftm-browse/daemon/server.ts +189 -189
- package/ftm-browse/daemon/snapshot.ts +519 -519
- package/ftm-browse/daemon/tsconfig.json +22 -22
- package/ftm-browse.yml +4 -4
- package/ftm-capture/SKILL.md +370 -370
- package/ftm-capture.yml +4 -4
- package/ftm-codex-gate/SKILL.md +361 -361
- package/ftm-codex-gate.yml +2 -2
- package/ftm-config/SKILL.md +422 -345
- package/ftm-config.default.yml +125 -82
- package/ftm-config.yml +44 -2
- package/ftm-council/SKILL.md +416 -416
- package/ftm-council/references/prompts/CLAUDE-INVESTIGATION.md +60 -60
- package/ftm-council/references/prompts/CODEX-INVESTIGATION.md +58 -58
- package/ftm-council/references/prompts/GEMINI-INVESTIGATION.md +58 -58
- package/ftm-council/references/prompts/REBUTTAL-TEMPLATE.md +57 -57
- package/ftm-council/references/protocols/PREREQUISITES.md +47 -47
- package/ftm-council/references/protocols/STEP-0-FRAMING.md +46 -46
- package/ftm-council.yml +2 -2
- package/ftm-dashboard/SKILL.md +163 -163
- package/ftm-dashboard.yml +4 -4
- package/ftm-debug/SKILL.md +1037 -1037
- package/ftm-debug/references/phases/PHASE-0-INTAKE.md +58 -58
- package/ftm-debug/references/phases/PHASE-1-TRIAGE.md +46 -46
- package/ftm-debug/references/phases/PHASE-2-WAR-ROOM-AGENTS.md +279 -279
- package/ftm-debug/references/phases/PHASE-3-TO-6-EXECUTION.md +436 -436
- package/ftm-debug/references/protocols/BLACKBOARD.md +86 -86
- package/ftm-debug/references/protocols/EDGE-CASES.md +103 -103
- package/ftm-debug.yml +2 -2
- package/ftm-diagram/SKILL.md +277 -277
- package/ftm-diagram.yml +2 -2
- package/ftm-executor/SKILL.md +777 -777
- package/ftm-executor/references/STYLE-TEMPLATE.md +73 -73
- package/ftm-executor/references/phases/PHASE-0-VERIFICATION.md +62 -62
- package/ftm-executor/references/phases/PHASE-2-AGENT-ASSEMBLY.md +34 -34
- package/ftm-executor/references/phases/PHASE-3-WORKTREES.md +38 -38
- package/ftm-executor/references/phases/PHASE-4-5-AUDIT.md +72 -72
- package/ftm-executor/references/phases/PHASE-4-DISPATCH.md +66 -66
- package/ftm-executor/references/phases/PHASE-5-5-CODEX-GATE.md +73 -73
- package/ftm-executor/references/protocols/DOCUMENTATION-BOOTSTRAP.md +36 -36
- package/ftm-executor/references/protocols/MODEL-PROFILE.md +59 -59
- package/ftm-executor/references/protocols/PROGRESS-TRACKING.md +66 -66
- package/ftm-executor/runtime/ftm-runtime.mjs +252 -252
- package/ftm-executor/runtime/package.json +8 -8
- package/ftm-executor.yml +2 -2
- package/ftm-git/SKILL.md +441 -441
- package/ftm-git/evals/evals.json +26 -26
- package/ftm-git/evals/promptfoo.yaml +75 -75
- package/ftm-git/hooks/post-commit-experience.sh +92 -92
- package/ftm-git/references/patterns/SECRET-PATTERNS.md +104 -104
- package/ftm-git/references/protocols/REMEDIATION.md +139 -139
- package/ftm-git/scripts/pre-commit-secrets.sh +110 -110
- package/ftm-git.yml +2 -2
- package/ftm-inbox/backend/__pycache__/main.cpython-314.pyc +0 -0
- package/ftm-inbox/backend/adapters/_retry.py +64 -64
- package/ftm-inbox/backend/adapters/base.py +230 -230
- package/ftm-inbox/backend/adapters/freshservice.py +104 -104
- package/ftm-inbox/backend/adapters/gmail.py +125 -125
- package/ftm-inbox/backend/adapters/jira.py +136 -136
- package/ftm-inbox/backend/adapters/registry.py +192 -192
- package/ftm-inbox/backend/adapters/slack.py +110 -110
- package/ftm-inbox/backend/db/connection.py +54 -54
- package/ftm-inbox/backend/db/schema.py +78 -78
- package/ftm-inbox/backend/executor/__init__.py +7 -7
- package/ftm-inbox/backend/executor/engine.py +149 -149
- package/ftm-inbox/backend/executor/step_runner.py +98 -98
- package/ftm-inbox/backend/main.py +103 -103
- package/ftm-inbox/backend/models/__init__.py +1 -1
- package/ftm-inbox/backend/models/unified_task.py +36 -36
- package/ftm-inbox/backend/planner/__init__.py +6 -6
- package/ftm-inbox/backend/planner/__pycache__/__init__.cpython-314.pyc +0 -0
- package/ftm-inbox/backend/planner/__pycache__/generator.cpython-314.pyc +0 -0
- package/ftm-inbox/backend/planner/__pycache__/schema.cpython-314.pyc +0 -0
- package/ftm-inbox/backend/planner/generator.py +127 -127
- package/ftm-inbox/backend/planner/schema.py +34 -34
- package/ftm-inbox/backend/requirements.txt +5 -5
- package/ftm-inbox/backend/routes/__pycache__/plan.cpython-314.pyc +0 -0
- package/ftm-inbox/backend/routes/execute.py +186 -186
- package/ftm-inbox/backend/routes/health.py +52 -52
- package/ftm-inbox/backend/routes/inbox.py +68 -68
- package/ftm-inbox/backend/routes/plan.py +271 -271
- package/ftm-inbox/bin/launchagent.mjs +91 -91
- package/ftm-inbox/bin/setup.mjs +188 -188
- package/ftm-inbox/bin/start.sh +10 -10
- package/ftm-inbox/bin/status.sh +17 -17
- package/ftm-inbox/bin/stop.sh +8 -8
- package/ftm-inbox/config.example.yml +55 -55
- package/ftm-inbox/package-lock.json +2898 -2898
- package/ftm-inbox/package.json +26 -26
- package/ftm-inbox/postcss.config.js +6 -6
- package/ftm-inbox/src/app.css +199 -199
- package/ftm-inbox/src/app.html +18 -18
- package/ftm-inbox/src/lib/api.ts +166 -166
- package/ftm-inbox/src/lib/components/ExecutionLog.svelte +81 -81
- package/ftm-inbox/src/lib/components/InboxFeed.svelte +143 -143
- package/ftm-inbox/src/lib/components/PlanStep.svelte +271 -271
- package/ftm-inbox/src/lib/components/PlanView.svelte +206 -206
- package/ftm-inbox/src/lib/components/StreamPanel.svelte +99 -99
- package/ftm-inbox/src/lib/components/TaskCard.svelte +190 -190
- package/ftm-inbox/src/lib/components/ui/EmptyState.svelte +63 -63
- package/ftm-inbox/src/lib/components/ui/KawaiiCard.svelte +86 -86
- package/ftm-inbox/src/lib/components/ui/PillButton.svelte +106 -106
- package/ftm-inbox/src/lib/components/ui/StatusBadge.svelte +67 -67
- package/ftm-inbox/src/lib/components/ui/StreamDrawer.svelte +149 -149
- package/ftm-inbox/src/lib/components/ui/ThemeToggle.svelte +80 -80
- package/ftm-inbox/src/lib/theme.ts +47 -47
- package/ftm-inbox/src/routes/+layout.svelte +76 -76
- package/ftm-inbox/src/routes/+page.svelte +401 -401
- package/ftm-inbox/svelte.config.js +12 -12
- package/ftm-inbox/tailwind.config.ts +63 -63
- package/ftm-inbox/tsconfig.json +13 -13
- package/ftm-inbox/vite.config.ts +6 -6
- package/ftm-intent/SKILL.md +241 -241
- package/ftm-intent.yml +2 -2
- package/ftm-manifest.json +3794 -3794
- package/ftm-map/SKILL.md +291 -291
- package/ftm-map/scripts/db.py +712 -712
- package/ftm-map/scripts/index.py +415 -415
- package/ftm-map/scripts/parser.py +224 -224
- package/ftm-map/scripts/queries/go-tags.scm +20 -20
- package/ftm-map/scripts/queries/javascript-tags.scm +35 -35
- package/ftm-map/scripts/queries/python-tags.scm +31 -31
- package/ftm-map/scripts/queries/ruby-tags.scm +19 -19
- package/ftm-map/scripts/queries/rust-tags.scm +37 -37
- package/ftm-map/scripts/queries/typescript-tags.scm +41 -41
- package/ftm-map/scripts/query.py +301 -301
- package/ftm-map/scripts/ranker.py +377 -377
- package/ftm-map/scripts/requirements.txt +5 -5
- package/ftm-map/scripts/setup-hooks.sh +27 -27
- package/ftm-map/scripts/setup.sh +56 -56
- package/ftm-map/scripts/test_db.py +364 -364
- package/ftm-map/scripts/test_parser.py +174 -174
- package/ftm-map/scripts/test_query.py +183 -183
- package/ftm-map/scripts/test_ranker.py +199 -199
- package/ftm-map/scripts/views.py +591 -591
- package/ftm-map.yml +2 -2
- package/ftm-mind/SKILL.md +201 -1943
- package/ftm-mind/evals/promptfoo.yaml +142 -142
- package/ftm-mind/references/blackboard-protocol.md +110 -0
- package/ftm-mind/references/blackboard-schema.md +328 -328
- package/ftm-mind/references/complexity-guide.md +110 -110
- package/ftm-mind/references/complexity-sizing.md +138 -0
- package/ftm-mind/references/decide-act-protocol.md +172 -0
- package/ftm-mind/references/direct-execution.md +51 -0
- package/ftm-mind/references/environment-discovery.md +77 -0
- package/ftm-mind/references/event-registry.md +319 -319
- package/ftm-mind/references/mcp-inventory.md +300 -296
- package/ftm-mind/references/ops-routing.md +47 -0
- package/ftm-mind/references/orient-protocol.md +234 -0
- package/ftm-mind/references/personality.md +40 -0
- package/ftm-mind/references/protocols/COMPLEXITY-SIZING.md +72 -72
- package/ftm-mind/references/protocols/MCP-HEURISTICS.md +32 -32
- package/ftm-mind/references/protocols/PLAN-APPROVAL.md +80 -80
- package/ftm-mind/references/reflexion-protocol.md +249 -249
- package/ftm-mind/references/routing/SCENARIOS.md +22 -22
- package/ftm-mind/references/routing-scenarios.md +35 -35
- package/ftm-mind.yml +2 -2
- package/ftm-ops.yml +4 -0
- package/ftm-pause/SKILL.md +395 -395
- package/ftm-pause/references/protocols/SKILL-RESTORE-PROTOCOLS.md +186 -186
- package/ftm-pause/references/protocols/VALIDATION.md +80 -80
- package/ftm-pause.yml +2 -2
- package/ftm-researcher/SKILL.md +275 -275
- package/ftm-researcher/evals/agent-diversity.yaml +17 -17
- package/ftm-researcher/evals/synthesis-quality.yaml +12 -12
- package/ftm-researcher/evals/trigger-accuracy.yaml +39 -39
- package/ftm-researcher/references/adaptive-search.md +116 -116
- package/ftm-researcher/references/agent-prompts.md +193 -193
- package/ftm-researcher/references/council-integration.md +193 -193
- package/ftm-researcher/references/output-format.md +203 -203
- package/ftm-researcher/references/synthesis-pipeline.md +165 -165
- package/ftm-researcher/scripts/score_credibility.py +234 -234
- package/ftm-researcher/scripts/validate_research.py +92 -92
- package/ftm-researcher.yml +2 -2
- package/ftm-resume/SKILL.md +518 -518
- package/ftm-resume/references/protocols/VALIDATION.md +172 -172
- package/ftm-resume.yml +2 -2
- package/ftm-retro/SKILL.md +380 -380
- package/ftm-retro/references/protocols/SCORING-RUBRICS.md +89 -89
- package/ftm-retro/references/templates/REPORT-FORMAT.md +109 -109
- package/ftm-retro.yml +2 -2
- package/ftm-routine/SKILL.md +170 -170
- package/ftm-routine.yml +4 -4
- package/ftm-state/blackboard/capabilities.json +5 -5
- package/ftm-state/blackboard/capabilities.schema.json +27 -27
- package/ftm-state/blackboard/context.json +37 -23
- package/ftm-state/blackboard/experiences/doom-statusline-fix.json +26 -0
- package/ftm-state/blackboard/experiences/hackathon-pages-site.json +26 -0
- package/ftm-state/blackboard/experiences/hindsight-sso-kickoff.json +42 -0
- package/ftm-state/blackboard/experiences/index.json +58 -9
- package/ftm-state/blackboard/experiences/learning-ragnarok-api-access.json +23 -0
- package/ftm-state/blackboard/experiences/nordlayer-members-auto-assign.json +26 -0
- package/ftm-state/blackboard/experiences/saml2aws-stale-session-fix.json +41 -0
- package/ftm-state/blackboard/patterns.json +6 -6
- package/ftm-state/schemas/context.schema.json +130 -130
- package/ftm-state/schemas/experience-index.schema.json +77 -77
- package/ftm-state/schemas/experience.schema.json +78 -78
- package/ftm-state/schemas/patterns.schema.json +44 -44
- package/ftm-upgrade/SKILL.md +194 -194
- package/ftm-upgrade/scripts/check-version.sh +76 -76
- package/ftm-upgrade/scripts/upgrade.sh +143 -143
- package/ftm-upgrade.yml +2 -2
- package/ftm-verify.yml +2 -2
- package/ftm.yml +2 -2
- package/hooks/ftm-auto-log.sh +137 -0
- package/hooks/ftm-blackboard-enforcer.sh +93 -93
- package/hooks/ftm-discovery-reminder.sh +90 -90
- package/hooks/ftm-drafts-gate.sh +61 -61
- package/hooks/ftm-event-logger.mjs +107 -107
- package/hooks/ftm-install-hooks.sh +240 -0
- package/hooks/ftm-learning-capture.sh +117 -0
- package/hooks/ftm-map-autodetect.sh +79 -79
- package/hooks/ftm-pending-sync-check.sh +22 -22
- package/hooks/ftm-plan-gate.sh +92 -92
- package/hooks/ftm-post-commit-trigger.sh +57 -57
- package/hooks/ftm-post-compaction.sh +138 -0
- package/hooks/ftm-pre-compaction.sh +147 -0
- package/hooks/ftm-session-end.sh +52 -0
- package/hooks/ftm-session-snapshot.sh +213 -0
- package/hooks/settings-template.json +81 -81
- package/install.sh +363 -363
- package/package.json +84 -84
- package/uninstall.sh +25 -25
package/ftm-map/scripts/views.py
CHANGED
|
@@ -1,591 +1,591 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""View generators: produce INTENT.md and ARCHITECTURE.mmd from the code graph.
|
|
3
|
-
|
|
4
|
-
Updated for v2 hybrid architecture with 5-table schema:
|
|
5
|
-
files, symbols, refs, file_edges, symbol_edges
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
import argparse
|
|
9
|
-
import os
|
|
10
|
-
import sys
|
|
11
|
-
from collections import defaultdict
|
|
12
|
-
from pathlib import Path
|
|
13
|
-
|
|
14
|
-
sys.path.insert(0, os.path.dirname(__file__))
|
|
15
|
-
from db import get_connection
|
|
16
|
-
|
|
17
|
-
# ---------------------------------------------------------------------------
|
|
18
|
-
# Module grouping helpers
|
|
19
|
-
# ---------------------------------------------------------------------------
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def _get_module_for_path(file_path: str) -> str:
|
|
23
|
-
"""Return the top-level directory component of a relative file path.
|
|
24
|
-
|
|
25
|
-
Files at the project root (no directory component) are grouped under '.'.
|
|
26
|
-
"""
|
|
27
|
-
parts = Path(file_path).parts
|
|
28
|
-
if len(parts) > 1:
|
|
29
|
-
return parts[0]
|
|
30
|
-
return "."
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def get_modules(conn) -> dict:
|
|
34
|
-
"""Group files by directory to identify modules.
|
|
35
|
-
|
|
36
|
-
Queries the files table directly (symbols no longer carry file_path).
|
|
37
|
-
Returns a dict mapping module name -> set of file paths.
|
|
38
|
-
"""
|
|
39
|
-
rows = conn.execute(
|
|
40
|
-
"SELECT DISTINCT path FROM files ORDER BY path"
|
|
41
|
-
).fetchall()
|
|
42
|
-
|
|
43
|
-
modules: dict = defaultdict(set)
|
|
44
|
-
for row in rows:
|
|
45
|
-
fp = row["path"]
|
|
46
|
-
module = _get_module_for_path(fp)
|
|
47
|
-
modules[module].add(fp)
|
|
48
|
-
|
|
49
|
-
return dict(modules)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def _get_symbols_for_module(conn, module: str, files: set) -> list:
|
|
53
|
-
"""Return all symbol rows for a module (identified by its set of files).
|
|
54
|
-
|
|
55
|
-
Joins symbols with files to resolve file_path and maps column names
|
|
56
|
-
to the view-layer conventions (file_path, start_line, end_line).
|
|
57
|
-
"""
|
|
58
|
-
placeholders = ",".join("?" * len(files))
|
|
59
|
-
rows = conn.execute(
|
|
60
|
-
f"""
|
|
61
|
-
SELECT s.id, s.name, s.qualified_name, s.kind,
|
|
62
|
-
s.line_start AS start_line, s.line_end AS end_line,
|
|
63
|
-
s.signature, s.parent_id,
|
|
64
|
-
f.path AS file_path
|
|
65
|
-
FROM symbols s
|
|
66
|
-
JOIN files f ON f.id = s.file_id
|
|
67
|
-
WHERE f.path IN ({placeholders})
|
|
68
|
-
ORDER BY f.path, s.line_start
|
|
69
|
-
""",
|
|
70
|
-
list(files),
|
|
71
|
-
).fetchall()
|
|
72
|
-
return [dict(r) for r in rows]
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def _get_callers(conn, symbol_id: int) -> list:
|
|
76
|
-
"""Return direct callers (symbols that call this one) via symbol_edges."""
|
|
77
|
-
rows = conn.execute(
|
|
78
|
-
"""
|
|
79
|
-
SELECT s.name, f.path AS file_path
|
|
80
|
-
FROM symbol_edges se
|
|
81
|
-
JOIN symbols s ON s.id = se.source_symbol_id
|
|
82
|
-
JOIN files f ON f.id = s.file_id
|
|
83
|
-
WHERE se.target_symbol_id = ?
|
|
84
|
-
LIMIT 10
|
|
85
|
-
""",
|
|
86
|
-
(symbol_id,),
|
|
87
|
-
).fetchall()
|
|
88
|
-
return [dict(r) for r in rows]
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
def _get_callees(conn, symbol_id: int) -> list:
|
|
92
|
-
"""Return direct callees (symbols this one calls) via symbol_edges."""
|
|
93
|
-
rows = conn.execute(
|
|
94
|
-
"""
|
|
95
|
-
SELECT s.name, f.path AS file_path
|
|
96
|
-
FROM symbol_edges se
|
|
97
|
-
JOIN symbols s ON s.id = se.target_symbol_id
|
|
98
|
-
JOIN files f ON f.id = s.file_id
|
|
99
|
-
WHERE se.source_symbol_id = ?
|
|
100
|
-
LIMIT 10
|
|
101
|
-
""",
|
|
102
|
-
(symbol_id,),
|
|
103
|
-
).fetchall()
|
|
104
|
-
return [dict(r) for r in rows]
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
def _get_ref_count(conn, symbol_name: str) -> int:
|
|
108
|
-
"""Return the number of references to a symbol from the refs table."""
|
|
109
|
-
row = conn.execute(
|
|
110
|
-
"SELECT COUNT(*) AS cnt FROM refs WHERE symbol_name = ?",
|
|
111
|
-
(symbol_name,),
|
|
112
|
-
).fetchone()
|
|
113
|
-
return row["cnt"] if row else 0
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def _top_symbols(symbols: list, n: int = 5) -> list:
|
|
117
|
-
"""Return top n function/method symbols from a list, falling back to any kind."""
|
|
118
|
-
funcs = [s for s in symbols if s["kind"] in ("function", "method", "definition")]
|
|
119
|
-
selection = funcs if funcs else symbols
|
|
120
|
-
return selection[:n]
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
def _infer_purpose(module: str, symbols: list) -> str:
|
|
124
|
-
"""Infer a one-line purpose description from module name and symbol kinds."""
|
|
125
|
-
if not symbols:
|
|
126
|
-
return "Empty module — no symbols indexed yet."
|
|
127
|
-
|
|
128
|
-
kinds = [s["kind"] for s in symbols]
|
|
129
|
-
kind_counts: dict = defaultdict(int)
|
|
130
|
-
for k in kinds:
|
|
131
|
-
kind_counts[k] += 1
|
|
132
|
-
|
|
133
|
-
dominant = sorted(kind_counts.items(), key=lambda x: x[1], reverse=True)[0][0]
|
|
134
|
-
|
|
135
|
-
name_lower = module.lower()
|
|
136
|
-
if any(kw in name_lower for kw in ("test", "spec", "__tests__")):
|
|
137
|
-
return "Test suite."
|
|
138
|
-
if any(kw in name_lower for kw in ("util", "helper", "common", "shared")):
|
|
139
|
-
return "Shared utilities and helpers."
|
|
140
|
-
if any(kw in name_lower for kw in ("model", "schema", "entity", "type")):
|
|
141
|
-
return "Data models and type definitions."
|
|
142
|
-
if any(kw in name_lower for kw in ("route", "api", "handler", "endpoint")):
|
|
143
|
-
return "API routes and request handlers."
|
|
144
|
-
if any(kw in name_lower for kw in ("db", "database", "repo", "store")):
|
|
145
|
-
return "Data access and persistence layer."
|
|
146
|
-
if any(kw in name_lower for kw in ("config", "setting", "env")):
|
|
147
|
-
return "Configuration and environment settings."
|
|
148
|
-
if any(kw in name_lower for kw in ("service", "manager", "controller")):
|
|
149
|
-
return "Business logic and service layer."
|
|
150
|
-
if any(kw in name_lower for kw in ("component", "view", "page", "ui")):
|
|
151
|
-
return "UI components and views."
|
|
152
|
-
|
|
153
|
-
if dominant == "class":
|
|
154
|
-
return f"Module defining {kind_counts['class']} class(es)."
|
|
155
|
-
if dominant == "function":
|
|
156
|
-
return f"Module with {kind_counts['function']} function(s)."
|
|
157
|
-
if dominant == "definition":
|
|
158
|
-
return f"Module with {kind_counts['definition']} definition(s)."
|
|
159
|
-
return f"Module containing {len(symbols)} symbols."
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
def _infer_function_does(sym: dict) -> str:
|
|
163
|
-
"""Infer what a function does from its name and signature."""
|
|
164
|
-
doc = (sym.get("doc_comment") or "").strip()
|
|
165
|
-
if doc:
|
|
166
|
-
first_sentence = doc.split(".")[0].strip()
|
|
167
|
-
if first_sentence:
|
|
168
|
-
return first_sentence + "."
|
|
169
|
-
|
|
170
|
-
sig = (sym.get("signature") or "").strip()
|
|
171
|
-
name = sym.get("name", "")
|
|
172
|
-
|
|
173
|
-
name_lower = name.lower()
|
|
174
|
-
if name_lower.startswith("get_") or name_lower.startswith("fetch_"):
|
|
175
|
-
subject = name_lower[4:].replace("_", " ")
|
|
176
|
-
return f"Retrieves {subject}."
|
|
177
|
-
if name_lower.startswith("set_") or name_lower.startswith("update_"):
|
|
178
|
-
subject = name_lower[4:].replace("_", " ")
|
|
179
|
-
return f"Updates {subject}."
|
|
180
|
-
if name_lower.startswith("create_") or name_lower.startswith("add_"):
|
|
181
|
-
subject = name_lower.split("_", 1)[1].replace("_", " ") if "_" in name_lower else name_lower
|
|
182
|
-
return f"Creates or adds {subject}."
|
|
183
|
-
if name_lower.startswith("delete_") or name_lower.startswith("remove_"):
|
|
184
|
-
subject = name_lower.split("_", 1)[1].replace("_", " ") if "_" in name_lower else name_lower
|
|
185
|
-
return f"Removes {subject}."
|
|
186
|
-
if name_lower.startswith("is_") or name_lower.startswith("has_") or name_lower.startswith("check_"):
|
|
187
|
-
subject = name_lower.split("_", 1)[1].replace("_", " ") if "_" in name_lower else name_lower
|
|
188
|
-
return f"Checks whether {subject}."
|
|
189
|
-
if name_lower.startswith("parse_") or name_lower.startswith("decode_"):
|
|
190
|
-
subject = name_lower.split("_", 1)[1].replace("_", " ") if "_" in name_lower else name_lower
|
|
191
|
-
return f"Parses {subject}."
|
|
192
|
-
if name_lower.startswith("render_") or name_lower.startswith("format_"):
|
|
193
|
-
subject = name_lower.split("_", 1)[1].replace("_", " ") if "_" in name_lower else name_lower
|
|
194
|
-
return f"Formats or renders {subject}."
|
|
195
|
-
if name_lower.startswith("handle_") or name_lower.startswith("on_"):
|
|
196
|
-
subject = name_lower.split("_", 1)[1].replace("_", " ") if "_" in name_lower else name_lower
|
|
197
|
-
return f"Handles {subject} event."
|
|
198
|
-
if name_lower.startswith("init") or name_lower.startswith("setup") or name_lower.startswith("bootstrap"):
|
|
199
|
-
return "Initializes and configures the component."
|
|
200
|
-
if name_lower in ("main", "__main__"):
|
|
201
|
-
return "Entry point for the module."
|
|
202
|
-
if name_lower.startswith("test_"):
|
|
203
|
-
subject = name_lower[5:].replace("_", " ")
|
|
204
|
-
return f"Tests {subject}."
|
|
205
|
-
|
|
206
|
-
if sig:
|
|
207
|
-
return f"Executes `{sig[:80]}`."
|
|
208
|
-
|
|
209
|
-
return f"Implements `{name}` logic."
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
# ---------------------------------------------------------------------------
|
|
213
|
-
# INTENT.md generation
|
|
214
|
-
# ---------------------------------------------------------------------------
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
def generate_intent(project_root: str, only_modules: set | None = None) -> None:
|
|
218
|
-
"""Generate root INTENT.md and per-module INTENT.md files.
|
|
219
|
-
|
|
220
|
-
If *only_modules* is provided, only regenerate views for those modules
|
|
221
|
-
(incremental mode). The root INTENT.md is always regenerated when any
|
|
222
|
-
module is affected.
|
|
223
|
-
"""
|
|
224
|
-
abs_root = os.path.abspath(project_root)
|
|
225
|
-
conn = get_connection(abs_root)
|
|
226
|
-
try:
|
|
227
|
-
modules = get_modules(conn)
|
|
228
|
-
if not modules:
|
|
229
|
-
print("No symbols found in database. Run the indexer first.", file=sys.stderr)
|
|
230
|
-
conn.close()
|
|
231
|
-
return
|
|
232
|
-
|
|
233
|
-
project_name = Path(abs_root).name
|
|
234
|
-
|
|
235
|
-
# Determine which modules to regenerate
|
|
236
|
-
target_modules = set(modules.keys())
|
|
237
|
-
if only_modules:
|
|
238
|
-
target_modules = {m for m in modules if m in only_modules}
|
|
239
|
-
|
|
240
|
-
# Always regenerate root INTENT.md when any module is touched
|
|
241
|
-
if target_modules or not only_modules:
|
|
242
|
-
_write_root_intent(conn, abs_root, project_name, modules)
|
|
243
|
-
|
|
244
|
-
for module in target_modules:
|
|
245
|
-
# Root-level files (module=".") are covered by the root INTENT.md
|
|
246
|
-
# written above — skip to avoid overwriting it.
|
|
247
|
-
if module == ".":
|
|
248
|
-
continue
|
|
249
|
-
files = modules[module]
|
|
250
|
-
symbols = _get_symbols_for_module(conn, module, files)
|
|
251
|
-
_write_module_intent(conn, abs_root, module, symbols)
|
|
252
|
-
|
|
253
|
-
print(
|
|
254
|
-
f"Generated INTENT.md for {len(target_modules)} module(s) + root.",
|
|
255
|
-
file=sys.stderr,
|
|
256
|
-
)
|
|
257
|
-
finally:
|
|
258
|
-
conn.close()
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
def _write_root_intent(conn, project_root: str, project_name: str, modules: dict) -> None:
|
|
262
|
-
"""Write the root-level INTENT.md."""
|
|
263
|
-
rows = []
|
|
264
|
-
for module, files in sorted(modules.items()):
|
|
265
|
-
symbols = _get_symbols_for_module(conn, module, files)
|
|
266
|
-
purpose = _infer_purpose(module, symbols)
|
|
267
|
-
top = _top_symbols(symbols)
|
|
268
|
-
key_fns = ", ".join(s["name"] for s in top) if top else "—"
|
|
269
|
-
display = module if module != "." else "(root)"
|
|
270
|
-
rows.append(f"| `{display}` | {purpose} | {key_fns} |")
|
|
271
|
-
|
|
272
|
-
module_table = "\n".join(rows) if rows else "| — | No modules found | — |"
|
|
273
|
-
|
|
274
|
-
content = f"""# {project_name} — Intent
|
|
275
|
-
|
|
276
|
-
## Vision
|
|
277
|
-
|
|
278
|
-
{project_name} is a codebase with {len(modules)} module(s). The structure below summarises each module's purpose and key entry points as derived from the code graph.
|
|
279
|
-
|
|
280
|
-
## Architecture Decisions
|
|
281
|
-
|
|
282
|
-
| Decision | Choice | Reasoning |
|
|
283
|
-
|---|---|---|
|
|
284
|
-
| Code indexing | SQLite + FTS5 | Persistent, queryable graph without external dependencies |
|
|
285
|
-
| Symbol extraction | tree-sitter | Language-agnostic AST parsing with multi-language support |
|
|
286
|
-
| Edge extraction | Aider-style def/ref with tags.scm | Reliable cross-language reference detection |
|
|
287
|
-
| Ranking | fast-pagerank with scipy sparse matrices | Hybrid file-level PageRank + symbol-level blast radius |
|
|
288
|
-
| Schema | 5-table (files, symbols, refs, file_edges, symbol_edges) | Separated concerns for file-level and symbol-level analysis |
|
|
289
|
-
| View generation | Markdown + Mermaid | Human-readable output compatible with most documentation tools |
|
|
290
|
-
|
|
291
|
-
## Module Map
|
|
292
|
-
|
|
293
|
-
| Module | Purpose | Key Functions |
|
|
294
|
-
|---|---|---|
|
|
295
|
-
{module_table}
|
|
296
|
-
"""
|
|
297
|
-
|
|
298
|
-
out_path = os.path.join(project_root, "INTENT.md")
|
|
299
|
-
_write_file(out_path, content)
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
def _write_module_intent(conn, project_root: str, module: str, symbols: list) -> None:
|
|
303
|
-
"""Write a per-module INTENT.md inside the module directory."""
|
|
304
|
-
if not symbols:
|
|
305
|
-
return
|
|
306
|
-
|
|
307
|
-
module_name = module if module != "." else Path(project_root).name
|
|
308
|
-
|
|
309
|
-
# Build function entries
|
|
310
|
-
entries = []
|
|
311
|
-
for sym in symbols:
|
|
312
|
-
if sym["kind"] not in ("function", "method", "class", "definition"):
|
|
313
|
-
continue
|
|
314
|
-
|
|
315
|
-
does = _infer_function_does(sym)
|
|
316
|
-
callers = _get_callers(conn, sym["id"])
|
|
317
|
-
callees = _get_callees(conn, sym["id"])
|
|
318
|
-
ref_count = _get_ref_count(conn, sym["name"])
|
|
319
|
-
|
|
320
|
-
called_by_str = ", ".join(c["name"] for c in callers) if callers else "none found"
|
|
321
|
-
calls_str = ", ".join(c["name"] for c in callees) if callees else "none found"
|
|
322
|
-
|
|
323
|
-
entry = f"""## {sym["name"]}
|
|
324
|
-
- **Does**: {does}
|
|
325
|
-
- **Why**: Supports the `{module_name}` module's responsibilities.
|
|
326
|
-
- **Relationships**: calls [{calls_str}], called by [{called_by_str}]
|
|
327
|
-
- **References**: {ref_count} reference(s) across codebase
|
|
328
|
-
- **Decisions**: `{sym.get("signature", "") or sym["name"]}` (line {sym.get("start_line", "?")} – {sym.get("end_line", "?")})
|
|
329
|
-
"""
|
|
330
|
-
entries.append(entry)
|
|
331
|
-
|
|
332
|
-
if not entries:
|
|
333
|
-
return
|
|
334
|
-
|
|
335
|
-
content = f"# {module_name} — Intent\n\n" + "\n".join(entries)
|
|
336
|
-
|
|
337
|
-
if module == ".":
|
|
338
|
-
out_path = os.path.join(project_root, "INTENT.md")
|
|
339
|
-
else:
|
|
340
|
-
module_dir = os.path.join(project_root, module)
|
|
341
|
-
os.makedirs(module_dir, exist_ok=True)
|
|
342
|
-
out_path = os.path.join(module_dir, "INTENT.md")
|
|
343
|
-
|
|
344
|
-
_write_file(out_path, content)
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
# ---------------------------------------------------------------------------
|
|
348
|
-
# ARCHITECTURE.mmd / DIAGRAM.mmd generation
|
|
349
|
-
# ---------------------------------------------------------------------------
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
def generate_diagrams(project_root: str, only_modules: set | None = None) -> None:
|
|
353
|
-
"""Generate root ARCHITECTURE.mmd and per-module DIAGRAM.mmd files.
|
|
354
|
-
|
|
355
|
-
If *only_modules* is provided, only regenerate views for those modules.
|
|
356
|
-
The root diagram is always regenerated when any module is affected.
|
|
357
|
-
"""
|
|
358
|
-
abs_root = os.path.abspath(project_root)
|
|
359
|
-
conn = get_connection(abs_root)
|
|
360
|
-
try:
|
|
361
|
-
modules = get_modules(conn)
|
|
362
|
-
if not modules:
|
|
363
|
-
print("No symbols found in database. Run the indexer first.", file=sys.stderr)
|
|
364
|
-
conn.close()
|
|
365
|
-
return
|
|
366
|
-
|
|
367
|
-
target_modules = set(modules.keys())
|
|
368
|
-
if only_modules:
|
|
369
|
-
target_modules = {m for m in modules if m in only_modules}
|
|
370
|
-
|
|
371
|
-
if target_modules or not only_modules:
|
|
372
|
-
_write_root_diagram(conn, abs_root, modules)
|
|
373
|
-
|
|
374
|
-
for module in target_modules:
|
|
375
|
-
# Root-level files (module=".") are covered by ARCHITECTURE.mmd
|
|
376
|
-
# written above — skip to avoid overwriting it.
|
|
377
|
-
if module == ".":
|
|
378
|
-
continue
|
|
379
|
-
files = modules[module]
|
|
380
|
-
symbols = _get_symbols_for_module(conn, module, files)
|
|
381
|
-
_write_module_diagram(conn, abs_root, module, symbols)
|
|
382
|
-
|
|
383
|
-
print(
|
|
384
|
-
f"Generated diagrams for {len(target_modules)} module(s) + root.",
|
|
385
|
-
file=sys.stderr,
|
|
386
|
-
)
|
|
387
|
-
finally:
|
|
388
|
-
conn.close()
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
def _write_root_diagram(conn, project_root: str, modules: dict) -> None:
|
|
392
|
-
"""Write root ARCHITECTURE.mmd showing module-level dependencies.
|
|
393
|
-
|
|
394
|
-
Uses the file_edges table for module-level dependency information,
|
|
395
|
-
which is more efficient than walking symbol-level edges.
|
|
396
|
-
"""
|
|
397
|
-
module_list = sorted(modules.keys())
|
|
398
|
-
|
|
399
|
-
# Build a file_path -> module lookup
|
|
400
|
-
file_to_module = {}
|
|
401
|
-
for module, files in modules.items():
|
|
402
|
-
for fp in files:
|
|
403
|
-
file_to_module[fp] = module
|
|
404
|
-
|
|
405
|
-
# Query file_edges and aggregate into module-level dependencies
|
|
406
|
-
module_deps: dict = defaultdict(set)
|
|
407
|
-
rows = conn.execute(
|
|
408
|
-
"""
|
|
409
|
-
SELECT sf.path AS source_path, tf.path AS target_path, fe.weight
|
|
410
|
-
FROM file_edges fe
|
|
411
|
-
JOIN files sf ON sf.id = fe.source_file_id
|
|
412
|
-
JOIN files tf ON tf.id = fe.target_file_id
|
|
413
|
-
"""
|
|
414
|
-
).fetchall()
|
|
415
|
-
|
|
416
|
-
for row in rows:
|
|
417
|
-
src_module = _get_module_for_path(row["source_path"])
|
|
418
|
-
tgt_module = _get_module_for_path(row["target_path"])
|
|
419
|
-
if src_module != tgt_module:
|
|
420
|
-
module_deps[src_module].add(tgt_module)
|
|
421
|
-
|
|
422
|
-
# Build mermaid lines
|
|
423
|
-
lines = ["graph LR"]
|
|
424
|
-
|
|
425
|
-
# Node declarations
|
|
426
|
-
for m in module_list:
|
|
427
|
-
safe_id = _mermaid_id(m)
|
|
428
|
-
label = m if m != "." else "(root)"
|
|
429
|
-
lines.append(f" {safe_id}[{label}]")
|
|
430
|
-
|
|
431
|
-
# Edge declarations
|
|
432
|
-
edge_added = False
|
|
433
|
-
for src_module in sorted(module_deps.keys()):
|
|
434
|
-
for tgt_module in sorted(module_deps[src_module]):
|
|
435
|
-
if tgt_module in modules:
|
|
436
|
-
src_id = _mermaid_id(src_module)
|
|
437
|
-
tgt_id = _mermaid_id(tgt_module)
|
|
438
|
-
lines.append(f" {src_id} --> {tgt_id}")
|
|
439
|
-
edge_added = True
|
|
440
|
-
|
|
441
|
-
if not edge_added and len(module_list) > 1:
|
|
442
|
-
# No edges detected — add a comment so the diagram is still valid
|
|
443
|
-
lines.append(" %% No inter-module dependencies detected in index")
|
|
444
|
-
|
|
445
|
-
content = "```mermaid\n" + "\n".join(lines) + "\n```\n"
|
|
446
|
-
out_path = os.path.join(project_root, "ARCHITECTURE.mmd")
|
|
447
|
-
_write_file(out_path, content)
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
def _write_module_diagram(conn, project_root: str, module: str, symbols: list) -> None:
|
|
451
|
-
"""Write per-module DIAGRAM.mmd showing function-level call graph.
|
|
452
|
-
|
|
453
|
-
Uses symbol_edges for intra-module edges.
|
|
454
|
-
"""
|
|
455
|
-
if not symbols:
|
|
456
|
-
return
|
|
457
|
-
|
|
458
|
-
# Collect symbol IDs and names in this module
|
|
459
|
-
sym_ids = {s["id"] for s in symbols}
|
|
460
|
-
sym_names = {s["id"]: s["name"] for s in symbols}
|
|
461
|
-
|
|
462
|
-
lines = ["graph TD"]
|
|
463
|
-
|
|
464
|
-
# Node declarations for all symbols with interesting kinds
|
|
465
|
-
interesting = [s for s in symbols if s["kind"] in ("function", "method", "class", "definition")]
|
|
466
|
-
if not interesting:
|
|
467
|
-
interesting = symbols
|
|
468
|
-
|
|
469
|
-
for sym in interesting:
|
|
470
|
-
safe_id = _mermaid_id(f"{sym['name']}_{sym['id']}")
|
|
471
|
-
lines.append(f" {safe_id}[{sym['name']}]")
|
|
472
|
-
|
|
473
|
-
# Edge declarations — only intra-module edges via symbol_edges
|
|
474
|
-
edges_added = False
|
|
475
|
-
for sym in interesting:
|
|
476
|
-
callees = _get_callees(conn, sym["id"])
|
|
477
|
-
src_id = _mermaid_id(f"{sym['name']}_{sym['id']}")
|
|
478
|
-
for callee_row in callees:
|
|
479
|
-
# Find callee in this module's symbol set
|
|
480
|
-
matching = [s for s in interesting if s["name"] == callee_row["name"]]
|
|
481
|
-
for tgt_sym in matching:
|
|
482
|
-
tgt_id = _mermaid_id(f"{tgt_sym['name']}_{tgt_sym['id']}")
|
|
483
|
-
lines.append(f" {src_id} --> {tgt_id}")
|
|
484
|
-
edges_added = True
|
|
485
|
-
|
|
486
|
-
if not edges_added and len(interesting) > 1:
|
|
487
|
-
lines.append(" %% No intra-module call edges detected in index")
|
|
488
|
-
|
|
489
|
-
content = "```mermaid\n" + "\n".join(lines) + "\n```\n"
|
|
490
|
-
|
|
491
|
-
if module == ".":
|
|
492
|
-
out_path = os.path.join(project_root, "DIAGRAM.mmd")
|
|
493
|
-
else:
|
|
494
|
-
module_dir = os.path.join(project_root, module)
|
|
495
|
-
os.makedirs(module_dir, exist_ok=True)
|
|
496
|
-
out_path = os.path.join(module_dir, "DIAGRAM.mmd")
|
|
497
|
-
|
|
498
|
-
_write_file(out_path, content)
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
# ---------------------------------------------------------------------------
|
|
502
|
-
# Shared helpers
|
|
503
|
-
# ---------------------------------------------------------------------------
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
def _mermaid_id(text: str) -> str:
|
|
507
|
-
"""Convert arbitrary text to a safe Mermaid node ID."""
|
|
508
|
-
safe = ""
|
|
509
|
-
for ch in text:
|
|
510
|
-
if ch.isalnum() or ch == "_":
|
|
511
|
-
safe += ch
|
|
512
|
-
else:
|
|
513
|
-
safe += "_"
|
|
514
|
-
# Mermaid IDs cannot start with a digit
|
|
515
|
-
if safe and safe[0].isdigit():
|
|
516
|
-
safe = "_" + safe
|
|
517
|
-
return safe or "_unknown"
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
def _write_file(path: str, content: str) -> None:
|
|
521
|
-
"""Write content to path, creating parent directories as needed."""
|
|
522
|
-
os.makedirs(os.path.dirname(path) if os.path.dirname(path) else ".", exist_ok=True)
|
|
523
|
-
with open(path, "w", encoding="utf-8") as fh:
|
|
524
|
-
fh.write(content)
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
def _files_to_modules(files_str: str) -> set:
|
|
528
|
-
"""Convert a comma-separated file list to a set of affected module names."""
|
|
529
|
-
raw = [f.strip() for f in files_str.split(",") if f.strip()]
|
|
530
|
-
return {_get_module_for_path(f) for f in raw}
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
# ---------------------------------------------------------------------------
|
|
534
|
-
# CLI entry point
|
|
535
|
-
# ---------------------------------------------------------------------------
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
def main() -> None:
|
|
539
|
-
parser = argparse.ArgumentParser(
|
|
540
|
-
description="ftm-map view generators — produce INTENT.md and ARCHITECTURE.mmd from the code graph.",
|
|
541
|
-
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
542
|
-
epilog=(
|
|
543
|
-
"Examples:\n"
|
|
544
|
-
" python3 views.py --intent --project-root /path/to/project\n"
|
|
545
|
-
" python3 views.py --diagram --project-root /path/to/project\n"
|
|
546
|
-
" python3 views.py --intent --files src/foo.ts,src/bar.py --project-root /path/to/project\n"
|
|
547
|
-
" python3 views.py --diagram --files src/foo.ts --project-root /path/to/project\n"
|
|
548
|
-
),
|
|
549
|
-
)
|
|
550
|
-
|
|
551
|
-
parser.add_argument(
|
|
552
|
-
"--intent",
|
|
553
|
-
action="store_true",
|
|
554
|
-
help="Generate root INTENT.md and per-module INTENT.md files.",
|
|
555
|
-
)
|
|
556
|
-
parser.add_argument(
|
|
557
|
-
"--diagram",
|
|
558
|
-
action="store_true",
|
|
559
|
-
help="Generate root ARCHITECTURE.mmd and per-module DIAGRAM.mmd files.",
|
|
560
|
-
)
|
|
561
|
-
parser.add_argument(
|
|
562
|
-
"--project-root",
|
|
563
|
-
metavar="PATH",
|
|
564
|
-
default=os.getcwd(),
|
|
565
|
-
help="Path to the project root directory (default: cwd).",
|
|
566
|
-
)
|
|
567
|
-
parser.add_argument(
|
|
568
|
-
"--files",
|
|
569
|
-
metavar="FILE_LIST",
|
|
570
|
-
default=None,
|
|
571
|
-
help="Comma-separated list of changed files (incremental mode — only regenerate affected modules).",
|
|
572
|
-
)
|
|
573
|
-
|
|
574
|
-
args = parser.parse_args()
|
|
575
|
-
|
|
576
|
-
if not args.intent and not args.diagram:
|
|
577
|
-
parser.print_help()
|
|
578
|
-
sys.exit(1)
|
|
579
|
-
|
|
580
|
-
only_modules: set | None = None
|
|
581
|
-
if args.files:
|
|
582
|
-
only_modules = _files_to_modules(args.files)
|
|
583
|
-
|
|
584
|
-
if args.intent:
|
|
585
|
-
generate_intent(args.project_root, only_modules)
|
|
586
|
-
if args.diagram:
|
|
587
|
-
generate_diagrams(args.project_root, only_modules)
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
if __name__ == "__main__":
|
|
591
|
-
main()
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""View generators: produce INTENT.md and ARCHITECTURE.mmd from the code graph.
|
|
3
|
+
|
|
4
|
+
Updated for v2 hybrid architecture with 5-table schema:
|
|
5
|
+
files, symbols, refs, file_edges, symbol_edges
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
sys.path.insert(0, os.path.dirname(__file__))
|
|
15
|
+
from db import get_connection
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# Module grouping helpers
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _get_module_for_path(file_path: str) -> str:
|
|
23
|
+
"""Return the top-level directory component of a relative file path.
|
|
24
|
+
|
|
25
|
+
Files at the project root (no directory component) are grouped under '.'.
|
|
26
|
+
"""
|
|
27
|
+
parts = Path(file_path).parts
|
|
28
|
+
if len(parts) > 1:
|
|
29
|
+
return parts[0]
|
|
30
|
+
return "."
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_modules(conn) -> dict:
|
|
34
|
+
"""Group files by directory to identify modules.
|
|
35
|
+
|
|
36
|
+
Queries the files table directly (symbols no longer carry file_path).
|
|
37
|
+
Returns a dict mapping module name -> set of file paths.
|
|
38
|
+
"""
|
|
39
|
+
rows = conn.execute(
|
|
40
|
+
"SELECT DISTINCT path FROM files ORDER BY path"
|
|
41
|
+
).fetchall()
|
|
42
|
+
|
|
43
|
+
modules: dict = defaultdict(set)
|
|
44
|
+
for row in rows:
|
|
45
|
+
fp = row["path"]
|
|
46
|
+
module = _get_module_for_path(fp)
|
|
47
|
+
modules[module].add(fp)
|
|
48
|
+
|
|
49
|
+
return dict(modules)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _get_symbols_for_module(conn, module: str, files: set) -> list:
|
|
53
|
+
"""Return all symbol rows for a module (identified by its set of files).
|
|
54
|
+
|
|
55
|
+
Joins symbols with files to resolve file_path and maps column names
|
|
56
|
+
to the view-layer conventions (file_path, start_line, end_line).
|
|
57
|
+
"""
|
|
58
|
+
placeholders = ",".join("?" * len(files))
|
|
59
|
+
rows = conn.execute(
|
|
60
|
+
f"""
|
|
61
|
+
SELECT s.id, s.name, s.qualified_name, s.kind,
|
|
62
|
+
s.line_start AS start_line, s.line_end AS end_line,
|
|
63
|
+
s.signature, s.parent_id,
|
|
64
|
+
f.path AS file_path
|
|
65
|
+
FROM symbols s
|
|
66
|
+
JOIN files f ON f.id = s.file_id
|
|
67
|
+
WHERE f.path IN ({placeholders})
|
|
68
|
+
ORDER BY f.path, s.line_start
|
|
69
|
+
""",
|
|
70
|
+
list(files),
|
|
71
|
+
).fetchall()
|
|
72
|
+
return [dict(r) for r in rows]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _get_callers(conn, symbol_id: int) -> list:
|
|
76
|
+
"""Return direct callers (symbols that call this one) via symbol_edges."""
|
|
77
|
+
rows = conn.execute(
|
|
78
|
+
"""
|
|
79
|
+
SELECT s.name, f.path AS file_path
|
|
80
|
+
FROM symbol_edges se
|
|
81
|
+
JOIN symbols s ON s.id = se.source_symbol_id
|
|
82
|
+
JOIN files f ON f.id = s.file_id
|
|
83
|
+
WHERE se.target_symbol_id = ?
|
|
84
|
+
LIMIT 10
|
|
85
|
+
""",
|
|
86
|
+
(symbol_id,),
|
|
87
|
+
).fetchall()
|
|
88
|
+
return [dict(r) for r in rows]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _get_callees(conn, symbol_id: int) -> list:
|
|
92
|
+
"""Return direct callees (symbols this one calls) via symbol_edges."""
|
|
93
|
+
rows = conn.execute(
|
|
94
|
+
"""
|
|
95
|
+
SELECT s.name, f.path AS file_path
|
|
96
|
+
FROM symbol_edges se
|
|
97
|
+
JOIN symbols s ON s.id = se.target_symbol_id
|
|
98
|
+
JOIN files f ON f.id = s.file_id
|
|
99
|
+
WHERE se.source_symbol_id = ?
|
|
100
|
+
LIMIT 10
|
|
101
|
+
""",
|
|
102
|
+
(symbol_id,),
|
|
103
|
+
).fetchall()
|
|
104
|
+
return [dict(r) for r in rows]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _get_ref_count(conn, symbol_name: str) -> int:
|
|
108
|
+
"""Return the number of references to a symbol from the refs table."""
|
|
109
|
+
row = conn.execute(
|
|
110
|
+
"SELECT COUNT(*) AS cnt FROM refs WHERE symbol_name = ?",
|
|
111
|
+
(symbol_name,),
|
|
112
|
+
).fetchone()
|
|
113
|
+
return row["cnt"] if row else 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _top_symbols(symbols: list, n: int = 5) -> list:
|
|
117
|
+
"""Return top n function/method symbols from a list, falling back to any kind."""
|
|
118
|
+
funcs = [s for s in symbols if s["kind"] in ("function", "method", "definition")]
|
|
119
|
+
selection = funcs if funcs else symbols
|
|
120
|
+
return selection[:n]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _infer_purpose(module: str, symbols: list) -> str:
|
|
124
|
+
"""Infer a one-line purpose description from module name and symbol kinds."""
|
|
125
|
+
if not symbols:
|
|
126
|
+
return "Empty module — no symbols indexed yet."
|
|
127
|
+
|
|
128
|
+
kinds = [s["kind"] for s in symbols]
|
|
129
|
+
kind_counts: dict = defaultdict(int)
|
|
130
|
+
for k in kinds:
|
|
131
|
+
kind_counts[k] += 1
|
|
132
|
+
|
|
133
|
+
dominant = sorted(kind_counts.items(), key=lambda x: x[1], reverse=True)[0][0]
|
|
134
|
+
|
|
135
|
+
name_lower = module.lower()
|
|
136
|
+
if any(kw in name_lower for kw in ("test", "spec", "__tests__")):
|
|
137
|
+
return "Test suite."
|
|
138
|
+
if any(kw in name_lower for kw in ("util", "helper", "common", "shared")):
|
|
139
|
+
return "Shared utilities and helpers."
|
|
140
|
+
if any(kw in name_lower for kw in ("model", "schema", "entity", "type")):
|
|
141
|
+
return "Data models and type definitions."
|
|
142
|
+
if any(kw in name_lower for kw in ("route", "api", "handler", "endpoint")):
|
|
143
|
+
return "API routes and request handlers."
|
|
144
|
+
if any(kw in name_lower for kw in ("db", "database", "repo", "store")):
|
|
145
|
+
return "Data access and persistence layer."
|
|
146
|
+
if any(kw in name_lower for kw in ("config", "setting", "env")):
|
|
147
|
+
return "Configuration and environment settings."
|
|
148
|
+
if any(kw in name_lower for kw in ("service", "manager", "controller")):
|
|
149
|
+
return "Business logic and service layer."
|
|
150
|
+
if any(kw in name_lower for kw in ("component", "view", "page", "ui")):
|
|
151
|
+
return "UI components and views."
|
|
152
|
+
|
|
153
|
+
if dominant == "class":
|
|
154
|
+
return f"Module defining {kind_counts['class']} class(es)."
|
|
155
|
+
if dominant == "function":
|
|
156
|
+
return f"Module with {kind_counts['function']} function(s)."
|
|
157
|
+
if dominant == "definition":
|
|
158
|
+
return f"Module with {kind_counts['definition']} definition(s)."
|
|
159
|
+
return f"Module containing {len(symbols)} symbols."
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _infer_function_does(sym: dict) -> str:
|
|
163
|
+
"""Infer what a function does from its name and signature."""
|
|
164
|
+
doc = (sym.get("doc_comment") or "").strip()
|
|
165
|
+
if doc:
|
|
166
|
+
first_sentence = doc.split(".")[0].strip()
|
|
167
|
+
if first_sentence:
|
|
168
|
+
return first_sentence + "."
|
|
169
|
+
|
|
170
|
+
sig = (sym.get("signature") or "").strip()
|
|
171
|
+
name = sym.get("name", "")
|
|
172
|
+
|
|
173
|
+
name_lower = name.lower()
|
|
174
|
+
if name_lower.startswith("get_") or name_lower.startswith("fetch_"):
|
|
175
|
+
subject = name_lower[4:].replace("_", " ")
|
|
176
|
+
return f"Retrieves {subject}."
|
|
177
|
+
if name_lower.startswith("set_") or name_lower.startswith("update_"):
|
|
178
|
+
subject = name_lower[4:].replace("_", " ")
|
|
179
|
+
return f"Updates {subject}."
|
|
180
|
+
if name_lower.startswith("create_") or name_lower.startswith("add_"):
|
|
181
|
+
subject = name_lower.split("_", 1)[1].replace("_", " ") if "_" in name_lower else name_lower
|
|
182
|
+
return f"Creates or adds {subject}."
|
|
183
|
+
if name_lower.startswith("delete_") or name_lower.startswith("remove_"):
|
|
184
|
+
subject = name_lower.split("_", 1)[1].replace("_", " ") if "_" in name_lower else name_lower
|
|
185
|
+
return f"Removes {subject}."
|
|
186
|
+
if name_lower.startswith("is_") or name_lower.startswith("has_") or name_lower.startswith("check_"):
|
|
187
|
+
subject = name_lower.split("_", 1)[1].replace("_", " ") if "_" in name_lower else name_lower
|
|
188
|
+
return f"Checks whether {subject}."
|
|
189
|
+
if name_lower.startswith("parse_") or name_lower.startswith("decode_"):
|
|
190
|
+
subject = name_lower.split("_", 1)[1].replace("_", " ") if "_" in name_lower else name_lower
|
|
191
|
+
return f"Parses {subject}."
|
|
192
|
+
if name_lower.startswith("render_") or name_lower.startswith("format_"):
|
|
193
|
+
subject = name_lower.split("_", 1)[1].replace("_", " ") if "_" in name_lower else name_lower
|
|
194
|
+
return f"Formats or renders {subject}."
|
|
195
|
+
if name_lower.startswith("handle_") or name_lower.startswith("on_"):
|
|
196
|
+
subject = name_lower.split("_", 1)[1].replace("_", " ") if "_" in name_lower else name_lower
|
|
197
|
+
return f"Handles {subject} event."
|
|
198
|
+
if name_lower.startswith("init") or name_lower.startswith("setup") or name_lower.startswith("bootstrap"):
|
|
199
|
+
return "Initializes and configures the component."
|
|
200
|
+
if name_lower in ("main", "__main__"):
|
|
201
|
+
return "Entry point for the module."
|
|
202
|
+
if name_lower.startswith("test_"):
|
|
203
|
+
subject = name_lower[5:].replace("_", " ")
|
|
204
|
+
return f"Tests {subject}."
|
|
205
|
+
|
|
206
|
+
if sig:
|
|
207
|
+
return f"Executes `{sig[:80]}`."
|
|
208
|
+
|
|
209
|
+
return f"Implements `{name}` logic."
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ---------------------------------------------------------------------------
|
|
213
|
+
# INTENT.md generation
|
|
214
|
+
# ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def generate_intent(project_root: str, only_modules: set | None = None) -> None:
|
|
218
|
+
"""Generate root INTENT.md and per-module INTENT.md files.
|
|
219
|
+
|
|
220
|
+
If *only_modules* is provided, only regenerate views for those modules
|
|
221
|
+
(incremental mode). The root INTENT.md is always regenerated when any
|
|
222
|
+
module is affected.
|
|
223
|
+
"""
|
|
224
|
+
abs_root = os.path.abspath(project_root)
|
|
225
|
+
conn = get_connection(abs_root)
|
|
226
|
+
try:
|
|
227
|
+
modules = get_modules(conn)
|
|
228
|
+
if not modules:
|
|
229
|
+
print("No symbols found in database. Run the indexer first.", file=sys.stderr)
|
|
230
|
+
conn.close()
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
project_name = Path(abs_root).name
|
|
234
|
+
|
|
235
|
+
# Determine which modules to regenerate
|
|
236
|
+
target_modules = set(modules.keys())
|
|
237
|
+
if only_modules:
|
|
238
|
+
target_modules = {m for m in modules if m in only_modules}
|
|
239
|
+
|
|
240
|
+
# Always regenerate root INTENT.md when any module is touched
|
|
241
|
+
if target_modules or not only_modules:
|
|
242
|
+
_write_root_intent(conn, abs_root, project_name, modules)
|
|
243
|
+
|
|
244
|
+
for module in target_modules:
|
|
245
|
+
# Root-level files (module=".") are covered by the root INTENT.md
|
|
246
|
+
# written above — skip to avoid overwriting it.
|
|
247
|
+
if module == ".":
|
|
248
|
+
continue
|
|
249
|
+
files = modules[module]
|
|
250
|
+
symbols = _get_symbols_for_module(conn, module, files)
|
|
251
|
+
_write_module_intent(conn, abs_root, module, symbols)
|
|
252
|
+
|
|
253
|
+
print(
|
|
254
|
+
f"Generated INTENT.md for {len(target_modules)} module(s) + root.",
|
|
255
|
+
file=sys.stderr,
|
|
256
|
+
)
|
|
257
|
+
finally:
|
|
258
|
+
conn.close()
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _write_root_intent(conn, project_root: str, project_name: str, modules: dict) -> None:
|
|
262
|
+
"""Write the root-level INTENT.md."""
|
|
263
|
+
rows = []
|
|
264
|
+
for module, files in sorted(modules.items()):
|
|
265
|
+
symbols = _get_symbols_for_module(conn, module, files)
|
|
266
|
+
purpose = _infer_purpose(module, symbols)
|
|
267
|
+
top = _top_symbols(symbols)
|
|
268
|
+
key_fns = ", ".join(s["name"] for s in top) if top else "—"
|
|
269
|
+
display = module if module != "." else "(root)"
|
|
270
|
+
rows.append(f"| `{display}` | {purpose} | {key_fns} |")
|
|
271
|
+
|
|
272
|
+
module_table = "\n".join(rows) if rows else "| — | No modules found | — |"
|
|
273
|
+
|
|
274
|
+
content = f"""# {project_name} — Intent
|
|
275
|
+
|
|
276
|
+
## Vision
|
|
277
|
+
|
|
278
|
+
{project_name} is a codebase with {len(modules)} module(s). The structure below summarises each module's purpose and key entry points as derived from the code graph.
|
|
279
|
+
|
|
280
|
+
## Architecture Decisions
|
|
281
|
+
|
|
282
|
+
| Decision | Choice | Reasoning |
|
|
283
|
+
|---|---|---|
|
|
284
|
+
| Code indexing | SQLite + FTS5 | Persistent, queryable graph without external dependencies |
|
|
285
|
+
| Symbol extraction | tree-sitter | Language-agnostic AST parsing with multi-language support |
|
|
286
|
+
| Edge extraction | Aider-style def/ref with tags.scm | Reliable cross-language reference detection |
|
|
287
|
+
| Ranking | fast-pagerank with scipy sparse matrices | Hybrid file-level PageRank + symbol-level blast radius |
|
|
288
|
+
| Schema | 5-table (files, symbols, refs, file_edges, symbol_edges) | Separated concerns for file-level and symbol-level analysis |
|
|
289
|
+
| View generation | Markdown + Mermaid | Human-readable output compatible with most documentation tools |
|
|
290
|
+
|
|
291
|
+
## Module Map
|
|
292
|
+
|
|
293
|
+
| Module | Purpose | Key Functions |
|
|
294
|
+
|---|---|---|
|
|
295
|
+
{module_table}
|
|
296
|
+
"""
|
|
297
|
+
|
|
298
|
+
out_path = os.path.join(project_root, "INTENT.md")
|
|
299
|
+
_write_file(out_path, content)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _write_module_intent(conn, project_root: str, module: str, symbols: list) -> None:
|
|
303
|
+
"""Write a per-module INTENT.md inside the module directory."""
|
|
304
|
+
if not symbols:
|
|
305
|
+
return
|
|
306
|
+
|
|
307
|
+
module_name = module if module != "." else Path(project_root).name
|
|
308
|
+
|
|
309
|
+
# Build function entries
|
|
310
|
+
entries = []
|
|
311
|
+
for sym in symbols:
|
|
312
|
+
if sym["kind"] not in ("function", "method", "class", "definition"):
|
|
313
|
+
continue
|
|
314
|
+
|
|
315
|
+
does = _infer_function_does(sym)
|
|
316
|
+
callers = _get_callers(conn, sym["id"])
|
|
317
|
+
callees = _get_callees(conn, sym["id"])
|
|
318
|
+
ref_count = _get_ref_count(conn, sym["name"])
|
|
319
|
+
|
|
320
|
+
called_by_str = ", ".join(c["name"] for c in callers) if callers else "none found"
|
|
321
|
+
calls_str = ", ".join(c["name"] for c in callees) if callees else "none found"
|
|
322
|
+
|
|
323
|
+
entry = f"""## {sym["name"]}
|
|
324
|
+
- **Does**: {does}
|
|
325
|
+
- **Why**: Supports the `{module_name}` module's responsibilities.
|
|
326
|
+
- **Relationships**: calls [{calls_str}], called by [{called_by_str}]
|
|
327
|
+
- **References**: {ref_count} reference(s) across codebase
|
|
328
|
+
- **Decisions**: `{sym.get("signature", "") or sym["name"]}` (line {sym.get("start_line", "?")} – {sym.get("end_line", "?")})
|
|
329
|
+
"""
|
|
330
|
+
entries.append(entry)
|
|
331
|
+
|
|
332
|
+
if not entries:
|
|
333
|
+
return
|
|
334
|
+
|
|
335
|
+
content = f"# {module_name} — Intent\n\n" + "\n".join(entries)
|
|
336
|
+
|
|
337
|
+
if module == ".":
|
|
338
|
+
out_path = os.path.join(project_root, "INTENT.md")
|
|
339
|
+
else:
|
|
340
|
+
module_dir = os.path.join(project_root, module)
|
|
341
|
+
os.makedirs(module_dir, exist_ok=True)
|
|
342
|
+
out_path = os.path.join(module_dir, "INTENT.md")
|
|
343
|
+
|
|
344
|
+
_write_file(out_path, content)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
# ---------------------------------------------------------------------------
|
|
348
|
+
# ARCHITECTURE.mmd / DIAGRAM.mmd generation
|
|
349
|
+
# ---------------------------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def generate_diagrams(project_root: str, only_modules: set | None = None) -> None:
|
|
353
|
+
"""Generate root ARCHITECTURE.mmd and per-module DIAGRAM.mmd files.
|
|
354
|
+
|
|
355
|
+
If *only_modules* is provided, only regenerate views for those modules.
|
|
356
|
+
The root diagram is always regenerated when any module is affected.
|
|
357
|
+
"""
|
|
358
|
+
abs_root = os.path.abspath(project_root)
|
|
359
|
+
conn = get_connection(abs_root)
|
|
360
|
+
try:
|
|
361
|
+
modules = get_modules(conn)
|
|
362
|
+
if not modules:
|
|
363
|
+
print("No symbols found in database. Run the indexer first.", file=sys.stderr)
|
|
364
|
+
conn.close()
|
|
365
|
+
return
|
|
366
|
+
|
|
367
|
+
target_modules = set(modules.keys())
|
|
368
|
+
if only_modules:
|
|
369
|
+
target_modules = {m for m in modules if m in only_modules}
|
|
370
|
+
|
|
371
|
+
if target_modules or not only_modules:
|
|
372
|
+
_write_root_diagram(conn, abs_root, modules)
|
|
373
|
+
|
|
374
|
+
for module in target_modules:
|
|
375
|
+
# Root-level files (module=".") are covered by ARCHITECTURE.mmd
|
|
376
|
+
# written above — skip to avoid overwriting it.
|
|
377
|
+
if module == ".":
|
|
378
|
+
continue
|
|
379
|
+
files = modules[module]
|
|
380
|
+
symbols = _get_symbols_for_module(conn, module, files)
|
|
381
|
+
_write_module_diagram(conn, abs_root, module, symbols)
|
|
382
|
+
|
|
383
|
+
print(
|
|
384
|
+
f"Generated diagrams for {len(target_modules)} module(s) + root.",
|
|
385
|
+
file=sys.stderr,
|
|
386
|
+
)
|
|
387
|
+
finally:
|
|
388
|
+
conn.close()
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _write_root_diagram(conn, project_root: str, modules: dict) -> None:
|
|
392
|
+
"""Write root ARCHITECTURE.mmd showing module-level dependencies.
|
|
393
|
+
|
|
394
|
+
Uses the file_edges table for module-level dependency information,
|
|
395
|
+
which is more efficient than walking symbol-level edges.
|
|
396
|
+
"""
|
|
397
|
+
module_list = sorted(modules.keys())
|
|
398
|
+
|
|
399
|
+
# Build a file_path -> module lookup
|
|
400
|
+
file_to_module = {}
|
|
401
|
+
for module, files in modules.items():
|
|
402
|
+
for fp in files:
|
|
403
|
+
file_to_module[fp] = module
|
|
404
|
+
|
|
405
|
+
# Query file_edges and aggregate into module-level dependencies
|
|
406
|
+
module_deps: dict = defaultdict(set)
|
|
407
|
+
rows = conn.execute(
|
|
408
|
+
"""
|
|
409
|
+
SELECT sf.path AS source_path, tf.path AS target_path, fe.weight
|
|
410
|
+
FROM file_edges fe
|
|
411
|
+
JOIN files sf ON sf.id = fe.source_file_id
|
|
412
|
+
JOIN files tf ON tf.id = fe.target_file_id
|
|
413
|
+
"""
|
|
414
|
+
).fetchall()
|
|
415
|
+
|
|
416
|
+
for row in rows:
|
|
417
|
+
src_module = _get_module_for_path(row["source_path"])
|
|
418
|
+
tgt_module = _get_module_for_path(row["target_path"])
|
|
419
|
+
if src_module != tgt_module:
|
|
420
|
+
module_deps[src_module].add(tgt_module)
|
|
421
|
+
|
|
422
|
+
# Build mermaid lines
|
|
423
|
+
lines = ["graph LR"]
|
|
424
|
+
|
|
425
|
+
# Node declarations
|
|
426
|
+
for m in module_list:
|
|
427
|
+
safe_id = _mermaid_id(m)
|
|
428
|
+
label = m if m != "." else "(root)"
|
|
429
|
+
lines.append(f" {safe_id}[{label}]")
|
|
430
|
+
|
|
431
|
+
# Edge declarations
|
|
432
|
+
edge_added = False
|
|
433
|
+
for src_module in sorted(module_deps.keys()):
|
|
434
|
+
for tgt_module in sorted(module_deps[src_module]):
|
|
435
|
+
if tgt_module in modules:
|
|
436
|
+
src_id = _mermaid_id(src_module)
|
|
437
|
+
tgt_id = _mermaid_id(tgt_module)
|
|
438
|
+
lines.append(f" {src_id} --> {tgt_id}")
|
|
439
|
+
edge_added = True
|
|
440
|
+
|
|
441
|
+
if not edge_added and len(module_list) > 1:
|
|
442
|
+
# No edges detected — add a comment so the diagram is still valid
|
|
443
|
+
lines.append(" %% No inter-module dependencies detected in index")
|
|
444
|
+
|
|
445
|
+
content = "```mermaid\n" + "\n".join(lines) + "\n```\n"
|
|
446
|
+
out_path = os.path.join(project_root, "ARCHITECTURE.mmd")
|
|
447
|
+
_write_file(out_path, content)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _write_module_diagram(conn, project_root: str, module: str, symbols: list) -> None:
|
|
451
|
+
"""Write per-module DIAGRAM.mmd showing function-level call graph.
|
|
452
|
+
|
|
453
|
+
Uses symbol_edges for intra-module edges.
|
|
454
|
+
"""
|
|
455
|
+
if not symbols:
|
|
456
|
+
return
|
|
457
|
+
|
|
458
|
+
# Collect symbol IDs and names in this module
|
|
459
|
+
sym_ids = {s["id"] for s in symbols}
|
|
460
|
+
sym_names = {s["id"]: s["name"] for s in symbols}
|
|
461
|
+
|
|
462
|
+
lines = ["graph TD"]
|
|
463
|
+
|
|
464
|
+
# Node declarations for all symbols with interesting kinds
|
|
465
|
+
interesting = [s for s in symbols if s["kind"] in ("function", "method", "class", "definition")]
|
|
466
|
+
if not interesting:
|
|
467
|
+
interesting = symbols
|
|
468
|
+
|
|
469
|
+
for sym in interesting:
|
|
470
|
+
safe_id = _mermaid_id(f"{sym['name']}_{sym['id']}")
|
|
471
|
+
lines.append(f" {safe_id}[{sym['name']}]")
|
|
472
|
+
|
|
473
|
+
# Edge declarations — only intra-module edges via symbol_edges
|
|
474
|
+
edges_added = False
|
|
475
|
+
for sym in interesting:
|
|
476
|
+
callees = _get_callees(conn, sym["id"])
|
|
477
|
+
src_id = _mermaid_id(f"{sym['name']}_{sym['id']}")
|
|
478
|
+
for callee_row in callees:
|
|
479
|
+
# Find callee in this module's symbol set
|
|
480
|
+
matching = [s for s in interesting if s["name"] == callee_row["name"]]
|
|
481
|
+
for tgt_sym in matching:
|
|
482
|
+
tgt_id = _mermaid_id(f"{tgt_sym['name']}_{tgt_sym['id']}")
|
|
483
|
+
lines.append(f" {src_id} --> {tgt_id}")
|
|
484
|
+
edges_added = True
|
|
485
|
+
|
|
486
|
+
if not edges_added and len(interesting) > 1:
|
|
487
|
+
lines.append(" %% No intra-module call edges detected in index")
|
|
488
|
+
|
|
489
|
+
content = "```mermaid\n" + "\n".join(lines) + "\n```\n"
|
|
490
|
+
|
|
491
|
+
if module == ".":
|
|
492
|
+
out_path = os.path.join(project_root, "DIAGRAM.mmd")
|
|
493
|
+
else:
|
|
494
|
+
module_dir = os.path.join(project_root, module)
|
|
495
|
+
os.makedirs(module_dir, exist_ok=True)
|
|
496
|
+
out_path = os.path.join(module_dir, "DIAGRAM.mmd")
|
|
497
|
+
|
|
498
|
+
_write_file(out_path, content)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
# ---------------------------------------------------------------------------
|
|
502
|
+
# Shared helpers
|
|
503
|
+
# ---------------------------------------------------------------------------
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _mermaid_id(text: str) -> str:
|
|
507
|
+
"""Convert arbitrary text to a safe Mermaid node ID."""
|
|
508
|
+
safe = ""
|
|
509
|
+
for ch in text:
|
|
510
|
+
if ch.isalnum() or ch == "_":
|
|
511
|
+
safe += ch
|
|
512
|
+
else:
|
|
513
|
+
safe += "_"
|
|
514
|
+
# Mermaid IDs cannot start with a digit
|
|
515
|
+
if safe and safe[0].isdigit():
|
|
516
|
+
safe = "_" + safe
|
|
517
|
+
return safe or "_unknown"
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _write_file(path: str, content: str) -> None:
|
|
521
|
+
"""Write content to path, creating parent directories as needed."""
|
|
522
|
+
os.makedirs(os.path.dirname(path) if os.path.dirname(path) else ".", exist_ok=True)
|
|
523
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
524
|
+
fh.write(content)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _files_to_modules(files_str: str) -> set:
|
|
528
|
+
"""Convert a comma-separated file list to a set of affected module names."""
|
|
529
|
+
raw = [f.strip() for f in files_str.split(",") if f.strip()]
|
|
530
|
+
return {_get_module_for_path(f) for f in raw}
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
# ---------------------------------------------------------------------------
|
|
534
|
+
# CLI entry point
|
|
535
|
+
# ---------------------------------------------------------------------------
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def main() -> None:
|
|
539
|
+
parser = argparse.ArgumentParser(
|
|
540
|
+
description="ftm-map view generators — produce INTENT.md and ARCHITECTURE.mmd from the code graph.",
|
|
541
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
542
|
+
epilog=(
|
|
543
|
+
"Examples:\n"
|
|
544
|
+
" python3 views.py --intent --project-root /path/to/project\n"
|
|
545
|
+
" python3 views.py --diagram --project-root /path/to/project\n"
|
|
546
|
+
" python3 views.py --intent --files src/foo.ts,src/bar.py --project-root /path/to/project\n"
|
|
547
|
+
" python3 views.py --diagram --files src/foo.ts --project-root /path/to/project\n"
|
|
548
|
+
),
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
parser.add_argument(
|
|
552
|
+
"--intent",
|
|
553
|
+
action="store_true",
|
|
554
|
+
help="Generate root INTENT.md and per-module INTENT.md files.",
|
|
555
|
+
)
|
|
556
|
+
parser.add_argument(
|
|
557
|
+
"--diagram",
|
|
558
|
+
action="store_true",
|
|
559
|
+
help="Generate root ARCHITECTURE.mmd and per-module DIAGRAM.mmd files.",
|
|
560
|
+
)
|
|
561
|
+
parser.add_argument(
|
|
562
|
+
"--project-root",
|
|
563
|
+
metavar="PATH",
|
|
564
|
+
default=os.getcwd(),
|
|
565
|
+
help="Path to the project root directory (default: cwd).",
|
|
566
|
+
)
|
|
567
|
+
parser.add_argument(
|
|
568
|
+
"--files",
|
|
569
|
+
metavar="FILE_LIST",
|
|
570
|
+
default=None,
|
|
571
|
+
help="Comma-separated list of changed files (incremental mode — only regenerate affected modules).",
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
args = parser.parse_args()
|
|
575
|
+
|
|
576
|
+
if not args.intent and not args.diagram:
|
|
577
|
+
parser.print_help()
|
|
578
|
+
sys.exit(1)
|
|
579
|
+
|
|
580
|
+
only_modules: set | None = None
|
|
581
|
+
if args.files:
|
|
582
|
+
only_modules = _files_to_modules(args.files)
|
|
583
|
+
|
|
584
|
+
if args.intent:
|
|
585
|
+
generate_intent(args.project_root, only_modules)
|
|
586
|
+
if args.diagram:
|
|
587
|
+
generate_diagrams(args.project_root, only_modules)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
if __name__ == "__main__":
|
|
591
|
+
main()
|