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.
- entrygraph/__init__.py +87 -0
- entrygraph/__main__.py +8 -0
- entrygraph/_version.py +24 -0
- entrygraph/api.py +549 -0
- entrygraph/cli/__init__.py +0 -0
- entrygraph/cli/main.py +387 -0
- entrygraph/cli/render.py +136 -0
- entrygraph/data/sinks/csharp.toml +106 -0
- entrygraph/data/sinks/go.toml +87 -0
- entrygraph/data/sinks/java.toml +92 -0
- entrygraph/data/sinks/javascript.toml +112 -0
- entrygraph/data/sinks/lib_javascript.toml +34 -0
- entrygraph/data/sinks/lib_python.toml +39 -0
- entrygraph/data/sinks/php.toml +125 -0
- entrygraph/data/sinks/python.toml +160 -0
- entrygraph/data/sinks/ruby.toml +102 -0
- entrygraph/data/sinks/rust.toml +68 -0
- entrygraph/db/__init__.py +0 -0
- entrygraph/db/engine.py +34 -0
- entrygraph/db/meta.py +70 -0
- entrygraph/db/models.py +176 -0
- entrygraph/db/queries.py +155 -0
- entrygraph/detect/__init__.py +0 -0
- entrygraph/detect/entrypoints/__init__.py +22 -0
- entrygraph/detect/entrypoints/base.py +124 -0
- entrygraph/detect/entrypoints/configs.py +139 -0
- entrygraph/detect/entrypoints/csharp.py +156 -0
- entrygraph/detect/entrypoints/golang.py +158 -0
- entrygraph/detect/entrypoints/java.py +187 -0
- entrygraph/detect/entrypoints/javascript.py +211 -0
- entrygraph/detect/entrypoints/php.py +133 -0
- entrygraph/detect/entrypoints/python.py +335 -0
- entrygraph/detect/entrypoints/ruby.py +147 -0
- entrygraph/detect/entrypoints/rust.py +153 -0
- entrygraph/detect/frameworks.py +369 -0
- entrygraph/detect/manifests.py +234 -0
- entrygraph/detect/taint.py +224 -0
- entrygraph/errors.py +27 -0
- entrygraph/extract/__init__.py +0 -0
- entrygraph/extract/base.py +51 -0
- entrygraph/extract/csharp.py +502 -0
- entrygraph/extract/golang.py +342 -0
- entrygraph/extract/ir.py +105 -0
- entrygraph/extract/java.py +329 -0
- entrygraph/extract/javascript.py +400 -0
- entrygraph/extract/php.py +426 -0
- entrygraph/extract/python.py +390 -0
- entrygraph/extract/registry.py +43 -0
- entrygraph/extract/ruby.py +321 -0
- entrygraph/extract/rust.py +482 -0
- entrygraph/fs/__init__.py +0 -0
- entrygraph/fs/hashing.py +78 -0
- entrygraph/fs/lang.py +134 -0
- entrygraph/fs/walker.py +167 -0
- entrygraph/graph/__init__.py +0 -0
- entrygraph/graph/adjacency.py +146 -0
- entrygraph/graph/cte.py +123 -0
- entrygraph/graph/scoring.py +101 -0
- entrygraph/kinds.py +51 -0
- entrygraph/parsing/__init__.py +0 -0
- entrygraph/parsing/parsers.py +49 -0
- entrygraph/parsing/queries.py +39 -0
- entrygraph/pipeline/__init__.py +0 -0
- entrygraph/pipeline/scanner.py +506 -0
- entrygraph/pipeline/worker.py +49 -0
- entrygraph/pipeline/writer.py +41 -0
- entrygraph/py.typed +0 -0
- entrygraph/queries/csharp/calls.scm +4 -0
- entrygraph/queries/csharp/definitions.scm +29 -0
- entrygraph/queries/csharp/imports.scm +4 -0
- entrygraph/queries/go/calls.scm +2 -0
- entrygraph/queries/go/definitions.scm +24 -0
- entrygraph/queries/go/imports.scm +1 -0
- entrygraph/queries/java/calls.scm +2 -0
- entrygraph/queries/java/definitions.scm +14 -0
- entrygraph/queries/java/imports.scm +2 -0
- entrygraph/queries/javascript/calls.scm +4 -0
- entrygraph/queries/javascript/definitions.scm +4 -0
- entrygraph/queries/javascript/imports.scm +6 -0
- entrygraph/queries/php/calls.scm +8 -0
- entrygraph/queries/php/definitions.scm +24 -0
- entrygraph/queries/php/imports.scm +1 -0
- entrygraph/queries/python/calls.scm +2 -0
- entrygraph/queries/python/definitions.scm +11 -0
- entrygraph/queries/python/imports.scm +2 -0
- entrygraph/queries/ruby/calls.scm +4 -0
- entrygraph/queries/ruby/definitions.scm +20 -0
- entrygraph/queries/ruby/imports.scm +7 -0
- entrygraph/queries/rust/calls.scm +5 -0
- entrygraph/queries/rust/definitions.scm +26 -0
- entrygraph/queries/rust/imports.scm +4 -0
- entrygraph/resolve/__init__.py +0 -0
- entrygraph/resolve/externals.py +61 -0
- entrygraph/resolve/hierarchy.py +152 -0
- entrygraph/resolve/resolver.py +275 -0
- entrygraph/resolve/symbol_table.py +48 -0
- entrygraph/results.py +138 -0
- entrygraph-0.1.0.dist-info/METADATA +204 -0
- entrygraph-0.1.0.dist-info/RECORD +102 -0
- entrygraph-0.1.0.dist-info/WHEEL +4 -0
- entrygraph-0.1.0.dist-info/entry_points.txt +2 -0
- entrygraph-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
"""Rust extractor: .scm queries harvest nodes, this shaper builds the IR.
|
|
2
|
+
|
|
3
|
+
Rust qualified names are module-path based. The module path is derived from the
|
|
4
|
+
file's location under ``src/``: ``src/main.rs``/``src/lib.rs`` collapse to the
|
|
5
|
+
crate root (``_root``); ``src/foo.rs`` and ``src/foo/mod.rs`` both map to module
|
|
6
|
+
``foo`` (``mod.rs`` behaves like Python's ``__init__.py`` -> ``is_package=True``).
|
|
7
|
+
|
|
8
|
+
``::`` path separators are normalized to ``.`` throughout qnames so externals
|
|
9
|
+
look like ``rs:std.process.Command.new`` and dot-based sink globs match.
|
|
10
|
+
|
|
11
|
+
``impl Foo { fn bar }`` yields a method ``<mod>.Foo.bar`` parented to
|
|
12
|
+
``<mod>.Foo``; ``impl Trait for Foo`` attaches the methods to ``Foo`` and emits
|
|
13
|
+
an ``inherit`` reference to ``Trait``. Free functions live at module scope.
|
|
14
|
+
Attributes preceding an item (``#[get("/x")]``, ``#[tokio::main]``,
|
|
15
|
+
``#[derive(Parser)]``) are captured as ``RawSymbol.decorators`` (raw text) and
|
|
16
|
+
also emitted as ``RawReference(kind="decorator")`` (the Java pattern).
|
|
17
|
+
|
|
18
|
+
``use`` declarations bind aliases into local scope and each import emits a
|
|
19
|
+
crate-root framework signal (``axum``, ``tokio``, ...) so detection fires. A
|
|
20
|
+
``macro_invocation`` (``sqlx::query!``) is emitted as a ``call`` reference with
|
|
21
|
+
the trailing ``!`` stripped so sink patterns can match the generated call.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
27
|
+
|
|
28
|
+
from entrygraph.extract.base import FileContext, node_text, span_of, truncate
|
|
29
|
+
from entrygraph.extract.ir import FileExtraction, RawImport, RawReference, RawSymbol
|
|
30
|
+
from entrygraph.kinds import SymbolKind
|
|
31
|
+
from entrygraph.parsing.queries import captures, load_query
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
34
|
+
from tree_sitter import Node, Tree
|
|
35
|
+
|
|
36
|
+
_SRC_ROOTS = ("src",)
|
|
37
|
+
_CRATE_ROOT = "_root"
|
|
38
|
+
# Attribute nodes precede an item as leading siblings.
|
|
39
|
+
_ATTRIBUTE = "attribute_item"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _norm(text: str) -> str:
|
|
43
|
+
"""Normalize Rust path separators to dot-qname form."""
|
|
44
|
+
return text.replace("::", ".")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class RustExtractor:
|
|
48
|
+
language_ids: ClassVar[tuple[str, ...]] = ("rust",)
|
|
49
|
+
|
|
50
|
+
def module_path_for(self, repo_relative_path: str) -> tuple[str, bool]:
|
|
51
|
+
parts = repo_relative_path.split("/")
|
|
52
|
+
if parts and parts[0] in _SRC_ROOTS and len(parts) > 1:
|
|
53
|
+
parts = parts[1:]
|
|
54
|
+
# crate roots collapse to _root
|
|
55
|
+
if parts and parts[-1] in ("main.rs", "lib.rs") and len(parts) == 1:
|
|
56
|
+
return _CRATE_ROOT, False
|
|
57
|
+
is_package = parts[-1] == "mod.rs"
|
|
58
|
+
if is_package:
|
|
59
|
+
parts = parts[:-1]
|
|
60
|
+
else:
|
|
61
|
+
parts[-1] = parts[-1].removesuffix(".rs")
|
|
62
|
+
module = ".".join(p for p in parts if p)
|
|
63
|
+
return module or _CRATE_ROOT, is_package
|
|
64
|
+
|
|
65
|
+
def extract(self, tree: "Tree", ctx: FileContext) -> FileExtraction:
|
|
66
|
+
root = tree.root_node
|
|
67
|
+
out = FileExtraction(
|
|
68
|
+
path=ctx.path,
|
|
69
|
+
language=ctx.language,
|
|
70
|
+
module_path=ctx.module_path,
|
|
71
|
+
parse_ok=not root.has_error,
|
|
72
|
+
error_count=1 if root.has_error else 0,
|
|
73
|
+
)
|
|
74
|
+
self._extract_definitions(root, ctx, out)
|
|
75
|
+
self._extract_imports(root, ctx, out)
|
|
76
|
+
self._extract_calls(root, ctx, out)
|
|
77
|
+
return out
|
|
78
|
+
|
|
79
|
+
# ---------------- definitions ----------------
|
|
80
|
+
|
|
81
|
+
def _extract_definitions(self, root: "Node", ctx: FileContext, out: FileExtraction) -> None:
|
|
82
|
+
caps = captures(load_query("rust", "definitions"), root)
|
|
83
|
+
|
|
84
|
+
for node in caps.get("def.impl", []):
|
|
85
|
+
self._add_impl(node, ctx, out)
|
|
86
|
+
|
|
87
|
+
for node in caps.get("def.function", []):
|
|
88
|
+
if self._in_impl(node):
|
|
89
|
+
continue # impl methods are handled by _add_impl
|
|
90
|
+
name_node = node.child_by_field_name("name")
|
|
91
|
+
if name_node is None:
|
|
92
|
+
continue
|
|
93
|
+
name = node_text(name_node)
|
|
94
|
+
decorators = self._decorators(node)
|
|
95
|
+
qname = f"{ctx.module_path}.{name}"
|
|
96
|
+
out.symbols.append(
|
|
97
|
+
RawSymbol(
|
|
98
|
+
kind=SymbolKind.FUNCTION,
|
|
99
|
+
name=name,
|
|
100
|
+
qualified_name=qname,
|
|
101
|
+
span=span_of(node),
|
|
102
|
+
parent_qualified_name=None,
|
|
103
|
+
signature=self._signature(node),
|
|
104
|
+
decorators=decorators,
|
|
105
|
+
is_exported=self._is_pub(node),
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
self._emit_decorator_refs(node, qname, out)
|
|
109
|
+
|
|
110
|
+
for node in caps.get("def.struct", []):
|
|
111
|
+
self._add_type(node, ctx, out, SymbolKind.STRUCT)
|
|
112
|
+
for node in caps.get("def.enum", []):
|
|
113
|
+
self._add_type(node, ctx, out, SymbolKind.STRUCT)
|
|
114
|
+
for node in caps.get("def.trait", []):
|
|
115
|
+
self._add_type(node, ctx, out, SymbolKind.INTERFACE)
|
|
116
|
+
|
|
117
|
+
for node in caps.get("def.const", []):
|
|
118
|
+
self._add_value(node, ctx, out, SymbolKind.CONSTANT)
|
|
119
|
+
for node in caps.get("def.static", []):
|
|
120
|
+
self._add_value(node, ctx, out, SymbolKind.CONSTANT)
|
|
121
|
+
for node in caps.get("def.mod", []):
|
|
122
|
+
name_node = node.child_by_field_name("name")
|
|
123
|
+
if name_node is None:
|
|
124
|
+
continue
|
|
125
|
+
name = node_text(name_node)
|
|
126
|
+
out.symbols.append(
|
|
127
|
+
RawSymbol(
|
|
128
|
+
kind=SymbolKind.MODULE,
|
|
129
|
+
name=name,
|
|
130
|
+
qualified_name=f"{ctx.module_path}.{name}",
|
|
131
|
+
span=span_of(node),
|
|
132
|
+
parent_qualified_name=None,
|
|
133
|
+
signature=self._signature(node),
|
|
134
|
+
is_exported=self._is_pub(node),
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def _add_type(self, node: "Node", ctx: FileContext, out: FileExtraction, kind: SymbolKind) -> None:
|
|
139
|
+
name_node = node.child_by_field_name("name")
|
|
140
|
+
if name_node is None:
|
|
141
|
+
return
|
|
142
|
+
name = node_text(name_node)
|
|
143
|
+
qname = f"{ctx.module_path}.{name}"
|
|
144
|
+
out.symbols.append(
|
|
145
|
+
RawSymbol(
|
|
146
|
+
kind=kind,
|
|
147
|
+
name=name,
|
|
148
|
+
qualified_name=qname,
|
|
149
|
+
span=span_of(node),
|
|
150
|
+
parent_qualified_name=None,
|
|
151
|
+
signature=self._signature(node),
|
|
152
|
+
decorators=self._decorators(node),
|
|
153
|
+
is_exported=self._is_pub(node),
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
self._emit_decorator_refs(node, qname, out)
|
|
157
|
+
|
|
158
|
+
def _add_value(self, node: "Node", ctx: FileContext, out: FileExtraction, kind: SymbolKind) -> None:
|
|
159
|
+
name_node = node.child_by_field_name("name")
|
|
160
|
+
if name_node is None:
|
|
161
|
+
return
|
|
162
|
+
name = node_text(name_node)
|
|
163
|
+
out.symbols.append(
|
|
164
|
+
RawSymbol(
|
|
165
|
+
kind=kind,
|
|
166
|
+
name=name,
|
|
167
|
+
qualified_name=f"{ctx.module_path}.{name}",
|
|
168
|
+
span=span_of(node),
|
|
169
|
+
parent_qualified_name=None,
|
|
170
|
+
signature=truncate(node_text(node)),
|
|
171
|
+
is_exported=self._is_pub(node),
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
def _add_impl(self, node: "Node", ctx: FileContext, out: FileExtraction) -> None:
|
|
176
|
+
"""`impl Foo { .. }` or `impl Trait for Foo { .. }` -> methods on Foo."""
|
|
177
|
+
type_node = node.child_by_field_name("type")
|
|
178
|
+
if type_node is None:
|
|
179
|
+
return
|
|
180
|
+
type_name = self._type_name(type_node)
|
|
181
|
+
if type_name is None:
|
|
182
|
+
return
|
|
183
|
+
parent_q = f"{ctx.module_path}.{type_name}"
|
|
184
|
+
|
|
185
|
+
trait_node = node.child_by_field_name("trait")
|
|
186
|
+
if trait_node is not None:
|
|
187
|
+
trait_name = self._type_name(trait_node)
|
|
188
|
+
if trait_name:
|
|
189
|
+
out.references.append(
|
|
190
|
+
RawReference(
|
|
191
|
+
kind="inherit",
|
|
192
|
+
callee_text=_norm(trait_name),
|
|
193
|
+
callee_name=_norm(trait_name).rsplit(".", 1)[-1],
|
|
194
|
+
receiver_text=None,
|
|
195
|
+
span=span_of(node),
|
|
196
|
+
caller_qualified_name=parent_q,
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
body = node.child_by_field_name("body")
|
|
201
|
+
if body is None:
|
|
202
|
+
return
|
|
203
|
+
for child in body.named_children:
|
|
204
|
+
if child.type != "function_item":
|
|
205
|
+
continue
|
|
206
|
+
name_node = child.child_by_field_name("name")
|
|
207
|
+
if name_node is None:
|
|
208
|
+
continue
|
|
209
|
+
name = node_text(name_node)
|
|
210
|
+
qname = f"{parent_q}.{name}"
|
|
211
|
+
out.symbols.append(
|
|
212
|
+
RawSymbol(
|
|
213
|
+
kind=SymbolKind.METHOD,
|
|
214
|
+
name=name,
|
|
215
|
+
qualified_name=qname,
|
|
216
|
+
span=span_of(child),
|
|
217
|
+
parent_qualified_name=parent_q,
|
|
218
|
+
signature=self._signature(child),
|
|
219
|
+
decorators=self._decorators(child),
|
|
220
|
+
is_exported=self._is_pub(child),
|
|
221
|
+
)
|
|
222
|
+
)
|
|
223
|
+
self._emit_decorator_refs(child, qname, out)
|
|
224
|
+
|
|
225
|
+
# ---------------- imports ----------------
|
|
226
|
+
|
|
227
|
+
def _extract_imports(self, root: "Node", ctx: FileContext, out: FileExtraction) -> None:
|
|
228
|
+
caps = captures(load_query("rust", "imports"), root)
|
|
229
|
+
for node in caps.get("import", []):
|
|
230
|
+
arg = next(
|
|
231
|
+
(c for c in node.named_children if c.type != "visibility_modifier"), None
|
|
232
|
+
)
|
|
233
|
+
if arg is None:
|
|
234
|
+
continue
|
|
235
|
+
self._unroll_use(arg, "", node, out)
|
|
236
|
+
for imp in out.imports:
|
|
237
|
+
if imp.module:
|
|
238
|
+
out.framework_signals.append(("import", imp.module.split(".")[0]))
|
|
239
|
+
|
|
240
|
+
def _unroll_use(self, node: "Node", prefix: str, decl: "Node", out: FileExtraction) -> None:
|
|
241
|
+
"""Recursively flatten a use-tree into RawImport rows.
|
|
242
|
+
|
|
243
|
+
`prefix` is the accumulated dotted module path to the left of `node`.
|
|
244
|
+
"""
|
|
245
|
+
t = node.type
|
|
246
|
+
if t == "scoped_identifier":
|
|
247
|
+
full = self._join(prefix, _norm(node_text(node)))
|
|
248
|
+
alias = full.rsplit(".", 1)[-1]
|
|
249
|
+
out.imports.append(
|
|
250
|
+
RawImport(module=full, imported_name=None, alias=alias, span=span_of(decl))
|
|
251
|
+
)
|
|
252
|
+
elif t == "identifier":
|
|
253
|
+
full = self._join(prefix, node_text(node))
|
|
254
|
+
alias = full.rsplit(".", 1)[-1]
|
|
255
|
+
out.imports.append(
|
|
256
|
+
RawImport(module=full, imported_name=None, alias=alias, span=span_of(decl))
|
|
257
|
+
)
|
|
258
|
+
elif t == "use_as_clause":
|
|
259
|
+
path_node = node.child_by_field_name("path")
|
|
260
|
+
alias_node = node.child_by_field_name("alias")
|
|
261
|
+
if path_node is None:
|
|
262
|
+
path_node = node.named_children[0] if node.named_children else None
|
|
263
|
+
if alias_node is None and len(node.named_children) > 1:
|
|
264
|
+
alias_node = node.named_children[-1]
|
|
265
|
+
if path_node is None or alias_node is None:
|
|
266
|
+
return
|
|
267
|
+
full = self._join(prefix, _norm(node_text(path_node)))
|
|
268
|
+
out.imports.append(
|
|
269
|
+
RawImport(
|
|
270
|
+
module=full, imported_name=None, alias=node_text(alias_node), span=span_of(decl)
|
|
271
|
+
)
|
|
272
|
+
)
|
|
273
|
+
elif t == "scoped_use_list":
|
|
274
|
+
path_node = node.child_by_field_name("path")
|
|
275
|
+
base = _norm(node_text(path_node)) if path_node is not None else ""
|
|
276
|
+
new_prefix = self._join(prefix, base)
|
|
277
|
+
list_node = next((c for c in node.named_children if c.type == "use_list"), None)
|
|
278
|
+
if list_node is not None:
|
|
279
|
+
for child in list_node.named_children:
|
|
280
|
+
self._unroll_use(child, new_prefix, decl, out)
|
|
281
|
+
elif t == "use_list":
|
|
282
|
+
for child in node.named_children:
|
|
283
|
+
self._unroll_use(child, prefix, decl, out)
|
|
284
|
+
elif t == "use_wildcard":
|
|
285
|
+
path_node = next(
|
|
286
|
+
(c for c in node.named_children if c.type in ("scoped_identifier", "identifier")),
|
|
287
|
+
None,
|
|
288
|
+
)
|
|
289
|
+
base = _norm(node_text(path_node)) if path_node is not None else prefix
|
|
290
|
+
module = self._join(prefix, base) if path_node is not None else prefix
|
|
291
|
+
out.imports.append(
|
|
292
|
+
RawImport(module=module, imported_name="*", alias="*", span=span_of(decl))
|
|
293
|
+
)
|
|
294
|
+
elif t == "self":
|
|
295
|
+
# `use std::fs::{self, ..}` -> bind the module itself under its last segment
|
|
296
|
+
alias = prefix.rsplit(".", 1)[-1] if prefix else "self"
|
|
297
|
+
out.imports.append(
|
|
298
|
+
RawImport(module=prefix, imported_name=None, alias=alias, span=span_of(decl))
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
@staticmethod
|
|
302
|
+
def _join(prefix: str, rest: str) -> str:
|
|
303
|
+
if not prefix:
|
|
304
|
+
return rest
|
|
305
|
+
if not rest:
|
|
306
|
+
return prefix
|
|
307
|
+
return f"{prefix}.{rest}"
|
|
308
|
+
|
|
309
|
+
# ---------------- calls / macros ----------------
|
|
310
|
+
|
|
311
|
+
def _extract_calls(self, root: "Node", ctx: FileContext, out: FileExtraction) -> None:
|
|
312
|
+
caps = captures(load_query("rust", "calls"), root)
|
|
313
|
+
|
|
314
|
+
for node in caps.get("call", []):
|
|
315
|
+
fn = node.child_by_field_name("function")
|
|
316
|
+
if fn is None:
|
|
317
|
+
continue
|
|
318
|
+
args = node.child_by_field_name("arguments")
|
|
319
|
+
if fn.type == "identifier":
|
|
320
|
+
callee_text = callee_name = node_text(fn)
|
|
321
|
+
receiver = None
|
|
322
|
+
elif fn.type == "scoped_identifier":
|
|
323
|
+
text = _norm(node_text(fn))
|
|
324
|
+
callee_text = text
|
|
325
|
+
callee_name = text.rsplit(".", 1)[-1]
|
|
326
|
+
receiver = text.rsplit(".", 1)[0] if "." in text else None
|
|
327
|
+
elif fn.type == "field_expression":
|
|
328
|
+
field = fn.child_by_field_name("field")
|
|
329
|
+
value = fn.child_by_field_name("value")
|
|
330
|
+
if field is None:
|
|
331
|
+
continue
|
|
332
|
+
callee_name = node_text(field)
|
|
333
|
+
receiver = _norm(node_text(value)) if value is not None else None
|
|
334
|
+
callee_text = f"{receiver}.{callee_name}" if receiver else callee_name
|
|
335
|
+
else:
|
|
336
|
+
continue
|
|
337
|
+
out.references.append(
|
|
338
|
+
RawReference(
|
|
339
|
+
kind="call",
|
|
340
|
+
callee_text=callee_text,
|
|
341
|
+
callee_name=callee_name,
|
|
342
|
+
receiver_text=receiver,
|
|
343
|
+
span=span_of(node),
|
|
344
|
+
caller_qualified_name=self._caller(node, ctx),
|
|
345
|
+
arg_count=len(args.named_children) if args is not None else 0,
|
|
346
|
+
arg_preview=truncate(node_text(args)) if args is not None else None,
|
|
347
|
+
)
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
for node in caps.get("macro", []):
|
|
351
|
+
path_node = node.child_by_field_name("macro")
|
|
352
|
+
if path_node is None:
|
|
353
|
+
path_node = next(
|
|
354
|
+
(c for c in node.named_children
|
|
355
|
+
if c.type in ("scoped_identifier", "identifier")),
|
|
356
|
+
None,
|
|
357
|
+
)
|
|
358
|
+
if path_node is None:
|
|
359
|
+
continue
|
|
360
|
+
text = _norm(node_text(path_node)) # `!` is not part of the path node
|
|
361
|
+
callee_name = text.rsplit(".", 1)[-1]
|
|
362
|
+
receiver = text.rsplit(".", 1)[0] if "." in text else None
|
|
363
|
+
token_tree = next(
|
|
364
|
+
(c for c in node.named_children if c.type == "token_tree"), None
|
|
365
|
+
)
|
|
366
|
+
out.references.append(
|
|
367
|
+
RawReference(
|
|
368
|
+
kind="call",
|
|
369
|
+
callee_text=text,
|
|
370
|
+
callee_name=callee_name,
|
|
371
|
+
receiver_text=receiver,
|
|
372
|
+
span=span_of(node),
|
|
373
|
+
caller_qualified_name=self._caller(node, ctx),
|
|
374
|
+
arg_count=len([c for c in token_tree.named_children]) if token_tree else 0,
|
|
375
|
+
arg_preview=truncate(node_text(token_tree)) if token_tree else None,
|
|
376
|
+
)
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
# ---------------- attributes (decorators) ----------------
|
|
380
|
+
|
|
381
|
+
def _attribute_nodes(self, node: "Node") -> list["Node"]:
|
|
382
|
+
"""Leading `attribute_item` siblings immediately preceding an item."""
|
|
383
|
+
attrs: list[Node] = []
|
|
384
|
+
prev = node.prev_named_sibling
|
|
385
|
+
while prev is not None and prev.type == _ATTRIBUTE:
|
|
386
|
+
attrs.append(prev)
|
|
387
|
+
prev = prev.prev_named_sibling
|
|
388
|
+
attrs.reverse()
|
|
389
|
+
return attrs
|
|
390
|
+
|
|
391
|
+
def _decorators(self, node: "Node") -> list[str]:
|
|
392
|
+
return [node_text(a) for a in self._attribute_nodes(node)]
|
|
393
|
+
|
|
394
|
+
def _emit_decorator_refs(self, node: "Node", owner_qname: str, out: FileExtraction) -> None:
|
|
395
|
+
for attr in self._attribute_nodes(node):
|
|
396
|
+
inner = next((c for c in attr.named_children if c.type == "attribute"), None)
|
|
397
|
+
if inner is None:
|
|
398
|
+
continue
|
|
399
|
+
path_node = next(
|
|
400
|
+
(c for c in inner.named_children
|
|
401
|
+
if c.type in ("scoped_identifier", "identifier")),
|
|
402
|
+
None,
|
|
403
|
+
)
|
|
404
|
+
if path_node is None:
|
|
405
|
+
continue
|
|
406
|
+
callee_text = _norm(node_text(path_node))
|
|
407
|
+
out.references.append(
|
|
408
|
+
RawReference(
|
|
409
|
+
kind="decorator",
|
|
410
|
+
callee_text=callee_text,
|
|
411
|
+
callee_name=callee_text.rsplit(".", 1)[-1],
|
|
412
|
+
receiver_text=callee_text.rsplit(".", 1)[0] if "." in callee_text else None,
|
|
413
|
+
span=span_of(attr),
|
|
414
|
+
caller_qualified_name=owner_qname,
|
|
415
|
+
)
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
# ---------------- walking helpers ----------------
|
|
419
|
+
|
|
420
|
+
def _in_impl(self, node: "Node") -> bool:
|
|
421
|
+
"""True if this function_item is a direct member of an impl body.
|
|
422
|
+
|
|
423
|
+
Only direct impl members are handled by _add_impl; nested functions
|
|
424
|
+
inside a free function are still module-scope free functions.
|
|
425
|
+
"""
|
|
426
|
+
parent = node.parent
|
|
427
|
+
if parent is not None and parent.type == "declaration_list":
|
|
428
|
+
grand = parent.parent
|
|
429
|
+
return grand is not None and grand.type == "impl_item"
|
|
430
|
+
return False
|
|
431
|
+
|
|
432
|
+
def _type_name(self, node: "Node | None") -> str | None:
|
|
433
|
+
if node is None:
|
|
434
|
+
return None
|
|
435
|
+
if node.type in ("type_identifier", "identifier"):
|
|
436
|
+
return node_text(node)
|
|
437
|
+
if node.type in ("scoped_type_identifier", "scoped_identifier"):
|
|
438
|
+
return node_text(node).rsplit("::", 1)[-1]
|
|
439
|
+
if node.type == "generic_type":
|
|
440
|
+
base = node.child_by_field_name("type") or (
|
|
441
|
+
node.named_children[0] if node.named_children else None
|
|
442
|
+
)
|
|
443
|
+
return self._type_name(base)
|
|
444
|
+
# fall back to raw text's last segment
|
|
445
|
+
return node_text(node).split("<", 1)[0].rsplit("::", 1)[-1] or None
|
|
446
|
+
|
|
447
|
+
def _is_pub(self, node: "Node") -> bool:
|
|
448
|
+
return any(c.type == "visibility_modifier" for c in node.named_children)
|
|
449
|
+
|
|
450
|
+
def _caller(self, node: "Node", ctx: FileContext) -> str | None:
|
|
451
|
+
"""FQN of the enclosing function/method, or None for module level."""
|
|
452
|
+
current = node.parent
|
|
453
|
+
while current is not None:
|
|
454
|
+
if current.type == "function_item":
|
|
455
|
+
name_node = current.child_by_field_name("name")
|
|
456
|
+
if name_node is None:
|
|
457
|
+
current = current.parent
|
|
458
|
+
continue
|
|
459
|
+
name = node_text(name_node)
|
|
460
|
+
impl = self._enclosing_impl(current)
|
|
461
|
+
if impl is not None:
|
|
462
|
+
type_node = impl.child_by_field_name("type")
|
|
463
|
+
type_name = self._type_name(type_node)
|
|
464
|
+
if type_name:
|
|
465
|
+
return f"{ctx.module_path}.{type_name}.{name}"
|
|
466
|
+
return f"{ctx.module_path}.{name}"
|
|
467
|
+
current = current.parent
|
|
468
|
+
return None
|
|
469
|
+
|
|
470
|
+
def _enclosing_impl(self, node: "Node") -> "Node | None":
|
|
471
|
+
current = node.parent
|
|
472
|
+
while current is not None:
|
|
473
|
+
if current.type == "impl_item":
|
|
474
|
+
return current
|
|
475
|
+
if current.type == "function_item":
|
|
476
|
+
return None # nested fn inside a free fn
|
|
477
|
+
current = current.parent
|
|
478
|
+
return None
|
|
479
|
+
|
|
480
|
+
def _signature(self, node: "Node") -> str:
|
|
481
|
+
first_line = node_text(node).split("\n", 1)[0].rstrip("{").strip()
|
|
482
|
+
return truncate(first_line, 120)
|
|
File without changes
|
entrygraph/fs/hashing.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Content hashing and change detection against the DB's file table."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
from entrygraph.fs.walker import WalkedFile
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def hash_file(abs_path: str) -> str:
|
|
12
|
+
h = hashlib.blake2b(digest_size=16)
|
|
13
|
+
with open(abs_path, "rb") as fh:
|
|
14
|
+
for chunk in iter(lambda: fh.read(1 << 20), b""):
|
|
15
|
+
h.update(chunk)
|
|
16
|
+
return h.hexdigest()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(slots=True)
|
|
20
|
+
class FileState:
|
|
21
|
+
content_hash: str
|
|
22
|
+
size_bytes: int
|
|
23
|
+
mtime_ns: int
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(slots=True)
|
|
27
|
+
class Diff:
|
|
28
|
+
added: list[WalkedFile] = field(default_factory=list)
|
|
29
|
+
changed: list[WalkedFile] = field(default_factory=list)
|
|
30
|
+
unchanged: list[WalkedFile] = field(default_factory=list)
|
|
31
|
+
deleted_paths: list[str] = field(default_factory=list)
|
|
32
|
+
hashes: dict[str, str] = field(default_factory=dict) # path -> content_hash
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def to_index(self) -> list[WalkedFile]:
|
|
36
|
+
return [*self.added, *self.changed]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def diff_files(
|
|
40
|
+
walked: list[WalkedFile],
|
|
41
|
+
known: dict[str, FileState],
|
|
42
|
+
*,
|
|
43
|
+
paranoid: bool = False,
|
|
44
|
+
) -> Diff:
|
|
45
|
+
"""Classify walked files vs the DB's recorded state.
|
|
46
|
+
|
|
47
|
+
Fast path: identical size+mtime is assumed unchanged (skips hashing) unless
|
|
48
|
+
``paranoid``. Otherwise the file is hashed and compared.
|
|
49
|
+
"""
|
|
50
|
+
diff = Diff()
|
|
51
|
+
seen: set[str] = set()
|
|
52
|
+
for wf in walked:
|
|
53
|
+
seen.add(wf.path)
|
|
54
|
+
prior = known.get(wf.path)
|
|
55
|
+
if prior is None:
|
|
56
|
+
if not wf.skip_reason:
|
|
57
|
+
diff.hashes[wf.path] = hash_file(wf.abs_path)
|
|
58
|
+
diff.added.append(wf)
|
|
59
|
+
continue
|
|
60
|
+
if (
|
|
61
|
+
not paranoid
|
|
62
|
+
and prior.size_bytes == wf.size_bytes
|
|
63
|
+
and prior.mtime_ns == wf.mtime_ns
|
|
64
|
+
):
|
|
65
|
+
diff.unchanged.append(wf)
|
|
66
|
+
diff.hashes[wf.path] = prior.content_hash
|
|
67
|
+
continue
|
|
68
|
+
new_hash = hash_file(wf.abs_path) if not wf.skip_reason else prior.content_hash
|
|
69
|
+
diff.hashes[wf.path] = new_hash
|
|
70
|
+
if new_hash == prior.content_hash:
|
|
71
|
+
diff.unchanged.append(wf)
|
|
72
|
+
else:
|
|
73
|
+
diff.changed.append(wf)
|
|
74
|
+
|
|
75
|
+
for path in known:
|
|
76
|
+
if path not in seen:
|
|
77
|
+
diff.deleted_paths.append(path)
|
|
78
|
+
return diff
|
entrygraph/fs/lang.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Language detection: extension map, filename map, shebang sniff.
|
|
2
|
+
|
|
3
|
+
Deliberately dependency-free. Recognizes more languages than we extract so
|
|
4
|
+
repo language stats are honest; ``EXTRACTABLE`` marks the ones with extractors.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import PurePosixPath
|
|
12
|
+
|
|
13
|
+
EXTRACTABLE = frozenset(
|
|
14
|
+
{"python", "javascript", "typescript", "tsx", "go", "java", "ruby",
|
|
15
|
+
"csharp", "php", "rust"}
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
_EXTENSION_MAP = {
|
|
19
|
+
".py": "python",
|
|
20
|
+
".pyi": "python",
|
|
21
|
+
".js": "javascript",
|
|
22
|
+
".mjs": "javascript",
|
|
23
|
+
".cjs": "javascript",
|
|
24
|
+
".jsx": "javascript",
|
|
25
|
+
".ts": "typescript",
|
|
26
|
+
".mts": "typescript",
|
|
27
|
+
".cts": "typescript",
|
|
28
|
+
".tsx": "tsx",
|
|
29
|
+
".go": "go",
|
|
30
|
+
".java": "java",
|
|
31
|
+
".rb": "ruby",
|
|
32
|
+
".rake": "ruby",
|
|
33
|
+
".gemspec": "ruby",
|
|
34
|
+
# recognized but not extracted — kept for honest stats
|
|
35
|
+
".c": "c",
|
|
36
|
+
".h": "c",
|
|
37
|
+
".cpp": "cpp",
|
|
38
|
+
".cc": "cpp",
|
|
39
|
+
".hpp": "cpp",
|
|
40
|
+
".cs": "csharp",
|
|
41
|
+
".rs": "rust",
|
|
42
|
+
".kt": "kotlin",
|
|
43
|
+
".swift": "swift",
|
|
44
|
+
".php": "php",
|
|
45
|
+
".phtml": "php",
|
|
46
|
+
".scala": "scala",
|
|
47
|
+
".sh": "shell",
|
|
48
|
+
".bash": "shell",
|
|
49
|
+
".zsh": "shell",
|
|
50
|
+
".sql": "sql",
|
|
51
|
+
".html": "html",
|
|
52
|
+
".css": "css",
|
|
53
|
+
".scss": "css",
|
|
54
|
+
".json": "json",
|
|
55
|
+
".yaml": "yaml",
|
|
56
|
+
".yml": "yaml",
|
|
57
|
+
".toml": "toml",
|
|
58
|
+
".md": "markdown",
|
|
59
|
+
".rst": "restructuredtext",
|
|
60
|
+
".xml": "xml",
|
|
61
|
+
".tf": "hcl",
|
|
62
|
+
".proto": "protobuf",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
_FILENAME_MAP = {
|
|
66
|
+
"Rakefile": "ruby",
|
|
67
|
+
"Gemfile": "ruby",
|
|
68
|
+
"Guardfile": "ruby",
|
|
69
|
+
"Vagrantfile": "ruby",
|
|
70
|
+
"Jenkinsfile": "groovy",
|
|
71
|
+
"Dockerfile": "dockerfile",
|
|
72
|
+
"Makefile": "make",
|
|
73
|
+
"BUILD": "starlark",
|
|
74
|
+
"WORKSPACE": "starlark",
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
_SHEBANG_RE = re.compile(rb"^#!\s*\S*?\b(?:env\s+)?(python3?|node|ruby)\b")
|
|
78
|
+
_SHEBANG_LANG = {b"python": "python", b"python3": "python", b"node": "javascript", b"ruby": "ruby"}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def detect_language(path: str, first_line: bytes | None = None) -> str | None:
|
|
82
|
+
"""Detect the language of a repo-relative file path.
|
|
83
|
+
|
|
84
|
+
``first_line`` (if provided) enables shebang sniffing for extensionless
|
|
85
|
+
executables; callers that have not read the file may omit it.
|
|
86
|
+
"""
|
|
87
|
+
p = PurePosixPath(path)
|
|
88
|
+
lang = _EXTENSION_MAP.get(p.suffix.lower())
|
|
89
|
+
if lang:
|
|
90
|
+
return lang
|
|
91
|
+
lang = _FILENAME_MAP.get(p.name)
|
|
92
|
+
if lang:
|
|
93
|
+
return lang
|
|
94
|
+
if not p.suffix and first_line:
|
|
95
|
+
match = _SHEBANG_RE.match(first_line)
|
|
96
|
+
if match:
|
|
97
|
+
return _SHEBANG_LANG.get(match.group(1))
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True, slots=True)
|
|
102
|
+
class LanguageStat:
|
|
103
|
+
name: str
|
|
104
|
+
file_count: int
|
|
105
|
+
byte_count: int
|
|
106
|
+
percent: float
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class RepoLanguageProfile:
|
|
110
|
+
"""Accumulates per-language file/byte counts during a walk."""
|
|
111
|
+
|
|
112
|
+
def __init__(self) -> None:
|
|
113
|
+
self._counts: dict[str, list[int]] = {} # lang -> [files, bytes]
|
|
114
|
+
|
|
115
|
+
def add(self, language: str | None, size_bytes: int) -> None:
|
|
116
|
+
if language is None:
|
|
117
|
+
return
|
|
118
|
+
entry = self._counts.setdefault(language, [0, 0])
|
|
119
|
+
entry[0] += 1
|
|
120
|
+
entry[1] += size_bytes
|
|
121
|
+
|
|
122
|
+
def stats(self) -> list[LanguageStat]:
|
|
123
|
+
total = sum(b for _, b in self._counts.values()) or 1
|
|
124
|
+
return sorted(
|
|
125
|
+
(
|
|
126
|
+
LanguageStat(name=lang, file_count=f, byte_count=b, percent=100.0 * b / total)
|
|
127
|
+
for lang, (f, b) in self._counts.items()
|
|
128
|
+
),
|
|
129
|
+
key=lambda s: s.byte_count,
|
|
130
|
+
reverse=True,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def extractable_languages(self) -> set[str]:
|
|
134
|
+
return {lang for lang in self._counts if lang in EXTRACTABLE}
|