codecompass-mcp 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codecompass_mcp-2.0.0.dist-info/METADATA +368 -0
- codecompass_mcp-2.0.0.dist-info/RECORD +28 -0
- codecompass_mcp-2.0.0.dist-info/WHEEL +5 -0
- codecompass_mcp-2.0.0.dist-info/entry_points.txt +3 -0
- codecompass_mcp-2.0.0.dist-info/licenses/LICENSE +21 -0
- codecompass_mcp-2.0.0.dist-info/top_level.txt +6 -0
- config.py +16 -0
- graph/__init__.py +0 -0
- graph/cli.py +13 -0
- graph/code_graph_client.py +485 -0
- graph/code_query_cli.py +504 -0
- graph/mcp_server.py +280 -0
- graph/setup.py +255 -0
- ingestion/__init__.py +0 -0
- ingestion/chunker.py +70 -0
- ingestion/code_normalizer.py +158 -0
- ingestion/code_parser.py +709 -0
- ingestion/entity_resolver.py +179 -0
- ingestion/file_watcher.py +165 -0
- ingestion/graph_writer.py +17 -0
- ingestion/hierarchy_builder.py +148 -0
- ingestion/reader_agent.py +135 -0
- main.py +306 -0
- models/__init__.py +0 -0
- models/code_types.py +35 -0
- models/types.py +45 -0
- utils/__init__.py +0 -0
- utils/formatting.py +24 -0
ingestion/code_parser.py
ADDED
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
"""Tree-sitter-based code parser.
|
|
2
|
+
|
|
3
|
+
Parses source files locally (no API calls) into typed CodeTriples.
|
|
4
|
+
Uses direct AST node walking — compatible with any tree-sitter version.
|
|
5
|
+
|
|
6
|
+
Supports: .py, .js, .ts, .tsx, .html, .css, .scss
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Callable
|
|
15
|
+
|
|
16
|
+
from tree_sitter import Language, Parser, Node
|
|
17
|
+
|
|
18
|
+
from models.code_types import CodeTriple
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# Constants
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
SUPPORTED_EXTENSIONS = {".py", ".js", ".ts", ".tsx", ".html", ".css", ".scss"}
|
|
25
|
+
|
|
26
|
+
# Relation types
|
|
27
|
+
DEFINED_IN = "DEFINED_IN"
|
|
28
|
+
CALLS = "CALLS"
|
|
29
|
+
INHERITS = "INHERITS"
|
|
30
|
+
IMPORTS = "IMPORTS"
|
|
31
|
+
HAS_CLASS = "HAS_CLASS"
|
|
32
|
+
POSTS_TO = "POSTS_TO"
|
|
33
|
+
INCLUDES = "INCLUDES"
|
|
34
|
+
STYLES = "STYLES"
|
|
35
|
+
USED_BY = "USED_BY"
|
|
36
|
+
USES_VAR = "USES_VAR"
|
|
37
|
+
REFERENCES = "REFERENCES"
|
|
38
|
+
|
|
39
|
+
# Entity types
|
|
40
|
+
TYPE_FUNCTION = "function"
|
|
41
|
+
TYPE_CLASS = "class"
|
|
42
|
+
TYPE_MODULE = "module"
|
|
43
|
+
TYPE_CSS_SELECTOR = "css_selector"
|
|
44
|
+
TYPE_HTML_ELEMENT = "html_element"
|
|
45
|
+
TYPE_SCSS_MIXIN = "scss_mixin"
|
|
46
|
+
TYPE_SCSS_VARIABLE = "scss_variable"
|
|
47
|
+
TYPE_ENDPOINT = "endpoint"
|
|
48
|
+
TYPE_CSS_CLASS = "css_class"
|
|
49
|
+
TYPE_FILE = "file"
|
|
50
|
+
|
|
51
|
+
# Regex patterns for CSS/SCSS source scanning
|
|
52
|
+
_CSS_VAR_RE = re.compile(r'var\(\s*(--[\w-]+)')
|
|
53
|
+
_SCSS_IMPORT_RE = re.compile(r'@(?:import|use|forward)\s+["\']([^"\']+)["\']', re.MULTILINE)
|
|
54
|
+
|
|
55
|
+
# Regex patterns for Lit css`...` tagged template literals in .styles.ts files
|
|
56
|
+
_LIT_CSS_TEMPLATE_RE = re.compile(r'css`(.*?)`', re.DOTALL)
|
|
57
|
+
_TEMPLATE_EXPR_RE = re.compile(r'\$\{[^}]*\}') # strips ${...} interpolations
|
|
58
|
+
_CSS_CUSTOM_PROP_RE = re.compile(r'(--[\w-]+)\s*:') # CSS custom property declarations
|
|
59
|
+
|
|
60
|
+
# Built-in names that add no signal as CALLS targets
|
|
61
|
+
_NOISE_CALLEES = {
|
|
62
|
+
"print", "len", "range", "str", "int", "float", "bool", "list", "dict",
|
|
63
|
+
"set", "tuple", "super", "type", "isinstance", "hasattr", "getattr",
|
|
64
|
+
"setattr", "open", "zip", "map", "filter", "enumerate", "sorted",
|
|
65
|
+
"reversed", "min", "max", "sum", "any", "all", "repr", "format",
|
|
66
|
+
"append", "extend", "update", "items", "keys", "values", "get",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# Language loader helpers (lazy — only import what's installed)
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
def _make_parser(language_callable) -> tuple[Parser, Language]:
|
|
75
|
+
lang = Language(language_callable())
|
|
76
|
+
return Parser(lang), lang
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _load_python_parser() -> tuple[Parser, Language]:
|
|
80
|
+
import tree_sitter_python as tsp
|
|
81
|
+
return _make_parser(tsp.language)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _load_javascript_parser() -> tuple[Parser, Language]:
|
|
85
|
+
import tree_sitter_javascript as tsjs
|
|
86
|
+
return _make_parser(tsjs.language)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _load_typescript_parser() -> tuple[Parser, Language]:
|
|
90
|
+
import tree_sitter_typescript as tsts
|
|
91
|
+
return _make_parser(tsts.language_typescript)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _load_tsx_parser() -> tuple[Parser, Language]:
|
|
95
|
+
import tree_sitter_typescript as tsts
|
|
96
|
+
return _make_parser(tsts.language_tsx)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _load_html_parser() -> tuple[Parser, Language]:
|
|
100
|
+
import tree_sitter_html as tshtml
|
|
101
|
+
return _make_parser(tshtml.language)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _load_css_parser() -> tuple[Parser, Language]:
|
|
105
|
+
import tree_sitter_css as tscss
|
|
106
|
+
return _make_parser(tscss.language)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
_PARSER_LOADERS: dict[str, Callable[[], tuple[Parser, Language]]] = {
|
|
110
|
+
".py": _load_python_parser,
|
|
111
|
+
".js": _load_javascript_parser,
|
|
112
|
+
".ts": _load_typescript_parser,
|
|
113
|
+
".tsx": _load_tsx_parser,
|
|
114
|
+
".html": _load_html_parser,
|
|
115
|
+
".css": _load_css_parser,
|
|
116
|
+
".scss": _load_css_parser,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# Generic AST walker
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
def _walk(node: Node) -> list[Node]:
|
|
125
|
+
"""Yield node and all its descendants depth-first."""
|
|
126
|
+
stack = [node]
|
|
127
|
+
result = []
|
|
128
|
+
while stack:
|
|
129
|
+
current = stack.pop()
|
|
130
|
+
result.append(current)
|
|
131
|
+
stack.extend(reversed(current.children))
|
|
132
|
+
return result
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _text(node: Node) -> str:
|
|
136
|
+
return node.text.decode("utf-8", errors="replace") if node.text else ""
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _line(node: Node) -> int:
|
|
140
|
+
return node.start_point[0] + 1 # tree-sitter uses 0-based rows
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _child_of_type(node: Node, type_name: str) -> Node | None:
|
|
144
|
+
for child in node.children:
|
|
145
|
+
if child.type == type_name:
|
|
146
|
+
return child
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _children_of_type(node: Node, type_name: str) -> list[Node]:
|
|
151
|
+
return [c for c in node.children if c.type == type_name]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
# Python extraction
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
def _extract_python(root: Node, source: bytes, file_path: str) -> list[CodeTriple]:
|
|
159
|
+
module_name = _module_name_from_path(file_path)
|
|
160
|
+
triples: list[CodeTriple] = []
|
|
161
|
+
|
|
162
|
+
for node in _walk(root):
|
|
163
|
+
match node.type:
|
|
164
|
+
case "function_definition":
|
|
165
|
+
name_node = _child_of_type(node, "identifier")
|
|
166
|
+
if name_node:
|
|
167
|
+
triples.append(CodeTriple(
|
|
168
|
+
from_entity=_text(name_node),
|
|
169
|
+
from_type=TYPE_FUNCTION,
|
|
170
|
+
relation_type=DEFINED_IN,
|
|
171
|
+
to_entity=module_name,
|
|
172
|
+
to_type=TYPE_MODULE,
|
|
173
|
+
source_file=file_path,
|
|
174
|
+
line_number=_line(name_node),
|
|
175
|
+
))
|
|
176
|
+
|
|
177
|
+
case "class_definition":
|
|
178
|
+
name_node = _child_of_type(node, "identifier")
|
|
179
|
+
if not name_node:
|
|
180
|
+
continue
|
|
181
|
+
class_name = _text(name_node)
|
|
182
|
+
|
|
183
|
+
# Extract base classes from argument_list
|
|
184
|
+
arg_list = _child_of_type(node, "argument_list")
|
|
185
|
+
if arg_list:
|
|
186
|
+
for base in _children_of_type(arg_list, "identifier"):
|
|
187
|
+
triples.append(CodeTriple(
|
|
188
|
+
from_entity=class_name,
|
|
189
|
+
from_type=TYPE_CLASS,
|
|
190
|
+
relation_type=INHERITS,
|
|
191
|
+
to_entity=_text(base),
|
|
192
|
+
to_type=TYPE_CLASS,
|
|
193
|
+
source_file=file_path,
|
|
194
|
+
line_number=_line(name_node),
|
|
195
|
+
))
|
|
196
|
+
|
|
197
|
+
case "import_statement":
|
|
198
|
+
for dotted in _children_of_type(node, "dotted_name"):
|
|
199
|
+
triples.append(CodeTriple(
|
|
200
|
+
from_entity=module_name,
|
|
201
|
+
from_type=TYPE_MODULE,
|
|
202
|
+
relation_type=IMPORTS,
|
|
203
|
+
to_entity=_text(dotted),
|
|
204
|
+
to_type=TYPE_MODULE,
|
|
205
|
+
source_file=file_path,
|
|
206
|
+
line_number=_line(dotted),
|
|
207
|
+
))
|
|
208
|
+
for alias in _children_of_type(node, "aliased_import"):
|
|
209
|
+
dotted = _child_of_type(alias, "dotted_name")
|
|
210
|
+
if dotted:
|
|
211
|
+
triples.append(CodeTriple(
|
|
212
|
+
from_entity=module_name,
|
|
213
|
+
from_type=TYPE_MODULE,
|
|
214
|
+
relation_type=IMPORTS,
|
|
215
|
+
to_entity=_text(dotted),
|
|
216
|
+
to_type=TYPE_MODULE,
|
|
217
|
+
source_file=file_path,
|
|
218
|
+
line_number=_line(dotted),
|
|
219
|
+
))
|
|
220
|
+
|
|
221
|
+
case "import_from_statement":
|
|
222
|
+
module_node = _child_of_type(node, "dotted_name")
|
|
223
|
+
if module_node:
|
|
224
|
+
triples.append(CodeTriple(
|
|
225
|
+
from_entity=module_name,
|
|
226
|
+
from_type=TYPE_MODULE,
|
|
227
|
+
relation_type=IMPORTS,
|
|
228
|
+
to_entity=_text(module_node),
|
|
229
|
+
to_type=TYPE_MODULE,
|
|
230
|
+
source_file=file_path,
|
|
231
|
+
line_number=_line(module_node),
|
|
232
|
+
))
|
|
233
|
+
|
|
234
|
+
case "call":
|
|
235
|
+
callee = _extract_python_callee(node)
|
|
236
|
+
if callee and _is_meaningful_callee(callee):
|
|
237
|
+
triples.append(CodeTriple(
|
|
238
|
+
from_entity=module_name,
|
|
239
|
+
from_type=TYPE_MODULE,
|
|
240
|
+
relation_type=CALLS,
|
|
241
|
+
to_entity=callee,
|
|
242
|
+
to_type=TYPE_FUNCTION,
|
|
243
|
+
source_file=file_path,
|
|
244
|
+
line_number=_line(node),
|
|
245
|
+
))
|
|
246
|
+
|
|
247
|
+
return triples
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _extract_python_callee(call_node: Node) -> str | None:
|
|
251
|
+
"""Extract the callee name from a `call` node."""
|
|
252
|
+
# First child is the function expression
|
|
253
|
+
if not call_node.children:
|
|
254
|
+
return None
|
|
255
|
+
fn_node = call_node.children[0]
|
|
256
|
+
if fn_node.type == "identifier":
|
|
257
|
+
return _text(fn_node)
|
|
258
|
+
if fn_node.type == "attribute":
|
|
259
|
+
attr = _child_of_type(fn_node, "identifier")
|
|
260
|
+
return _text(attr) if attr else None
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# ---------------------------------------------------------------------------
|
|
265
|
+
# JavaScript / TypeScript extraction
|
|
266
|
+
# ---------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
def _extract_javascript(root: Node, source: bytes, file_path: str) -> list[CodeTriple]:
|
|
269
|
+
module_name = _module_name_from_path(file_path)
|
|
270
|
+
triples: list[CodeTriple] = []
|
|
271
|
+
|
|
272
|
+
for node in _walk(root):
|
|
273
|
+
match node.type:
|
|
274
|
+
case "function_declaration" | "function_expression":
|
|
275
|
+
name_node = _child_of_type(node, "identifier")
|
|
276
|
+
if name_node:
|
|
277
|
+
triples.append(CodeTriple(
|
|
278
|
+
from_entity=_text(name_node),
|
|
279
|
+
from_type=TYPE_FUNCTION,
|
|
280
|
+
relation_type=DEFINED_IN,
|
|
281
|
+
to_entity=module_name,
|
|
282
|
+
to_type=TYPE_MODULE,
|
|
283
|
+
source_file=file_path,
|
|
284
|
+
line_number=_line(name_node),
|
|
285
|
+
))
|
|
286
|
+
|
|
287
|
+
case "method_definition":
|
|
288
|
+
name_node = _child_of_type(node, "property_identifier")
|
|
289
|
+
if name_node:
|
|
290
|
+
triples.append(CodeTriple(
|
|
291
|
+
from_entity=_text(name_node),
|
|
292
|
+
from_type=TYPE_FUNCTION,
|
|
293
|
+
relation_type=DEFINED_IN,
|
|
294
|
+
to_entity=module_name,
|
|
295
|
+
to_type=TYPE_MODULE,
|
|
296
|
+
source_file=file_path,
|
|
297
|
+
line_number=_line(name_node),
|
|
298
|
+
))
|
|
299
|
+
|
|
300
|
+
case "variable_declarator":
|
|
301
|
+
# Capture: const foo = () => ...
|
|
302
|
+
value = None
|
|
303
|
+
for child in node.children:
|
|
304
|
+
if child.type in ("arrow_function", "function_expression"):
|
|
305
|
+
value = child
|
|
306
|
+
break
|
|
307
|
+
if value:
|
|
308
|
+
name_node = _child_of_type(node, "identifier")
|
|
309
|
+
if name_node:
|
|
310
|
+
triples.append(CodeTriple(
|
|
311
|
+
from_entity=_text(name_node),
|
|
312
|
+
from_type=TYPE_FUNCTION,
|
|
313
|
+
relation_type=DEFINED_IN,
|
|
314
|
+
to_entity=module_name,
|
|
315
|
+
to_type=TYPE_MODULE,
|
|
316
|
+
source_file=file_path,
|
|
317
|
+
line_number=_line(name_node),
|
|
318
|
+
))
|
|
319
|
+
|
|
320
|
+
case "import_statement":
|
|
321
|
+
source_node = _child_of_type(node, "string")
|
|
322
|
+
if source_node:
|
|
323
|
+
# string → string_fragment (inner text without quotes)
|
|
324
|
+
frag = _child_of_type(source_node, "string_fragment")
|
|
325
|
+
module_text = _text(frag) if frag else _text(source_node).strip("'\"`")
|
|
326
|
+
triples.append(CodeTriple(
|
|
327
|
+
from_entity=module_name,
|
|
328
|
+
from_type=TYPE_MODULE,
|
|
329
|
+
relation_type=IMPORTS,
|
|
330
|
+
to_entity=module_text,
|
|
331
|
+
to_type=TYPE_MODULE,
|
|
332
|
+
source_file=file_path,
|
|
333
|
+
line_number=_line(source_node),
|
|
334
|
+
))
|
|
335
|
+
|
|
336
|
+
case "call_expression":
|
|
337
|
+
callee = _extract_js_callee(node)
|
|
338
|
+
if callee and _is_meaningful_callee(callee):
|
|
339
|
+
triples.append(CodeTriple(
|
|
340
|
+
from_entity=module_name,
|
|
341
|
+
from_type=TYPE_MODULE,
|
|
342
|
+
relation_type=CALLS,
|
|
343
|
+
to_entity=callee,
|
|
344
|
+
to_type=TYPE_FUNCTION,
|
|
345
|
+
source_file=file_path,
|
|
346
|
+
line_number=_line(node),
|
|
347
|
+
))
|
|
348
|
+
|
|
349
|
+
return triples
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _extract_js_callee(call_node: Node) -> str | None:
|
|
353
|
+
if not call_node.children:
|
|
354
|
+
return None
|
|
355
|
+
fn_node = call_node.children[0]
|
|
356
|
+
if fn_node.type == "identifier":
|
|
357
|
+
return _text(fn_node)
|
|
358
|
+
if fn_node.type == "member_expression":
|
|
359
|
+
prop = _child_of_type(fn_node, "property_identifier")
|
|
360
|
+
return _text(prop) if prop else None
|
|
361
|
+
return None
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
# ---------------------------------------------------------------------------
|
|
365
|
+
# HTML extraction
|
|
366
|
+
# ---------------------------------------------------------------------------
|
|
367
|
+
|
|
368
|
+
def _extract_html(root: Node, source: bytes, file_path: str) -> list[CodeTriple]:
|
|
369
|
+
component_name = Path(file_path).stem
|
|
370
|
+
triples: list[CodeTriple] = []
|
|
371
|
+
seen_tags: set[str] = set()
|
|
372
|
+
|
|
373
|
+
for node in _walk(root):
|
|
374
|
+
# Index custom element tag references (<a-button>, <tm-icon>, etc.)
|
|
375
|
+
# Custom elements always contain a hyphen per the HTML spec.
|
|
376
|
+
if node.type == "start_tag":
|
|
377
|
+
tag_node = _child_of_type(node, "tag_name")
|
|
378
|
+
if tag_node:
|
|
379
|
+
tag_name = _text(tag_node)
|
|
380
|
+
if "-" in tag_name and tag_name not in seen_tags:
|
|
381
|
+
seen_tags.add(tag_name)
|
|
382
|
+
triples.append(CodeTriple(
|
|
383
|
+
from_entity=component_name,
|
|
384
|
+
from_type=TYPE_HTML_ELEMENT,
|
|
385
|
+
relation_type=REFERENCES,
|
|
386
|
+
to_entity=tag_name,
|
|
387
|
+
to_type=TYPE_HTML_ELEMENT,
|
|
388
|
+
source_file=file_path,
|
|
389
|
+
line_number=_line(tag_node),
|
|
390
|
+
))
|
|
391
|
+
|
|
392
|
+
if node.type != "attribute":
|
|
393
|
+
continue
|
|
394
|
+
|
|
395
|
+
name_node = _child_of_type(node, "attribute_name")
|
|
396
|
+
value_node = _child_of_type(node, "quoted_attribute_value")
|
|
397
|
+
if not name_node or not value_node:
|
|
398
|
+
continue
|
|
399
|
+
|
|
400
|
+
attr_name = _text(name_node).lower()
|
|
401
|
+
# quoted_attribute_value → attribute_value (inner text)
|
|
402
|
+
inner = _child_of_type(value_node, "attribute_value")
|
|
403
|
+
attr_value = _text(inner) if inner else _text(value_node).strip("\"'")
|
|
404
|
+
|
|
405
|
+
if attr_name == "class":
|
|
406
|
+
for css_class in attr_value.split():
|
|
407
|
+
triples.append(CodeTriple(
|
|
408
|
+
from_entity=component_name,
|
|
409
|
+
from_type=TYPE_HTML_ELEMENT,
|
|
410
|
+
relation_type=HAS_CLASS,
|
|
411
|
+
to_entity=css_class,
|
|
412
|
+
to_type=TYPE_CSS_CLASS,
|
|
413
|
+
source_file=file_path,
|
|
414
|
+
line_number=_line(name_node),
|
|
415
|
+
))
|
|
416
|
+
|
|
417
|
+
elif attr_name == "action":
|
|
418
|
+
triples.append(CodeTriple(
|
|
419
|
+
from_entity=component_name,
|
|
420
|
+
from_type=TYPE_HTML_ELEMENT,
|
|
421
|
+
relation_type=POSTS_TO,
|
|
422
|
+
to_entity=attr_value,
|
|
423
|
+
to_type=TYPE_ENDPOINT,
|
|
424
|
+
source_file=file_path,
|
|
425
|
+
line_number=_line(name_node),
|
|
426
|
+
))
|
|
427
|
+
|
|
428
|
+
elif attr_name == "src" and attr_value.endswith((".js", ".ts")):
|
|
429
|
+
triples.append(CodeTriple(
|
|
430
|
+
from_entity=component_name,
|
|
431
|
+
from_type=TYPE_HTML_ELEMENT,
|
|
432
|
+
relation_type=INCLUDES,
|
|
433
|
+
to_entity=attr_value,
|
|
434
|
+
to_type=TYPE_MODULE,
|
|
435
|
+
source_file=file_path,
|
|
436
|
+
line_number=_line(name_node),
|
|
437
|
+
))
|
|
438
|
+
|
|
439
|
+
return triples
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
# ---------------------------------------------------------------------------
|
|
443
|
+
# CSS / SCSS extraction
|
|
444
|
+
# ---------------------------------------------------------------------------
|
|
445
|
+
|
|
446
|
+
def _extract_css(root: Node, source: bytes, file_path: str) -> list[CodeTriple]:
|
|
447
|
+
file_name = Path(file_path).name
|
|
448
|
+
is_scss = file_path.endswith(".scss")
|
|
449
|
+
module_name = _module_name_from_path(file_path)
|
|
450
|
+
triples: list[CodeTriple] = []
|
|
451
|
+
|
|
452
|
+
for node in _walk(root):
|
|
453
|
+
if node.type == "rule_set":
|
|
454
|
+
selectors_node = _child_of_type(node, "selectors")
|
|
455
|
+
if not selectors_node:
|
|
456
|
+
continue
|
|
457
|
+
selector_text = _text(selectors_node).strip()
|
|
458
|
+
|
|
459
|
+
# Extract bare element names from the selector string
|
|
460
|
+
for part in selector_text.split():
|
|
461
|
+
element = part.strip(",>+~")
|
|
462
|
+
if element and not element.startswith((".", "#", "&", "@", ":", "[")):
|
|
463
|
+
if element.replace("-", "").isalpha():
|
|
464
|
+
triples.append(CodeTriple(
|
|
465
|
+
from_entity=selector_text,
|
|
466
|
+
from_type=TYPE_CSS_SELECTOR,
|
|
467
|
+
relation_type=STYLES,
|
|
468
|
+
to_entity=element,
|
|
469
|
+
to_type=TYPE_HTML_ELEMENT,
|
|
470
|
+
source_file=file_path,
|
|
471
|
+
line_number=_line(selectors_node),
|
|
472
|
+
))
|
|
473
|
+
|
|
474
|
+
elif node.type == "declaration":
|
|
475
|
+
prop_node = _child_of_type(node, "property_name")
|
|
476
|
+
if prop_node:
|
|
477
|
+
prop = _text(prop_node)
|
|
478
|
+
if prop.startswith("--") or (is_scss and prop.startswith("$")):
|
|
479
|
+
triples.append(CodeTriple(
|
|
480
|
+
from_entity=prop,
|
|
481
|
+
from_type=TYPE_SCSS_VARIABLE,
|
|
482
|
+
relation_type=DEFINED_IN,
|
|
483
|
+
to_entity=file_name,
|
|
484
|
+
to_type=TYPE_FILE,
|
|
485
|
+
source_file=file_path,
|
|
486
|
+
line_number=_line(prop_node),
|
|
487
|
+
))
|
|
488
|
+
|
|
489
|
+
# Scan source text for var(--foo) usages — emit one USES_VAR triple per unique variable.
|
|
490
|
+
source_text = source.decode("utf-8", errors="replace")
|
|
491
|
+
seen_vars: set[str] = set()
|
|
492
|
+
for match in _CSS_VAR_RE.finditer(source_text):
|
|
493
|
+
var_name = match.group(1)
|
|
494
|
+
if var_name in seen_vars:
|
|
495
|
+
continue
|
|
496
|
+
seen_vars.add(var_name)
|
|
497
|
+
line = source_text[: match.start()].count("\n") + 1
|
|
498
|
+
triples.append(CodeTriple(
|
|
499
|
+
from_entity=module_name,
|
|
500
|
+
from_type=TYPE_MODULE,
|
|
501
|
+
relation_type=USES_VAR,
|
|
502
|
+
to_entity=var_name,
|
|
503
|
+
to_type=TYPE_SCSS_VARIABLE,
|
|
504
|
+
source_file=file_path,
|
|
505
|
+
line_number=line,
|
|
506
|
+
))
|
|
507
|
+
|
|
508
|
+
# For SCSS files, index @import / @use / @forward as IMPORTS edges so --deps works.
|
|
509
|
+
if is_scss:
|
|
510
|
+
for match in _SCSS_IMPORT_RE.finditer(source_text):
|
|
511
|
+
imported = match.group(1)
|
|
512
|
+
line = source_text[: match.start()].count("\n") + 1
|
|
513
|
+
triples.append(CodeTriple(
|
|
514
|
+
from_entity=module_name,
|
|
515
|
+
from_type=TYPE_MODULE,
|
|
516
|
+
relation_type=IMPORTS,
|
|
517
|
+
to_entity=imported,
|
|
518
|
+
to_type=TYPE_MODULE,
|
|
519
|
+
source_file=file_path,
|
|
520
|
+
line_number=line,
|
|
521
|
+
))
|
|
522
|
+
|
|
523
|
+
return triples
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
# ---------------------------------------------------------------------------
|
|
527
|
+
# Lit CSS template literal extraction (.styles.ts)
|
|
528
|
+
# ---------------------------------------------------------------------------
|
|
529
|
+
|
|
530
|
+
def _extract_lit_css_tokens(source_text: str, file_path: str) -> list[CodeTriple]:
|
|
531
|
+
"""Extract USES_VAR and DEFINED_IN triples from Lit css`...` template literals.
|
|
532
|
+
|
|
533
|
+
Finds every css`...` block in the source, strips ${...} interpolations so
|
|
534
|
+
dynamic expressions don't cause false positives or crashes, then scans the
|
|
535
|
+
remaining CSS text with the same regexes used for plain CSS/SCSS files.
|
|
536
|
+
|
|
537
|
+
Called as a secondary pass on top of the normal TS extraction — existing
|
|
538
|
+
CALLS/IMPORTS/INHERITS triples are unaffected.
|
|
539
|
+
"""
|
|
540
|
+
module_name = _module_name_from_path(file_path)
|
|
541
|
+
file_name = Path(file_path).name
|
|
542
|
+
triples: list[CodeTriple] = []
|
|
543
|
+
seen_uses: set[str] = set()
|
|
544
|
+
seen_defs: set[str] = set()
|
|
545
|
+
|
|
546
|
+
for block_match in _LIT_CSS_TEMPLATE_RE.finditer(source_text):
|
|
547
|
+
block_content = block_match.group(1)
|
|
548
|
+
block_start = block_match.start(1)
|
|
549
|
+
|
|
550
|
+
# Strip ${...} interpolations — leave surrounding CSS text intact.
|
|
551
|
+
css_text = _TEMPLATE_EXPR_RE.sub("", block_content)
|
|
552
|
+
|
|
553
|
+
# var(--foo) usages → USES_VAR (one triple per unique variable per file)
|
|
554
|
+
for m in _CSS_VAR_RE.finditer(css_text):
|
|
555
|
+
var_name = m.group(1)
|
|
556
|
+
if var_name in seen_uses:
|
|
557
|
+
continue
|
|
558
|
+
seen_uses.add(var_name)
|
|
559
|
+
abs_pos = block_start + m.start()
|
|
560
|
+
line = source_text[:abs_pos].count("\n") + 1
|
|
561
|
+
triples.append(CodeTriple(
|
|
562
|
+
from_entity=module_name,
|
|
563
|
+
from_type=TYPE_MODULE,
|
|
564
|
+
relation_type=USES_VAR,
|
|
565
|
+
to_entity=var_name,
|
|
566
|
+
to_type=TYPE_SCSS_VARIABLE,
|
|
567
|
+
source_file=file_path,
|
|
568
|
+
line_number=line,
|
|
569
|
+
))
|
|
570
|
+
|
|
571
|
+
# --foo: value declarations → DEFINED_IN (one triple per unique prop per file)
|
|
572
|
+
for m in _CSS_CUSTOM_PROP_RE.finditer(css_text):
|
|
573
|
+
prop_name = m.group(1)
|
|
574
|
+
if prop_name in seen_defs:
|
|
575
|
+
continue
|
|
576
|
+
seen_defs.add(prop_name)
|
|
577
|
+
abs_pos = block_start + m.start()
|
|
578
|
+
line = source_text[:abs_pos].count("\n") + 1
|
|
579
|
+
triples.append(CodeTriple(
|
|
580
|
+
from_entity=prop_name,
|
|
581
|
+
from_type=TYPE_SCSS_VARIABLE,
|
|
582
|
+
relation_type=DEFINED_IN,
|
|
583
|
+
to_entity=file_name,
|
|
584
|
+
to_type=TYPE_FILE,
|
|
585
|
+
source_file=file_path,
|
|
586
|
+
line_number=line,
|
|
587
|
+
))
|
|
588
|
+
|
|
589
|
+
return triples
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
# ---------------------------------------------------------------------------
|
|
593
|
+
# Dispatch table
|
|
594
|
+
# ---------------------------------------------------------------------------
|
|
595
|
+
|
|
596
|
+
_EXTRACTORS: dict[str, Callable] = {
|
|
597
|
+
".py": _extract_python,
|
|
598
|
+
".js": _extract_javascript,
|
|
599
|
+
".ts": _extract_javascript,
|
|
600
|
+
".tsx": _extract_javascript,
|
|
601
|
+
".html": _extract_html,
|
|
602
|
+
".css": _extract_css,
|
|
603
|
+
".scss": _extract_css,
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
# ---------------------------------------------------------------------------
|
|
608
|
+
# Public API
|
|
609
|
+
# ---------------------------------------------------------------------------
|
|
610
|
+
|
|
611
|
+
def parse_file(file_path: str, project_root: str) -> list[CodeTriple]:
|
|
612
|
+
"""Parse a single source file into CodeTriples.
|
|
613
|
+
|
|
614
|
+
Returns an empty list if the extension is unsupported or parsing fails.
|
|
615
|
+
Never raises — a single bad file does not abort full-repo ingestion.
|
|
616
|
+
"""
|
|
617
|
+
ext = Path(file_path).suffix.lower()
|
|
618
|
+
if ext not in SUPPORTED_EXTENSIONS:
|
|
619
|
+
return []
|
|
620
|
+
|
|
621
|
+
loader = _PARSER_LOADERS.get(ext)
|
|
622
|
+
extractor = _EXTRACTORS.get(ext)
|
|
623
|
+
if loader is None or extractor is None:
|
|
624
|
+
return []
|
|
625
|
+
|
|
626
|
+
try:
|
|
627
|
+
parser, _lang = loader()
|
|
628
|
+
source = Path(file_path).read_bytes()
|
|
629
|
+
tree = parser.parse(source)
|
|
630
|
+
relative_path = os.path.relpath(file_path, project_root)
|
|
631
|
+
triples = extractor(tree.root_node, source, relative_path)
|
|
632
|
+
|
|
633
|
+
# Secondary pass: extract CSS tokens from Lit css`...` template literals.
|
|
634
|
+
# Applies to any .styles.ts file — these are invisible to the TS extractor
|
|
635
|
+
# because css`...` content is opaque to tree-sitter's TypeScript grammar.
|
|
636
|
+
if relative_path.endswith(".styles.ts"):
|
|
637
|
+
triples += _extract_lit_css_tokens(
|
|
638
|
+
source.decode("utf-8", errors="replace"), relative_path
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
return triples
|
|
642
|
+
except Exception as exc:
|
|
643
|
+
print(f"[code_parser] skipping {file_path}: {exc}")
|
|
644
|
+
return []
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def parse_directory(
|
|
648
|
+
project_root: str,
|
|
649
|
+
skip_dirs: set[str] | None = None,
|
|
650
|
+
progress: bool = False,
|
|
651
|
+
) -> list[CodeTriple]:
|
|
652
|
+
"""Recursively parse all supported files under project_root.
|
|
653
|
+
|
|
654
|
+
Args:
|
|
655
|
+
project_root: Absolute path to the repo root.
|
|
656
|
+
skip_dirs: Directory names to skip (merged with defaults).
|
|
657
|
+
progress: Show a tqdm progress bar while parsing.
|
|
658
|
+
"""
|
|
659
|
+
default_skip = {
|
|
660
|
+
".git", "node_modules", "__pycache__", ".venv", "venv",
|
|
661
|
+
"dist", "build", ".mypy_cache", ".pytest_cache",
|
|
662
|
+
"coverage", "tmp", "cache", ".nx", "lcov-report",
|
|
663
|
+
}
|
|
664
|
+
ignored = (skip_dirs or set()) | default_skip
|
|
665
|
+
|
|
666
|
+
# Collect all files first so tqdm can show a total
|
|
667
|
+
source_files: list[str] = []
|
|
668
|
+
for dirpath, dirnames, filenames in os.walk(project_root):
|
|
669
|
+
dirnames[:] = [d for d in dirnames if d not in ignored]
|
|
670
|
+
for filename in filenames:
|
|
671
|
+
if Path(filename).suffix.lower() in SUPPORTED_EXTENSIONS:
|
|
672
|
+
source_files.append(os.path.join(dirpath, filename))
|
|
673
|
+
|
|
674
|
+
if progress:
|
|
675
|
+
try:
|
|
676
|
+
from tqdm import tqdm
|
|
677
|
+
source_files_iter = tqdm(source_files, desc="Parsing files", unit="file")
|
|
678
|
+
except ImportError:
|
|
679
|
+
source_files_iter = source_files
|
|
680
|
+
else:
|
|
681
|
+
source_files_iter = source_files
|
|
682
|
+
|
|
683
|
+
triples: list[CodeTriple] = []
|
|
684
|
+
for full_path in source_files_iter:
|
|
685
|
+
triples.extend(parse_file(full_path, project_root))
|
|
686
|
+
|
|
687
|
+
return triples
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
# ---------------------------------------------------------------------------
|
|
691
|
+
# Internal helpers
|
|
692
|
+
# ---------------------------------------------------------------------------
|
|
693
|
+
|
|
694
|
+
def _is_meaningful_callee(name: str) -> bool:
|
|
695
|
+
"""Return True if name is worth recording as a CALLS target."""
|
|
696
|
+
return (
|
|
697
|
+
name not in _NOISE_CALLEES
|
|
698
|
+
and len(name) > 1
|
|
699
|
+
and not name.startswith("_")
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _module_name_from_path(file_path: str) -> str:
|
|
704
|
+
"""Convert a relative file path to a dotted module name.
|
|
705
|
+
|
|
706
|
+
'src/auth/login.py' → 'src.auth.login'
|
|
707
|
+
"""
|
|
708
|
+
without_ext = os.path.splitext(file_path)[0]
|
|
709
|
+
return without_ext.replace(os.sep, ".").replace("/", ".")
|