feed-the-machine 1.0.0 → 1.2.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/bin/generate-manifest.mjs +253 -0
- package/bin/install.mjs +134 -4
- package/docs/HOOKS.md +243 -0
- package/docs/INBOX.md +233 -0
- package/ftm/SKILL.md +34 -0
- package/ftm-audit/SKILL.md +69 -0
- package/ftm-brainstorm/SKILL.md +51 -0
- package/ftm-browse/SKILL.md +39 -0
- package/ftm-capture/SKILL.md +370 -0
- package/ftm-capture.yml +4 -0
- package/ftm-codex-gate/SKILL.md +59 -0
- package/ftm-config/SKILL.md +35 -0
- package/ftm-council/SKILL.md +56 -0
- package/ftm-dashboard/SKILL.md +163 -0
- package/ftm-debug/SKILL.md +84 -0
- package/ftm-diagram/SKILL.md +44 -0
- package/ftm-executor/SKILL.md +97 -0
- package/ftm-git/SKILL.md +60 -0
- package/ftm-inbox/backend/__init__.py +0 -0
- package/ftm-inbox/backend/__pycache__/main.cpython-314.pyc +0 -0
- package/ftm-inbox/backend/adapters/__init__.py +0 -0
- package/ftm-inbox/backend/adapters/_retry.py +64 -0
- package/ftm-inbox/backend/adapters/base.py +230 -0
- package/ftm-inbox/backend/adapters/freshservice.py +104 -0
- package/ftm-inbox/backend/adapters/gmail.py +125 -0
- package/ftm-inbox/backend/adapters/jira.py +136 -0
- package/ftm-inbox/backend/adapters/registry.py +192 -0
- package/ftm-inbox/backend/adapters/slack.py +110 -0
- package/ftm-inbox/backend/db/__init__.py +0 -0
- package/ftm-inbox/backend/db/connection.py +54 -0
- package/ftm-inbox/backend/db/schema.py +78 -0
- package/ftm-inbox/backend/executor/__init__.py +7 -0
- package/ftm-inbox/backend/executor/engine.py +149 -0
- package/ftm-inbox/backend/executor/step_runner.py +98 -0
- package/ftm-inbox/backend/main.py +103 -0
- package/ftm-inbox/backend/models/__init__.py +1 -0
- package/ftm-inbox/backend/models/unified_task.py +36 -0
- package/ftm-inbox/backend/planner/__init__.py +6 -0
- 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 -0
- package/ftm-inbox/backend/planner/schema.py +34 -0
- package/ftm-inbox/backend/requirements.txt +5 -0
- package/ftm-inbox/backend/routes/__init__.py +0 -0
- package/ftm-inbox/backend/routes/__pycache__/plan.cpython-314.pyc +0 -0
- package/ftm-inbox/backend/routes/execute.py +186 -0
- package/ftm-inbox/backend/routes/health.py +52 -0
- package/ftm-inbox/backend/routes/inbox.py +68 -0
- package/ftm-inbox/backend/routes/plan.py +271 -0
- package/ftm-inbox/bin/launchagent.mjs +91 -0
- package/ftm-inbox/bin/setup.mjs +188 -0
- package/ftm-inbox/bin/start.sh +10 -0
- package/ftm-inbox/bin/status.sh +17 -0
- package/ftm-inbox/bin/stop.sh +8 -0
- package/ftm-inbox/config.example.yml +55 -0
- package/ftm-inbox/package-lock.json +2898 -0
- package/ftm-inbox/package.json +26 -0
- package/ftm-inbox/postcss.config.js +6 -0
- package/ftm-inbox/src/app.css +199 -0
- package/ftm-inbox/src/app.html +18 -0
- package/ftm-inbox/src/lib/api.ts +166 -0
- package/ftm-inbox/src/lib/components/ExecutionLog.svelte +81 -0
- package/ftm-inbox/src/lib/components/InboxFeed.svelte +143 -0
- package/ftm-inbox/src/lib/components/PlanStep.svelte +271 -0
- package/ftm-inbox/src/lib/components/PlanView.svelte +206 -0
- package/ftm-inbox/src/lib/components/StreamPanel.svelte +99 -0
- package/ftm-inbox/src/lib/components/TaskCard.svelte +190 -0
- package/ftm-inbox/src/lib/components/ui/EmptyState.svelte +63 -0
- package/ftm-inbox/src/lib/components/ui/KawaiiCard.svelte +86 -0
- package/ftm-inbox/src/lib/components/ui/PillButton.svelte +106 -0
- package/ftm-inbox/src/lib/components/ui/StatusBadge.svelte +67 -0
- package/ftm-inbox/src/lib/components/ui/StreamDrawer.svelte +149 -0
- package/ftm-inbox/src/lib/components/ui/ThemeToggle.svelte +80 -0
- package/ftm-inbox/src/lib/theme.ts +47 -0
- package/ftm-inbox/src/routes/+layout.svelte +76 -0
- package/ftm-inbox/src/routes/+page.svelte +401 -0
- package/ftm-inbox/static/favicon.png +0 -0
- package/ftm-inbox/svelte.config.js +12 -0
- package/ftm-inbox/tailwind.config.ts +63 -0
- package/ftm-inbox/tsconfig.json +13 -0
- package/ftm-inbox/vite.config.ts +6 -0
- package/ftm-intent/SKILL.md +44 -0
- package/ftm-manifest.json +3794 -0
- package/ftm-map/SKILL.md +259 -0
- package/ftm-map/scripts/db.py +391 -0
- package/ftm-map/scripts/index.py +341 -0
- package/ftm-map/scripts/parser.py +455 -0
- package/ftm-map/scripts/queries/.gitkeep +0 -0
- package/ftm-map/scripts/queries/javascript-tags.scm +23 -0
- package/ftm-map/scripts/queries/python-tags.scm +17 -0
- package/ftm-map/scripts/queries/typescript-tags.scm +29 -0
- package/ftm-map/scripts/query.py +149 -0
- package/ftm-map/scripts/requirements.txt +2 -0
- package/ftm-map/scripts/setup-hooks.sh +27 -0
- package/ftm-map/scripts/setup.sh +45 -0
- package/ftm-map/scripts/test_db.py +124 -0
- package/ftm-map/scripts/test_parser.py +106 -0
- package/ftm-map/scripts/test_query.py +66 -0
- package/ftm-map/scripts/tests/fixtures/__init__.py +0 -0
- package/ftm-map/scripts/tests/fixtures/sample_project/api.ts +16 -0
- package/ftm-map/scripts/tests/fixtures/sample_project/auth.py +15 -0
- package/ftm-map/scripts/tests/fixtures/sample_project/utils.js +16 -0
- package/ftm-map/scripts/views.py +545 -0
- package/ftm-mind/SKILL.md +173 -66
- package/ftm-pause/SKILL.md +43 -0
- package/ftm-researcher/SKILL.md +275 -0
- package/ftm-researcher/evals/agent-diversity.yaml +17 -0
- package/ftm-researcher/evals/synthesis-quality.yaml +12 -0
- package/ftm-researcher/evals/trigger-accuracy.yaml +39 -0
- package/ftm-researcher/references/adaptive-search.md +116 -0
- package/ftm-researcher/references/agent-prompts.md +193 -0
- package/ftm-researcher/references/council-integration.md +193 -0
- package/ftm-researcher/references/output-format.md +203 -0
- package/ftm-researcher/references/synthesis-pipeline.md +165 -0
- package/ftm-researcher/scripts/score_credibility.py +234 -0
- package/ftm-researcher/scripts/validate_research.py +92 -0
- package/ftm-resume/SKILL.md +47 -0
- package/ftm-retro/SKILL.md +54 -0
- package/ftm-routine/SKILL.md +170 -0
- package/ftm-state/blackboard/capabilities.json +5 -0
- package/ftm-state/blackboard/capabilities.schema.json +27 -0
- package/ftm-upgrade/SKILL.md +41 -0
- package/ftm-upgrade/scripts/check-version.sh +1 -1
- package/ftm-upgrade/scripts/upgrade.sh +1 -1
- package/hooks/ftm-blackboard-enforcer.sh +94 -0
- package/hooks/ftm-discovery-reminder.sh +90 -0
- package/hooks/ftm-drafts-gate.sh +61 -0
- package/hooks/ftm-event-logger.mjs +107 -0
- package/hooks/ftm-map-autodetect.sh +79 -0
- package/hooks/ftm-pending-sync-check.sh +22 -0
- package/hooks/ftm-plan-gate.sh +96 -0
- package/hooks/ftm-post-commit-trigger.sh +57 -0
- package/hooks/settings-template.json +81 -0
- package/install.sh +140 -11
- package/package.json +12 -2
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tree-sitter based parser for extracting symbols and relationships from source code.
|
|
3
|
+
Uses tree-sitter-language-pack for multi-language support and per-language .scm query files.
|
|
4
|
+
"""
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
import tree_sitter as ts
|
|
13
|
+
from tree_sitter_language_pack import get_language, get_parser
|
|
14
|
+
|
|
15
|
+
QUERIES_DIR = os.path.join(os.path.dirname(__file__), "queries")
|
|
16
|
+
|
|
17
|
+
# Map file extensions to tree-sitter language names
|
|
18
|
+
EXTENSION_MAP = {
|
|
19
|
+
".ts": "typescript",
|
|
20
|
+
".tsx": "tsx",
|
|
21
|
+
".js": "javascript",
|
|
22
|
+
".jsx": "javascript",
|
|
23
|
+
".py": "python",
|
|
24
|
+
".rs": "rust",
|
|
25
|
+
".go": "go",
|
|
26
|
+
".rb": "ruby",
|
|
27
|
+
".java": "java",
|
|
28
|
+
".swift": "swift",
|
|
29
|
+
".kt": "kotlin",
|
|
30
|
+
".c": "c",
|
|
31
|
+
".cpp": "cpp",
|
|
32
|
+
".h": "c",
|
|
33
|
+
".hpp": "cpp",
|
|
34
|
+
".cs": "c_sharp",
|
|
35
|
+
".sh": "bash",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# Node types that represent definitions in generic AST walk, keyed by node type
|
|
39
|
+
DEFINITION_TYPES = {
|
|
40
|
+
# TypeScript / JavaScript
|
|
41
|
+
"function_declaration": "function",
|
|
42
|
+
"method_definition": "method",
|
|
43
|
+
"class_declaration": "class",
|
|
44
|
+
"arrow_function": "function",
|
|
45
|
+
"lexical_declaration": "variable",
|
|
46
|
+
"variable_declaration": "variable",
|
|
47
|
+
"interface_declaration": "class",
|
|
48
|
+
"type_alias_declaration": "type",
|
|
49
|
+
"enum_declaration": "class",
|
|
50
|
+
# Python
|
|
51
|
+
"function_definition": "function",
|
|
52
|
+
"class_definition": "class",
|
|
53
|
+
"decorated_definition": None, # unwrap to inner definition
|
|
54
|
+
# Imports
|
|
55
|
+
"import_statement": "import",
|
|
56
|
+
"import_from_statement": "import",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
# Node types that carry a symbol name field
|
|
60
|
+
NAME_TYPES = frozenset({
|
|
61
|
+
"identifier",
|
|
62
|
+
"property_identifier",
|
|
63
|
+
"type_identifier",
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
# Node types representing call expressions per language
|
|
67
|
+
CALL_TYPES = frozenset({
|
|
68
|
+
"call_expression", # JS/TS/Go/Rust
|
|
69
|
+
"call", # Ruby
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
# Node types representing import statements
|
|
73
|
+
IMPORT_TYPES = frozenset({
|
|
74
|
+
"import_statement",
|
|
75
|
+
"import_from_statement",
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class Symbol:
|
|
81
|
+
name: str
|
|
82
|
+
kind: str # function, class, method, variable, import, type
|
|
83
|
+
file_path: str
|
|
84
|
+
start_line: int
|
|
85
|
+
end_line: int
|
|
86
|
+
signature: str = ""
|
|
87
|
+
doc_comment: str = ""
|
|
88
|
+
content_hash: str = ""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class Relationship:
|
|
93
|
+
source_name: str
|
|
94
|
+
target_name: str
|
|
95
|
+
kind: str # calls, imports, extends, implements, uses
|
|
96
|
+
source_file: str
|
|
97
|
+
target_file: str = "" # may be unknown for cross-file refs
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
# Public API
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
def detect_language(file_path: str) -> Optional[str]:
|
|
105
|
+
"""Detect tree-sitter language from file extension."""
|
|
106
|
+
ext = Path(file_path).suffix.lower()
|
|
107
|
+
return EXTENSION_MAP.get(ext)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def compute_content_hash(content: str) -> str:
|
|
111
|
+
"""Compute a short SHA-256 hash of content for change detection."""
|
|
112
|
+
return hashlib.sha256(content.encode()).hexdigest()[:16]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def parse_file(file_path: str) -> list[Symbol]:
|
|
116
|
+
"""Parse a source file and extract symbols.
|
|
117
|
+
|
|
118
|
+
Returns a list of Symbol objects. Handles parse errors gracefully by
|
|
119
|
+
returning partial results and logging warnings to stderr.
|
|
120
|
+
"""
|
|
121
|
+
lang = detect_language(file_path)
|
|
122
|
+
if not lang:
|
|
123
|
+
return []
|
|
124
|
+
|
|
125
|
+
source = _read_source(file_path)
|
|
126
|
+
if source is None:
|
|
127
|
+
return []
|
|
128
|
+
|
|
129
|
+
tree = _parse_source(source, lang, file_path)
|
|
130
|
+
if tree is None:
|
|
131
|
+
return []
|
|
132
|
+
|
|
133
|
+
scm_path = os.path.join(QUERIES_DIR, f"{lang}-tags.scm")
|
|
134
|
+
if os.path.exists(scm_path):
|
|
135
|
+
symbols = _extract_with_query(tree, source, file_path, lang, scm_path)
|
|
136
|
+
# Fall back to generic walk if the query produced nothing
|
|
137
|
+
if symbols:
|
|
138
|
+
return symbols
|
|
139
|
+
|
|
140
|
+
return _extract_generic(tree, source, file_path)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def extract_relationships(file_path: str) -> list[Relationship]:
|
|
144
|
+
"""Extract relationships (calls, imports) from a source file.
|
|
145
|
+
|
|
146
|
+
Returns a list of Relationship objects.
|
|
147
|
+
"""
|
|
148
|
+
lang = detect_language(file_path)
|
|
149
|
+
if not lang:
|
|
150
|
+
return []
|
|
151
|
+
|
|
152
|
+
source = _read_source(file_path)
|
|
153
|
+
if source is None:
|
|
154
|
+
return []
|
|
155
|
+
|
|
156
|
+
tree = _parse_source(source, lang, file_path)
|
|
157
|
+
if tree is None:
|
|
158
|
+
return []
|
|
159
|
+
|
|
160
|
+
relationships: list[Relationship] = []
|
|
161
|
+
_extract_calls(tree.root_node, source, file_path, relationships)
|
|
162
|
+
_extract_imports(tree.root_node, source, file_path, relationships)
|
|
163
|
+
return relationships
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
# Internal helpers
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
def _read_source(file_path: str) -> Optional[str]:
|
|
171
|
+
"""Read a source file, returning None on IO error."""
|
|
172
|
+
try:
|
|
173
|
+
with open(file_path, "r", encoding="utf-8", errors="replace") as fh:
|
|
174
|
+
return fh.read()
|
|
175
|
+
except (IOError, OSError) as exc:
|
|
176
|
+
print(f"Warning: Cannot read {file_path}: {exc}", file=sys.stderr)
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _parse_source(source: str, lang: str, file_path: str):
|
|
181
|
+
"""Parse source text with tree-sitter, returning None on error."""
|
|
182
|
+
try:
|
|
183
|
+
parser = get_parser(lang)
|
|
184
|
+
return parser.parse(source.encode())
|
|
185
|
+
except Exception as exc: # noqa: BLE001
|
|
186
|
+
print(f"Warning: Parse error for {file_path}: {exc}", file=sys.stderr)
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
# Query-based extraction
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
def _extract_with_query(tree, source: str, file_path: str, lang: str, scm_path: str) -> list[Symbol]:
|
|
195
|
+
"""Extract symbols using a language-specific .scm query file.
|
|
196
|
+
|
|
197
|
+
Uses tree-sitter QueryCursor.matches() which returns per-pattern match dicts.
|
|
198
|
+
Each match dict contains both the @definition.X node and the @name node so
|
|
199
|
+
they are already correlated — no post-hoc joining needed.
|
|
200
|
+
|
|
201
|
+
Falls back to empty list on any error so callers can use generic extraction.
|
|
202
|
+
"""
|
|
203
|
+
try:
|
|
204
|
+
with open(scm_path) as fh:
|
|
205
|
+
query_text = fh.read()
|
|
206
|
+
except (IOError, OSError) as exc:
|
|
207
|
+
print(f"Warning: Cannot read query {scm_path}: {exc}", file=sys.stderr)
|
|
208
|
+
return []
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
language = get_language(lang)
|
|
212
|
+
query = ts.Query(language, query_text)
|
|
213
|
+
cursor = ts.QueryCursor(query)
|
|
214
|
+
matches = list(cursor.matches(tree.root_node))
|
|
215
|
+
except Exception as exc: # noqa: BLE001
|
|
216
|
+
print(f"Warning: Query execution failed for {file_path}: {exc}", file=sys.stderr)
|
|
217
|
+
return []
|
|
218
|
+
|
|
219
|
+
return _process_matches(matches, source, file_path)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _process_matches(matches: list, source: str, file_path: str) -> list[Symbol]:
|
|
223
|
+
"""Convert QueryCursor.matches() results into Symbol objects.
|
|
224
|
+
|
|
225
|
+
Each match is a (pattern_index, capture_dict) tuple where capture_dict maps
|
|
226
|
+
capture name → list[Node]. Both @definition.X and @name appear in the same
|
|
227
|
+
capture_dict for each pattern match, making correlation trivial.
|
|
228
|
+
"""
|
|
229
|
+
symbols: list[Symbol] = []
|
|
230
|
+
|
|
231
|
+
for _pattern_idx, capture_dict in matches:
|
|
232
|
+
# Find the definition capture (e.g. "definition.function")
|
|
233
|
+
def_key = next(
|
|
234
|
+
(k for k in capture_dict if k.startswith("definition.")),
|
|
235
|
+
None,
|
|
236
|
+
)
|
|
237
|
+
if not def_key:
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
kind = def_key[len("definition."):]
|
|
241
|
+
def_nodes = capture_dict[def_key]
|
|
242
|
+
name_nodes = capture_dict.get("name", [])
|
|
243
|
+
|
|
244
|
+
if not def_nodes:
|
|
245
|
+
continue
|
|
246
|
+
|
|
247
|
+
def_node = def_nodes[0]
|
|
248
|
+
|
|
249
|
+
# Prefer the @name capture; fall back to identifier child walk
|
|
250
|
+
if name_nodes:
|
|
251
|
+
sym_name = source[name_nodes[0].start_byte:name_nodes[0].end_byte].strip()
|
|
252
|
+
else:
|
|
253
|
+
sym_name = _find_name(def_node, source)
|
|
254
|
+
|
|
255
|
+
if not sym_name:
|
|
256
|
+
continue
|
|
257
|
+
|
|
258
|
+
body = source[def_node.start_byte:def_node.end_byte]
|
|
259
|
+
sig = _first_line(body)
|
|
260
|
+
doc = _find_doc_comment(def_node, source)
|
|
261
|
+
|
|
262
|
+
symbols.append(Symbol(
|
|
263
|
+
name=sym_name,
|
|
264
|
+
kind=kind,
|
|
265
|
+
file_path=file_path,
|
|
266
|
+
start_line=def_node.start_point[0] + 1,
|
|
267
|
+
end_line=def_node.end_point[0] + 1,
|
|
268
|
+
signature=sig,
|
|
269
|
+
doc_comment=doc,
|
|
270
|
+
content_hash=compute_content_hash(body),
|
|
271
|
+
))
|
|
272
|
+
|
|
273
|
+
return symbols
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# ---------------------------------------------------------------------------
|
|
277
|
+
# Generic AST walk extraction
|
|
278
|
+
# ---------------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
def _extract_generic(tree, source: str, file_path: str) -> list[Symbol]:
|
|
281
|
+
"""Walk the AST and extract symbols without a query file."""
|
|
282
|
+
symbols: list[Symbol] = []
|
|
283
|
+
_walk_node(tree.root_node, source, file_path, symbols)
|
|
284
|
+
return symbols
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _walk_node(node, source: str, file_path: str, symbols: list[Symbol]) -> None:
|
|
288
|
+
"""Recursively walk AST nodes looking for definition nodes."""
|
|
289
|
+
node_type = node.type
|
|
290
|
+
|
|
291
|
+
if node_type in DEFINITION_TYPES:
|
|
292
|
+
kind = DEFINITION_TYPES[node_type]
|
|
293
|
+
|
|
294
|
+
if kind is None:
|
|
295
|
+
# Decorated definition — unwrap inner nodes only
|
|
296
|
+
for child in node.children:
|
|
297
|
+
_walk_node(child, source, file_path, symbols)
|
|
298
|
+
return
|
|
299
|
+
|
|
300
|
+
# Skip bare arrow functions without a variable name context
|
|
301
|
+
if node_type == "arrow_function":
|
|
302
|
+
for child in node.children:
|
|
303
|
+
_walk_node(child, source, file_path, symbols)
|
|
304
|
+
return
|
|
305
|
+
|
|
306
|
+
name = _find_name(node, source)
|
|
307
|
+
if not name:
|
|
308
|
+
for child in node.children:
|
|
309
|
+
_walk_node(child, source, file_path, symbols)
|
|
310
|
+
return
|
|
311
|
+
|
|
312
|
+
body = source[node.start_byte:node.end_byte]
|
|
313
|
+
sig = _first_line(body)
|
|
314
|
+
doc = _find_doc_comment(node, source)
|
|
315
|
+
|
|
316
|
+
symbols.append(Symbol(
|
|
317
|
+
name=name,
|
|
318
|
+
kind=kind,
|
|
319
|
+
file_path=file_path,
|
|
320
|
+
start_line=node.start_point[0] + 1,
|
|
321
|
+
end_line=node.end_point[0] + 1,
|
|
322
|
+
signature=sig,
|
|
323
|
+
doc_comment=doc,
|
|
324
|
+
content_hash=compute_content_hash(body),
|
|
325
|
+
))
|
|
326
|
+
|
|
327
|
+
# Always recurse (definitions can be nested)
|
|
328
|
+
for child in node.children:
|
|
329
|
+
_walk_node(child, source, file_path, symbols)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# ---------------------------------------------------------------------------
|
|
333
|
+
# Relationship extraction
|
|
334
|
+
# ---------------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
def _extract_calls(node, source: str, file_path: str, rels: list[Relationship]) -> None:
|
|
337
|
+
"""Recursively extract function call relationships."""
|
|
338
|
+
if node.type in CALL_TYPES:
|
|
339
|
+
func_node = node.children[0] if node.children else None
|
|
340
|
+
if func_node:
|
|
341
|
+
callee_text = source[func_node.start_byte:func_node.end_byte]
|
|
342
|
+
# Simplify dotted paths to last component
|
|
343
|
+
callee_name = callee_text.split(".")[-1].split("(")[0].strip()
|
|
344
|
+
caller_name = _find_enclosing_function(node, source)
|
|
345
|
+
if callee_name and caller_name:
|
|
346
|
+
rels.append(Relationship(
|
|
347
|
+
source_name=caller_name,
|
|
348
|
+
target_name=callee_name,
|
|
349
|
+
kind="calls",
|
|
350
|
+
source_file=file_path,
|
|
351
|
+
))
|
|
352
|
+
|
|
353
|
+
for child in node.children:
|
|
354
|
+
_extract_calls(child, source, file_path, rels)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _extract_imports(node, source: str, file_path: str, rels: list[Relationship]) -> None:
|
|
358
|
+
"""Recursively extract import relationships."""
|
|
359
|
+
if node.type in IMPORT_TYPES:
|
|
360
|
+
module_stem = Path(file_path).stem
|
|
361
|
+
_collect_import_names(node, source, module_stem, file_path, rels)
|
|
362
|
+
|
|
363
|
+
for child in node.children:
|
|
364
|
+
_extract_imports(child, source, file_path, rels)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _collect_import_names(
|
|
368
|
+
node,
|
|
369
|
+
source: str,
|
|
370
|
+
module_stem: str,
|
|
371
|
+
file_path: str,
|
|
372
|
+
rels: list[Relationship],
|
|
373
|
+
) -> None:
|
|
374
|
+
"""Walk an import node and emit Relationship objects for each imported name."""
|
|
375
|
+
for child in node.children:
|
|
376
|
+
child_type = child.type
|
|
377
|
+
|
|
378
|
+
if child_type == "dotted_name":
|
|
379
|
+
imported = source[child.start_byte:child.end_byte]
|
|
380
|
+
rels.append(Relationship(
|
|
381
|
+
source_name=module_stem,
|
|
382
|
+
target_name=imported,
|
|
383
|
+
kind="imports",
|
|
384
|
+
source_file=file_path,
|
|
385
|
+
))
|
|
386
|
+
|
|
387
|
+
elif child_type in ("import_clause", "named_imports", "import_specifier"):
|
|
388
|
+
for grandchild in child.children:
|
|
389
|
+
if grandchild.type in NAME_TYPES:
|
|
390
|
+
name = source[grandchild.start_byte:grandchild.end_byte].strip()
|
|
391
|
+
if name and name not in ("{", "}", ","):
|
|
392
|
+
rels.append(Relationship(
|
|
393
|
+
source_name=module_stem,
|
|
394
|
+
target_name=name,
|
|
395
|
+
kind="imports",
|
|
396
|
+
source_file=file_path,
|
|
397
|
+
))
|
|
398
|
+
|
|
399
|
+
elif child_type == "string":
|
|
400
|
+
# import ... from "module-path"
|
|
401
|
+
raw = source[child.start_byte:child.end_byte].strip("'\"")
|
|
402
|
+
rels.append(Relationship(
|
|
403
|
+
source_name=module_stem,
|
|
404
|
+
target_name=raw,
|
|
405
|
+
kind="imports",
|
|
406
|
+
source_file=file_path,
|
|
407
|
+
))
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
# ---------------------------------------------------------------------------
|
|
411
|
+
# Small AST utilities
|
|
412
|
+
# ---------------------------------------------------------------------------
|
|
413
|
+
|
|
414
|
+
def _find_name(node, source: str) -> str:
|
|
415
|
+
"""Find the first name-like identifier child of a node."""
|
|
416
|
+
for child in node.children:
|
|
417
|
+
if child.type in NAME_TYPES:
|
|
418
|
+
return source[child.start_byte:child.end_byte]
|
|
419
|
+
return ""
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _find_doc_comment(node, source: str) -> str:
|
|
423
|
+
"""Try to extract a doc comment from the node's previous sibling."""
|
|
424
|
+
prev = node.prev_named_sibling
|
|
425
|
+
if prev and prev.type in ("comment", "block_comment", "string", "string_literal"):
|
|
426
|
+
text = source[prev.start_byte:prev.end_byte].strip()
|
|
427
|
+
# Strip common comment markers
|
|
428
|
+
for marker in ("///", "/**", "/*", "*/", "//", "#", '"""', "'''"):
|
|
429
|
+
text = text.strip(marker)
|
|
430
|
+
return text.strip()[:500]
|
|
431
|
+
return ""
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _first_line(text: str, max_len: int = 200) -> str:
|
|
435
|
+
"""Return the first non-empty line of text, truncated to max_len."""
|
|
436
|
+
line = text.split("\n")[0].strip()
|
|
437
|
+
return line[:max_len] + "..." if len(line) > max_len else line
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _find_enclosing_function(node, source: str) -> str:
|
|
441
|
+
"""Walk up the AST to find the name of the nearest enclosing function."""
|
|
442
|
+
enclosing_types = {
|
|
443
|
+
"function_declaration",
|
|
444
|
+
"function_definition",
|
|
445
|
+
"method_definition",
|
|
446
|
+
"arrow_function",
|
|
447
|
+
}
|
|
448
|
+
current = node.parent
|
|
449
|
+
while current:
|
|
450
|
+
if current.type in enclosing_types:
|
|
451
|
+
name = _find_name(current, source)
|
|
452
|
+
if name:
|
|
453
|
+
return name
|
|
454
|
+
current = current.parent
|
|
455
|
+
return ""
|
|
File without changes
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
; Functions
|
|
2
|
+
(function_declaration
|
|
3
|
+
name: (identifier) @name) @definition.function
|
|
4
|
+
|
|
5
|
+
; Methods
|
|
6
|
+
(method_definition
|
|
7
|
+
name: (property_identifier) @name) @definition.method
|
|
8
|
+
|
|
9
|
+
; Classes
|
|
10
|
+
(class_declaration
|
|
11
|
+
name: (identifier) @name) @definition.class
|
|
12
|
+
|
|
13
|
+
; Arrow functions assigned to const/let variables
|
|
14
|
+
(lexical_declaration
|
|
15
|
+
(variable_declarator
|
|
16
|
+
name: (identifier) @name
|
|
17
|
+
value: (arrow_function))) @definition.function
|
|
18
|
+
|
|
19
|
+
; Arrow functions assigned to var variables
|
|
20
|
+
(variable_declaration
|
|
21
|
+
(variable_declarator
|
|
22
|
+
name: (identifier) @name
|
|
23
|
+
value: (arrow_function))) @definition.function
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
; Functions
|
|
2
|
+
(function_definition
|
|
3
|
+
name: (identifier) @name) @definition.function
|
|
4
|
+
|
|
5
|
+
; Classes
|
|
6
|
+
(class_definition
|
|
7
|
+
name: (identifier) @name) @definition.class
|
|
8
|
+
|
|
9
|
+
; Decorated functions
|
|
10
|
+
(decorated_definition
|
|
11
|
+
definition: (function_definition
|
|
12
|
+
name: (identifier) @name) @definition.function)
|
|
13
|
+
|
|
14
|
+
; Decorated classes
|
|
15
|
+
(decorated_definition
|
|
16
|
+
definition: (class_definition
|
|
17
|
+
name: (identifier) @name) @definition.class)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
; Functions
|
|
2
|
+
(function_declaration
|
|
3
|
+
name: (identifier) @name) @definition.function
|
|
4
|
+
|
|
5
|
+
; Methods
|
|
6
|
+
(method_definition
|
|
7
|
+
name: (property_identifier) @name) @definition.method
|
|
8
|
+
|
|
9
|
+
; Classes
|
|
10
|
+
(class_declaration
|
|
11
|
+
name: (type_identifier) @name) @definition.class
|
|
12
|
+
|
|
13
|
+
; Arrow functions assigned to const/let variables
|
|
14
|
+
(lexical_declaration
|
|
15
|
+
(variable_declarator
|
|
16
|
+
name: (identifier) @name
|
|
17
|
+
value: (arrow_function))) @definition.function
|
|
18
|
+
|
|
19
|
+
; Interfaces
|
|
20
|
+
(interface_declaration
|
|
21
|
+
name: (type_identifier) @name) @definition.class
|
|
22
|
+
|
|
23
|
+
; Type aliases
|
|
24
|
+
(type_alias_declaration
|
|
25
|
+
name: (type_identifier) @name) @definition.type
|
|
26
|
+
|
|
27
|
+
; Enums
|
|
28
|
+
(enum_declaration
|
|
29
|
+
name: (identifier) @name) @definition.class
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""ftm-map query interface: structural and text queries against the code graph."""
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, os.path.dirname(__file__))
|
|
10
|
+
|
|
11
|
+
from db import get_connection, get_symbol_by_name, get_transitive_deps, get_reverse_deps, fts_search
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def blast_radius(conn, symbol_name: str, max_depth: int = 10) -> dict:
|
|
15
|
+
"""Get all symbols that would be affected if this symbol changes."""
|
|
16
|
+
symbols = get_symbol_by_name(conn, symbol_name)
|
|
17
|
+
if not symbols:
|
|
18
|
+
return {"error": f"Symbol '{symbol_name}' not found", "results": []}
|
|
19
|
+
|
|
20
|
+
# Use first match
|
|
21
|
+
sym = symbols[0]
|
|
22
|
+
deps = get_reverse_deps(conn, sym["id"], max_depth)
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
"symbol": symbol_name,
|
|
26
|
+
"symbol_file": sym["file_path"],
|
|
27
|
+
"affected_count": len(deps),
|
|
28
|
+
"results": deps,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def dependency_chain(conn, symbol_name: str, max_depth: int = 10) -> dict:
|
|
33
|
+
"""Get all symbols this one depends on."""
|
|
34
|
+
symbols = get_symbol_by_name(conn, symbol_name)
|
|
35
|
+
if not symbols:
|
|
36
|
+
return {"error": f"Symbol '{symbol_name}' not found", "results": []}
|
|
37
|
+
|
|
38
|
+
sym = symbols[0]
|
|
39
|
+
deps = get_transitive_deps(conn, sym["id"], max_depth)
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
"symbol": symbol_name,
|
|
43
|
+
"symbol_file": sym["file_path"],
|
|
44
|
+
"dependency_count": len(deps),
|
|
45
|
+
"results": deps,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def search(conn, query_text: str, limit: int = 10) -> dict:
|
|
50
|
+
"""BM25-ranked full-text search."""
|
|
51
|
+
results = fts_search(conn, query_text, limit)
|
|
52
|
+
return {
|
|
53
|
+
"query": query_text,
|
|
54
|
+
"result_count": len(results),
|
|
55
|
+
"results": results,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def symbol_info(conn, symbol_name: str) -> dict:
|
|
60
|
+
"""Full details about a symbol including callers, callees, and blast radius count."""
|
|
61
|
+
symbols = get_symbol_by_name(conn, symbol_name)
|
|
62
|
+
if not symbols:
|
|
63
|
+
return {"error": f"Symbol '{symbol_name}' not found"}
|
|
64
|
+
|
|
65
|
+
sym = symbols[0]
|
|
66
|
+
sym_id = sym["id"]
|
|
67
|
+
|
|
68
|
+
# Direct callers (who calls me)
|
|
69
|
+
callers = conn.execute(
|
|
70
|
+
"""
|
|
71
|
+
SELECT s.name, s.kind, s.file_path, s.start_line
|
|
72
|
+
FROM edges e JOIN symbols s ON s.id = e.source_id
|
|
73
|
+
WHERE e.target_id = ?
|
|
74
|
+
""",
|
|
75
|
+
(sym_id,),
|
|
76
|
+
).fetchall()
|
|
77
|
+
|
|
78
|
+
# Direct callees (who do I call)
|
|
79
|
+
callees = conn.execute(
|
|
80
|
+
"""
|
|
81
|
+
SELECT s.name, s.kind, s.file_path, s.start_line
|
|
82
|
+
FROM edges e JOIN symbols s ON s.id = e.target_id
|
|
83
|
+
WHERE e.source_id = ?
|
|
84
|
+
""",
|
|
85
|
+
(sym_id,),
|
|
86
|
+
).fetchall()
|
|
87
|
+
|
|
88
|
+
# Blast radius count
|
|
89
|
+
blast = get_reverse_deps(conn, sym_id)
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
"name": sym["name"],
|
|
93
|
+
"kind": sym["kind"],
|
|
94
|
+
"file": sym["file_path"],
|
|
95
|
+
"start_line": sym["start_line"],
|
|
96
|
+
"end_line": sym["end_line"],
|
|
97
|
+
"signature": sym.get("signature", ""),
|
|
98
|
+
"doc_comment": sym.get("doc_comment", ""),
|
|
99
|
+
"callers": [dict(r) for r in callers],
|
|
100
|
+
"callees": [dict(r) for r in callees],
|
|
101
|
+
"blast_radius_count": len(blast),
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def main():
|
|
106
|
+
parser = argparse.ArgumentParser(description="ftm-map query interface")
|
|
107
|
+
parser.add_argument(
|
|
108
|
+
"--blast-radius", metavar="SYMBOL", help="Show blast radius for a symbol"
|
|
109
|
+
)
|
|
110
|
+
parser.add_argument(
|
|
111
|
+
"--deps", metavar="SYMBOL", help="Show dependency chain for a symbol"
|
|
112
|
+
)
|
|
113
|
+
parser.add_argument("--search", metavar="QUERY", help="Full-text search")
|
|
114
|
+
parser.add_argument("--info", metavar="SYMBOL", help="Full symbol info")
|
|
115
|
+
parser.add_argument(
|
|
116
|
+
"--limit", type=int, default=10, help="Result limit for search"
|
|
117
|
+
)
|
|
118
|
+
parser.add_argument(
|
|
119
|
+
"--max-depth", type=int, default=10, help="Max traversal depth"
|
|
120
|
+
)
|
|
121
|
+
parser.add_argument(
|
|
122
|
+
"--project-root",
|
|
123
|
+
default=os.getcwd(),
|
|
124
|
+
help="Project root directory",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
args = parser.parse_args()
|
|
128
|
+
|
|
129
|
+
conn = get_connection(args.project_root)
|
|
130
|
+
try:
|
|
131
|
+
if args.blast_radius:
|
|
132
|
+
result = blast_radius(conn, args.blast_radius, args.max_depth)
|
|
133
|
+
elif args.deps:
|
|
134
|
+
result = dependency_chain(conn, args.deps, args.max_depth)
|
|
135
|
+
elif args.search:
|
|
136
|
+
result = search(conn, args.search, args.limit)
|
|
137
|
+
elif args.info:
|
|
138
|
+
result = symbol_info(conn, args.info)
|
|
139
|
+
else:
|
|
140
|
+
parser.print_help()
|
|
141
|
+
sys.exit(1)
|
|
142
|
+
|
|
143
|
+
print(json.dumps(result, indent=2, default=str))
|
|
144
|
+
finally:
|
|
145
|
+
conn.close()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
if __name__ == "__main__":
|
|
149
|
+
main()
|