codecompass-mcp 2.2.0__tar.gz → 2.4.0__tar.gz

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 (39) hide show
  1. {codecompass_mcp-2.2.0 → codecompass_mcp-2.4.0}/PKG-INFO +3 -3
  2. {codecompass_mcp-2.2.0 → codecompass_mcp-2.4.0}/README.md +2 -2
  3. {codecompass_mcp-2.2.0 → codecompass_mcp-2.4.0}/codecompass_mcp.egg-info/PKG-INFO +3 -3
  4. {codecompass_mcp-2.2.0 → codecompass_mcp-2.4.0}/codecompass_mcp.egg-info/SOURCES.txt +1 -0
  5. {codecompass_mcp-2.2.0 → codecompass_mcp-2.4.0}/graph/code_graph_client.py +2 -0
  6. {codecompass_mcp-2.2.0 → codecompass_mcp-2.4.0}/graph/code_query_cli.py +5 -0
  7. {codecompass_mcp-2.2.0 → codecompass_mcp-2.4.0}/ingestion/chunker.py +0 -9
  8. {codecompass_mcp-2.2.0 → codecompass_mcp-2.4.0}/ingestion/code_normalizer.py +2 -1
  9. {codecompass_mcp-2.2.0 → codecompass_mcp-2.4.0}/ingestion/code_parser.py +409 -10
  10. {codecompass_mcp-2.2.0 → codecompass_mcp-2.4.0}/ingestion/entity_resolver.py +13 -15
  11. {codecompass_mcp-2.2.0 → codecompass_mcp-2.4.0}/ingestion/reader_agent.py +4 -3
  12. {codecompass_mcp-2.2.0 → codecompass_mcp-2.4.0}/main.py +20 -0
  13. {codecompass_mcp-2.2.0 → codecompass_mcp-2.4.0}/models/code_types.py +2 -1
  14. {codecompass_mcp-2.2.0 → codecompass_mcp-2.4.0}/pyproject.toml +1 -1
  15. codecompass_mcp-2.4.0/tests/test_php_parser.py +217 -0
  16. {codecompass_mcp-2.2.0 → codecompass_mcp-2.4.0}/LICENSE +0 -0
  17. {codecompass_mcp-2.2.0 → codecompass_mcp-2.4.0}/codecompass_mcp.egg-info/dependency_links.txt +0 -0
  18. {codecompass_mcp-2.2.0 → codecompass_mcp-2.4.0}/codecompass_mcp.egg-info/entry_points.txt +0 -0
  19. {codecompass_mcp-2.2.0 → codecompass_mcp-2.4.0}/codecompass_mcp.egg-info/requires.txt +0 -0
  20. {codecompass_mcp-2.2.0 → codecompass_mcp-2.4.0}/codecompass_mcp.egg-info/top_level.txt +0 -0
  21. {codecompass_mcp-2.2.0 → codecompass_mcp-2.4.0}/config.py +0 -0
  22. {codecompass_mcp-2.2.0 → codecompass_mcp-2.4.0}/graph/__init__.py +0 -0
  23. {codecompass_mcp-2.2.0 → codecompass_mcp-2.4.0}/graph/cli.py +0 -0
  24. {codecompass_mcp-2.2.0 → codecompass_mcp-2.4.0}/graph/setup.py +0 -0
  25. {codecompass_mcp-2.2.0 → codecompass_mcp-2.4.0}/ingestion/__init__.py +0 -0
  26. {codecompass_mcp-2.2.0 → codecompass_mcp-2.4.0}/ingestion/file_watcher.py +0 -0
  27. {codecompass_mcp-2.2.0 → codecompass_mcp-2.4.0}/ingestion/graph_writer.py +0 -0
  28. {codecompass_mcp-2.2.0 → codecompass_mcp-2.4.0}/ingestion/hierarchy_builder.py +0 -0
  29. {codecompass_mcp-2.2.0 → codecompass_mcp-2.4.0}/ingestion/source_context.py +0 -0
  30. {codecompass_mcp-2.2.0 → codecompass_mcp-2.4.0}/models/__init__.py +0 -0
  31. {codecompass_mcp-2.2.0 → codecompass_mcp-2.4.0}/models/types.py +0 -0
  32. {codecompass_mcp-2.2.0 → codecompass_mcp-2.4.0}/setup.cfg +0 -0
  33. {codecompass_mcp-2.2.0 → codecompass_mcp-2.4.0}/tests/test_batch_impact.py +0 -0
  34. {codecompass_mcp-2.2.0 → codecompass_mcp-2.4.0}/tests/test_blast_radius.py +0 -0
  35. {codecompass_mcp-2.2.0 → codecompass_mcp-2.4.0}/tests/test_code_parser.py +0 -0
  36. {codecompass_mcp-2.2.0 → codecompass_mcp-2.4.0}/tests/test_ingestion.py +0 -0
  37. {codecompass_mcp-2.2.0 → codecompass_mcp-2.4.0}/tests/test_lit_css_parser.py +0 -0
  38. {codecompass_mcp-2.2.0 → codecompass_mcp-2.4.0}/utils/__init__.py +0 -0
  39. {codecompass_mcp-2.2.0 → codecompass_mcp-2.4.0}/utils/formatting.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codecompass-mcp
3
- Version: 2.2.0
3
+ Version: 2.4.0
4
4
  Summary: Structural code context for AI coding agents — a local code knowledge graph for blast radius, impact, deps, dead code, and flow tracing
5
5
  Author: CodeCompass contributors
6
6
  License: MIT
@@ -129,10 +129,10 @@ All commands default to the current directory.
129
129
  ### Install
130
130
 
131
131
  ```bash
132
- # From the codecompass directory
133
- pip install -e .
132
+ pip install codecompass-mcp
134
133
  ```
135
134
 
135
+
136
136
  ### Index a project
137
137
 
138
138
  ```bash
@@ -92,10 +92,10 @@ All commands default to the current directory.
92
92
  ### Install
93
93
 
94
94
  ```bash
