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,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
@@ -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}