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,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
@@ -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
@@ -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
+ )
@@ -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"]