95
- # From the codecompass directory
96
- pip install -e .
95
+ pip install codecompass-mcp
97
96
  ```
98
97
 
98
+
99
99
  ### Index a project
100
100
 
101
101
  ```bash
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codecompass-mcp
3
- Version: 2.2.0
3
+ Version: 2.4.0
4
4
  Summary: Structural code context for AI coding agents — a local code knowledge graph for blast radius, impact, deps, dead code, and flow tracing
5
5
  Author: CodeCompass contributors
6
6
  License: MIT
@@ -129,10 +129,10 @@ All commands default to the current directory.
129
129
  ### Install
130
130
 
131
131
  ```bash
132
- # From the codecompass directory
133
- pip install -e .
132
+ pip install codecompass-mcp
134
133
  ```
135
134
 
135
+
136
136
  ### Index a project
137
137
 
138
138
  ```bash
@@ -32,5 +32,6 @@ tests/test_blast_radius.py
32
32
  tests/test_code_parser.py
33
33
  tests/test_ingestion.py
34
34
  tests/test_lit_css_parser.py
35
+ tests/test_php_parser.py
35
36
  utils/__init__.py
36
37
  utils/formatting.py
@@ -17,11 +17,13 @@ from models.code_types import CodeTriple, FileNode, FolderNode
17
17
  _EXT_TO_LANGUAGE = {
18
18
  ".py": "python",
19
19
  ".js": "javascript",
20
+ ".jsx": "javascript",
20
21
  ".ts": "typescript",
21
22
  ".tsx": "typescript",
22
23
  ".html": "html",
23
24
  ".css": "css",
24
25
  ".scss": "scss",
26
+ ".php": "php",
25
27
  }
26
28
 
27
29
  _ALLOWED_REL_TYPES = frozenset({
@@ -667,6 +667,11 @@ _KIND_FILL = {
667
667
  "module": "#E8F5E9",
668
668
  "endpoint": "#FCE4EC",
669
669
  "variable": "#F3E5F5",
670
+ "interface": "#FFF8E1",
671
+ "trait": "#E0F7FA",
672
+ "enum": "#EDE7F6",
673
+ "property": "#FBE9E7",
674
+ "constant": "#F1F8E9",
670
675
  }
671
676
 
672
677
 
@@ -1,5 +1,4 @@
1
1
  import re
2
- import PyPDF2
3
2
 
4
3
  # Rough approximation: 1 token ≈ 4 characters (standard for English prose/code).
5
4
  # Used when a tokeniser library is unavailable.
@@ -10,14 +9,6 @@ def _estimate_tokens(text: str) -> int:
10
9
  return max(1, len(text) // _CHARS_PER_TOKEN)
11
10
 
12
11
 
13
- def chunk_pdf(filepath: str, tokens_per_chunk: int = 500, overlap_tokens: int = 50) -> list[str]:
14
- """Split PDF into overlapping token-sized chunks."""
15
- with open(filepath, "rb") as f:
16
- reader = PyPDF2.PdfReader(f)
17
- full_text = " ".join(page.extract_text() or "" for page in reader.pages)
18
- return chunk_text(full_text, tokens_per_chunk=tokens_per_chunk, overlap_tokens=overlap_tokens)
19
-
20
-
21
12
  def chunk_text(text: str, tokens_per_chunk: int = 500, overlap_tokens: int = 50) -> list[str]:
22
13
  """
23
14
  Split text into chunks of approximately `tokens_per_chunk` tokens with
@@ -44,7 +44,8 @@ Your job:
44
44
  POSTS_TO, INCLUDES, USED_BY, OVERRIDES, RAISES, RETURNS_TYPE.
45
45
  3. Correct entity types if obviously wrong.
46
46
  Allowed types: function, class, module, css_selector, html_element,
47
- scss_mixin, scss_variable, endpoint, css_class, file.
47
+ scss_mixin, scss_variable, endpoint, css_class, file, interface, trait,
48
+ enum, property, constant.
48
49
 
49
50
  Return the same JSON array with corrections applied.
50
51
  Do NOT add, remove, or reorder triples.
@@ -3,7 +3,7 @@
3
3
  Parses source files locally (no API calls) into typed CodeTriples.
4
4
  Uses direct AST node walking — compatible with any tree-sitter version.
5
5
 
6
- Supports: .py, .js, .ts, .tsx, .html, .css, .scss
6
+ Supports: .py, .js, .jsx, .ts, .tsx, .html, .css, .scss, .php
7
7
  """
8
8
 
9
9
  from __future__ import annotations
@@ -21,7 +21,7 @@ from models.code_types import CodeTriple
21
21
  # Constants
22
22
  # ---------------------------------------------------------------------------
23
23
 
24
- SUPPORTED_EXTENSIONS = {".py", ".js", ".ts", ".tsx", ".html", ".css", ".scss"}
24
+ SUPPORTED_EXTENSIONS = {".py", ".js", ".jsx", ".ts", ".tsx", ".html", ".css", ".scss", ".php"}
25
25
 
26
26
  # Relation types
27
27
  DEFINED_IN = "DEFINED_IN"
@@ -47,6 +47,23 @@ TYPE_SCSS_VARIABLE = "scss_variable"
47
47
  TYPE_ENDPOINT = "endpoint"
48
48
  TYPE_CSS_CLASS = "css_class"
49
49
  TYPE_FILE = "file"
50
+ TYPE_INTERFACE = "interface"
51
+ TYPE_TRAIT = "trait"
52
+ TYPE_ENUM = "enum"
53
+ TYPE_PROPERTY = "property"
54
+ TYPE_CONSTANT = "constant"
55
+
56
+ # PHP pseudo-class references that never resolve to a real, distinct entity.
57
+ _PHP_SELF_REFERENTIAL = {"self", "static", "parent"}
58
+
59
+ # Container node types that own PHP class-body members (properties, consts,
60
+ # trait uses), mapped to the entity type of the container itself.
61
+ _PHP_CONTAINER_TYPES = {
62
+ "class_declaration": TYPE_CLASS,
63
+ "trait_declaration": TYPE_TRAIT,
64
+ "interface_declaration": TYPE_INTERFACE,
65
+ "enum_declaration": TYPE_ENUM,
66
+ }
50
67
 
51
68
  # Regex patterns for CSS/SCSS source scanning
52
69
  _CSS_VAR_RE = re.compile(r'var\(\s*(--[\w-]+)')
@@ -101,6 +118,11 @@ def _load_html_parser() -> tuple[Parser, Language]:
101
118
  return _make_parser(tshtml.language)
102
119
 
103
120
 
121
+ def _load_php_parser() -> tuple[Parser, Language]:
122
+ import tree_sitter_php as tsphp
123
+ return _make_parser(tsphp.language_php)
124
+
125
+
104
126
  def _load_css_parser() -> tuple[Parser, Language]:
105
127
  import tree_sitter_css as tscss
106
128
  return _make_parser(tscss.language)
@@ -109,11 +131,13 @@ def _load_css_parser() -> tuple[Parser, Language]:
109
131
  _PARSER_LOADERS: dict[str, Callable[[], tuple[Parser, Language]]] = {
110
132
  ".py": _load_python_parser,
111
133
  ".js": _load_javascript_parser,
134
+ ".jsx": _load_javascript_parser,
112
135
  ".ts": _load_typescript_parser,
113
136
  ".tsx": _load_tsx_parser,
114
137
  ".html": _load_html_parser,
115
138
  ".css": _load_css_parser,
116
139
  ".scss": _load_css_parser,
140
+ ".php": _load_php_parser,
117
141
  }
118
142
 
119
143
 
@@ -147,6 +171,15 @@ def _child_of_type(node: Node, type_name: str) -> Node | None:
147
171
  return None
148
172
 
149
173
 
174
+ def _get_node_name(node: Node, types: list[str]) -> str | None:
175
+ """Try multiple type names to find an identifier/name node."""
176
+ for t in types:
177
+ found = _child_of_type(node, t)
178
+ if found:
179
+ return _text(found)
180
+ return None
181
+
182
+
150
183
  def _children_of_type(node: Node, type_name: str) -> list[Node]:
151
184
  return [c for c in node.children if c.type == type_name]
152
185
 
@@ -174,23 +207,23 @@ def _extract_python(root: Node, source: bytes, file_path: str) -> list[CodeTripl
174
207
  for node in _walk(root):
175
208
  match node.type:
176
209
  case "function_definition":
177
- name_node = _child_of_type(node, "identifier")
178
- if name_node:
210
+ name = _get_node_name(node, ["identifier"])
211
+ if name:
179
212
  triples.append(CodeTriple(
180
- from_entity=_text(name_node),
213
+ from_entity=name,
181
214
  from_type=TYPE_FUNCTION,
182
215
  relation_type=DEFINED_IN,
183
216
  to_entity=module_name,
184
217
  to_type=TYPE_MODULE,
185
218
  source_file=file_path,
186
- line_number=_line(name_node),
219
+ line_number=_line(node),
187
220
  ))
188
221
 
189
222
  case "class_definition":
190
- name_node = _child_of_type(node, "identifier")
191
- if not name_node:
223
+ name = _get_node_name(node, ["identifier"])
224
+ if not name:
192
225
  continue
193
- class_name = _text(name_node)
226
+ class_name = name
194
227
 
195
228
  # Extract base classes from argument_list
196
229
  arg_list = _child_of_type(node, "argument_list")
@@ -203,7 +236,7 @@ def _extract_python(root: Node, source: bytes, file_path: str) -> list[CodeTripl
203
236
  to_entity=_text(base),
204
237
  to_type=TYPE_CLASS,
205
238
  source_file=file_path,
206
- line_number=_line(name_node),
239
+ line_number=_line(base),
207
240
  ))
208
241
 
209
242
  case "import_statement":
@@ -281,6 +314,370 @@ def _extract_python_callee(call_node: Node) -> str | None:
281
314
  return None
282
315
 
283
316
 
317
+ # ---------------------------------------------------------------------------
318
+ # PHP extraction
319
+ # ---------------------------------------------------------------------------
320
+ #
321
+ # Node-type names below were verified against tree_sitter_php 0.24.1 by
322
+ # parsing representative snippets and inspecting the resulting AST directly
323
+ # (the grammar ships no node-types.json in the pip package). Names differ
324
+ # substantially from other PHP tree-sitter grammar versions found online —
325
+ # e.g. classes are `class_declaration` (not `class_definition`), function
326
+ # calls are `function_call_expression` (not `function_call`), and name
327
+ # fields are typed `name` (not `identifier`).
328
+
329
+ def _php_name(node: Node) -> str | None:
330
+ """Direct `name` child — used for the declared name of most PHP nodes."""
331
+ found = _child_of_type(node, "name")
332
+ return _text(found) if found else None
333
+
334
+
335
+ def _php_basename(node: Node) -> str:
336
+ """Strip a namespace prefix: `App\\Models\\Foo` -> `Foo`. Also handles a
337
+ bare `name` node (no backslash) and a leading-backslash FQN (`\\Foo`)."""
338
+ return _text(node).rsplit("\\", 1)[-1]
339
+
340
+
341
+ def _php_dotted(node: Node) -> str:
342
+ """Namespace-qualified name -> dotted form, matching the module-name
343
+ convention used elsewhere (`App\\Models\\Foo` -> `App.Models.Foo`)."""
344
+ return _text(node).lstrip("\\").replace("\\", ".")
345
+
346
+
347
+ def _php_var_name(node: Node) -> str:
348
+ """`variable_name` node text (`$name`) -> bare name (`name`)."""
349
+ return _text(node).lstrip("$")
350
+
351
+
352
+ def _php_extends_targets(clause: Node) -> list[Node]:
353
+ """`name`/`qualified_name` children of a base_clause or interface clause.
354
+
355
+ Handles both single-target class `extends` and multi-target interface
356
+ `extends`/`implements` (comma-separated siblings, not a nested list).
357
+ """
358
+ return [c for c in clause.children if c.type in ("name", "qualified_name")]
359
+
360
+
361
+ def _enclosing_php_container(node: Node) -> tuple[str, str] | None:
362
+ """Walk up to the nearest enclosing class/trait/interface/enum.
363
+
364
+ Used to attach properties, constants, and trait-uses to their owner.
365
+ """
366
+ current = node.parent
367
+ while current:
368
+ entity_type = _PHP_CONTAINER_TYPES.get(current.type)
369
+ if entity_type:
370
+ name = _php_name(current)
371
+ if name:
372
+ return name, entity_type
373
+ current = current.parent
374
+ return None
375
+
376
+
377
+ def _enclosing_php_scope(node: Node) -> tuple[str, str] | None:
378
+ """Walk up from node to find the nearest enclosing function/method/closure.
379
+
380
+ Closures (anonymous_function, arrow_function) only count as a scope if
381
+ assigned to a variable (`$fn = function() {...}`), whose name is then
382
+ used as the caller entity; unnamed inline closures are transparent and
383
+ resolution continues further up the tree.
384
+ """
385
+ current = node.parent
386
+ while current:
387
+ if current.type in ("function_definition", "method_declaration"):
388
+ name = _php_name(current)
389
+ if name:
390
+ return name, TYPE_FUNCTION
391
+ elif current.type in ("anonymous_function", "arrow_function"):
392
+ parent = current.parent
393
+ if parent and parent.type == "assignment_expression" and parent.children:
394
+ lhs = parent.children[0]
395
+ if lhs.type == "variable_name":
396
+ return _php_var_name(lhs), TYPE_FUNCTION
397
+ current = current.parent
398
+ return None
399
+
400
+
401
+ def _extract_php_namespace_use(node: Node) -> list[tuple[str, Node]]:
402
+ """Resolve every imported target of a `use ...;` statement to a dotted
403
+ name plus the node to attribute the line number to.
404
+
405
+ Handles plain (`use App\\Foo;`), aliased (`use App\\Foo as F;`), grouped
406
+ (`use App\\{Bar, Baz as Z};`), and `use function`/`use const` forms.
407
+ """
408
+ results: list[tuple[str, Node]] = []
409
+ group = _child_of_type(node, "namespace_use_group")
410
+ if group is not None:
411
+ prefix_node = _child_of_type(node, "namespace_name")
412
+ prefix = _text(prefix_node) if prefix_node else ""
413
+ for clause in _children_of_type(group, "namespace_use_clause"):
414
+ target = _child_of_type(clause, "qualified_name") or _child_of_type(clause, "name")
415
+ if target:
416
+ full = f"{prefix}\\{_text(target)}" if prefix else _text(target)
417
+ results.append((full.lstrip("\\").replace("\\", "."), target))
418
+ else:
419
+ for clause in _children_of_type(node, "namespace_use_clause"):
420
+ target = _child_of_type(clause, "qualified_name") or _child_of_type(clause, "name")
421
+ if target:
422
+ results.append((_php_dotted(target), target))
423
+ return results
424
+
425
+
426
+ def _php_include_target(node: Node) -> Node | None:
427
+ """The literal string argument of a require/include expression, if any.
428
+
429
+ Dynamic paths (`require __DIR__ . '/x.php';`) have no plain `string`
430
+ child and are skipped — no static target to record.
431
+ """
432
+ return _child_of_type(node, "string")
433
+
434
+
435
+ def _php_string_text(string_node: Node) -> str:
436
+ frag = _child_of_type(string_node, "string_content")
437
+ return _text(frag) if frag else _text(string_node).strip("'\"")
438
+
439
+
440
+ def _extract_php(root: Node, source: bytes, file_path: str) -> list[CodeTriple]:
441
+ module_name = _module_name_from_path(file_path)
442
+ triples: list[CodeTriple] = []
443
+
444
+ def defined_in(name: str, entity_type: str, line: int) -> None:
445
+ triples.append(CodeTriple(
446
+ from_entity=name,
447
+ from_type=entity_type,
448
+ relation_type=DEFINED_IN,
449
+ to_entity=module_name,
450
+ to_type=TYPE_MODULE,
451
+ source_file=file_path,
452
+ line_number=line,
453
+ ))
454
+
455
+ def inherits(from_name: str, from_type: str, to_node: Node, to_type: str) -> None:
456
+ basename = _php_basename(to_node)
457
+ if basename and basename.lower() not in _PHP_SELF_REFERENTIAL:
458
+ triples.append(CodeTriple(
459
+ from_entity=from_name,
460
+ from_type=from_type,
461
+ relation_type=INHERITS,
462
+ to_entity=basename,
463
+ to_type=to_type,
464
+ source_file=file_path,
465
+ line_number=_line(to_node),
466
+ ))
467
+
468
+ def calls(callee: str, callee_type: str, line: int, scope_node: Node) -> None:
469
+ if not callee or not _is_meaningful_callee(callee):
470
+ return
471
+ scope = _enclosing_php_scope(scope_node)
472
+ caller, caller_type = scope if scope else (module_name, TYPE_MODULE)
473
+ triples.append(CodeTriple(
474
+ from_entity=caller,
475
+ from_type=caller_type,
476
+ relation_type=CALLS,
477
+ to_entity=callee,
478
+ to_type=callee_type,
479
+ source_file=file_path,
480
+ line_number=line,
481
+ ))
482
+
483
+ for node in _walk(root):
484
+ match node.type:
485
+ # -- Functions -----------------------------------------------
486
+ case "function_definition":
487
+ name = _php_name(node)
488
+ if name:
489
+ defined_in(name, TYPE_FUNCTION, _line(node))
490
+
491
+ case "method_declaration":
492
+ name = _php_name(node)
493
+ if name:
494
+ defined_in(name, TYPE_FUNCTION, _line(node))
495
+
496
+ case "anonymous_function" | "arrow_function":
497
+ parent = node.parent
498
+ if parent and parent.type == "assignment_expression" and parent.children:
499
+ lhs = parent.children[0]
500
+ if lhs.type == "variable_name":
501
+ defined_in(_php_var_name(lhs), TYPE_FUNCTION, _line(node))
502
+
503
+ # -- Classes / interfaces / traits / enums --------------------
504
+ case "class_declaration":
505
+ name = _php_name(node)
506
+ if not name:
507
+ continue
508
+ defined_in(name, TYPE_CLASS, _line(node))
509
+ base_clause = _child_of_type(node, "base_clause")
510
+ if base_clause:
511
+ for target in _php_extends_targets(base_clause):
512
+ inherits(name, TYPE_CLASS, target, TYPE_CLASS)
513
+ iface_clause = _child_of_type(node, "class_interface_clause")
514
+ if iface_clause:
515
+ for target in _php_extends_targets(iface_clause):
516
+ inherits(name, TYPE_CLASS, target, TYPE_INTERFACE)
517
+
518
+ case "interface_declaration":
519
+ name = _php_name(node)
520
+ if not name:
521
+ continue
522
+ defined_in(name, TYPE_INTERFACE, _line(node))
523
+ base_clause = _child_of_type(node, "base_clause")
524
+ if base_clause:
525
+ for target in _php_extends_targets(base_clause):
526
+ inherits(name, TYPE_INTERFACE, target, TYPE_INTERFACE)
527
+
528
+ case "trait_declaration":
529
+ name = _php_name(node)
530
+ if name:
531
+ defined_in(name, TYPE_TRAIT, _line(node))
532
+
533
+ case "enum_declaration":
534
+ name = _php_name(node)
535
+ if not name:
536
+ continue
537
+ defined_in(name, TYPE_ENUM, _line(node))
538
+ iface_clause = _child_of_type(node, "class_interface_clause")
539
+ if iface_clause:
540
+ for target in _php_extends_targets(iface_clause):
541
+ inherits(name, TYPE_ENUM, target, TYPE_INTERFACE)
542
+
543
+ case "enum_case":
544
+ container = _enclosing_php_container(node)
545
+ name = _php_name(node)
546
+ if name and container:
547
+ container_name, container_type = container
548
+ triples.append(CodeTriple(
549
+ from_entity=name,
550
+ from_type=TYPE_CONSTANT,
551
+ relation_type=DEFINED_IN,
552
+ to_entity=container_name,
553
+ to_type=container_type,
554
+ source_file=file_path,
555
+ line_number=_line(node),
556
+ ))
557
+
558
+ # -- Trait usage inside a class/trait body --------------------
559
+ case "use_declaration":
560
+ container = _enclosing_php_container(node)
561
+ if container:
562
+ container_name, container_type = container
563
+ for target in _children_of_type(node, "name"):
564
+ triples.append(CodeTriple(
565
+ from_entity=container_name,
566
+ from_type=container_type,
567
+ relation_type=INHERITS,
568
+ to_entity=_text(target),
569
+ to_type=TYPE_TRAIT,
570
+ source_file=file_path,
571
+ line_number=_line(target),
572
+ ))
573
+
574
+ # -- Namespace imports -----------------------------------------
575
+ case "namespace_use_declaration":
576
+ for dotted, target_node in _extract_php_namespace_use(node):
577
+ triples.append(CodeTriple(
578
+ from_entity=module_name,
579
+ from_type=TYPE_MODULE,
580
+ relation_type=IMPORTS,
581
+ to_entity=dotted,
582
+ to_type=TYPE_MODULE,
583
+ source_file=file_path,
584
+ line_number=_line(target_node),
585
+ ))
586
+
587
+ # -- require/include -------------------------------------------
588
+ case "require_once_expression" | "require_expression" | "include_expression" | "include_once_expression":
589
+ str_node = _php_include_target(node)
590
+ if str_node:
591
+ triples.append(CodeTriple(
592
+ from_entity=module_name,
593
+ from_type=TYPE_MODULE,
594
+ relation_type=INCLUDES,
595
+ to_entity=_php_string_text(str_node),
596
+ to_type=TYPE_MODULE,
597
+ source_file=file_path,
598
+ line_number=_line(str_node),
599
+ ))
600
+
601
+ # -- Properties / constructor-promoted properties ---------------
602
+ case "property_declaration":
603
+ container = _enclosing_php_container(node)
604
+ if container:
605
+ container_name, container_type = container
606
+ for element in _children_of_type(node, "property_element"):
607
+ var_node = _child_of_type(element, "variable_name")
608
+ if var_node:
609
+ triples.append(CodeTriple(
610
+ from_entity=_php_var_name(var_node),
611
+ from_type=TYPE_PROPERTY,
612
+ relation_type=DEFINED_IN,
613
+ to_entity=container_name,
614
+ to_type=container_type,
615
+ source_file=file_path,
616
+ line_number=_line(var_node),
617
+ ))
618
+
619
+ case "property_promotion_parameter":
620
+ container = _enclosing_php_container(node)
621
+ var_node = _child_of_type(node, "variable_name")
622
+ if container and var_node:
623
+ container_name, container_type = container
624
+ triples.append(CodeTriple(
625
+ from_entity=_php_var_name(var_node),
626
+ from_type=TYPE_PROPERTY,
627
+ relation_type=DEFINED_IN,
628
+ to_entity=container_name,
629
+ to_type=container_type,
630
+ source_file=file_path,
631
+ line_number=_line(var_node),
632
+ ))
633
+
634
+ # -- Constants (class/interface/trait/enum or top-level) -------
635
+ case "const_declaration":
636
+ container = _enclosing_php_container(node)
637
+ for element in _children_of_type(node, "const_element"):
638
+ name_node = _child_of_type(element, "name")
639
+ if not name_node:
640
+ continue
641
+ to_entity, to_type = container if container else (module_name, TYPE_MODULE)
642
+ triples.append(CodeTriple(
643
+ from_entity=_text(name_node),
644
+ from_type=TYPE_CONSTANT,
645
+ relation_type=DEFINED_IN,
646
+ to_entity=to_entity,
647
+ to_type=to_type,
648
+ source_file=file_path,
649
+ line_number=_line(name_node),
650
+ ))
651
+
652
+ # -- Calls -------------------------------------------------------
653
+ case "function_call_expression":
654
+ callee_node = _child_of_type(node, "name") or _child_of_type(node, "qualified_name")
655
+ if callee_node:
656
+ calls(_php_basename(callee_node), TYPE_FUNCTION, _line(node), node)
657
+
658
+ case "member_call_expression" | "nullsafe_member_call_expression":
659
+ callee_node = _child_of_type(node, "name")
660
+ if callee_node:
661
+ calls(_text(callee_node), TYPE_FUNCTION, _line(node), node)
662
+
663
+ case "scoped_call_expression":
664
+ names = _children_of_type(node, "name")
665
+ if names:
666
+ calls(_text(names[-1]), TYPE_FUNCTION, _line(node), node)
667
+
668
+ case "object_creation_expression":
669
+ class_node = None
670
+ for child in node.children:
671
+ if child.type in ("name", "qualified_name"):
672
+ class_node = child
673
+ break
674
+ if class_node:
675
+ basename = _php_basename(class_node)
676
+ if basename.lower() not in _PHP_SELF_REFERENTIAL:
677
+ calls(basename, TYPE_CLASS, _line(node), node)
678
+
679
+ return triples
680
+
284
681
  # ---------------------------------------------------------------------------
285
682
  # JavaScript / TypeScript extraction
286
683
  # ---------------------------------------------------------------------------
@@ -642,11 +1039,13 @@ def _extract_lit_css_tokens(source_text: str, file_path: str) -> list[CodeTriple
642
1039
  _EXTRACTORS: dict[str, Callable] = {
643
1040
  ".py": _extract_python,
644
1041
  ".js": _extract_javascript,
1042
+ ".jsx": _extract_javascript,
645
1043
  ".ts": _extract_javascript,
646
1044
  ".tsx": _extract_javascript,
647
1045
  ".html": _extract_html,
648
1046
  ".css": _extract_css,
649
1047
  ".scss": _extract_css,
1048
+ ".php": _extract_php,
650
1049
  }
651
1050
 
652
1051
 
@@ -23,7 +23,7 @@ Return ONLY valid JSON, no other text:
23
23
  If no clear duplicates exist, return {"groups": []}"""
24
24
 
25
25
 
26
- def resolve_entities(client, dry_run: bool = False) -> int:
26
+ def resolve_entities(client) -> int:
27
27
  """Identify and merge duplicate entity nodes in the local graph."""
28
28
  all_nodes = [
29
29
  {"id": n, "name": attr.get("name", n), "type": attr.get("entity_type", "")}
@@ -79,20 +79,18 @@ def resolve_entities(client, dry_run: bool = False) -> int:
79
79
  dup_node = name_to_node.get(dup_name)
80
80
  if not dup_node or dup_node["id"] == canonical_node["id"]:
81
81
  continue
82
- tag = "[dry-run] " if dry_run else ""
83
- print(f" {tag}'{dup_name}' → '{canonical_name}'")
84
- if not dry_run:
85
- # Remap all edges from duplicate to canonical
86
- dup_id = dup_node["id"]
87
- canon_id = canonical_node["id"]
88
- for pred in list(client.graph.predecessors(dup_id)):
89
- for key, data in list(client.graph.get_edge_data(pred, dup_id).items()):
90
- client.graph.add_edge(pred, canon_id, **data)
91
- for succ in list(client.graph.successors(dup_id)):
92
- for key, data in list(client.graph.get_edge_data(dup_id, succ).items()):
93
- client.graph.add_edge(canon_id, succ, **data)
94
- client.graph.remove_node(dup_id)
95
- merged_count += 1
82
+ print(f" '{dup_name}' → '{canonical_name}'")
83
+ # Remap all edges from duplicate to canonical
84
+ dup_id = dup_node["id"]
85
+ canon_id = canonical_node["id"]
86
+ for pred in list(client.graph.predecessors(dup_id)):
87
+ for key, data in list(client.graph.get_edge_data(pred, dup_id).items()):
88
+ client.graph.add_edge(pred, canon_id, **data)
89
+ for succ in list(client.graph.successors(dup_id)):
90
+ for key, data in list(client.graph.get_edge_data(dup_id, succ).items()):
91
+ client.graph.add_edge(canon_id, succ, **data)
92
+ client.graph.remove_node(dup_id)
93
+ merged_count += 1
96
94
 
97
95
  if merged_count:
98
96
  client.save()
@@ -58,9 +58,10 @@ def _extract_triples_sync(chunk: str) -> list[Triple]:
58
58
  )
59
59
 
60
60
  try:
61
- raw = response.content[0].text
62
- raw = re.sub(r"^```[a-z]*\n?", "", raw.strip(), flags=re.MULTILINE)
63
- raw = re.sub(r"```$", "", raw.strip(), flags=re.MULTILINE)
61
+ raw = response.content[0].text.strip()
62
+ if raw.startswith("```"):
63
+ raw = re.sub(r"^```[a-z]*\n?", "", raw)
64
+ raw = re.sub(r"```$", "", raw)
64
65
  data = json.loads(raw.strip())
