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
entrygraph/__init__.py ADDED
@@ -0,0 +1,87 @@
1
+ """entrygraph — query your codebase like a graph.
2
+
3
+ Index a repository into SQLite (via the SQLAlchemy ORM) and query symbols,
4
+ classes, methods, entrypoints, and source-to-sink call paths across languages.
5
+
6
+ from entrygraph import CodeGraph
7
+
8
+ graph = CodeGraph.index("/path/to/repo")
9
+ graph.entrypoints(framework="flask")
10
+ graph.paths(source="app.routes.*", sink_category="command_exec")
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ from entrygraph.errors import (
18
+ DatabaseNotFoundError,
19
+ EntrygraphError,
20
+ SchemaMismatchError,
21
+ SymbolNotFoundError,
22
+ )
23
+ from entrygraph.kinds import Confidence, EdgeKind, EntrypointKind, SymbolKind
24
+ from entrygraph.results import (
25
+ CallPath,
26
+ DetectedFramework,
27
+ DetectedLanguage,
28
+ DetectionReport,
29
+ Edge,
30
+ Entrypoint,
31
+ FileInfo,
32
+ GraphStats,
33
+ IndexStats,
34
+ PathEdge,
35
+ Symbol,
36
+ )
37
+
38
+ try:
39
+ # written at build time by hatch-vcs (see pyproject [tool.hatch.build.hooks.vcs])
40
+ from entrygraph._version import __version__
41
+ except ImportError: # running from a raw source tree that was never built
42
+ try:
43
+ from importlib.metadata import version as _pkg_version
44
+
45
+ __version__ = _pkg_version("entrygraph")
46
+ except Exception: # pragma: no cover - not installed at all
47
+ __version__ = "0.0.0"
48
+
49
+ if TYPE_CHECKING: # pragma: no cover
50
+ from entrygraph.api import CodeGraph
51
+
52
+ _LAZY = {"CodeGraph": ("entrygraph.api", "CodeGraph")}
53
+
54
+
55
+ def __getattr__(name: str) -> Any:
56
+ # CodeGraph pulls in the full indexing stack; keep `import entrygraph` light.
57
+ if name in _LAZY:
58
+ import importlib
59
+
60
+ module_name, attr = _LAZY[name]
61
+ return getattr(importlib.import_module(module_name), attr)
62
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
63
+
64
+
65
+ __all__ = [
66
+ "CallPath",
67
+ "CodeGraph",
68
+ "Confidence",
69
+ "DatabaseNotFoundError",
70
+ "DetectedFramework",
71
+ "DetectedLanguage",
72
+ "DetectionReport",
73
+ "Edge",
74
+ "EdgeKind",
75
+ "Entrypoint",
76
+ "EntrypointKind",
77
+ "EntrygraphError",
78
+ "FileInfo",
79
+ "GraphStats",
80
+ "IndexStats",
81
+ "PathEdge",
82
+ "SchemaMismatchError",
83
+ "Symbol",
84
+ "SymbolKind",
85
+ "SymbolNotFoundError",
86
+ "__version__",
87
+ ]
entrygraph/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Enable `python -m entrygraph` alongside the `entrygraph` console script."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from entrygraph.cli.main import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
entrygraph/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
entrygraph/api.py ADDED
@@ -0,0 +1,549 @@
1
+ """CodeGraph — the public facade.
2
+
3
+ Every method opens a short-lived ORM Session internally and returns detached,
4
+ frozen dataclasses; the Session never escapes (except via the explicit
5
+ ``session()`` escape hatch).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+ from typing import Iterator
13
+
14
+ from sqlalchemy import Engine, func, select, text
15
+ from sqlalchemy.orm import Session
16
+
17
+ from entrygraph.db import models
18
+ from entrygraph.db import queries as q
19
+ from entrygraph.db.engine import make_engine, make_session_factory
20
+ from entrygraph.db.meta import check_schema
21
+ from entrygraph.errors import (
22
+ DatabaseNotFoundError,
23
+ RepositoryNotIndexedError,
24
+ SymbolNotFoundError,
25
+ )
26
+ from entrygraph.graph.adjacency import AdjacencyCache, Hop
27
+ from entrygraph.graph.scoring import is_constant_args, score_path
28
+ from entrygraph.kinds import Confidence
29
+ from entrygraph.results import (
30
+ CallPath,
31
+ DetectedFramework,
32
+ DetectedLanguage,
33
+ DetectionReport,
34
+ Edge,
35
+ Entrypoint,
36
+ FileInfo,
37
+ GraphStats,
38
+ IndexStats,
39
+ PathEdge,
40
+ Symbol,
41
+ )
42
+
43
+ DEFAULT_DB_NAME = ".entrygraph.db"
44
+
45
+ SourceSpec = "str | Symbol | Entrypoint | list[str | Symbol | Entrypoint]"
46
+
47
+
48
+ def _traversal_params(
49
+ min_confidence: int | None, include_fuzzy: bool, include_unresolved: bool
50
+ ) -> tuple[int, bool]:
51
+ """Derive (confidence floor, include_cha) for traversal.
52
+
53
+ Default floor is FUZZY: EXACT/IMPORT/unique-name-FUZZY edges are traversed,
54
+ which keeps ordinary method dispatch on a local variable reachable. The
55
+ speculative class-hierarchy fan-out (via="cha") stays hidden until
56
+ `include_fuzzy=True`. `include_unresolved=True` lowers the floor to 0, which
57
+ admits UNRESOLVED wildcard-sink guesses (`py:*.execute`) and dynamic-call
58
+ placeholders. An explicit `min_confidence` int overrides the floor.
59
+ """
60
+ if min_confidence is not None:
61
+ floor = min_confidence
62
+ elif include_unresolved:
63
+ floor = int(Confidence.UNRESOLVED)
64
+ else:
65
+ floor = int(Confidence.FUZZY)
66
+ return floor, include_fuzzy
67
+
68
+
69
+ def _fully_sanitized(path: CallPath) -> bool:
70
+ """True if the terminal hop was neutralized by a sanitizer (risk driven to 0)."""
71
+ return path.risk_score == 0.0
72
+
73
+
74
+ class CodeGraph:
75
+ def __init__(self, engine: Engine) -> None:
76
+ self._engine = engine
77
+ self._session_factory = make_session_factory(engine)
78
+ self._adjacency: dict[tuple[frozenset[str], int], AdjacencyCache] = {}
79
+
80
+ # ---------------- construction ----------------
81
+
82
+ @classmethod
83
+ def index(cls, root: str | Path, db: str | Path | None = None) -> "CodeGraph":
84
+ """Index (or fully re-index) a repository and return an open graph."""
85
+ from entrygraph.pipeline.scanner import index_repository
86
+
87
+ root = Path(root).resolve()
88
+ db_path = Path(db) if db else root / DEFAULT_DB_NAME
89
+ engine = make_engine(db_path)
90
+ graph = cls(engine)
91
+ graph._last_index_stats = index_repository(root, engine)
92
+ return graph
93
+
94
+ @classmethod
95
+ def open(cls, db: str | Path) -> "CodeGraph":
96
+ db_path = Path(db)
97
+ if not db_path.exists():
98
+ raise DatabaseNotFoundError(f"no index database at {db_path}")
99
+ engine = make_engine(db_path)
100
+ check_schema(engine)
101
+ return cls(engine)
102
+
103
+ def close(self) -> None:
104
+ self._engine.dispose()
105
+
106
+ def __enter__(self) -> "CodeGraph":
107
+ return self
108
+
109
+ def __exit__(self, *exc) -> None:
110
+ self.close()
111
+
112
+ # ---------------- symbols / files ----------------
113
+
114
+ def symbols(
115
+ self,
116
+ *,
117
+ kind: str | None = None,
118
+ name: str | None = None,
119
+ qname: str | None = None,
120
+ file: str | None = None,
121
+ include_external: bool = False,
122
+ limit: int | None = None,
123
+ offset: int | None = None,
124
+ ) -> list[Symbol]:
125
+ with self._session_factory() as session:
126
+ return q.select_symbols(
127
+ session, kind=kind, name=name, qname=qname, file=file,
128
+ include_external=include_external, limit=limit, offset=offset,
129
+ )
130
+
131
+ def symbol(self, qname: str) -> Symbol:
132
+ matches = self.symbols(qname=qname, include_external=True, limit=2)
133
+ exact = [s for s in matches if s.qname == qname]
134
+ if not exact:
135
+ raise SymbolNotFoundError(f"no symbol with qname {qname!r}")
136
+ return exact[0]
137
+
138
+ def iter_symbols(self, *, batch_size: int = 1000, **filters) -> Iterator[Symbol]:
139
+ offset = 0
140
+ while True:
141
+ batch = self.symbols(**filters, limit=batch_size, offset=offset)
142
+ yield from batch
143
+ if len(batch) < batch_size:
144
+ return
145
+ offset += batch_size
146
+
147
+ def files(self, *, language: str | None = None, path: str | None = None) -> list[FileInfo]:
148
+ with self._session_factory() as session:
149
+ return q.select_files(session, language=language, path=path)
150
+
151
+ # ---------------- detection ----------------
152
+
153
+ def detect(self) -> DetectionReport:
154
+ with self._session_factory() as session:
155
+ rows = session.execute(
156
+ select(models.Detection).order_by(models.Detection.confidence.desc())
157
+ ).scalars().all()
158
+ languages = []
159
+ frameworks = []
160
+ for row in rows:
161
+ evidence = json.loads(row.evidence) if row.evidence else {}
162
+ if row.category == "language":
163
+ languages.append(
164
+ DetectedLanguage(
165
+ name=row.name,
166
+ file_count=evidence.get("files", 0),
167
+ byte_count=evidence.get("bytes", 0),
168
+ percent=evidence.get("percent", 0.0),
169
+ )
170
+ )
171
+ else:
172
+ frameworks.append(
173
+ DetectedFramework(
174
+ name=row.name,
175
+ language=evidence.get("language", ""),
176
+ confidence=row.confidence,
177
+ evidence=tuple(evidence.get("signals", [])),
178
+ )
179
+ )
180
+ languages.sort(key=lambda l: l.byte_count, reverse=True)
181
+ return DetectionReport(languages=tuple(languages), frameworks=tuple(frameworks))
182
+
183
+ # ---------------- entrypoints ----------------
184
+
185
+ def entrypoints(
186
+ self,
187
+ *,
188
+ kind: str | None = None,
189
+ framework: str | None = None,
190
+ route: str | None = None,
191
+ limit: int | None = None,
192
+ ) -> list[Entrypoint]:
193
+ with self._session_factory() as session:
194
+ return q.select_entrypoints(
195
+ session, kind=kind, framework=framework, route=route, limit=limit
196
+ )
197
+
198
+ # ---------------- traversal ----------------
199
+
200
+ def callers(self, target: "SourceSpec", *, depth: int = 1,
201
+ edge_kinds: tuple[str, ...] = ("calls",)) -> list[Symbol]:
202
+ return self._neighbors(target, depth, "in", edge_kinds)
203
+
204
+ def callees(self, target: "SourceSpec", *, depth: int = 1,
205
+ edge_kinds: tuple[str, ...] = ("calls",)) -> list[Symbol]:
206
+ return self._neighbors(target, depth, "out", edge_kinds)
207
+
208
+ def references(self, target: "SourceSpec") -> list[Edge]:
209
+ """All inbound edges (any kind) to the matching symbols."""
210
+ with self._session_factory() as session:
211
+ ids = self._spec_to_ids(session, target)
212
+ if not ids:
213
+ return []
214
+ rows = session.execute(
215
+ select(models.Edge).where(models.Edge.dst_symbol_id.in_(ids))
216
+ ).scalars().all()
217
+ src_map = q.symbols_by_ids(session, {r.src_symbol_id for r in rows})
218
+ return [
219
+ Edge(
220
+ id=r.id,
221
+ kind=r.kind.value,
222
+ src_qname=src_map[r.src_symbol_id].qname if r.src_symbol_id in src_map else "?",
223
+ dst_qname=r.dst_qname,
224
+ resolved=r.dst_symbol_id is not None,
225
+ line=r.line,
226
+ confidence=r.confidence,
227
+ file=src_map[r.src_symbol_id].file if r.src_symbol_id in src_map else None,
228
+ sink_id=r.sink_id,
229
+ arg_preview=r.arg_preview,
230
+ )
231
+ for r in rows
232
+ ]
233
+
234
+ # ---------------- reachability ----------------
235
+
236
+ def paths(
237
+ self,
238
+ *,
239
+ source: "SourceSpec",
240
+ sink: "SourceSpec | None" = None,
241
+ sink_category: str | None = None,
242
+ max_depth: int = 25,
243
+ max_paths: int = 10,
244
+ edge_kinds: tuple[str, ...] = ("calls",),
245
+ min_confidence: int | None = None,
246
+ include_fuzzy: bool = False,
247
+ include_unresolved: bool = False,
248
+ include_callbacks: bool = False,
249
+ prune_sanitized: bool = False,
250
+ engine: str = "memory",
251
+ ) -> list[CallPath]:
252
+ """Enumerate source->sink call paths, risk-ranked (highest first).
253
+
254
+ By default only EXACT/IMPORT edges are traversed; `include_fuzzy` /
255
+ `include_unresolved` lower the confidence floor (an explicit
256
+ `min_confidence` int overrides the flags). `include_callbacks` also
257
+ follows PASSED_AS_CALLBACK edges. Each returned path carries a heuristic
258
+ `risk_score` and a `may_continue` flag; `prune_sanitized` drops paths
259
+ neutralized by a registered sanitizer.
260
+ """
261
+ floor, include_cha = _traversal_params(min_confidence, include_fuzzy, include_unresolved)
262
+ kinds = (*edge_kinds, "callback") if include_callbacks else edge_kinds
263
+ with self._session_factory() as session:
264
+ sources = self._spec_to_ids(session, source)
265
+ sinks = self._sink_ids(session, sink, sink_category)
266
+ if not sources or not sinks:
267
+ return []
268
+ traverser = self._traverser(session, engine, kinds, floor, include_cha)
269
+ raw_paths = traverser.paths(sources, sinks, max_depth=max_depth, max_paths=max_paths)
270
+ if not raw_paths:
271
+ return []
272
+ all_ids = {node for path in raw_paths for node, _ in path}
273
+ symbol_map = q.symbols_by_ids(session, all_ids)
274
+ registry = self._registry(session)
275
+ edge_map = self._edge_rows(session, raw_paths)
276
+ tainted_sources = self._tainted_source_ids(session, sources)
277
+ excluded_nodes = self._nodes_with_open_frontier(session, all_ids, kinds, floor)
278
+ built = [
279
+ self._materialize_path(
280
+ path, symbol_map, edge_map, registry, tainted_sources, excluded_nodes
281
+ )
282
+ for path in raw_paths
283
+ ]
284
+ results = [cp for cp in built if not (prune_sanitized and _fully_sanitized(cp))]
285
+ results.sort(key=lambda p: (-(p.risk_score or 0.0), len(p.symbols),
286
+ [s.id for s in p.symbols]))
287
+ return results
288
+
289
+ def reachable(
290
+ self,
291
+ *,
292
+ source: "SourceSpec",
293
+ sink: "SourceSpec | None" = None,
294
+ sink_category: str | None = None,
295
+ max_depth: int = 25,
296
+ edge_kinds: tuple[str, ...] = ("calls",),
297
+ min_confidence: int | None = None,
298
+ include_fuzzy: bool = False,
299
+ include_unresolved: bool = False,
300
+ include_callbacks: bool = False,
301
+ engine: str = "memory",
302
+ ) -> bool:
303
+ floor, include_cha = _traversal_params(min_confidence, include_fuzzy, include_unresolved)
304
+ kinds = (*edge_kinds, "callback") if include_callbacks else edge_kinds
305
+ with self._session_factory() as session:
306
+ sources = self._spec_to_ids(session, source)
307
+ sinks = self._sink_ids(session, sink, sink_category)
308
+ if not sources or not sinks:
309
+ return False
310
+ traverser = self._traverser(session, engine, kinds, floor, include_cha)
311
+ return traverser.reachable(sources, sinks, max_depth)
312
+
313
+ # ---------------- maintenance ----------------
314
+
315
+ def refresh(self, *, paranoid: bool = False) -> IndexStats:
316
+ """Incrementally re-index: only changed/added/deleted files are reparsed."""
317
+ from entrygraph.pipeline.scanner import index_repository
318
+
319
+ with self._session_factory() as session:
320
+ repo = session.execute(select(models.Repository)).scalars().first()
321
+ if repo is None:
322
+ raise RepositoryNotIndexedError("database has no indexed repository")
323
+ stats = index_repository(repo.root_path, self._engine, incremental=True,
324
+ paranoid=paranoid)
325
+ self._adjacency.clear()
326
+ return stats
327
+
328
+ def stats(self) -> GraphStats:
329
+ with self._session_factory() as session:
330
+ repo = session.execute(select(models.Repository)).scalars().first()
331
+ if repo is None:
332
+ raise RepositoryNotIndexedError("database has no indexed repository")
333
+
334
+ def count(stmt) -> int:
335
+ return session.execute(stmt).scalar() or 0
336
+
337
+ return GraphStats(
338
+ repo_root=repo.root_path,
339
+ index_generation=repo.index_generation,
340
+ files=count(select(func.count(models.File.id))),
341
+ symbols=count(select(func.count(models.Symbol.id))),
342
+ edges=count(select(func.count(models.Edge.id))),
343
+ resolved_edges=count(
344
+ select(func.count(models.Edge.id)).where(
345
+ models.Edge.dst_symbol_id.is_not(None)
346
+ )
347
+ ),
348
+ entrypoints=count(select(func.count(models.Entrypoint.id))),
349
+ sink_edges=count(
350
+ select(func.count(models.Edge.id)).where(models.Edge.sink_id.is_not(None))
351
+ ),
352
+ )
353
+
354
+ def session(self) -> Session:
355
+ """Raw ORM session — the escape hatch. Caller owns the lifecycle."""
356
+ return self._session_factory()
357
+
358
+ def sql(self, statement: str, params: dict | None = None) -> list[dict]:
359
+ with self._session_factory() as session:
360
+ result = session.execute(text(statement), params or {})
361
+ return [dict(row._mapping) for row in result]
362
+
363
+ # ---------------- internals ----------------
364
+
365
+ def _generation(self, session: Session) -> int:
366
+ gen = session.execute(select(models.Repository.index_generation)).scalar()
367
+ return gen or 0
368
+
369
+ def _traverser(self, session: Session, engine: str, edge_kinds: tuple[str, ...],
370
+ min_confidence: int, include_cha: bool = True):
371
+ if engine == "sql":
372
+ from entrygraph.graph.cte import CteEngine
373
+
374
+ return CteEngine(session, frozenset(edge_kinds), min_confidence, include_cha)
375
+ if engine != "memory":
376
+ raise ValueError(f"unknown reachability engine {engine!r} (use 'memory' or 'sql')")
377
+ return self._cache(session, edge_kinds, min_confidence, include_cha)
378
+
379
+ def _cache(self, session: Session, edge_kinds: tuple[str, ...],
380
+ min_confidence: int, include_cha: bool = True) -> AdjacencyCache:
381
+ kinds = frozenset(edge_kinds)
382
+ generation = self._generation(session)
383
+ key = (kinds | {f"minconf:{min_confidence}", f"cha:{include_cha}"}, generation)
384
+ cache = self._adjacency.get(key)
385
+ if cache is None:
386
+ cache = AdjacencyCache.build(session, generation, kinds, min_confidence, include_cha)
387
+ self._adjacency = {k: v for k, v in self._adjacency.items() if k[1] == generation}
388
+ self._adjacency[key] = cache
389
+ return cache
390
+
391
+ def _spec_to_ids(self, session: Session, spec) -> set[int]:
392
+ if spec is None:
393
+ return set()
394
+ if isinstance(spec, (list, tuple, set)):
395
+ ids: set[int] = set()
396
+ for item in spec:
397
+ ids |= self._spec_to_ids(session, item)
398
+ return ids
399
+ if isinstance(spec, Symbol):
400
+ return {spec.id}
401
+ if isinstance(spec, Entrypoint):
402
+ return {spec.symbol.id}
403
+ return q.symbol_ids_matching(session, str(spec))
404
+
405
+ def _sink_ids(self, session: Session, sink, sink_category: str | None) -> set[int]:
406
+ ids = self._spec_to_ids(session, sink) if sink is not None else set()
407
+ if sink_category is not None:
408
+ from entrygraph.detect.taint import registry_for_repo
409
+
410
+ repo = session.execute(select(models.Repository)).scalars().first()
411
+ registry = registry_for_repo(repo.root_path if repo else None)
412
+ sink_ids = registry.ids_for_category(sink_category)
413
+ if sink_ids:
414
+ rows = session.execute(
415
+ select(models.Edge.dst_symbol_id).where(
416
+ models.Edge.sink_id.in_(sink_ids),
417
+ models.Edge.dst_symbol_id.is_not(None),
418
+ )
419
+ ).scalars()
420
+ ids |= set(rows)
421
+ return ids
422
+
423
+ def _neighbors(self, target, depth: int, direction: str,
424
+ edge_kinds: tuple[str, ...]) -> list[Symbol]:
425
+ with self._session_factory() as session:
426
+ ids = self._spec_to_ids(session, target)
427
+ if not ids:
428
+ raise SymbolNotFoundError(f"no symbol matching {target!r}")
429
+ cache = self._cache(session, edge_kinds, 0)
430
+ found = cache.neighborhood(ids, depth, direction)
431
+ symbol_map = q.symbols_by_ids(session, found)
432
+ return sorted(symbol_map.values(), key=lambda s: s.qname)
433
+
434
+ def _registry(self, session: Session):
435
+ from entrygraph.detect.taint import registry_for_repo
436
+
437
+ repo = session.execute(select(models.Repository)).scalars().first()
438
+ return registry_for_repo(repo.root_path if repo else None)
439
+
440
+ @staticmethod
441
+ def _edge_rows(session: Session, raw_paths) -> dict[int, "models.Edge"]:
442
+ edge_ids = {hop.edge_id for path in raw_paths for _, hop in path if hop and hop.edge_id}
443
+ if not edge_ids:
444
+ return {}
445
+ rows = session.execute(
446
+ select(models.Edge).where(models.Edge.id.in_(edge_ids))
447
+ ).scalars().all()
448
+ return {r.id: r for r in rows}
449
+
450
+ @staticmethod
451
+ def _tainted_source_ids(session: Session, sources: set[int]) -> set[int]:
452
+ """Source symbol ids that are entrypoints with known user-controlled params."""
453
+ if not sources:
454
+ return set()
455
+ rows = session.execute(
456
+ select(models.Entrypoint.symbol_id, models.Entrypoint.extra).where(
457
+ models.Entrypoint.symbol_id.in_(sources)
458
+ )
459
+ ).all()
460
+ tainted: set[int] = set()
461
+ for sid, extra in rows:
462
+ if extra and json.loads(extra).get("tainted_params"):
463
+ tainted.add(sid)
464
+ return tainted
465
+
466
+ @staticmethod
467
+ def _nodes_with_open_frontier(
468
+ session: Session, node_ids: set[int], kinds: tuple[str, ...], floor: int
469
+ ) -> set[int]:
470
+ """Path nodes that have outgoing call edges the confidence filter excluded
471
+ (or dynamic placeholders) — i.e. reachability may continue past them."""
472
+ if not node_ids:
473
+ return set()
474
+ from entrygraph.kinds import EdgeKind
475
+
476
+ kind_enums = [EdgeKind(k) for k in kinds if k in {e.value for e in EdgeKind}]
477
+ rows = session.execute(
478
+ select(models.Edge.src_symbol_id).where(
479
+ models.Edge.src_symbol_id.in_(node_ids),
480
+ models.Edge.dst_symbol_id.is_not(None),
481
+ models.Edge.kind.in_(kind_enums),
482
+ (models.Edge.confidence < floor) | (models.Edge.via == "dynamic"),
483
+ )
484
+ ).scalars()
485
+ return set(rows)
486
+
487
+ def _materialize_path(
488
+ self, path, symbol_map, edge_map, registry, tainted_sources, excluded_nodes
489
+ ) -> CallPath:
490
+ symbols = tuple(symbol_map[node] for node, _ in path)
491
+ hops = [hop for _, hop in path[1:]]
492
+ rows = [edge_map.get(hop.edge_id) if hop else None for hop in hops]
493
+
494
+ # sink is the terminal edge; its category drives sanitizer matching
495
+ terminal = rows[-1] if rows else None
496
+ sink_id = terminal.sink_id if terminal else None
497
+ sink_category = registry.sinks[sink_id].category if sink_id in registry.sinks else None
498
+ terminal_const = is_constant_args(terminal.arg_preview) if terminal else False
499
+
500
+ # sanitizer detection: any on-path symbol OR sibling call matching a
501
+ # sanitizer for the sink's category
502
+ sanitized_ids, sanitized_effect = self._path_sanitizers(
503
+ symbols, rows, registry, sink_category
504
+ )
505
+
506
+ path_edges: list[PathEdge] = []
507
+ for i, hop in enumerate(hops):
508
+ row = rows[i]
509
+ is_terminal = i == len(hops) - 1
510
+ path_edges.append(
511
+ PathEdge(
512
+ kind=hop.kind,
513
+ line=hop.line,
514
+ confidence=hop.confidence,
515
+ sink_id=row.sink_id if row else None,
516
+ via=row.via if row else hop.via,
517
+ arg_preview=row.arg_preview if row else None,
518
+ constant_args=terminal_const if is_terminal else False,
519
+ sanitized_by=tuple(sanitized_ids) if is_terminal else (),
520
+ )
521
+ )
522
+
523
+ risk = score_path(
524
+ hop_confidences=[h.confidence for h in hops],
525
+ hop_vias=[(rows[i].via if rows[i] else h.via) for i, h in enumerate(hops)],
526
+ sink_severity=registry.severity_of(sink_id),
527
+ sanitized_effect=sanitized_effect,
528
+ constant_args=terminal_const,
529
+ source_tainted=path[0][0] in tainted_sources,
530
+ )
531
+ may_continue = any(node in excluded_nodes for node, _ in path)
532
+ return CallPath(symbols=symbols, edges=tuple(path_edges),
533
+ risk_score=risk, may_continue=may_continue)
534
+
535
+ @staticmethod
536
+ def _path_sanitizers(symbols, rows, registry, sink_category):
537
+ """Return (matched sanitizer ids, strongest effect) for the sink category."""
538
+ if not sink_category:
539
+ return [], None
540
+ matched: list = []
541
+ candidates = [s.qname for s in symbols] + [r.dst_qname for r in rows if r is not None]
542
+ for qname in candidates:
543
+ for san in registry.match_sanitizers(qname):
544
+ if san.category == sink_category:
545
+ matched.append(san)
546
+ if not matched:
547
+ return [], None
548
+ effect = "neutralizes" if any(s.effect == "neutralizes" for s in matched) else "reduces"
549
+ return [s.id for s in matched], effect
File without changes