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,102 @@
|
|
|
1
|
+
# Built-in Ruby sink/source catalog.
|
|
2
|
+
# Patterns are brace-globs matched against canonical callee qnames:
|
|
3
|
+
# unresolved bare calls -> "rb:system"
|
|
4
|
+
# unresolved attribute calls -> "rb:*.execute"
|
|
5
|
+
# fuzzy/import project calls -> the project qname (not matched here)
|
|
6
|
+
|
|
7
|
+
[[sink]]
|
|
8
|
+
id = "rb.command-exec.kernel"
|
|
9
|
+
category = "command_exec"
|
|
10
|
+
severity = "high"
|
|
11
|
+
callee = "rb:{system,exec,spawn,fork,syscall}"
|
|
12
|
+
description = "Kernel command execution"
|
|
13
|
+
|
|
14
|
+
[[sink]]
|
|
15
|
+
id = "rb.command-exec.attr"
|
|
16
|
+
category = "command_exec"
|
|
17
|
+
severity = "high"
|
|
18
|
+
callee = "rb:*.{system,exec,spawn}"
|
|
19
|
+
description = "Command execution on a receiver (Open3, Process, etc.)"
|
|
20
|
+
|
|
21
|
+
[[sink]]
|
|
22
|
+
id = "rb.code-eval"
|
|
23
|
+
category = "code_eval"
|
|
24
|
+
severity = "critical"
|
|
25
|
+
callee = "rb:{eval,instance_eval,class_eval,module_eval,define_method}"
|
|
26
|
+
description = "Dynamic code evaluation"
|
|
27
|
+
|
|
28
|
+
[[sink]]
|
|
29
|
+
id = "rb.code-eval.attr"
|
|
30
|
+
category = "code_eval"
|
|
31
|
+
severity = "critical"
|
|
32
|
+
callee = "rb:*.{eval,instance_eval,class_eval,module_eval}"
|
|
33
|
+
description = "Dynamic code evaluation on a receiver"
|
|
34
|
+
|
|
35
|
+
[[sink]]
|
|
36
|
+
id = "rb.sql-execute"
|
|
37
|
+
category = "sql"
|
|
38
|
+
severity = "medium"
|
|
39
|
+
callee = "rb:*.{execute,find_by_sql,exec_query,select_all}"
|
|
40
|
+
description = "SQL statement execution (receiver-agnostic)"
|
|
41
|
+
|
|
42
|
+
[[source]]
|
|
43
|
+
id = "rb.sinatra.params"
|
|
44
|
+
category = "http_input"
|
|
45
|
+
callee = "rb:*.params*"
|
|
46
|
+
description = "Sinatra/Rails request params"
|
|
47
|
+
|
|
48
|
+
# --- C5 deepening ---
|
|
49
|
+
# NOTE: `X::Y.method` receivers canonicalize to `rb:*.method` when unresolved,
|
|
50
|
+
# so receiver-agnostic patterns (with arg hints) are used for stdlib sinks.
|
|
51
|
+
[[sink]]
|
|
52
|
+
id = "rb.command-exec.open3"
|
|
53
|
+
category = "command_exec"
|
|
54
|
+
severity = "high"
|
|
55
|
+
callee = "rb:*.{capture2,capture2e,capture3,popen2,popen3,popen2e}"
|
|
56
|
+
description = "Open3 command execution"
|
|
57
|
+
|
|
58
|
+
[[sink]]
|
|
59
|
+
id = "rb.deserialization"
|
|
60
|
+
category = "deserialization"
|
|
61
|
+
severity = "high"
|
|
62
|
+
callee = "rb:*.{load,restore,unsafe_load,object_load}"
|
|
63
|
+
require_arg_hint = "params|\\#\\{|@|\\("
|
|
64
|
+
description = "Unsafe deserialization (Marshal/YAML/Oj)"
|
|
65
|
+
|
|
66
|
+
[[sink]]
|
|
67
|
+
id = "rb.code-eval.send"
|
|
68
|
+
category = "code_eval"
|
|
69
|
+
severity = "high"
|
|
70
|
+
callee = "rb:*.{send,public_send,constantize,const_get}"
|
|
71
|
+
require_arg_hint = "params|\\#\\{|@"
|
|
72
|
+
description = "Dynamic dispatch / constant resolution from input"
|
|
73
|
+
|
|
74
|
+
[[sink]]
|
|
75
|
+
id = "rb.path-traversal"
|
|
76
|
+
category = "path_traversal"
|
|
77
|
+
severity = "medium"
|
|
78
|
+
callee = "rb:*.{read,open,write,binread}"
|
|
79
|
+
require_arg_hint = "params|\\#\\{|\\+"
|
|
80
|
+
description = "File access with a non-literal path"
|
|
81
|
+
|
|
82
|
+
[[sink]]
|
|
83
|
+
id = "rb.ssrf"
|
|
84
|
+
category = "ssrf"
|
|
85
|
+
severity = "medium"
|
|
86
|
+
callee = "rb:*.{get_response,post_form}"
|
|
87
|
+
description = "Net::HTTP outbound request"
|
|
88
|
+
|
|
89
|
+
[[sink]]
|
|
90
|
+
id = "rb.template-injection"
|
|
91
|
+
category = "template_injection"
|
|
92
|
+
severity = "high"
|
|
93
|
+
callee = "rb:*.{new,result}"
|
|
94
|
+
require_arg_hint = "\\#\\{"
|
|
95
|
+
description = "ERB template built from interpolated input"
|
|
96
|
+
|
|
97
|
+
[[source]]
|
|
98
|
+
id = "rb.env"
|
|
99
|
+
category = "env_input"
|
|
100
|
+
callee = "rb:*.{fetch}"
|
|
101
|
+
require_arg_hint = "ENV"
|
|
102
|
+
description = "Environment variable input"
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Built-in Rust sink/source catalog.
|
|
2
|
+
# Patterns are brace-globs matched against canonical callee qnames. Rust qnames
|
|
3
|
+
# normalize `::` -> `.`, so:
|
|
4
|
+
# import-expanded external calls -> "rs:std.process.Command.new"
|
|
5
|
+
# unresolved method calls -> "rs:*.Command.new"
|
|
6
|
+
# macro invocations -> "rs:sqlx.query" (the trailing `!` is stripped)
|
|
7
|
+
# `use std::fs` binds `fs`, so file sinks list both `std.fs.*` and `fs.*` aliases.
|
|
8
|
+
|
|
9
|
+
[[sink]]
|
|
10
|
+
id = "rust.command-exec"
|
|
11
|
+
category = "command_exec"
|
|
12
|
+
severity = "high"
|
|
13
|
+
callee = "rs:{std.process.Command.new,Command.new,*.Command.new}"
|
|
14
|
+
description = "Subprocess construction via std::process::Command (sink on the builder, not spawn/output)"
|
|
15
|
+
|
|
16
|
+
[[sink]]
|
|
17
|
+
id = "rust.sql-query"
|
|
18
|
+
category = "sql"
|
|
19
|
+
severity = "medium"
|
|
20
|
+
callee = "rs:{sqlx.query,sqlx.query_as,sqlx.query_scalar,sqlx.raw_sql,diesel.sql_query,*.sql_query}"
|
|
21
|
+
description = "SQL execution (matches both fn calls and sqlx!/diesel! macros after `!`-stripping)"
|
|
22
|
+
|
|
23
|
+
[[sink]]
|
|
24
|
+
id = "rust.path-traversal"
|
|
25
|
+
category = "path_traversal"
|
|
26
|
+
severity = "medium"
|
|
27
|
+
callee = "rs:std.fs.{write,read,read_to_string,remove_file,remove_dir_all,copy,rename}"
|
|
28
|
+
description = "Filesystem access via std::fs (verify the path is not attacker-controlled)"
|
|
29
|
+
|
|
30
|
+
[[sink]]
|
|
31
|
+
id = "rust.file-alias"
|
|
32
|
+
category = "path_traversal"
|
|
33
|
+
severity = "medium"
|
|
34
|
+
callee = "rs:fs.{write,read,read_to_string,remove_file}"
|
|
35
|
+
description = "Filesystem access via a `use std::fs` alias"
|
|
36
|
+
|
|
37
|
+
[[sink]]
|
|
38
|
+
id = "rust.ssrf"
|
|
39
|
+
category = "ssrf"
|
|
40
|
+
severity = "low"
|
|
41
|
+
callee = "rs:{reqwest.get,reqwest.blocking.get}"
|
|
42
|
+
description = "Outbound HTTP request via reqwest"
|
|
43
|
+
|
|
44
|
+
[[sink]]
|
|
45
|
+
id = "rust.dynamic-load"
|
|
46
|
+
category = "dynamic_load"
|
|
47
|
+
severity = "medium"
|
|
48
|
+
callee = "rs:{libloading.Library.new,*.Library.new}"
|
|
49
|
+
description = "Dynamic library / shared object load via libloading"
|
|
50
|
+
|
|
51
|
+
[[sink]]
|
|
52
|
+
id = "rust.deserialization"
|
|
53
|
+
category = "deserialization"
|
|
54
|
+
severity = "medium"
|
|
55
|
+
callee = "rs:{bincode.deserialize,serde_yaml.from_str}"
|
|
56
|
+
description = "Deserialization of untrusted input"
|
|
57
|
+
|
|
58
|
+
[[source]]
|
|
59
|
+
id = "rust.env"
|
|
60
|
+
category = "env_input"
|
|
61
|
+
callee = "rs:{std.env.args,env.args,std.env.var,env.var}"
|
|
62
|
+
description = "Process arguments / environment variable input"
|
|
63
|
+
|
|
64
|
+
[[source]]
|
|
65
|
+
id = "rust.stdin"
|
|
66
|
+
category = "stdin_input"
|
|
67
|
+
callee = "rs:{std.io.stdin,io.stdin}"
|
|
68
|
+
description = "Standard input"
|
|
File without changes
|
entrygraph/db/engine.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Engine construction with SQLite tuned for bulk-write and read-heavy workloads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import Engine, create_engine, event
|
|
8
|
+
from sqlalchemy.orm import Session, sessionmaker
|
|
9
|
+
|
|
10
|
+
_PRAGMAS = (
|
|
11
|
+
"PRAGMA journal_mode=WAL",
|
|
12
|
+
"PRAGMA synchronous=NORMAL",
|
|
13
|
+
"PRAGMA foreign_keys=ON",
|
|
14
|
+
"PRAGMA temp_store=MEMORY",
|
|
15
|
+
"PRAGMA cache_size=-64000", # 64 MiB page cache
|
|
16
|
+
"PRAGMA mmap_size=268435456", # 256 MiB
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def make_engine(db_path: str | Path) -> Engine:
|
|
21
|
+
engine = create_engine(f"sqlite:///{Path(db_path)}")
|
|
22
|
+
|
|
23
|
+
@event.listens_for(engine, "connect")
|
|
24
|
+
def _set_pragmas(dbapi_conn, _record) -> None: # pragma: no cover - exercised via tests
|
|
25
|
+
cursor = dbapi_conn.cursor()
|
|
26
|
+
for pragma in _PRAGMAS:
|
|
27
|
+
cursor.execute(pragma)
|
|
28
|
+
cursor.close()
|
|
29
|
+
|
|
30
|
+
return engine
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def make_session_factory(engine: Engine) -> sessionmaker[Session]:
|
|
34
|
+
return sessionmaker(engine, expire_on_commit=False)
|
entrygraph/db/meta.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Schema-version bookkeeping.
|
|
2
|
+
|
|
3
|
+
The database is a rebuildable cache of the repository, not a system of record:
|
|
4
|
+
instead of migrations, a version mismatch either raises (read paths) or drops
|
|
5
|
+
and recreates every table (index paths).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
|
|
12
|
+
from sqlalchemy import Engine, select
|
|
13
|
+
from sqlalchemy.orm import Session
|
|
14
|
+
|
|
15
|
+
from entrygraph.db.models import Base, Meta
|
|
16
|
+
from entrygraph.errors import SchemaMismatchError
|
|
17
|
+
|
|
18
|
+
SCHEMA_VERSION = 2
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def create_schema(engine: Engine) -> None:
|
|
22
|
+
"""Create all tables and stamp the current schema version."""
|
|
23
|
+
from entrygraph import __version__
|
|
24
|
+
|
|
25
|
+
Base.metadata.create_all(engine)
|
|
26
|
+
with Session(engine) as session:
|
|
27
|
+
existing = session.get(Meta, "schema_version")
|
|
28
|
+
if existing is None:
|
|
29
|
+
session.add_all(
|
|
30
|
+
[
|
|
31
|
+
Meta(key="schema_version", value=str(SCHEMA_VERSION)),
|
|
32
|
+
Meta(key="entrygraph_version", value=__version__),
|
|
33
|
+
Meta(key="created_at", value=datetime.now(timezone.utc).isoformat()),
|
|
34
|
+
]
|
|
35
|
+
)
|
|
36
|
+
session.commit()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def stored_schema_version(engine: Engine) -> int | None:
|
|
40
|
+
"""Return the version stamped in the db, or None if the meta table is absent/empty."""
|
|
41
|
+
with Session(engine) as session:
|
|
42
|
+
try:
|
|
43
|
+
row = session.execute(select(Meta.value).where(Meta.key == "schema_version")).scalar()
|
|
44
|
+
except Exception:
|
|
45
|
+
return None
|
|
46
|
+
return int(row) if row is not None else None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def check_schema(engine: Engine) -> None:
|
|
50
|
+
"""Raise SchemaMismatchError unless the db matches SCHEMA_VERSION."""
|
|
51
|
+
version = stored_schema_version(engine)
|
|
52
|
+
if version != SCHEMA_VERSION:
|
|
53
|
+
raise SchemaMismatchError(
|
|
54
|
+
f"database schema version {version!r} != expected {SCHEMA_VERSION}; "
|
|
55
|
+
"re-run `entrygraph index` to rebuild the index"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def ensure_schema(engine: Engine) -> bool:
|
|
60
|
+
"""Make the db usable for indexing: create if empty, rebuild if mismatched.
|
|
61
|
+
|
|
62
|
+
Returns True if the schema was (re)created from scratch.
|
|
63
|
+
"""
|
|
64
|
+
version = stored_schema_version(engine)
|
|
65
|
+
if version == SCHEMA_VERSION:
|
|
66
|
+
return False
|
|
67
|
+
if version is not None:
|
|
68
|
+
Base.metadata.drop_all(engine)
|
|
69
|
+
create_schema(engine)
|
|
70
|
+
return True
|
entrygraph/db/models.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""SQLAlchemy 2.0 typed ORM models — the single definition of the on-disk schema.
|
|
2
|
+
|
|
3
|
+
Bump ``meta.SCHEMA_VERSION`` whenever anything here changes; the database is a
|
|
4
|
+
rebuildable cache, so a version mismatch triggers a full re-index rather than a
|
|
5
|
+
migration.
|
|
6
|
+
|
|
7
|
+
There are deliberately no relationship() constructs: all writes go through the
|
|
8
|
+
bulk-insert writer with app-assigned PKs, which inserts tables in dependency
|
|
9
|
+
order itself (repositories -> files -> symbols -> edges/entrypoints). Without
|
|
10
|
+
relationships the unit of work cannot order cross-table inserts within one
|
|
11
|
+
flush, so ad-hoc sessions must flush between dependency levels.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
|
|
18
|
+
from sqlalchemy import (
|
|
19
|
+
BigInteger,
|
|
20
|
+
Enum,
|
|
21
|
+
ForeignKey,
|
|
22
|
+
Index,
|
|
23
|
+
Integer,
|
|
24
|
+
String,
|
|
25
|
+
Text,
|
|
26
|
+
UniqueConstraint,
|
|
27
|
+
text,
|
|
28
|
+
)
|
|
29
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
30
|
+
|
|
31
|
+
from entrygraph.kinds import EdgeKind, EntrypointKind, SymbolKind
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _values(enum_cls: type) -> list[str]:
|
|
35
|
+
return [m.value for m in enum_cls]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Base(DeclarativeBase):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Meta(Base):
|
|
43
|
+
"""Key/value schema metadata; checked on every open()."""
|
|
44
|
+
|
|
45
|
+
__tablename__ = "meta"
|
|
46
|
+
|
|
47
|
+
key: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
48
|
+
value: Mapped[str] = mapped_column(Text)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Repository(Base):
|
|
52
|
+
__tablename__ = "repositories"
|
|
53
|
+
|
|
54
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
55
|
+
root_path: Mapped[str] = mapped_column(Text, unique=True)
|
|
56
|
+
indexed_at: Mapped[datetime | None]
|
|
57
|
+
# Bumped once per index run; drives adjacency-cache invalidation.
|
|
58
|
+
index_generation: Mapped[int] = mapped_column(Integer, default=0)
|
|
59
|
+
file_count: Mapped[int] = mapped_column(Integer, default=0)
|
|
60
|
+
symbol_count: Mapped[int] = mapped_column(Integer, default=0)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class File(Base):
|
|
64
|
+
__tablename__ = "files"
|
|
65
|
+
|
|
66
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True) # app-assigned
|
|
67
|
+
repo_id: Mapped[int] = mapped_column(ForeignKey("repositories.id", ondelete="CASCADE"))
|
|
68
|
+
path: Mapped[str] = mapped_column(Text) # repo-relative, posix separators
|
|
69
|
+
language: Mapped[str | None] = mapped_column(String(32))
|
|
70
|
+
content_hash: Mapped[str] = mapped_column(String(64)) # blake2b-128 hex
|
|
71
|
+
size_bytes: Mapped[int] = mapped_column(BigInteger)
|
|
72
|
+
mtime_ns: Mapped[int] = mapped_column(BigInteger)
|
|
73
|
+
generation: Mapped[int] = mapped_column(Integer) # index_generation at last (re)index
|
|
74
|
+
skip_reason: Mapped[str | None] = mapped_column(String(32)) # too_large/binary/minified/parse_error
|
|
75
|
+
|
|
76
|
+
__table_args__ = (
|
|
77
|
+
UniqueConstraint("repo_id", "path", name="uq_files_repo_path"),
|
|
78
|
+
Index("ix_files_repo_lang", "repo_id", "language"),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Symbol(Base):
|
|
83
|
+
__tablename__ = "symbols"
|
|
84
|
+
|
|
85
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True) # app-assigned
|
|
86
|
+
# NULL for kind=external placeholder symbols (no defining file in the repo).
|
|
87
|
+
file_id: Mapped[int | None] = mapped_column(ForeignKey("files.id", ondelete="CASCADE"))
|
|
88
|
+
kind: Mapped[SymbolKind] = mapped_column(
|
|
89
|
+
Enum(SymbolKind, native_enum=False, length=16, values_callable=_values)
|
|
90
|
+
)
|
|
91
|
+
name: Mapped[str] = mapped_column(Text) # unqualified: "run"
|
|
92
|
+
qname: Mapped[str] = mapped_column(Text) # "pkg.mod.Class.run" / external: "py:subprocess.run"
|
|
93
|
+
parent_id: Mapped[int | None] = mapped_column(ForeignKey("symbols.id", ondelete="CASCADE"))
|
|
94
|
+
start_line: Mapped[int] = mapped_column(Integer, default=0)
|
|
95
|
+
end_line: Mapped[int] = mapped_column(Integer, default=0)
|
|
96
|
+
start_col: Mapped[int] = mapped_column(Integer, default=0)
|
|
97
|
+
signature: Mapped[str | None] = mapped_column(Text)
|
|
98
|
+
docstring: Mapped[str | None] = mapped_column(Text)
|
|
99
|
+
is_exported: Mapped[bool] = mapped_column(default=True)
|
|
100
|
+
|
|
101
|
+
__table_args__ = (
|
|
102
|
+
Index("ix_symbols_file", "file_id"),
|
|
103
|
+
Index("ix_symbols_qname", "qname"),
|
|
104
|
+
Index("ix_symbols_kind_name", "kind", "name"),
|
|
105
|
+
Index("ix_symbols_parent", "parent_id"),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Edge(Base):
|
|
110
|
+
__tablename__ = "edges"
|
|
111
|
+
|
|
112
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True) # app-assigned
|
|
113
|
+
kind: Mapped[EdgeKind] = mapped_column(
|
|
114
|
+
Enum(EdgeKind, native_enum=False, length=16, values_callable=_values)
|
|
115
|
+
)
|
|
116
|
+
src_symbol_id: Mapped[int] = mapped_column(ForeignKey("symbols.id", ondelete="CASCADE"))
|
|
117
|
+
# SET NULL so a deleted target degrades the edge to "unresolved" instead of
|
|
118
|
+
# deleting it; dst_qname keeps the textual target for later re-resolution.
|
|
119
|
+
dst_symbol_id: Mapped[int | None] = mapped_column(ForeignKey("symbols.id", ondelete="SET NULL"))
|
|
120
|
+
dst_qname: Mapped[str] = mapped_column(Text)
|
|
121
|
+
# Denormalized so incremental re-index can wipe a file's edges in one statement.
|
|
122
|
+
src_file_id: Mapped[int] = mapped_column(ForeignKey("files.id", ondelete="CASCADE"))
|
|
123
|
+
line: Mapped[int] = mapped_column(Integer, default=0)
|
|
124
|
+
confidence: Mapped[int] = mapped_column(Integer, default=0) # kinds.Confidence value
|
|
125
|
+
arg_preview: Mapped[str | None] = mapped_column(Text)
|
|
126
|
+
sink_id: Mapped[str | None] = mapped_column(String(64)) # pre-tagged at index time
|
|
127
|
+
# Edge provenance for edges not resolved by the direct import/scope pass:
|
|
128
|
+
# "cha" (class-hierarchy candidate), "dynamic" (getattr/computed call),
|
|
129
|
+
# "reexport" (chased through a barrel file). NULL for directly-resolved edges.
|
|
130
|
+
via: Mapped[str | None] = mapped_column(String(12))
|
|
131
|
+
|
|
132
|
+
__table_args__ = (
|
|
133
|
+
Index("ix_edges_src_kind", "src_symbol_id", "kind"),
|
|
134
|
+
Index("ix_edges_dst_kind", "dst_symbol_id", "kind"),
|
|
135
|
+
Index("ix_edges_srcfile", "src_file_id"),
|
|
136
|
+
Index("ix_edges_unresolved", "dst_qname", sqlite_where=text("dst_symbol_id IS NULL")),
|
|
137
|
+
Index("ix_edges_sink", "sink_id", sqlite_where=text("sink_id IS NOT NULL")),
|
|
138
|
+
Index("ix_edges_via", "via", sqlite_where=text("via IS NOT NULL")),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class Entrypoint(Base):
|
|
143
|
+
__tablename__ = "entrypoints"
|
|
144
|
+
|
|
145
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True) # app-assigned
|
|
146
|
+
kind: Mapped[EntrypointKind] = mapped_column(
|
|
147
|
+
Enum(EntrypointKind, native_enum=False, length=24, values_callable=_values)
|
|
148
|
+
)
|
|
149
|
+
framework: Mapped[str | None] = mapped_column(String(32))
|
|
150
|
+
symbol_id: Mapped[int] = mapped_column(ForeignKey("symbols.id", ondelete="CASCADE"))
|
|
151
|
+
route: Mapped[str | None] = mapped_column(Text)
|
|
152
|
+
http_method: Mapped[str | None] = mapped_column(String(16))
|
|
153
|
+
extra: Mapped[str | None] = mapped_column(Text) # JSON blob: decorator args etc.
|
|
154
|
+
|
|
155
|
+
__table_args__ = (
|
|
156
|
+
Index("ix_entrypoints_kind_fw", "kind", "framework"),
|
|
157
|
+
Index("ix_entrypoints_symbol", "symbol_id"),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class Detection(Base):
|
|
162
|
+
"""Per-repo detected languages and frameworks."""
|
|
163
|
+
|
|
164
|
+
__tablename__ = "detections"
|
|
165
|
+
|
|
166
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
167
|
+
repo_id: Mapped[int] = mapped_column(ForeignKey("repositories.id", ondelete="CASCADE"))
|
|
168
|
+
category: Mapped[str] = mapped_column(String(16)) # "language" | "framework"
|
|
169
|
+
name: Mapped[str] = mapped_column(String(48))
|
|
170
|
+
version: Mapped[str | None] = mapped_column(String(32))
|
|
171
|
+
confidence: Mapped[float] = mapped_column(default=1.0)
|
|
172
|
+
evidence: Mapped[str | None] = mapped_column(Text) # JSON: file counts, manifest paths
|
|
173
|
+
|
|
174
|
+
__table_args__ = (
|
|
175
|
+
UniqueConstraint("repo_id", "category", "name", name="uq_detections"),
|
|
176
|
+
)
|
entrygraph/db/queries.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""SELECT builders and row -> DTO conversion.
|
|
2
|
+
|
|
3
|
+
Globs use ``*`` and ``?`` (translated to SQL LIKE with escaping); a pattern
|
|
4
|
+
without glob characters is an exact match.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
|
|
11
|
+
from sqlalchemy import Select, select
|
|
12
|
+
from sqlalchemy.orm import Session
|
|
13
|
+
|
|
14
|
+
from entrygraph.db import models
|
|
15
|
+
from entrygraph.kinds import EntrypointKind, SymbolKind
|
|
16
|
+
from entrygraph.results import Entrypoint, FileInfo, Symbol
|
|
17
|
+
|
|
18
|
+
_LIKE_ESCAPE = "\\"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def glob_to_like(pattern: str) -> str:
|
|
22
|
+
escaped = (
|
|
23
|
+
pattern.replace(_LIKE_ESCAPE, _LIKE_ESCAPE * 2)
|
|
24
|
+
.replace("%", _LIKE_ESCAPE + "%")
|
|
25
|
+
.replace("_", _LIKE_ESCAPE + "_")
|
|
26
|
+
)
|
|
27
|
+
return escaped.replace("*", "%").replace("?", "_")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _match(column, pattern: str):
|
|
31
|
+
if "*" in pattern or "?" in pattern:
|
|
32
|
+
return column.like(glob_to_like(pattern), escape=_LIKE_ESCAPE)
|
|
33
|
+
return column == pattern
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def symbol_to_dto(row: models.Symbol, file_path: str | None) -> Symbol:
|
|
37
|
+
return Symbol(
|
|
38
|
+
id=row.id,
|
|
39
|
+
kind=row.kind.value,
|
|
40
|
+
name=row.name,
|
|
41
|
+
qname=row.qname,
|
|
42
|
+
file=file_path,
|
|
43
|
+
start_line=row.start_line,
|
|
44
|
+
end_line=row.end_line,
|
|
45
|
+
signature=row.signature,
|
|
46
|
+
docstring=row.docstring,
|
|
47
|
+
is_exported=row.is_exported,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _symbol_select() -> Select:
|
|
52
|
+
return select(models.Symbol, models.File.path).join(
|
|
53
|
+
models.File, models.Symbol.file_id == models.File.id, isouter=True
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def select_symbols(
|
|
58
|
+
session: Session,
|
|
59
|
+
*,
|
|
60
|
+
kind: str | SymbolKind | None = None,
|
|
61
|
+
name: str | None = None,
|
|
62
|
+
qname: str | None = None,
|
|
63
|
+
file: str | None = None,
|
|
64
|
+
include_external: bool = False,
|
|
65
|
+
limit: int | None = None,
|
|
66
|
+
offset: int | None = None,
|
|
67
|
+
) -> list[Symbol]:
|
|
68
|
+
stmt = _symbol_select().order_by(models.Symbol.qname)
|
|
69
|
+
if kind is not None:
|
|
70
|
+
stmt = stmt.where(models.Symbol.kind == SymbolKind(kind))
|
|
71
|
+
elif not include_external:
|
|
72
|
+
stmt = stmt.where(models.Symbol.kind != SymbolKind.EXTERNAL)
|
|
73
|
+
if name is not None:
|
|
74
|
+
stmt = stmt.where(_match(models.Symbol.name, name))
|
|
75
|
+
if qname is not None:
|
|
76
|
+
stmt = stmt.where(_match(models.Symbol.qname, qname))
|
|
77
|
+
if file is not None:
|
|
78
|
+
stmt = stmt.where(_match(models.File.path, file))
|
|
79
|
+
if limit is not None:
|
|
80
|
+
stmt = stmt.limit(limit)
|
|
81
|
+
if offset is not None:
|
|
82
|
+
stmt = stmt.offset(offset)
|
|
83
|
+
return [symbol_to_dto(sym, path) for sym, path in session.execute(stmt)]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def symbols_by_ids(session: Session, ids: set[int]) -> dict[int, Symbol]:
|
|
87
|
+
if not ids:
|
|
88
|
+
return {}
|
|
89
|
+
stmt = _symbol_select().where(models.Symbol.id.in_(ids))
|
|
90
|
+
return {sym.id: symbol_to_dto(sym, path) for sym, path in session.execute(stmt)}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def symbol_ids_matching(session: Session, pattern: str) -> set[int]:
|
|
94
|
+
"""Symbol ids whose qname matches a glob (or exact qname)."""
|
|
95
|
+
return set(
|
|
96
|
+
session.execute(
|
|
97
|
+
select(models.Symbol.id).where(_match(models.Symbol.qname, pattern))
|
|
98
|
+
).scalars()
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def select_files(
|
|
103
|
+
session: Session, *, language: str | None = None, path: str | None = None
|
|
104
|
+
) -> list[FileInfo]:
|
|
105
|
+
stmt = select(models.File).order_by(models.File.path)
|
|
106
|
+
if language is not None:
|
|
107
|
+
stmt = stmt.where(models.File.language == language)
|
|
108
|
+
if path is not None:
|
|
109
|
+
stmt = stmt.where(_match(models.File.path, path))
|
|
110
|
+
return [
|
|
111
|
+
FileInfo(
|
|
112
|
+
id=f.id,
|
|
113
|
+
path=f.path,
|
|
114
|
+
language=f.language,
|
|
115
|
+
size_bytes=f.size_bytes,
|
|
116
|
+
skip_reason=f.skip_reason,
|
|
117
|
+
)
|
|
118
|
+
for f in session.execute(stmt).scalars()
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def select_entrypoints(
|
|
123
|
+
session: Session,
|
|
124
|
+
*,
|
|
125
|
+
kind: str | EntrypointKind | None = None,
|
|
126
|
+
framework: str | None = None,
|
|
127
|
+
route: str | None = None,
|
|
128
|
+
limit: int | None = None,
|
|
129
|
+
) -> list[Entrypoint]:
|
|
130
|
+
stmt = (
|
|
131
|
+
select(models.Entrypoint, models.Symbol, models.File.path)
|
|
132
|
+
.join(models.Symbol, models.Entrypoint.symbol_id == models.Symbol.id)
|
|
133
|
+
.join(models.File, models.Symbol.file_id == models.File.id, isouter=True)
|
|
134
|
+
.order_by(models.Entrypoint.id)
|
|
135
|
+
)
|
|
136
|
+
if kind is not None:
|
|
137
|
+
stmt = stmt.where(models.Entrypoint.kind == EntrypointKind(kind))
|
|
138
|
+
if framework is not None:
|
|
139
|
+
stmt = stmt.where(models.Entrypoint.framework == framework)
|
|
140
|
+
if route is not None:
|
|
141
|
+
stmt = stmt.where(_match(models.Entrypoint.route, route))
|
|
142
|
+
if limit is not None:
|
|
143
|
+
stmt = stmt.limit(limit)
|
|
144
|
+
return [
|
|
145
|
+
Entrypoint(
|
|
146
|
+
id=ep.id,
|
|
147
|
+
kind=ep.kind.value,
|
|
148
|
+
framework=ep.framework,
|
|
149
|
+
symbol=symbol_to_dto(sym, path),
|
|
150
|
+
route=ep.route,
|
|
151
|
+
http_method=ep.http_method,
|
|
152
|
+
extra=json.loads(ep.extra) if ep.extra else {},
|
|
153
|
+
)
|
|
154
|
+
for ep, sym, path in session.execute(stmt)
|
|
155
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Entrypoint rule registry. Importing this package registers built-in rules."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
|
|
5
|
+
from entrygraph.detect.entrypoints.base import (
|
|
6
|
+
EntrypointRule,
|
|
7
|
+
all_rules,
|
|
8
|
+
register,
|
|
9
|
+
rules_for,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
# Importing each rule module registers its rules. Done defensively so a
|
|
13
|
+
# language still under construction doesn't break the whole registry.
|
|
14
|
+
for _mod in ("python", "javascript", "golang", "java", "ruby", "csharp", "php", "rust"):
|
|
15
|
+
_full = f"entrygraph.detect.entrypoints.{_mod}"
|
|
16
|
+
try:
|
|
17
|
+
importlib.import_module(_full)
|
|
18
|
+
except ModuleNotFoundError as _exc: # noqa: PERF203
|
|
19
|
+
if _exc.name != _full:
|
|
20
|
+
raise
|
|
21
|
+
|
|
22
|
+
__all__ = ["EntrypointRule", "all_rules", "register", "rules_for"]
|