65
66
  except (json.JSONDecodeError, IndexError) as e:
66
67
  print(f"[reader_agent] JSON parse failed: {e} | raw: {raw[:200]!r}")
@@ -23,6 +23,10 @@ console = Console()
23
23
 
24
24
  _CODECOMPASS_START = "<!-- codecompass-code-graph-start -->"
25
25
  _CODECOMPASS_END = "<!-- codecompass-code-graph-end -->"
26
+ _CODECOMPASS_READ_INSTRUCTION = (
27
+ "Read `.codecompass` before making any changes or before reading any file. "
28
+ "If you think codecompass will help in any way, use it."
29
+ )
26
30
 
27
31
 
28
32
  def init_project(repo_path: str) -> None:
@@ -69,6 +73,20 @@ def init_project(repo_path: str) -> None:
69
73
  with open(file_path, "w") as f:
70
74
  f.write(content)
71
75
 
76
+ # ponytail: minimal way to create claude.md in repo root
77
+ claude_md_path = os.path.join(repo_path, "claude.md")
78
+ if not os.path.exists(claude_md_path):
79
+ with open(claude_md_path, "w") as f:
80
+ f.write(f"AGENTS.md\n\n{_CODECOMPASS_READ_INSTRUCTION}\n")
81
+ else:
82
+ with open(claude_md_path) as f:
83
+ claude_md_content = f.read()
84
+ if _CODECOMPASS_READ_INSTRUCTION not in claude_md_content:
85
+ with open(claude_md_path, "a") as f:
86
+ if claude_md_content and not claude_md_content.endswith("\n"):
87
+ f.write("\n")
88
+ f.write(f"\n{_CODECOMPASS_READ_INSTRUCTION}\n")
89
+
72
90
  _ensure_gitignore(repo_path)
