entrygraph 0.1.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.
Files changed (102) hide show
  1. entrygraph/__init__.py +87 -0
  2. entrygraph/__main__.py +8 -0
  3. entrygraph/_version.py +24 -0
  4. entrygraph/api.py +549 -0
  5. entrygraph/cli/__init__.py +0 -0
  6. entrygraph/cli/main.py +387 -0
  7. entrygraph/cli/render.py +136 -0
  8. entrygraph/data/sinks/csharp.toml +106 -0
  9. entrygraph/data/sinks/go.toml +87 -0
  10. entrygraph/data/sinks/java.toml +92 -0
  11. entrygraph/data/sinks/javascript.toml +112 -0
  12. entrygraph/data/sinks/lib_javascript.toml +34 -0
  13. entrygraph/data/sinks/lib_python.toml +39 -0
  14. entrygraph/data/sinks/php.toml +125 -0
  15. entrygraph/data/sinks/python.toml +160 -0
  16. entrygraph/data/sinks/ruby.toml +102 -0
  17. entrygraph/data/sinks/rust.toml +68 -0
  18. entrygraph/db/__init__.py +0 -0
  19. entrygraph/db/engine.py +34 -0
  20. entrygraph/db/meta.py +70 -0
  21. entrygraph/db/models.py +176 -0
  22. entrygraph/db/queries.py +155 -0
  23. entrygraph/detect/__init__.py +0 -0
  24. entrygraph/detect/entrypoints/__init__.py +22 -0
  25. entrygraph/detect/entrypoints/base.py +124 -0
  26. entrygraph/detect/entrypoints/configs.py +139 -0
  27. entrygraph/detect/entrypoints/csharp.py +156 -0
  28. entrygraph/detect/entrypoints/golang.py +158 -0
  29. entrygraph/detect/entrypoints/java.py +187 -0
  30. entrygraph/detect/entrypoints/javascript.py +211 -0
  31. entrygraph/detect/entrypoints/php.py +133 -0
  32. entrygraph/detect/entrypoints/python.py +335 -0
  33. entrygraph/detect/entrypoints/ruby.py +147 -0
  34. entrygraph/detect/entrypoints/rust.py +153 -0
  35. entrygraph/detect/frameworks.py +369 -0
  36. entrygraph/detect/manifests.py +234 -0
  37. entrygraph/detect/taint.py +224 -0
  38. entrygraph/errors.py +27 -0
  39. entrygraph/extract/__init__.py +0 -0
  40. entrygraph/extract/base.py +51 -0
  41. entrygraph/extract/csharp.py +502 -0
  42. entrygraph/extract/golang.py +342 -0
  43. entrygraph/extract/ir.py +105 -0
  44. entrygraph/extract/java.py +329 -0
  45. entrygraph/extract/javascript.py +400 -0
  46. entrygraph/extract/php.py +426 -0
  47. entrygraph/extract/python.py +390 -0
  48. entrygraph/extract/registry.py +43 -0
  49. entrygraph/extract/ruby.py +321 -0
  50. entrygraph/extract/rust.py +482 -0
  51. entrygraph/fs/__init__.py +0 -0
  52. entrygraph/fs/hashing.py +78 -0
  53. entrygraph/fs/lang.py +134 -0
  54. entrygraph/fs/walker.py +167 -0
  55. entrygraph/graph/__init__.py +0 -0
  56. entrygraph/graph/adjacency.py +146 -0
  57. entrygraph/graph/cte.py +123 -0
  58. entrygraph/graph/scoring.py +101 -0
  59. entrygraph/kinds.py +51 -0
  60. entrygraph/parsing/__init__.py +0 -0
  61. entrygraph/parsing/parsers.py +49 -0
  62. entrygraph/parsing/queries.py +39 -0
  63. entrygraph/pipeline/__init__.py +0 -0
  64. entrygraph/pipeline/scanner.py +506 -0
  65. entrygraph/pipeline/worker.py +49 -0
  66. entrygraph/pipeline/writer.py +41 -0
  67. entrygraph/py.typed +0 -0
  68. entrygraph/queries/csharp/calls.scm +4 -0
  69. entrygraph/queries/csharp/definitions.scm +29 -0
  70. entrygraph/queries/csharp/imports.scm +4 -0
  71. entrygraph/queries/go/calls.scm +2 -0
  72. entrygraph/queries/go/definitions.scm +24 -0
  73. entrygraph/queries/go/imports.scm +1 -0
  74. entrygraph/queries/java/calls.scm +2 -0
  75. entrygraph/queries/java/definitions.scm +14 -0
  76. entrygraph/queries/java/imports.scm +2 -0
  77. entrygraph/queries/javascript/calls.scm +4 -0
  78. entrygraph/queries/javascript/definitions.scm +4 -0
  79. entrygraph/queries/javascript/imports.scm +6 -0
  80. entrygraph/queries/php/calls.scm +8 -0
  81. entrygraph/queries/php/definitions.scm +24 -0
  82. entrygraph/queries/php/imports.scm +1 -0
  83. entrygraph/queries/python/calls.scm +2 -0
  84. entrygraph/queries/python/definitions.scm +11 -0
  85. entrygraph/queries/python/imports.scm +2 -0
  86. entrygraph/queries/ruby/calls.scm +4 -0
  87. entrygraph/queries/ruby/definitions.scm +20 -0
  88. entrygraph/queries/ruby/imports.scm +7 -0
  89. entrygraph/queries/rust/calls.scm +5 -0
  90. entrygraph/queries/rust/definitions.scm +26 -0
  91. entrygraph/queries/rust/imports.scm +4 -0
  92. entrygraph/resolve/__init__.py +0 -0
  93. entrygraph/resolve/externals.py +61 -0
  94. entrygraph/resolve/hierarchy.py +152 -0
  95. entrygraph/resolve/resolver.py +275 -0
  96. entrygraph/resolve/symbol_table.py +48 -0
  97. entrygraph/results.py +138 -0
  98. entrygraph-0.1.0.dist-info/METADATA +204 -0
  99. entrygraph-0.1.0.dist-info/RECORD +102 -0
  100. entrygraph-0.1.0.dist-info/WHEEL +4 -0
  101. entrygraph-0.1.0.dist-info/entry_points.txt +2 -0
  102. entrygraph-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,426 @@
