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,167 @@
1
+ """Repository file walk with pruning, .gitignore support, and content gates.
2
+
3
+ Order of gates (cheapest first):
4
+ 1. hard-pruned directory names (checked before pathspec — pruning node_modules
5
+ at the directory level is the single biggest walk speedup),
6
+ 2. .gitignore rules (root + nested, via pathspec),
7
+ 3. per-file gates: size cap, NUL-byte binary sniff, minified-JS heuristics.
8
+
9
+ Every skipped-but-recognized file is reported with a reason so "why isn't my
10
+ file indexed" is always answerable.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+
19
+ import pathspec
20
+
21
+ from entrygraph.fs.lang import RepoLanguageProfile, detect_language
22
+
23
+ PRUNED_DIRS = frozenset(
24
+ {
25
+ ".git", ".hg", ".svn",
26
+ "node_modules", "bower_components",
27
+ ".venv", "venv", ".tox", "__pycache__", ".mypy_cache", ".ruff_cache",
28
+ ".pytest_cache", "site-packages", ".eggs",
29
+ "vendor", "third_party",
30
+ "dist", "build", "target", "out",
31
+ ".next", ".nuxt", ".gradle", ".idea", ".vscode",
32
+ "Pods", "DerivedData",
33
+ }
34
+ )
35
+
36
+ MAX_FILE_BYTES = 2 * 1024 * 1024 # 2 MiB
37
+ _MINIFIED_SUFFIXES = (".min.js", ".min.css", ".bundle.js", ".map")
38
+
39
+
40
+ @dataclass(slots=True)
41
+ class WalkedFile:
42
+ path: str # repo-relative, posix separators
43
+ abs_path: str
44
+ language: str | None
45
+ size_bytes: int
46
+ mtime_ns: int
47
+ skip_reason: str | None = None # too_large | binary | minified | None
48
+
49
+
50
+ def _load_gitignore(root: Path) -> pathspec.GitIgnoreSpec | None:
51
+ patterns: list[str] = []
52
+ for ignore_file in sorted(root.rglob(".gitignore")):
53
+ try:
54
+ rel_dir = ignore_file.parent.relative_to(root).as_posix()
55
+ except ValueError: # pragma: no cover - symlink escape
56
+ continue
57
+ prefix = "" if rel_dir == "." else rel_dir + "/"
58
+ try:
59
+ lines = ignore_file.read_text(encoding="utf-8", errors="replace").splitlines()
60
+ except OSError:
61
+ continue
62
+ for line in lines:
63
+ stripped = line.strip()
64
+ if not stripped or stripped.startswith("#"):
65
+ continue
66
+ if prefix:
67
+ # scope nested .gitignore patterns to their directory
68
+ negate = stripped.startswith("!")
69
+ body = stripped[1:] if negate else stripped
70
+ anchored = body.lstrip("/")
71
+ scoped = f"{prefix}{anchored}" if body.startswith("/") else f"{prefix}**/{anchored}"
72
+ patterns.append(("!" if negate else "") + scoped)
73
+ else:
74
+ patterns.append(stripped)
75
+ if not patterns:
76
+ return None
77
+ return pathspec.GitIgnoreSpec.from_lines(patterns)
78
+
79
+
80
+ def _content_gate(abs_path: str, language: str | None, size_bytes: int) -> str | None:
81
+ """Return a skip reason, or None if the file should be parsed."""
82
+ if size_bytes > MAX_FILE_BYTES:
83
+ return "too_large"
84
+ name = os.path.basename(abs_path)
85
+ if name.endswith(_MINIFIED_SUFFIXES):
86
+ return "minified"
87
+ try:
88
+ with open(abs_path, "rb") as fh:
89
+ head = fh.read(8192)
90
+ except OSError:
91
+ return "unreadable"
92
+ if b"\x00" in head:
93
+ return "binary"
94
+ if language in ("javascript", "typescript", "tsx"):
95
+ lines = head.splitlines()
96
+ if any(len(line) > 5000 for line in lines):
97
+ return "minified"
98
+ if size_bytes > 50 * 1024 and lines and (len(head) / max(len(lines), 1)) > 250:
99
+ return "minified"
100
+ return None
101
+
102
+
103
+ def walk_repo(root: str | Path) -> tuple[list[WalkedFile], RepoLanguageProfile]:
104
+ """Walk a repository, returning candidate files and a language profile.
105
+
106
+ Files in unrecognized languages are omitted; recognized-but-gated files are
107
+ included with ``skip_reason`` set so they can be recorded in the DB.
108
+ """
109
+ root = Path(root).resolve()
110
+ spec = _load_gitignore(root)
111
+ profile = RepoLanguageProfile()
112
+ results: list[WalkedFile] = []
113
+
114
+ stack = [str(root)]
115
+ while stack:
116
+ current = stack.pop()
117
+ try:
118
+ entries = list(os.scandir(current))
119
+ except OSError:
120
+ continue
121
+ for entry in entries:
122
+ name = entry.name
123
+ try:
124
+ is_dir = entry.is_dir(follow_symlinks=False)
125
+ except OSError:
126
+ continue
127
+ if is_dir:
128
+ if name in PRUNED_DIRS or name.startswith(".git"):
129
+ continue
130
+ rel = os.path.relpath(entry.path, root).replace(os.sep, "/")
131
+ if spec and spec.match_file(rel + "/"):
132
+ continue
133
+ stack.append(entry.path)
134
+ continue
135
+ if not entry.is_file(follow_symlinks=False):
136
+ continue
137
+ rel = os.path.relpath(entry.path, root).replace(os.sep, "/")
138
+ if spec and spec.match_file(rel):
139
+ continue
140
+
141
+ language = detect_language(rel)
142
+ if language is None:
143
+ # one cheap read for shebang sniffing of extensionless files
144
+ if "." not in name:
145
+ try:
146
+ with open(entry.path, "rb") as fh:
147
+ language = detect_language(rel, fh.readline(256))
148
+ except OSError:
149
+ continue
150
+ if language is None:
151
+ continue
152
+
153
+ stat = entry.stat(follow_symlinks=False)
154
+ profile.add(language, stat.st_size)
155
+ results.append(
156
+ WalkedFile(
157
+ path=rel,
158
+ abs_path=entry.path,
159
+ language=language,
160
+ size_bytes=stat.st_size,
161
+ mtime_ns=stat.st_mtime_ns,
162
+ skip_reason=_content_gate(entry.path, language, stat.st_size),
163
+ )
164
+ )
165
+
166
+ results.sort(key=lambda f: f.path)
167
+ return results, profile
File without changes
@@ -0,0 +1,146 @@
1
+ """In-memory adjacency over the edges table — the primary reachability engine.
2
+
3
+ One indexed scan loads all resolved edges of the requested kinds into forward
4
+ and reverse adjacency dicts; every subsequent traversal is pure-Python BFS/DFS.
5
+ The cache is keyed by (edge kinds, index generation) and dropped on re-index.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections import deque
11
+ from dataclasses import dataclass
12
+
13
+ from sqlalchemy import select
14
+ from sqlalchemy.orm import Session
15
+
16
+ from entrygraph.db.models import Edge
17
+ from entrygraph.kinds import EdgeKind
18
+
19
+ _MAX_DFS_VISITS = 200_000 # hard bound on path-enumeration work
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class Hop:
24
+ dst: int
25
+ kind: str
26
+ line: int
27
+ confidence: int
28
+ edge_id: int = 0
29
+ via: str | None = None
30
+
31
+
32
+ class AdjacencyCache:
33
+ def __init__(self, generation: int, kinds: frozenset[str]) -> None:
34
+ self.generation = generation
35
+ self.kinds = kinds
36
+ self.forward: dict[int, list[Hop]] = {}
37
+ self.reverse: dict[int, list[Hop]] = {}
38
+
39
+ @classmethod
40
+ def build(cls, session: Session, generation: int, kinds: frozenset[str],
41
+ min_confidence: int = 0, include_cha: bool = True) -> "AdjacencyCache":
42
+ cache = cls(generation, kinds)
43
+ conditions = [
44
+ Edge.kind.in_([EdgeKind(k) for k in kinds]),
45
+ Edge.dst_symbol_id.is_not(None),
46
+ Edge.confidence >= min_confidence,
47
+ ]
48
+ if not include_cha: # class-hierarchy candidates are opt-in speculative edges
49
+ conditions.append((Edge.via.is_(None)) | (Edge.via != "cha"))
50
+ stmt = select(
51
+ Edge.src_symbol_id, Edge.dst_symbol_id, Edge.kind, Edge.line,
52
+ Edge.confidence, Edge.id, Edge.via,
53
+ ).where(*conditions)
54
+ for src, dst, kind, line, confidence, edge_id, via in session.execute(stmt):
55
+ cache.forward.setdefault(src, []).append(
56
+ Hop(dst, kind.value, line, confidence, edge_id, via))
57
+ cache.reverse.setdefault(dst, []).append(
58
+ Hop(src, kind.value, line, confidence, edge_id, via))
59
+ for adjacency in (cache.forward, cache.reverse):
60
+ for hops in adjacency.values():
61
+ hops.sort(key=lambda h: (h.dst, h.line))
62
+ return cache
63
+
64
+ # ---------------- traversals ----------------
65
+
66
+ def neighborhood(self, starts: set[int], depth: int, direction: str) -> set[int]:
67
+ """All nodes within `depth` hops (excluding the starts themselves)."""
68
+ adjacency = self.forward if direction == "out" else self.reverse
69
+ seen = set(starts)
70
+ frontier = set(starts)
71
+ found: set[int] = set()
72
+ for _ in range(depth):
73
+ next_frontier: set[int] = set()
74
+ for node in frontier:
75
+ for hop in adjacency.get(node, ()):
76
+ if hop.dst not in seen:
77
+ seen.add(hop.dst)
78
+ next_frontier.add(hop.dst)
79
+ found.add(hop.dst)
80
+ if not next_frontier:
81
+ break
82
+ frontier = next_frontier
83
+ return found
84
+
85
+ def reachable(self, sources: set[int], sinks: set[int], max_depth: int) -> bool:
86
+ if sources & sinks:
87
+ return True
88
+ seen = set(sources)
89
+ frontier = deque((s, 0) for s in sources)
90
+ while frontier:
91
+ node, depth = frontier.popleft()
92
+ if depth >= max_depth:
93
+ continue
94
+ for hop in self.forward.get(node, ()):
95
+ if hop.dst in sinks:
96
+ return True
97
+ if hop.dst not in seen:
98
+ seen.add(hop.dst)
99
+ frontier.append((hop.dst, depth + 1))
100
+ return False
101
+
102
+ def paths(
103
+ self,
104
+ sources: set[int],
105
+ sinks: set[int],
106
+ max_depth: int = 25,
107
+ max_paths: int = 10,
108
+ ) -> list[list[tuple[int, Hop | None]]]:
109
+ """Enumerate simple paths as [(symbol_id, hop_into_it | None), ...].
110
+
111
+ DFS with an on-path visited set; neighbor order is deterministic.
112
+ Results are sorted shortest-first. A global visit budget bounds work on
113
+ pathological graphs; enumeration also stops at max_paths.
114
+ """
115
+ results: list[list[tuple[int, Hop | None]]] = []
116
+ budget = _MAX_DFS_VISITS
117
+
118
+ for source in sorted(sources):
119
+ if budget <= 0 or len(results) >= max_paths:
120
+ break
121
+ stack: list[tuple[int, Hop | None]] = [(source, None)]
122
+ on_path = {source}
123
+
124
+ def dfs(node: int, depth: int) -> None:
125
+ nonlocal budget
126
+ if budget <= 0 or len(results) >= max_paths:
127
+ return
128
+ budget -= 1
129
+ if node in sinks:
130
+ results.append(list(stack))
131
+ return
132
+ if depth >= max_depth:
133
+ return
134
+ for hop in self.forward.get(node, ()):
135
+ if hop.dst in on_path:
136
+ continue
137
+ stack.append((hop.dst, hop))
138
+ on_path.add(hop.dst)
139
+ dfs(hop.dst, depth + 1)
140
+ on_path.discard(hop.dst)
141
+ stack.pop()
142
+
143
+ dfs(source, 0)
144
+
145
+ results.sort(key=lambda p: (len(p), [n for n, _ in p]))
146
+ return results[:max_paths]
@@ -0,0 +1,123 @@
1
+ """Recursive-CTE reachability engine — the SQL-side fallback.
2
+
3
+ Same paths()/reachable() contract as AdjacencyCache, but the traversal runs
4
+ inside SQLite via a recursive CTE instead of loading adjacency into memory.
5
+ Path reconstruction and cycle-guarding use an encoded path string, which is why
6
+ this is the fallback (it does more work per row); the memory engine is primary.
7
+ The two are kept behaviorally identical by the parametrized reachability tests.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from sqlalchemy import bindparam, text
13
+ from sqlalchemy.orm import Session
14
+
15
+ from entrygraph.graph.adjacency import Hop
16
+ from entrygraph.kinds import EdgeKind
17
+
18
+ _SEP = ","
19
+
20
+
21
+ class CteEngine:
22
+ def __init__(self, session: Session, kinds: frozenset[str], min_confidence: int = 0,
23
+ include_cha: bool = True) -> None:
24
+ self.session = session
25
+ self.kind_values = [EdgeKind(k).value for k in kinds]
26
+ self.min_confidence = min_confidence
27
+ self.include_cha = include_cha
28
+
29
+ def reachable(self, sources: set[int], sinks: set[int], max_depth: int) -> bool:
30
+ if sources & sinks:
31
+ return True
32
+ return bool(self.paths(sources, sinks, max_depth=max_depth, max_paths=1))
33
+
34
+ def paths(
35
+ self, sources: set[int], sinks: set[int], max_depth: int = 25, max_paths: int = 10
36
+ ) -> list[list[tuple[int, Hop | None]]]:
37
+ results: list[list[tuple[int, Hop | None]]] = []
38
+ # a source that is itself a sink is a length-1 path (matches memory engine)
39
+ for src in sorted(sources & sinks):
40
+ results.append([(src, None)])
41
+
42
+ rows = self.session.execute(
43
+ text(
44
+ """
45
+ WITH RECURSIVE walk(node, nodes, lines, kinds, ids, confs, depth) AS (
46
+ SELECT e.dst_symbol_id,
47
+ :sep || e.src_symbol_id || :sep || e.dst_symbol_id || :sep,
48
+ :sep || e.line || :sep,
49
+ :sep || e.kind || :sep,
50
+ :sep || e.id || :sep,
51
+ :sep || e.confidence || :sep,
52
+ 1
53
+ FROM edges e
54
+ WHERE e.src_symbol_id IN :sources
55
+ AND e.dst_symbol_id IS NOT NULL
56
+ AND e.kind IN :kinds
57
+ AND e.confidence >= :minconf
58
+ AND (:include_cha OR e.via IS NULL OR e.via != 'cha')
59
+ UNION ALL
60
+ SELECT e.dst_symbol_id,
61
+ w.nodes || e.dst_symbol_id || :sep,
62
+ w.lines || e.line || :sep,
63
+ w.kinds || e.kind || :sep,
64
+ w.ids || e.id || :sep,
65
+ w.confs || e.confidence || :sep,
66
+ w.depth + 1
67
+ FROM walk w
68
+ JOIN edges e ON e.src_symbol_id = w.node
69
+ WHERE w.depth < :max_depth
70
+ AND e.dst_symbol_id IS NOT NULL
71
+ AND e.kind IN :kinds
72
+ AND e.confidence >= :minconf
73
+ AND (:include_cha OR e.via IS NULL OR e.via != 'cha')
74
+ AND w.nodes NOT LIKE '%' || :sep || e.dst_symbol_id || :sep || '%'
75
+ )
76
+ SELECT nodes, lines, kinds, ids, confs, depth FROM walk
77
+ WHERE node IN :sinks
78
+ ORDER BY depth
79
+ """
80
+ ).bindparams(
81
+ bindparam("sources", expanding=True),
82
+ bindparam("sinks", expanding=True),
83
+ bindparam("kinds", expanding=True),
84
+ ),
85
+ {
86
+ "sources": list(sources),
87
+ "sinks": list(sinks),
88
+ "kinds": self.kind_values,
89
+ "minconf": self.min_confidence,
90
+ "include_cha": 1 if self.include_cha else 0,
91
+ "max_depth": max_depth,
92
+ "sep": _SEP,
93
+ },
94
+ ).all()
95
+
96
+ for nodes_str, lines_str, kinds_str, ids_str, confs_str, _depth in rows:
97
+ if len(results) >= max_paths:
98
+ break
99
+ path = self._decode(nodes_str, lines_str, kinds_str, ids_str, confs_str)
100
+ if path is not None:
101
+ results.append(path)
102
+
103
+ results.sort(key=lambda p: (len(p), [n for n, _ in p]))
104
+ return results[:max_paths]
105
+
106
+ @staticmethod
107
+ def _decode(
108
+ nodes_str, lines_str, kinds_str, ids_str, confs_str
109
+ ) -> list[tuple[int, Hop | None]] | None:
110
+ node_ids = [int(x) for x in nodes_str.strip(_SEP).split(_SEP) if x]
111
+ lines = [int(x) for x in lines_str.strip(_SEP).split(_SEP) if x]
112
+ kinds = [x for x in kinds_str.strip(_SEP).split(_SEP) if x]
113
+ edge_ids = [int(x) for x in ids_str.strip(_SEP).split(_SEP) if x]
114
+ confs = [int(x) for x in confs_str.strip(_SEP).split(_SEP) if x]
115
+ if len(lines) != len(node_ids) - 1:
116
+ return None
117
+ path: list[tuple[int, Hop | None]] = [(node_ids[0], None)]
118
+ for i in range(1, len(node_ids)):
119
+ path.append(
120
+ (node_ids[i], Hop(node_ids[i], kinds[i - 1], lines[i - 1],
121
+ confs[i - 1], edge_ids[i - 1]))
122
+ )
123
+ return path
@@ -0,0 +1,101 @@
1
+ """Query-time path risk scoring (heuristic taint tier).
2
+
3
+ Everything here runs at query time, never at index time, so weights can be
4
+ retuned without re-indexing. A path's risk combines: the terminal sink's
5
+ severity, the weakest edge confidence along the path, path length, whether any
6
+ hop is speculative (CHA/dynamic/callback), whether a sanitizer intervened,
7
+ whether the sink was called with only constant arguments, and whether the
8
+ source's user-controlled parameters are known.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+
15
+ from entrygraph.kinds import Confidence
16
+
17
+ _SEVERITY_BASE = {"critical": 1.0, "high": 0.85, "medium": 0.6, "low": 0.35}
18
+ _DEFAULT_SEVERITY = 0.6
19
+
20
+ _CONFIDENCE_WEIGHT = {
21
+ int(Confidence.EXACT): 1.0,
22
+ int(Confidence.IMPORT): 0.95,
23
+ int(Confidence.FUZZY): 0.7,
24
+ int(Confidence.UNRESOLVED): 0.5,
25
+ }
26
+
27
+ _SPECULATIVE_VIA = {"cha", "dynamic"}
28
+ _LENGTH_DECAY = 0.97
29
+
30
+ # A literal-only argument preview: strings, numbers, bools, None, kwarg names,
31
+ # and bracketed literal collections. Anything with an identifier/operator that
32
+ # could carry a variable makes it non-constant.
33
+ _CONST_TOKEN = re.compile(
34
+ r"""^(
35
+ \s | , | = | \( | \) | \[ | \] | \{ | \} | : |
36
+ '[^']*' | "[^"]*" | `[^`]*` |
37
+ \d[\d_.eExXaAbBcCdDfF]* |
38
+ True|False|None|null|true|false|nil |
39
+ [A-Za-z_]\w*\s*= # kwarg name before '='
40
+ )*$""",
41
+ re.VERBOSE,
42
+ )
43
+
44
+
45
+ def is_constant_args(arg_preview: str | None) -> bool:
46
+ """True if the sink was called with only literal/constant arguments.
47
+
48
+ Conservative: an empty/None preview is constant (no args); a preview that was
49
+ truncated at the 80-char cap (trailing ellipsis) returns False because we
50
+ can't see the whole argument list.
51
+ """
52
+ if not arg_preview:
53
+ return True
54
+ text = arg_preview.strip()
55
+ if text.endswith("…") or text.endswith("..."):
56
+ return False
57
+ inner = text
58
+ if inner.startswith("(") and inner.endswith(")"):
59
+ inner = inner[1:-1]
60
+ if not inner.strip():
61
+ return True
62
+ return bool(_CONST_TOKEN.match(text))
63
+
64
+
65
+ def confidence_factor(confidences: list[int]) -> float:
66
+ return min((_CONFIDENCE_WEIGHT.get(c, 0.5) for c in confidences), default=1.0)
67
+
68
+
69
+ def score_path(
70
+ *,
71
+ hop_confidences: list[int],
72
+ hop_vias: list[str | None],
73
+ sink_severity: str | None,
74
+ sanitized_effect: str | None, # "neutralizes" | "reduces" | None
75
+ constant_args: bool,
76
+ source_tainted: bool,
77
+ ) -> float:
78
+ """Return a risk score in [0, 1]; higher = riskier. See module docstring."""
79
+ severity_base = _SEVERITY_BASE.get(sink_severity or "", _DEFAULT_SEVERITY)
80
+ conf = confidence_factor(hop_confidences)
81
+ hops = max(len(hop_confidences), 1)
82
+ length_decay = _LENGTH_DECAY ** (hops - 1)
83
+ speculative = 0.85 if any(v in _SPECULATIVE_VIA for v in hop_vias) else 1.0
84
+ if sanitized_effect == "neutralizes":
85
+ sanitizer_factor = 0.0
86
+ elif sanitized_effect == "reduces":
87
+ sanitizer_factor = 0.3
88
+ else:
89
+ sanitizer_factor = 1.0
90
+ const_factor = 0.4 if constant_args else 1.0
91
+ source_factor = 1.0 if source_tainted else 0.9
92
+ risk = (
93
+ severity_base
94
+ * conf
95
+ * length_decay
96
+ * speculative
97
+ * sanitizer_factor
98
+ * const_factor
99
+ * source_factor
100
+ )
101
+ return round(risk, 4)
entrygraph/kinds.py ADDED
@@ -0,0 +1,51 @@
1
+ """Shared enums used by the ORM models, the extraction IR, and the public API.
2
+
3
+ Kept dependency-free so worker processes can unpickle IR without importing
4
+ SQLAlchemy machinery.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import enum
10
+
11
+
12
+ class SymbolKind(enum.Enum):
13
+ MODULE = "module"
14
+ CLASS = "class"
15
+ FUNCTION = "function"
16
+ METHOD = "method"
17
+ VARIABLE = "variable"
18
+ CONSTANT = "constant"
19
+ INTERFACE = "interface"
20
+ STRUCT = "struct"
21
+ PROPERTY = "property"
22
+ FIELD = "field"
23
+ EXTERNAL = "external"
24
+
25
+
26
+ class EdgeKind(enum.Enum):
27
+ CALLS = "calls"
28
+ IMPORTS = "imports"
29
+ INHERITS = "inherits"
30
+ IMPLEMENTS = "implements"
31
+ REFERENCES = "references"
32
+ PASSED_AS_CALLBACK = "callback" # function name handed to another call as an argument
33
+
34
+
35
+ class EntrypointKind(enum.Enum):
36
+ HTTP_ROUTE = "http_route"
37
+ CLI_COMMAND = "cli_command"
38
+ MAIN = "main"
39
+ TASK = "task"
40
+ LAMBDA_HANDLER = "lambda_handler"
41
+ EVENT_HANDLER = "event_handler"
42
+ MIDDLEWARE = "middleware" # request/response interceptor (before_request, app.use, ...)
43
+
44
+
45
+ class Confidence(enum.IntEnum):
46
+ """How a reference was bound to its target symbol."""
47
+
48
+ UNRESOLVED = 0 # kept with its textual target only
49
+ FUZZY = 1 # unique-name match, no import evidence
50
+ IMPORT = 2 # via the file's import map (project or external)
51
+ EXACT = 3 # same-scope / same-module / known-class-method
File without changes
@@ -0,0 +1,49 @@
1
+ """Parser construction over tree-sitter-language-pack.
2
+
3
+ We build tree_sitter.Parser objects from the pack's Language handles rather
4
+ than using tree_sitter_language_pack.get_parser(), whose bundled Parser class
5
+ is incompatible with the py-tree-sitter 0.25 API surface we use.
6
+
7
+ Parsers and Language handles are cached per process; they are created lazily
8
+ inside worker processes and must never be pickled.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from functools import cache
14
+
15
+ from tree_sitter import Language, Parser
16
+
17
+ # our language ids -> tree-sitter-language-pack grammar names
18
+ _GRAMMAR_NAMES = {
19
+ "python": "python",
20
+ "javascript": "javascript",
21
+ "typescript": "typescript",
22
+ "tsx": "tsx",
23
+ "go": "go",
24
+ "java": "java",
25
+ "ruby": "ruby",
26
+ "csharp": "csharp",
27
+ "php": "php",
28
+ "rust": "rust",
29
+ }
30
+
31
+
32
+ def supported(lang_id: str) -> bool:
33
+ return lang_id in _GRAMMAR_NAMES
34
+
35
+
36
+ @cache
37
+ def language(lang_id: str) -> Language:
38
+ from tree_sitter_language_pack import get_language
39
+
40
+ return get_language(_GRAMMAR_NAMES[lang_id])
41
+
42
+
43
+ @cache
44
+ def parser(lang_id: str) -> Parser:
45
+ return Parser(language(lang_id))
46
+
47
+
48
+ def parse(lang_id: str, source: bytes):
49
+ return parser(lang_id).parse(source)
@@ -0,0 +1,39 @@
1
+ """Compiled tree-sitter query cache.
2
+
3
+ The single seam between entrygraph and the py-tree-sitter query API — if the
4
+ API shifts again (as it did at 0.24/0.25), this is the only file to update.
5
+ `.scm` sources ship as package data under entrygraph/queries/<lang>/.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from functools import cache
11
+ from importlib.resources import files as resource_files
12
+
13
+ from tree_sitter import Node, Query, QueryCursor
14
+
15
+ from entrygraph.parsing.parsers import language
16
+
17
+
18
+ @cache
19
+ def load_query_for(grammar_lang: str, source_lang: str, name: str) -> Query:
20
+ """Compile a `.scm` source (shipped under queries/<source_lang>/) against a
21
+ possibly-different grammar.
22
+
23
+ A tree-sitter Query is bound to one Language and only matches trees from that
24
+ same grammar. TypeScript/TSX are supersets of JavaScript with the same node
25
+ names for the constructs we harvest, so their extractor reuses the JavaScript
26
+ query sources compiled against the TS/TSX grammars — without this, a `.ts`
27
+ tree queried with JS-compiled queries silently matches nothing.
28
+ """
29
+ source = (resource_files("entrygraph") / "queries" / source_lang / f"{name}.scm").read_text()
30
+ return Query(language(grammar_lang), source)
31
+
32
+
33
+ @cache
34
+ def load_query(lang_id: str, name: str) -> Query:
35
+ return load_query_for(lang_id, lang_id, name)
36
+
37
+
38
+ def captures(query: Query, node: Node) -> dict[str, list[Node]]:
39
+ return QueryCursor(query).captures(node)
File without changes