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.
Files changed (136) hide show
  1. package/bin/generate-manifest.mjs +253 -0
  2. package/bin/install.mjs +134 -4
  3. package/docs/HOOKS.md +243 -0
  4. package/docs/INBOX.md +233 -0
  5. package/ftm/SKILL.md +34 -0
  6. package/ftm-audit/SKILL.md +69 -0
  7. package/ftm-brainstorm/SKILL.md +51 -0
  8. package/ftm-browse/SKILL.md +39 -0
  9. package/ftm-capture/SKILL.md +370 -0
  10. package/ftm-capture.yml +4 -0
  11. package/ftm-codex-gate/SKILL.md +59 -0
  12. package/ftm-config/SKILL.md +35 -0
  13. package/ftm-council/SKILL.md +56 -0
  14. package/ftm-dashboard/SKILL.md +163 -0
  15. package/ftm-debug/SKILL.md +84 -0
  16. package/ftm-diagram/SKILL.md +44 -0
  17. package/ftm-executor/SKILL.md +97 -0
  18. package/ftm-git/SKILL.md +60 -0
  19. package/ftm-inbox/backend/__init__.py +0 -0
  20. package/ftm-inbox/backend/__pycache__/main.cpython-314.pyc +0 -0
  21. package/ftm-inbox/backend/adapters/__init__.py +0 -0
  22. package/ftm-inbox/backend/adapters/_retry.py +64 -0
  23. package/ftm-inbox/backend/adapters/base.py +230 -0
  24. package/ftm-inbox/backend/adapters/freshservice.py +104 -0
  25. package/ftm-inbox/backend/adapters/gmail.py +125 -0
  26. package/ftm-inbox/backend/adapters/jira.py +136 -0
  27. package/ftm-inbox/backend/adapters/registry.py +192 -0
  28. package/ftm-inbox/backend/adapters/slack.py +110 -0
  29. package/ftm-inbox/backend/db/__init__.py +0 -0
  30. package/ftm-inbox/backend/db/connection.py +54 -0
  31. package/ftm-inbox/backend/db/schema.py +78 -0
  32. package/ftm-inbox/backend/executor/__init__.py +7 -0
  33. package/ftm-inbox/backend/executor/engine.py +149 -0
  34. package/ftm-inbox/backend/executor/step_runner.py +98 -0
  35. package/ftm-inbox/backend/main.py +103 -0
  36. package/ftm-inbox/backend/models/__init__.py +1 -0
  37. package/ftm-inbox/backend/models/unified_task.py +36 -0
  38. package/ftm-inbox/backend/planner/__init__.py +6 -0
  39. package/ftm-inbox/backend/planner/__pycache__/__init__.cpython-314.pyc +0 -0
  40. package/ftm-inbox/backend/planner/__pycache__/generator.cpython-314.pyc +0 -0
  41. package/ftm-inbox/backend/planner/__pycache__/schema.cpython-314.pyc +0 -0
  42. package/ftm-inbox/backend/planner/generator.py +127 -0
  43. package/ftm-inbox/backend/planner/schema.py +34 -0
  44. package/ftm-inbox/backend/requirements.txt +5 -0
  45. package/ftm-inbox/backend/routes/__init__.py +0 -0
  46. package/ftm-inbox/backend/routes/__pycache__/plan.cpython-314.pyc +0 -0
  47. package/ftm-inbox/backend/routes/execute.py +186 -0
  48. package/ftm-inbox/backend/routes/health.py +52 -0
  49. package/ftm-inbox/backend/routes/inbox.py +68 -0
  50. package/ftm-inbox/backend/routes/plan.py +271 -0
  51. package/ftm-inbox/bin/launchagent.mjs +91 -0
  52. package/ftm-inbox/bin/setup.mjs +188 -0
  53. package/ftm-inbox/bin/start.sh +10 -0
  54. package/ftm-inbox/bin/status.sh +17 -0
  55. package/ftm-inbox/bin/stop.sh +8 -0
  56. package/ftm-inbox/config.example.yml +55 -0
  57. package/ftm-inbox/package-lock.json +2898 -0
  58. package/ftm-inbox/package.json +26 -0
  59. package/ftm-inbox/postcss.config.js +6 -0
  60. package/ftm-inbox/src/app.css +199 -0
  61. package/ftm-inbox/src/app.html +18 -0
  62. package/ftm-inbox/src/lib/api.ts +166 -0
  63. package/ftm-inbox/src/lib/components/ExecutionLog.svelte +81 -0
  64. package/ftm-inbox/src/lib/components/InboxFeed.svelte +143 -0
  65. package/ftm-inbox/src/lib/components/PlanStep.svelte +271 -0
  66. package/ftm-inbox/src/lib/components/PlanView.svelte +206 -0
  67. package/ftm-inbox/src/lib/components/StreamPanel.svelte +99 -0
  68. package/ftm-inbox/src/lib/components/TaskCard.svelte +190 -0
  69. package/ftm-inbox/src/lib/components/ui/EmptyState.svelte +63 -0
  70. package/ftm-inbox/src/lib/components/ui/KawaiiCard.svelte +86 -0
  71. package/ftm-inbox/src/lib/components/ui/PillButton.svelte +106 -0
  72. package/ftm-inbox/src/lib/components/ui/StatusBadge.svelte +67 -0
  73. package/ftm-inbox/src/lib/components/ui/StreamDrawer.svelte +149 -0
  74. package/ftm-inbox/src/lib/components/ui/ThemeToggle.svelte +80 -0
  75. package/ftm-inbox/src/lib/theme.ts +47 -0
  76. package/ftm-inbox/src/routes/+layout.svelte +76 -0
  77. package/ftm-inbox/src/routes/+page.svelte +401 -0
  78. package/ftm-inbox/static/favicon.png +0 -0
  79. package/ftm-inbox/svelte.config.js +12 -0
  80. package/ftm-inbox/tailwind.config.ts +63 -0
  81. package/ftm-inbox/tsconfig.json +13 -0
  82. package/ftm-inbox/vite.config.ts +6 -0
  83. package/ftm-intent/SKILL.md +44 -0
  84. package/ftm-manifest.json +3794 -0
  85. package/ftm-map/SKILL.md +259 -0
  86. package/ftm-map/scripts/db.py +391 -0
  87. package/ftm-map/scripts/index.py +341 -0
  88. package/ftm-map/scripts/parser.py +455 -0
  89. package/ftm-map/scripts/queries/.gitkeep +0 -0
  90. package/ftm-map/scripts/queries/javascript-tags.scm +23 -0
  91. package/ftm-map/scripts/queries/python-tags.scm +17 -0
  92. package/ftm-map/scripts/queries/typescript-tags.scm +29 -0
  93. package/ftm-map/scripts/query.py +149 -0
  94. package/ftm-map/scripts/requirements.txt +2 -0
  95. package/ftm-map/scripts/setup-hooks.sh +27 -0
  96. package/ftm-map/scripts/setup.sh +45 -0
  97. package/ftm-map/scripts/test_db.py +124 -0
  98. package/ftm-map/scripts/test_parser.py +106 -0
  99. package/ftm-map/scripts/test_query.py +66 -0
  100. package/ftm-map/scripts/tests/fixtures/__init__.py +0 -0
  101. package/ftm-map/scripts/tests/fixtures/sample_project/api.ts +16 -0
  102. package/ftm-map/scripts/tests/fixtures/sample_project/auth.py +15 -0
  103. package/ftm-map/scripts/tests/fixtures/sample_project/utils.js +16 -0
  104. package/ftm-map/scripts/views.py +545 -0
  105. package/ftm-mind/SKILL.md +173 -66
  106. package/ftm-pause/SKILL.md +43 -0
  107. package/ftm-researcher/SKILL.md +275 -0
  108. package/ftm-researcher/evals/agent-diversity.yaml +17 -0
  109. package/ftm-researcher/evals/synthesis-quality.yaml +12 -0
  110. package/ftm-researcher/evals/trigger-accuracy.yaml +39 -0
  111. package/ftm-researcher/references/adaptive-search.md +116 -0
  112. package/ftm-researcher/references/agent-prompts.md +193 -0
  113. package/ftm-researcher/references/council-integration.md +193 -0
  114. package/ftm-researcher/references/output-format.md +203 -0
  115. package/ftm-researcher/references/synthesis-pipeline.md +165 -0
  116. package/ftm-researcher/scripts/score_credibility.py +234 -0
  117. package/ftm-researcher/scripts/validate_research.py +92 -0
  118. package/ftm-resume/SKILL.md +47 -0
  119. package/ftm-retro/SKILL.md +54 -0
  120. package/ftm-routine/SKILL.md +170 -0
  121. package/ftm-state/blackboard/capabilities.json +5 -0
  122. package/ftm-state/blackboard/capabilities.schema.json +27 -0
  123. package/ftm-upgrade/SKILL.md +41 -0
  124. package/ftm-upgrade/scripts/check-version.sh +1 -1
  125. package/ftm-upgrade/scripts/upgrade.sh +1 -1
  126. package/hooks/ftm-blackboard-enforcer.sh +94 -0
  127. package/hooks/ftm-discovery-reminder.sh +90 -0
  128. package/hooks/ftm-drafts-gate.sh +61 -0
  129. package/hooks/ftm-event-logger.mjs +107 -0
  130. package/hooks/ftm-map-autodetect.sh +79 -0
  131. package/hooks/ftm-pending-sync-check.sh +22 -0
  132. package/hooks/ftm-plan-gate.sh +96 -0
  133. package/hooks/ftm-post-commit-trigger.sh +57 -0
  134. package/hooks/settings-template.json +81 -0
  135. package/install.sh +140 -11
  136. 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()
@@ -0,0 +1,2 @@
1
+ tree-sitter-language-pack>=0.4.0
2
+ sqlite-vec>=0.1.6