1
+ r"""PHP extractor: .scm queries harvest nodes, this shaper builds the IR.
2
+
3
+ Module paths are the file's PHP namespace, ``\`` normalized to ``.`` (so
4
+ ``namespace App\Http\Controllers;`` -> ``App.Http.Controllers``). A
5
+ namespace-less script (a plain ``index.php`` with no ``namespace`` line) falls
6
+ back to its directory-derived dotted path. PHP has no package concept in the IR
7
+ sense, so ``is_package`` is always False.
8
+
9
+ Qualified names are namespace-scoped and class-nested:
10
+ ``App.Http.Controllers.ReportController.store``. The ``\`` separator is
11
+ normalized to ``.`` *everywhere* — qnames, base expressions, and import-map
12
+ values — so externals look like ``php:App.Services.Runner.run`` and the sink
13
+ globs stay dot-based.
14
+
15
+ PHP 8 attributes (``#[Route('/x')]``) are captured on ``RawSymbol.decorators``
16
+ as raw source text and emitted as ``RawReference(kind="decorator")`` — the same
17
+ shape Java uses for annotations — so entrypoint rules can match them.
18
+ ``Foo::bar()`` yields receiver ``Foo`` / callee ``bar``; ``$obj->m()`` yields
19
+ receiver ``$obj``. ``include``/``require`` of a variable is modeled as a call
20
+ ref with ``callee_name="include"`` so it can be a sink.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from typing import TYPE_CHECKING, ClassVar
26
+
27
+ from entrygraph.extract.base import FileContext, node_text, span_of, truncate
28
+ from entrygraph.extract.ir import FileExtraction, RawImport, RawReference, RawSymbol
29
+ from entrygraph.kinds import SymbolKind
30
+ from entrygraph.parsing.queries import captures, load_query
31
+
32
+ if TYPE_CHECKING: # pragma: no cover
33
+ from tree_sitter import Node, Tree
34
+
35
+ _SCOPE_TYPES = frozenset(
36
+ {"class_declaration", "interface_declaration", "trait_declaration"}
37
+ )
38
+ _FUNC_SCOPES = frozenset({"function_definition", "method_declaration"})
39
+ _SRC_ROOTS = ("src", "lib", "app")
40
+
41
+
42
+ def _norm(text: str) -> str:
43
+ r"""Normalize PHP's ``\`` namespace separator to ``.`` and strip leads."""
44
+ return text.replace("\\", ".").strip(".")
45
+
46
+
47
+ class PhpExtractor:
48
+ language_ids: ClassVar[tuple[str, ...]] = ("php",)
49
+
50
+ def module_path_for(self, repo_relative_path: str) -> tuple[str, bool]:
51
+ # The authoritative module path is the file's PHP namespace, computed in
52
+ # extract(); this directory-derived fallback is only used for
53
+ # namespace-less scripts. The scanner calls this before parse, so we
54
+ # return the directory form and let extract() override via ctx.
55
+ parts = repo_relative_path.split("/")
56
+ if parts and parts[0] in _SRC_ROOTS and len(parts) > 1:
57
+ parts = parts[1:]
58
+ if parts:
59
+ parts[-1] = parts[-1].removesuffix(".php").removesuffix(".phtml")
60
+ return ".".join(p for p in parts if p) or "_root", False
61
+
62
+ def extract(self, tree: "Tree", ctx: FileContext) -> FileExtraction:
63
+ root = tree.root_node
64
+ namespace = self._namespace(root)
65
+ # The namespace (when present) is the real module path — override the
66
+ # directory-derived ctx.module_path so qnames are namespace-scoped.
67
+ module_path = namespace or ctx.module_path
68
+ out = FileExtraction(
69
+ path=ctx.path,
70
+ language=ctx.language,
71
+ module_path=module_path,
72
+ parse_ok=not root.has_error,
73
+ error_count=1 if root.has_error else 0,
74
+ )
75
+ self._namespace_prefix = module_path
76
+ self._definitions(root, ctx, out)
77
+ self._imports(root, ctx, out)
78
+ self._calls(root, ctx, out)
79
+ return out
80
+
81
+ # ---------------- namespace ----------------
82
+
83
+ def _namespace(self, root: "Node") -> str | None:
84
+ caps = captures(load_query("php", "definitions"), root)
85
+ for node in caps.get("namespace", []):
86
+ name = next(
87
+ (c for c in node.named_children if c.type == "namespace_name"), None
88
+ )
89
+ if name is not None:
90
+ return _norm(node_text(name))
91
+ return None
92
+
93
+ # ---------------- definitions ----------------
94
+
95
+ def _definitions(self, root: "Node", ctx: FileContext, out: FileExtraction) -> None:
96
+ caps = captures(load_query("php", "definitions"), root)
97
+
98
+ for node in caps.get("def.class", []):
99
+ self._add_type(node, ctx, out, SymbolKind.CLASS)
100
+ for node in caps.get("def.interface", []):
101
+ self._add_type(node, ctx, out, SymbolKind.INTERFACE)
102
+ for node in caps.get("def.trait", []):
103
+ self._add_type(node, ctx, out, SymbolKind.CLASS)
104
+
105
+ for node in caps.get("def.function", []):
106
+ self._add_callable(node, ctx, out, SymbolKind.FUNCTION)
107
+ for node in caps.get("def.method", []):
108
+ self._add_callable(node, ctx, out, SymbolKind.METHOD)
109
+
110
+ for node in caps.get("def.const", []):
111
+ for element in node.named_children:
112
+ if element.type != "const_element":
113
+ continue
114
+ name_node = element.child_by_field_name("name") or next(
115
+ (c for c in element.named_children if c.type == "name"), None
116
+ )
117
+ if name_node is None:
118
+ continue
119
+ name = node_text(name_node)
120
+ qname, parent_q = self._qualify(node, name, ctx)
121
+ out.symbols.append(
122
+ RawSymbol(
123
+ kind=SymbolKind.CONSTANT, name=name, qualified_name=qname,
124
+ span=span_of(node), parent_qualified_name=parent_q,
125
+ signature=truncate(node_text(node)),
126
+ )
127
+ )
128
+
129
+ for node in caps.get("def.property", []):
130
+ if self._nearest_scope(node) is None:
131
+ continue
132
+ modifiers = self._prop_modifiers(node)
133
+ for element in node.named_children:
134
+ if element.type != "property_element":
135
+ continue
136
+ var = next(
137
+ (c for c in element.named_children if c.type == "variable_name"), None
138
+ )
139
+ name_node = var.child_by_field_name("name") if var else None
140
+ if name_node is None and var is not None:
141
+ name_node = next(
142
+ (c for c in var.named_children if c.type == "name"), None
143
+ )
144
+ if name_node is None:
145
+ continue
146
+ name = node_text(name_node)
147
+ qname, parent_q = self._qualify(node, name, ctx)
148
+ out.symbols.append(
149
+ RawSymbol(
150
+ kind=SymbolKind.FIELD, name=name, qualified_name=qname,
151
+ span=span_of(node), parent_qualified_name=parent_q,
152
+ signature=truncate(node_text(node)), modifiers=modifiers,
153
+ is_exported="public" in modifiers or not modifiers,
154
+ )
155
+ )
156
+
157
+ def _add_type(
158
+ self, node: "Node", ctx: FileContext, out: FileExtraction, kind: SymbolKind
159
+ ) -> None:
160
+ name_node = node.child_by_field_name("name")
161
+ if name_node is None:
162
+ return
163
+ name = node_text(name_node)
164
+ qname, parent_q = self._qualify(node, name, ctx)
165
+ extends, interfaces = self._supertypes(node)
166
+ out.symbols.append(
167
+ RawSymbol(
168
+ kind=kind, name=name, qualified_name=qname, span=span_of(node),
169
+ parent_qualified_name=parent_q, signature=self._signature(node),
170
+ decorators=self._attributes(node), bases=[*extends, *interfaces],
171
+ )
172
+ )
173
+ for base in extends:
174
+ out.references.append(
175
+ RawReference(
176
+ kind="inherit", callee_text=base,
177
+ callee_name=base.rsplit(".", 1)[-1], receiver_text=None,
178
+ span=span_of(node), caller_qualified_name=qname,
179
+ )
180
+ )
181
+ for iface in interfaces:
182
+ out.references.append(
183
+ RawReference(
184
+ kind="implement", callee_text=iface,
185
+ callee_name=iface.rsplit(".", 1)[-1], receiver_text=None,
186
+ span=span_of(node), caller_qualified_name=qname,
187
+ )
188
+ )
189
+ self._emit_attribute_refs(node, qname, out)
190
+
191
+ def _add_callable(
192
+ self, node: "Node", ctx: FileContext, out: FileExtraction, kind: SymbolKind
193
+ ) -> None:
194
+ name_node = node.child_by_field_name("name")
195
+ if name_node is None:
196
+ return
197
+ name = node_text(name_node)
198
+ qname, parent_q = self._qualify(node, name, ctx)
199
+ modifiers = self._method_modifiers(node)
200
+ out.symbols.append(
201
+ RawSymbol(
202
+ kind=kind, name=name, qualified_name=qname, span=span_of(node),
203
+ parent_qualified_name=parent_q, signature=self._signature(node),
204
+ decorators=self._attributes(node), modifiers=modifiers,
205
+ is_exported="private" not in modifiers and "protected" not in modifiers,
206
+ )
207
+ )
208
+ self._emit_attribute_refs(node, qname, out)
209
+
210
+ # ---------------- imports ----------------
211
+
212
+ def _imports(self, root: "Node", ctx: FileContext, out: FileExtraction) -> None:
213
+ caps = captures(load_query("php", "imports"), root)
214
+ for node in caps.get("import", []):
215
+ for clause in node.named_children:
216
+ if clause.type != "namespace_use_clause":
217
+ continue
218
+ qualified = next(
219
+ (c for c in clause.named_children
220
+ if c.type in ("qualified_name", "name")),
221
+ None,
222
+ )
223
+ if qualified is None:
224
+ continue
225
+ full = _norm(node_text(qualified))
226
+ alias_node = clause.child_by_field_name("alias")
227
+ last = full.rsplit(".", 1)[-1]
228
+ alias = node_text(alias_node) if alias_node is not None else last
229
+ out.imports.append(
230
+ RawImport(
231
+ module=full, imported_name=last, alias=alias, span=span_of(node)
232
+ )
233
+ )
234
+ for imp in out.imports:
235
+ if imp.module:
236
+ out.framework_signals.append(("import", imp.module))
237
+
238
+ # ---------------- calls / attributes ----------------
239
+
240
+ def _calls(self, root: "Node", ctx: FileContext, out: FileExtraction) -> None:
241
+ caps = captures(load_query("php", "calls"), root)
242
+
243
+ for node in caps.get("call", []):
244
+ fn = node.child_by_field_name("function")
245
+ if fn is None:
246
+ continue
247
+ callee_text = _norm(node_text(fn))
248
+ callee_name = callee_text.rsplit(".", 1)[-1]
249
+ receiver = (
250
+ callee_text.rsplit(".", 1)[0] if "." in callee_text else None
251
+ )
252
+ self._emit_call(node, callee_text, callee_name, receiver, ctx, out)
253
+
254
+ for node in caps.get("member_call", []):
255
+ name_node = node.child_by_field_name("name")
256
+ obj = node.child_by_field_name("object")
257
+ if name_node is None or obj is None:
258
+ continue
259
+ callee_name = node_text(name_node)
260
+ receiver = node_text(obj)
261
+ self._emit_call(
262
+ node, f"{receiver}.{callee_name}", callee_name, receiver, ctx, out
263
+ )
264
+
265
+ for node in caps.get("scoped_call", []):
266
+ name_node = node.child_by_field_name("name")
267
+ scope = node.child_by_field_name("scope")
268
+ if name_node is None or scope is None:
269
+ continue
270
+ callee_name = node_text(name_node)
271
+ receiver = _norm(node_text(scope))
272
+ self._emit_call(
273
+ node, f"{receiver}.{callee_name}", callee_name, receiver, ctx, out
274
+ )
275
+
276
+ for node in caps.get("new", []):
277
+ type_node = next(
278
+ (c for c in node.named_children if c.type != "arguments"), None
279
+ )
280
+ if type_node is None:
281
+ continue
282
+ type_text = _norm(node_text(type_node))
283
+ callee_name = type_text.rsplit(".", 1)[-1]
284
+ self._emit_call(node, type_text, callee_name, None, ctx, out)
285
+
286
+ for node in caps.get("include", []):
287
+ # include/require of anything: model as a bare call so it can be a
288
+ # sink. `include $var` -> callee_name="include" with the variable as
289
+ # arg_preview.
290
+ arg = node.named_children[0] if node.named_children else None
291
+ out.references.append(
292
+ RawReference(
293
+ kind="call", callee_text="include", callee_name="include",
294
+ receiver_text=None, span=span_of(node),
295
+ caller_qualified_name=self._caller(node, ctx),
296
+ arg_count=1 if arg is not None else 0,
297
+ arg_preview=truncate(node_text(arg)) if arg is not None else None,
298
+ )
299
+ )
300
+
301
+ def _emit_call(
302
+ self, node: "Node", callee_text: str, callee_name: str,
303
+ receiver: str | None, ctx: FileContext, out: FileExtraction,
304
+ ) -> None:
305
+ args = node.child_by_field_name("arguments")
306
+ out.references.append(
307
+ RawReference(
308
+ kind="call", callee_text=callee_text, callee_name=callee_name,
309
+ receiver_text=receiver, span=span_of(node),
310
+ caller_qualified_name=self._caller(node, ctx),
311
+ arg_count=len(args.named_children) if args is not None else 0,
312
+ arg_preview=truncate(node_text(args)) if args is not None else None,
313
+ )
314
+ )
315
+
316
+ # ---------------- helpers ----------------
317
+
318
+ def _scope_chain(self, node: "Node") -> list[str]:
319
+ parts: list[str] = []
320
+ current = node.parent
321
+ while current is not None:
322
+ if current.type in _SCOPE_TYPES:
323
+ name = current.child_by_field_name("name")
324
+ if name is not None:
325
+ parts.append(node_text(name))
326
+ current = current.parent
327
+ parts.reverse()
328
+ return parts
329
+
330
+ def _nearest_scope(self, node: "Node") -> str | None:
331
+ current = node.parent
332
+ while current is not None:
333
+ if current.type in _SCOPE_TYPES:
334
+ return current.type
335
+ current = current.parent
336
+ return None
337
+
338
+ def _qualify(
339
+ self, node: "Node", name: str, ctx: FileContext
340
+ ) -> tuple[str, str | None]:
341
+ chain = self._scope_chain(node)
342
+ base = self._namespace_prefix
343
+ prefix_parts = [base, *chain] if base else [*chain]
344
+ parent_q = ".".join(prefix_parts) if prefix_parts else None
345
+ qname = ".".join([*prefix_parts, name])
346
+ return qname, parent_q
347
+
348
+ def _caller(self, node: "Node", ctx: FileContext) -> str | None:
349
+ current = node.parent
350
+ while current is not None:
351
+ if current.type in _FUNC_SCOPES:
352
+ name = current.child_by_field_name("name")
353
+ if name is not None:
354
+ return self._qualify(current, node_text(name), ctx)[0]
355
+ current = current.parent
356
+ return None
357
+
358
+ def _signature(self, node: "Node") -> str:
359
+ return truncate(node_text(node).split("{", 1)[0].strip(), 120)
360
+
361
+ def _method_modifiers(self, node: "Node") -> list[str]:
362
+ mods: list[str] = []
363
+ for child in node.children:
364
+ if child.type in ("visibility_modifier", "static_modifier",
365
+ "abstract_modifier", "final_modifier"):
366
+ mods.append(node_text(child))
367
+ return mods
368
+
369
+ def _prop_modifiers(self, node: "Node") -> list[str]:
370
+ return [
371
+ node_text(c) for c in node.named_children
372
+ if c.type in ("visibility_modifier", "static_modifier",
373
+ "readonly_modifier", "final_modifier")
374
+ ]
375
+
376
+ def _attribute_lists(self, node: "Node") -> list["Node"]:
377
+ return [c for c in node.named_children if c.type == "attribute_list"]
378
+
379
+ def _attributes(self, node: "Node") -> list[str]:
380
+ # The attribute_list node text already includes the leading `#[ ... ]`.
381
+ return [node_text(attr_list) for attr_list in self._attribute_lists(node)]
382
+
383
+ def _emit_attribute_refs(
384
+ self, node: "Node", owner_qname: str, out: FileExtraction
385
+ ) -> None:
386
+ for attr_list in self._attribute_lists(node):
387
+ for group in attr_list.named_children:
388
+ if group.type != "attribute_group":
389
+ continue
390
+ for attr in group.named_children:
391
+ if attr.type != "attribute":
392
+ continue
393
+ name_node = attr.child_by_field_name("name") or next(
394
+ (c for c in attr.named_children
395
+ if c.type in ("name", "qualified_name")),
396
+ None,
397
+ )
398
+ if name_node is None:
399
+ continue
400
+ callee_text = _norm(node_text(name_node))
401
+ out.references.append(
402
+ RawReference(
403
+ kind="decorator", callee_text=callee_text,
404
+ callee_name=callee_text.rsplit(".", 1)[-1],
405
+ receiver_text=(
406
+ callee_text.rsplit(".", 1)[0]
407
+ if "." in callee_text else None
408
+ ),
409
+ span=span_of(attr), caller_qualified_name=owner_qname,
410
+ )
411
+ )
412
+
413
+ def _supertypes(self, node: "Node") -> tuple[list[str], list[str]]:
414
+ """Return (extends supertypes, implements interfaces)."""
415
+ extends: list[str] = []
416
+ interfaces: list[str] = []
417
+ for child in node.named_children:
418
+ if child.type == "base_clause":
419
+ for name in child.named_children:
420
+ if name.type in ("name", "qualified_name"):
421
+ extends.append(_norm(node_text(name)))
422
+ elif child.type == "class_interface_clause":
423
+ for name in child.named_children:
424
+ if name.type in ("name", "qualified_name"):
425
+ interfaces.append(_norm(node_text(name)))
426
+ return extends, interfaces