73
91
  console.print(f"[bold green]Initialized CodeCompass in:[/] {compass_dir}")
74
92
  _register_project_agents_md(repo_path)
@@ -173,6 +191,8 @@ def _register_project_agents_md(repo_path: str) -> None:
173
191
  block = f"""{_CODECOMPASS_START}
174
192
  ## Code graph
175
193
 
194
+ **{_CODECOMPASS_READ_INSTRUCTION}**
195
+
176
196
  This project has a CodeCompass code knowledge graph at `.codecompass/graph.json`.
177
197
  Every node carries `kind` (e.g. `function:python`, `class:typescript`) and a
178
198
  human-readable `description`. Use it as your primary navigation tool.
@@ -8,7 +8,8 @@ class CodeTriple:
8
8
  """A single typed relationship extracted from source code."""
9
9
 
10
10
  from_entity: str
11
- from_type: str # function | class | module | css_selector | html_element | scss_mixin | scss_variable
11
+ from_type: str # function | class | module | css_selector | html_element | scss_mixin | scss_variable |
12
+ # interface | trait | enum | property | constant
12
13
  relation_type: str # CALLS | IMPORTS | INHERITS | DEFINED_IN | STYLES | HAS_CLASS | POSTS_TO | INCLUDES | USED_BY
13
14
  to_entity: str
14
15
  to_type: str
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codecompass-mcp"
3
- version = "2.2.0"
3
+ version = "2.4.0"
4
4
  description = "Structural code context for AI coding agents — a local code knowledge graph for blast radius, impact, deps, dead code, and flow tracing"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -0,0 +1,217 @@
1
+ """Tests for PHP ingestion entity coverage."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import sys
6
+ import tempfile
7
+
8
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
9
+
10
+ from ingestion.code_parser import parse_file
11
+
12
+
13
+ def _parse(content: str) -> list:
14
+ with tempfile.NamedTemporaryFile(suffix=".php", mode="w", delete=False) as f:
15
+ f.write(content)
16
+ path = f.name
17
+ try:
18
+ return parse_file(path, os.path.dirname(path))
19
+ finally:
20
+ os.unlink(path)
21
+
22
+
23
+ def _rels(triples, relation_type=None, from_type=None, to_type=None):
24
+ out = triples
25
+ if relation_type is not None:
26
+ out = [t for t in out if t.relation_type == relation_type]
27
+ if from_type is not None:
28
+ out = [t for t in out if t.from_type == from_type]
29
+ if to_type is not None:
30
+ out = [t for t in out if t.to_type == to_type]
31
+ return out
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # The parser must actually run — regression guard for the broken loader
36
+ # (tsphp.language vs tsphp.language_php) that silently no-op'd every .php file.
37
+ # ---------------------------------------------------------------------------
38
+
39
+ def test_php_file_parses_without_error():
40
+ triples = _parse("<?php\nfunction foo() { return 1; }\n")
41
+ assert triples, "PHP parsing should produce triples, not silently fail"
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Functions, classes, inheritance
46
+ # ---------------------------------------------------------------------------
47
+
48
+ def test_top_level_function_indexed():
49
+ triples = _parse("<?php\nfunction topLevel($x) { return $x; }\n")
50
+ defs = _rels(triples, relation_type="DEFINED_IN", from_type="function")
51
+ assert any(t.from_entity == "topLevel" for t in defs)
52
+
53
+
54
+ def test_class_extends_indexed():
55
+ triples = _parse("<?php\nclass Foo extends Bar {}\n")
56
+ inh = _rels(triples, relation_type="INHERITS", to_type="class")
57
+ assert any(t.from_entity == "Foo" and t.to_entity == "Bar" for t in inh)
58
+
59
+
60
+ def test_class_implements_indexed():
61
+ triples = _parse("<?php\nclass Foo implements A, B {}\n")
62
+ inh = _rels(triples, relation_type="INHERITS", to_type="interface")
63
+ targets = {t.to_entity for t in inh}
64
+ assert {"A", "B"} <= targets
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Methods, properties, constructor promotion, constants
69
+ # ---------------------------------------------------------------------------
70
+
71
+ def test_class_method_indexed():
72
+ src = "<?php\nclass Foo {\n public function bar() { return 1; }\n}\n"
73
+ triples = _parse(src)
74
+ defs = _rels(triples, relation_type="DEFINED_IN", from_type="function")
75
+ assert any(t.from_entity == "bar" for t in defs)
76
+
77
+
78
+ def test_property_declaration_indexed():
79
+ src = "<?php\nclass Foo {\n protected static ?string $name = \"x\";\n}\n"
80
+ triples = _parse(src)
81
+ props = _rels(triples, relation_type="DEFINED_IN", from_type="property")
82
+ assert any(t.from_entity == "name" and t.to_entity == "Foo" for t in props)
83
+
84
+
85
+ def test_constructor_promoted_property_indexed():
86
+ src = "<?php\nclass Foo {\n public function __construct(private readonly int $id) {}\n}\n"
87
+ triples = _parse(src)
88
+ props = _rels(triples, relation_type="DEFINED_IN", from_type="property")
89
+ assert any(t.from_entity == "id" and t.to_entity == "Foo" for t in props)
90
+
91
+
92
+ def test_class_constant_indexed():
93
+ src = "<?php\nclass Foo {\n public const MAX = 10;\n}\n"
94
+ triples = _parse(src)
95
+ consts = _rels(triples, relation_type="DEFINED_IN", from_type="constant")
96
+ assert any(t.from_entity == "MAX" and t.to_entity == "Foo" for t in consts)
97
+
98
+
99
+ def test_top_level_constant_indexed():
100
+ triples = _parse("<?php\nconst MAX = 10;\n")
101
+ consts = _rels(triples, relation_type="DEFINED_IN", from_type="constant")
102
+ assert any(t.from_entity == "MAX" and t.to_type == "module" for t in consts)
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # Interfaces, traits, enums
107
+ # ---------------------------------------------------------------------------
108
+
109
+ def test_interface_declaration_indexed():
110
+ triples = _parse("<?php\ninterface Payable {\n public function pay(): void;\n}\n")
111
+ defs = _rels(triples, relation_type="DEFINED_IN", from_type="interface")
112
+ assert any(t.from_entity == "Payable" for t in defs)
113
+
114
+
115
+ def test_interface_extends_multiple_indexed():
116
+ triples = _parse("<?php\ninterface Multi extends X, Y {}\n")
117
+ inh = _rels(triples, relation_type="INHERITS", from_type="interface", to_type="interface")
118
+ targets = {t.to_entity for t in inh}
119
+ assert {"X", "Y"} <= targets
120
+
121
+
122
+ def test_trait_declaration_and_use_indexed():
123
+ src = "<?php\ntrait Loggable {\n public function log() {}\n}\nclass Foo {\n use Loggable;\n}\n"
124
+ triples = _parse(src)
125
+ trait_defs = _rels(triples, relation_type="DEFINED_IN", from_type="trait")
126
+ assert any(t.from_entity == "Loggable" for t in trait_defs)
127
+ uses = _rels(triples, relation_type="INHERITS", to_type="trait")
128
+ assert any(t.from_entity == "Foo" and t.to_entity == "Loggable" for t in uses)
129
+
130
+
131
+ def test_enum_and_case_indexed():
132
+ src = "<?php\nenum Status: string {\n case Active = 'active';\n}\n"
133
+ triples = _parse(src)
134
+ enum_defs = _rels(triples, relation_type="DEFINED_IN", from_type="enum")
135
+ assert any(t.from_entity == "Status" for t in enum_defs)
136
+ cases = _rels(triples, relation_type="DEFINED_IN", from_type="constant", to_type="enum")
137
+ assert any(t.from_entity == "Active" and t.to_entity == "Status" for t in cases)
138
+
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # Namespaces / imports / includes
142
+ # ---------------------------------------------------------------------------
143
+
144
+ def test_namespace_use_indexed():
145
+ triples = _parse("<?php\nuse App\\Contracts\\Payable;\n")
146
+ imports = _rels(triples, relation_type="IMPORTS")
147
+ assert any(t.to_entity == "App.Contracts.Payable" for t in imports)
148
+
149
+
150
+ def test_namespace_use_group_indexed():
151
+ triples = _parse("<?php\nuse App\\{Bar, Baz};\n")
152
+ imports = _rels(triples, relation_type="IMPORTS")
153
+ targets = {t.to_entity for t in imports}
154
+ assert {"App.Bar", "App.Baz"} <= targets
155
+
156
+
157
+ def test_require_once_indexed():
158
+ triples = _parse("<?php\nrequire_once 'bootstrap.php';\n")
159
+ includes = _rels(triples, relation_type="INCLUDES")
160
+ assert any(t.to_entity == "bootstrap.php" for t in includes)
161
+
162
+
163
+ # ---------------------------------------------------------------------------
164
+ # Calls: plain, method, static, new, closures
165
+ # ---------------------------------------------------------------------------
166
+
167
+ def test_plain_function_call_indexed():
168
+ triples = _parse("<?php\nfunction outer() { inner(); }\nfunction inner() {}\n")
169
+ calls = _rels(triples, relation_type="CALLS")
170
+ assert any(t.from_entity == "outer" and t.to_entity == "inner" for t in calls)
171
+
172
+
173
+ def test_method_call_indexed():
174
+ src = "<?php\nfunction run() {\n $obj = new Foo();\n $obj->save();\n}\n"
175
+ triples = _parse(src)
176
+ calls = _rels(triples, relation_type="CALLS")
177
+ assert any(t.to_entity == "save" and t.to_type == "function" for t in calls)
178
+ assert any(t.to_entity == "Foo" and t.to_type == "class" for t in calls)
179
+
180
+
181
+ def test_static_call_indexed():
182
+ src = "<?php\nfunction run() {\n BaseModel::make();\n}\n"
183
+ triples = _parse(src)
184
+ calls = _rels(triples, relation_type="CALLS")
185
+ assert any(t.to_entity == "make" for t in calls)
186
+
187
+
188
+ def test_self_new_not_recorded_as_class_entity():
189
+ src = "<?php\nclass Foo {\n public static function make() {\n return new static();\n }\n}\n"
190
+ triples = _parse(src)
191
+ calls = _rels(triples, relation_type="CALLS", to_type="class")
192
+ assert not any(t.to_entity.lower() in ("self", "static", "parent") for t in calls)
193
+
194
+
195
+ def test_closure_assigned_to_variable_indexed():
196
+ src = "<?php\nfunction run() {\n $fn = function($x) { return $x; };\n}\n"
197
+ triples = _parse(src)
198
+ defs = _rels(triples, relation_type="DEFINED_IN", from_type="function")
199
+ assert any(t.from_entity == "fn" for t in defs)
200
+
201
+
202
+ def test_arrow_function_assigned_to_variable_indexed():
203
+ src = "<?php\nfunction run() {\n $arrow = fn($x) => $x + 1;\n}\n"
204
+ triples = _parse(src)
205
+ defs = _rels(triples, relation_type="DEFINED_IN", from_type="function")
206
+ assert any(t.from_entity == "arrow" for t in defs)
207
+
208
+
209
+ # ---------------------------------------------------------------------------
210
+ # No regression on other extractors
211
+ # ---------------------------------------------------------------------------
212
+
213
+ def test_php_does_not_emit_html_relations():
214
+ triples = _parse("<?php\nclass Foo {}\n")
215
+ rel_types = {t.relation_type for t in triples}
216
+ assert "HAS_CLASS" not in rel_types
217
+ assert "STYLES" not in rel_types
File without changes