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.
@@ -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("/", ".")