codebase-explorer 2.2.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.
- cbe/__init__.py +9 -0
- cbe/__main__.py +4 -0
- cbe/aggregation.py +183 -0
- cbe/api_completion.py +410 -0
- cbe/batching.py +283 -0
- cbe/catalog_query.py +376 -0
- cbe/checks.py +390 -0
- cbe/cli.py +434 -0
- cbe/concrete_facts.py +321 -0
- cbe/data/LICENSE.tiktoken +30 -0
- cbe/data/o200k_base.tiktoken +199998 -0
- cbe/declarations.py +537 -0
- cbe/docs.py +929 -0
- cbe/generate.py +316 -0
- cbe/grammars.py +741 -0
- cbe/graph.py +857 -0
- cbe/graph_backends/__init__.py +43 -0
- cbe/graph_backends/_calls.py +262 -0
- cbe/graph_backends/_paths.py +42 -0
- cbe/graph_backends/identity.py +93 -0
- cbe/graph_backends/imports.py +256 -0
- cbe/graph_backends/joern.py +1021 -0
- cbe/graph_backends/resolution.py +288 -0
- cbe/graph_backends/toolchain.py +396 -0
- cbe/grounding.py +57 -0
- cbe/host_run.py +1752 -0
- cbe/model_settings.py +80 -0
- cbe/projection.py +305 -0
- cbe/prompts.py +316 -0
- cbe/records.py +200 -0
- cbe/skeleton.py +474 -0
- cbe/source_context.py +709 -0
- cbe/symbols.py +136 -0
- cbe/token_budget.py +68 -0
- cbe/treename.py +398 -0
- cbe/usage.py +300 -0
- cbe_install/__init__.py +11 -0
- cbe_install/cli.py +434 -0
- cbe_install/resources/agents/cbe-writer.md +16 -0
- cbe_install/resources/codebase-explorer/SKILL.md +101 -0
- cbe_install/resources/codebase-explorer/references/claude-code.md +63 -0
- cbe_install/resources/codebase-explorer/references/codex.md +37 -0
- codebase_explorer-2.2.0.dist-info/METADATA +233 -0
- codebase_explorer-2.2.0.dist-info/RECORD +47 -0
- codebase_explorer-2.2.0.dist-info/WHEEL +4 -0
- codebase_explorer-2.2.0.dist-info/entry_points.txt +3 -0
- codebase_explorer-2.2.0.dist-info/licenses/LICENSE +21 -0
cbe/grammars.py
ADDED
|
@@ -0,0 +1,741 @@
|
|
|
1
|
+
"""Grammar registration, source discovery, and source coordinates."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path, PurePosixPath
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
SCHEMA_CONTEXT = "cbe-context/1"
|
|
12
|
+
SCHEMA_GRAPH = "cbe-graph/1"
|
|
13
|
+
|
|
14
|
+
#: Default target for a single chunk segment. A segment never exceeds this
|
|
15
|
+
#: value unless one indivisible grammar token is larger.
|
|
16
|
+
DEFAULT_MAX_SEGMENT_BYTES = 32 * 1024
|
|
17
|
+
|
|
18
|
+
#: Files larger than this are skipped during discovery (not during an explicit
|
|
19
|
+
#: ``paths`` request) and reported with ``file_too_large``.
|
|
20
|
+
MAX_FILE_BYTES = 8 * 1024 * 1024
|
|
21
|
+
|
|
22
|
+
#: Version-control metadata, dependency caches and tool output: never own source.
|
|
23
|
+
#: Folder names such as build/dist/vendor and other dot-directories are *not*
|
|
24
|
+
#: proof of third-party or generated code, so they stay enrolled.
|
|
25
|
+
DEFAULT_EXCLUDE_DIR_NAMES = frozenset({
|
|
26
|
+
".git", ".hg", ".svn", ".venv", "venv", "node_modules", "__pycache__",
|
|
27
|
+
".mypy_cache", ".pytest_cache", ".tox", ".codebase-analysis",
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
#: Suffixes that are valid in more than one registered grammar. The first
|
|
32
|
+
#: candidate is the default; a real diagnostic states the chosen parse so a C
|
|
33
|
+
#: header is never silently documented as the wrong language.
|
|
34
|
+
AMBIGUOUS_SUFFIXES: dict[str, tuple[str, ...]] = {".h": ("c", "cpp")}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def sha256_bytes(payload: bytes) -> str:
|
|
39
|
+
return hashlib.sha256(payload).hexdigest()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def sha256_text(payload: str) -> str:
|
|
43
|
+
return sha256_bytes(payload.encode("utf-8"))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class OffsetMap:
|
|
48
|
+
"""Byte offset (parser) to character offset for one UTF-8 file; CRLF stays two characters."""
|
|
49
|
+
|
|
50
|
+
raw: bytes
|
|
51
|
+
text: str
|
|
52
|
+
byte_to_char: tuple[int, ...]
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_bytes(cls, raw: bytes) -> "OffsetMap":
|
|
56
|
+
text = raw.decode("utf-8")
|
|
57
|
+
byte_to_char: list[int] = [0] * (len(raw) + 1)
|
|
58
|
+
byte_index = 0
|
|
59
|
+
for char_index, char in enumerate(text):
|
|
60
|
+
for _ in char.encode("utf-8"):
|
|
61
|
+
byte_to_char[byte_index] = char_index
|
|
62
|
+
byte_index += 1
|
|
63
|
+
byte_to_char[len(raw)] = len(text)
|
|
64
|
+
return cls(raw=raw, text=text, byte_to_char=tuple(byte_to_char))
|
|
65
|
+
|
|
66
|
+
def char_of_byte(self, byte_index: int) -> int:
|
|
67
|
+
if byte_index < 0 or byte_index > len(self.raw):
|
|
68
|
+
raise ValueError(f"byte offset {byte_index} out of range")
|
|
69
|
+
return self.byte_to_char[byte_index]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def line_starts(text: str) -> tuple[int, ...]:
|
|
73
|
+
"""Character offsets of each 1-indexed line, keeping CRLF as two characters."""
|
|
74
|
+
|
|
75
|
+
starts = [0]
|
|
76
|
+
index = 0
|
|
77
|
+
length = len(text)
|
|
78
|
+
while index < length:
|
|
79
|
+
char = text[index]
|
|
80
|
+
if char == "\r" and index + 1 < length and text[index + 1] == "\n":
|
|
81
|
+
index += 2
|
|
82
|
+
starts.append(index)
|
|
83
|
+
elif char in "\n\r":
|
|
84
|
+
index += 1
|
|
85
|
+
starts.append(index)
|
|
86
|
+
else:
|
|
87
|
+
index += 1
|
|
88
|
+
return tuple(starts)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def canonical_symbol_id(path: str, kind: str, scope: str, anchor: int) -> str:
|
|
92
|
+
"""Stable id: path + kind + lexical scope + declaration byte anchor (lines are not identities)."""
|
|
93
|
+
|
|
94
|
+
return f"{path}::{kind}::{scope}::{anchor}"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class GrammarSpec:
|
|
100
|
+
"""A tree-sitter grammar plus the node kinds that mean "chunk boundary"."""
|
|
101
|
+
|
|
102
|
+
name: str
|
|
103
|
+
suffixes: tuple[str, ...]
|
|
104
|
+
module: str
|
|
105
|
+
attr: str
|
|
106
|
+
#: node type -> cbe chunk kind for function-like declarations
|
|
107
|
+
function_kinds: dict[str, str]
|
|
108
|
+
#: node type -> cbe chunk kind for class-like declarations
|
|
109
|
+
class_kinds: dict[str, str]
|
|
110
|
+
#: transparent wrappers whose span extends the declaration they contain
|
|
111
|
+
wrappers: frozenset[str]
|
|
112
|
+
#: expression node types that become a declaration when bound to a name
|
|
113
|
+
value_function_kinds: frozenset[str]
|
|
114
|
+
#: node types that bind a value to a name (``const f = () =>``)
|
|
115
|
+
binding_kinds: frozenset[str] = frozenset()
|
|
116
|
+
#: grammar provenance: ``official`` (tree-sitter org) or ``community``
|
|
117
|
+
provenance: str = "official"
|
|
118
|
+
#: Joern native frontends that already provide this language in the graph
|
|
119
|
+
#: backend, so the source interface covers the same set without a plugin.
|
|
120
|
+
joern_frontends: tuple[str, ...] = ()
|
|
121
|
+
#: node types published as ordinary module-region declarations
|
|
122
|
+
ordinary_declaration_kinds: frozenset[str] = frozenset()
|
|
123
|
+
#: name extraction strategy for ordinary declarations
|
|
124
|
+
ordinary_name_style: str = "node_names"
|
|
125
|
+
#: whether ordinary declarations may be owned by a class scope
|
|
126
|
+
ordinary_in_class_scope: bool = False
|
|
127
|
+
#: C-family scope, error recovery, and declarator rules
|
|
128
|
+
c_declaration_rules: bool = False
|
|
129
|
+
#: recovered declared types that must not become ordinary declarations
|
|
130
|
+
recovery_type_keywords: frozenset[str] = frozenset()
|
|
131
|
+
#: grouped type declarations need one declaration per type spec
|
|
132
|
+
grouped_type_declarations: bool = False
|
|
133
|
+
#: type declarations retain the surrounding scope for their children
|
|
134
|
+
preserve_type_scope: bool = False
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
#: Frontend coverage for the languages already registered above.
|
|
138
|
+
_JOERN_FRONTENDS: dict[str, tuple[str, ...]] = {
|
|
139
|
+
"c": ("c2cpg",),
|
|
140
|
+
"cpp": ("c2cpg",),
|
|
141
|
+
"python": ("pysrc2cpg",),
|
|
142
|
+
"javascript": ("jssrc2cpg",),
|
|
143
|
+
"typescript": ("jssrc2cpg",),
|
|
144
|
+
"tsx": ("jssrc2cpg",),
|
|
145
|
+
"java": ("javasrc2cpg",),
|
|
146
|
+
"go": ("gosrc2cpg",),
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
#: What running Joern 4.0.654 on a real small repository of each language gave.
|
|
150
|
+
#: A language that is not listed has not been tried.
|
|
151
|
+
_JOERN_TESTED = "Joern 4.0.654"
|
|
152
|
+
_CALL_GRAPH: dict[str, dict[str, str]] = {
|
|
153
|
+
**{name: {"status": "works"} for name in ("c", "cpp", "python", "javascript", "typescript", "java", "go", "ruby")},
|
|
154
|
+
"c_sharp": {"status": "known_failure",
|
|
155
|
+
"note": f"{_JOERN_TESTED}: failed on a real 55-file repository (the export script hit a null "
|
|
156
|
+
"pointer error); a two-file test fixture works"},
|
|
157
|
+
"kotlin": {"status": "known_failure",
|
|
158
|
+
"note": f"{_JOERN_TESTED}: failed on a real 146-file repository (joern-parse: key not found: "
|
|
159
|
+
"MethodRef); a two-file test fixture works"},
|
|
160
|
+
"php": {"status": "needs_tool", "note": "needs `php` on PATH"},
|
|
161
|
+
"rust": {"status": "needs_tool", "note": "needs `cargo` on PATH and a Cargo project layout"},
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
_VARIABLE_FUNCTION_VALUES = frozenset(
|
|
166
|
+
{"arrow_function", "function_expression", "generator_function", "function"}
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
GRAMMARS: tuple[GrammarSpec, ...] = (
|
|
170
|
+
GrammarSpec(
|
|
171
|
+
name="c",
|
|
172
|
+
suffixes=(".c", ".h"),
|
|
173
|
+
module="tree_sitter_c",
|
|
174
|
+
attr="language",
|
|
175
|
+
function_kinds={"function_definition": "function"},
|
|
176
|
+
class_kinds={
|
|
177
|
+
"struct_specifier": "struct",
|
|
178
|
+
"union_specifier": "union",
|
|
179
|
+
"enum_specifier": "enum",
|
|
180
|
+
},
|
|
181
|
+
wrappers=frozenset(),
|
|
182
|
+
value_function_kinds=frozenset(),
|
|
183
|
+
ordinary_declaration_kinds=frozenset(
|
|
184
|
+
{"preproc_def", "preproc_function_def", "declaration", "type_definition"}
|
|
185
|
+
),
|
|
186
|
+
c_declaration_rules=True,
|
|
187
|
+
recovery_type_keywords=frozenset({"class"}),
|
|
188
|
+
),
|
|
189
|
+
GrammarSpec(
|
|
190
|
+
name="cpp",
|
|
191
|
+
suffixes=(".cc", ".cpp", ".cxx", ".c++", ".hh", ".hpp", ".hxx", ".h++", ".inl"),
|
|
192
|
+
module="tree_sitter_cpp",
|
|
193
|
+
attr="language",
|
|
194
|
+
function_kinds={"function_definition": "function"},
|
|
195
|
+
class_kinds={
|
|
196
|
+
"class_specifier": "class",
|
|
197
|
+
"struct_specifier": "struct",
|
|
198
|
+
"union_specifier": "union",
|
|
199
|
+
"enum_specifier": "enum",
|
|
200
|
+
"namespace_definition": "namespace",
|
|
201
|
+
},
|
|
202
|
+
wrappers=frozenset({"template_declaration"}),
|
|
203
|
+
value_function_kinds=frozenset(),
|
|
204
|
+
binding_kinds=frozenset(),
|
|
205
|
+
ordinary_declaration_kinds=frozenset(
|
|
206
|
+
{"preproc_def", "preproc_function_def", "declaration", "type_definition", "alias_declaration"}
|
|
207
|
+
),
|
|
208
|
+
c_declaration_rules=True,
|
|
209
|
+
recovery_type_keywords=frozenset({"import", "module"}),
|
|
210
|
+
),
|
|
211
|
+
GrammarSpec(
|
|
212
|
+
name="python",
|
|
213
|
+
suffixes=(".py",),
|
|
214
|
+
module="tree_sitter_python",
|
|
215
|
+
attr="language",
|
|
216
|
+
function_kinds={"function_definition": "function"},
|
|
217
|
+
class_kinds={"class_definition": "class"},
|
|
218
|
+
wrappers=frozenset({"decorated_definition"}),
|
|
219
|
+
value_function_kinds=frozenset(),
|
|
220
|
+
ordinary_declaration_kinds=frozenset({"assignment"}),
|
|
221
|
+
ordinary_name_style="python",
|
|
222
|
+
ordinary_in_class_scope=True,
|
|
223
|
+
),
|
|
224
|
+
GrammarSpec(
|
|
225
|
+
name="javascript",
|
|
226
|
+
suffixes=(".js", ".jsx", ".mjs", ".cjs"),
|
|
227
|
+
module="tree_sitter_javascript",
|
|
228
|
+
attr="language",
|
|
229
|
+
function_kinds={
|
|
230
|
+
"function_declaration": "function",
|
|
231
|
+
"generator_function_declaration": "function",
|
|
232
|
+
"method_definition": "method",
|
|
233
|
+
},
|
|
234
|
+
class_kinds={"class_declaration": "class", "class": "class"},
|
|
235
|
+
wrappers=frozenset(
|
|
236
|
+
{"export_statement", "ambient_declaration", "lexical_declaration", "variable_declaration"}
|
|
237
|
+
),
|
|
238
|
+
value_function_kinds=_VARIABLE_FUNCTION_VALUES,
|
|
239
|
+
binding_kinds=frozenset({"variable_declarator"}),
|
|
240
|
+
ordinary_declaration_kinds=frozenset({"variable_declarator"}),
|
|
241
|
+
ordinary_name_style="bindings",
|
|
242
|
+
),
|
|
243
|
+
GrammarSpec(
|
|
244
|
+
name="typescript",
|
|
245
|
+
suffixes=(".ts", ".mts", ".cts"),
|
|
246
|
+
module="tree_sitter_typescript",
|
|
247
|
+
attr="language_typescript",
|
|
248
|
+
function_kinds={
|
|
249
|
+
"function_declaration": "function",
|
|
250
|
+
"generator_function_declaration": "function",
|
|
251
|
+
"function_signature": "function",
|
|
252
|
+
"method_signature": "method",
|
|
253
|
+
"method_definition": "method",
|
|
254
|
+
},
|
|
255
|
+
class_kinds={
|
|
256
|
+
"class_declaration": "class",
|
|
257
|
+
"abstract_class_declaration": "class",
|
|
258
|
+
"interface_declaration": "interface",
|
|
259
|
+
"enum_declaration": "enum",
|
|
260
|
+
"type_alias_declaration": "type_alias",
|
|
261
|
+
},
|
|
262
|
+
wrappers=frozenset(
|
|
263
|
+
{"export_statement", "ambient_declaration", "lexical_declaration", "variable_declaration", "declare_statement"}
|
|
264
|
+
),
|
|
265
|
+
value_function_kinds=_VARIABLE_FUNCTION_VALUES,
|
|
266
|
+
binding_kinds=frozenset({"variable_declarator"}),
|
|
267
|
+
ordinary_declaration_kinds=frozenset({"variable_declarator"}),
|
|
268
|
+
ordinary_name_style="bindings",
|
|
269
|
+
),
|
|
270
|
+
GrammarSpec(
|
|
271
|
+
name="java",
|
|
272
|
+
suffixes=(".java",),
|
|
273
|
+
module="tree_sitter_java",
|
|
274
|
+
attr="language",
|
|
275
|
+
function_kinds={
|
|
276
|
+
"method_declaration": "method",
|
|
277
|
+
"constructor_declaration": "constructor",
|
|
278
|
+
},
|
|
279
|
+
class_kinds={
|
|
280
|
+
"class_declaration": "class",
|
|
281
|
+
"interface_declaration": "interface",
|
|
282
|
+
"enum_declaration": "enum",
|
|
283
|
+
"annotation_type_declaration": "annotation",
|
|
284
|
+
"record_declaration": "record",
|
|
285
|
+
},
|
|
286
|
+
wrappers=frozenset(),
|
|
287
|
+
value_function_kinds=frozenset(),
|
|
288
|
+
ordinary_declaration_kinds=frozenset({"field_declaration", "constant_declaration"}),
|
|
289
|
+
ordinary_name_style="fields",
|
|
290
|
+
ordinary_in_class_scope=True,
|
|
291
|
+
),
|
|
292
|
+
GrammarSpec(
|
|
293
|
+
name="go",
|
|
294
|
+
suffixes=(".go",),
|
|
295
|
+
module="tree_sitter_go",
|
|
296
|
+
attr="language",
|
|
297
|
+
function_kinds={
|
|
298
|
+
"function_declaration": "function",
|
|
299
|
+
"method_declaration": "method",
|
|
300
|
+
},
|
|
301
|
+
class_kinds={"type_declaration": "type"},
|
|
302
|
+
wrappers=frozenset(),
|
|
303
|
+
value_function_kinds=frozenset(),
|
|
304
|
+
ordinary_declaration_kinds=frozenset({"var_declaration", "const_declaration"}),
|
|
305
|
+
ordinary_name_style="grouped_names",
|
|
306
|
+
grouped_type_declarations=True,
|
|
307
|
+
preserve_type_scope=True,
|
|
308
|
+
),
|
|
309
|
+
# --- Joern extra native frontends, wired with mature tree-sitter grammars ---
|
|
310
|
+
GrammarSpec(
|
|
311
|
+
name="ruby",
|
|
312
|
+
suffixes=(".rb", ".rake", ".gemspec", ".ru"),
|
|
313
|
+
module="tree_sitter_ruby",
|
|
314
|
+
attr="language",
|
|
315
|
+
function_kinds={"method": "function", "singleton_method": "method"},
|
|
316
|
+
class_kinds={"class": "class", "module": "module", "singleton_class": "class"},
|
|
317
|
+
wrappers=frozenset(),
|
|
318
|
+
value_function_kinds=frozenset(),
|
|
319
|
+
joern_frontends=("rubysrc2cpg",),
|
|
320
|
+
ordinary_declaration_kinds=frozenset({"assignment"}),
|
|
321
|
+
ordinary_name_style="constants",
|
|
322
|
+
ordinary_in_class_scope=True,
|
|
323
|
+
),
|
|
324
|
+
GrammarSpec(
|
|
325
|
+
name="rust",
|
|
326
|
+
suffixes=(".rs",),
|
|
327
|
+
module="tree_sitter_rust",
|
|
328
|
+
attr="language",
|
|
329
|
+
function_kinds={"function_item": "function"},
|
|
330
|
+
class_kinds={
|
|
331
|
+
"struct_item": "struct",
|
|
332
|
+
"enum_item": "enum",
|
|
333
|
+
"union_item": "union",
|
|
334
|
+
"impl_item": "impl",
|
|
335
|
+
"trait_item": "trait",
|
|
336
|
+
"mod_item": "module",
|
|
337
|
+
},
|
|
338
|
+
wrappers=frozenset(),
|
|
339
|
+
value_function_kinds=frozenset(),
|
|
340
|
+
joern_frontends=("rust2cpg",),
|
|
341
|
+
),
|
|
342
|
+
GrammarSpec(
|
|
343
|
+
name="php",
|
|
344
|
+
suffixes=(".php", ".phtml"),
|
|
345
|
+
module="tree_sitter_php",
|
|
346
|
+
attr="language_php",
|
|
347
|
+
function_kinds={"function_definition": "function", "method_declaration": "method"},
|
|
348
|
+
class_kinds={
|
|
349
|
+
"class_declaration": "class",
|
|
350
|
+
"interface_declaration": "interface",
|
|
351
|
+
"trait_declaration": "trait",
|
|
352
|
+
"enum_declaration": "enum",
|
|
353
|
+
},
|
|
354
|
+
wrappers=frozenset(),
|
|
355
|
+
value_function_kinds=frozenset(),
|
|
356
|
+
joern_frontends=("php2cpg",),
|
|
357
|
+
),
|
|
358
|
+
GrammarSpec(
|
|
359
|
+
name="c_sharp",
|
|
360
|
+
suffixes=(".cs",),
|
|
361
|
+
module="tree_sitter_c_sharp",
|
|
362
|
+
attr="language",
|
|
363
|
+
function_kinds={
|
|
364
|
+
"method_declaration": "method",
|
|
365
|
+
"constructor_declaration": "constructor",
|
|
366
|
+
"destructor_declaration": "destructor",
|
|
367
|
+
"local_function_statement": "function",
|
|
368
|
+
"operator_declaration": "method",
|
|
369
|
+
},
|
|
370
|
+
class_kinds={
|
|
371
|
+
"class_declaration": "class",
|
|
372
|
+
"interface_declaration": "interface",
|
|
373
|
+
"struct_declaration": "struct",
|
|
374
|
+
"enum_declaration": "enum",
|
|
375
|
+
"record_declaration": "record",
|
|
376
|
+
"namespace_declaration": "namespace",
|
|
377
|
+
},
|
|
378
|
+
wrappers=frozenset(),
|
|
379
|
+
value_function_kinds=frozenset(),
|
|
380
|
+
joern_frontends=("csharpsrc2cpg",),
|
|
381
|
+
),
|
|
382
|
+
GrammarSpec(
|
|
383
|
+
name="kotlin",
|
|
384
|
+
suffixes=(".kt", ".kts"),
|
|
385
|
+
module="tree_sitter_kotlin",
|
|
386
|
+
attr="language",
|
|
387
|
+
function_kinds={"function_declaration": "function", "secondary_constructor": "constructor"},
|
|
388
|
+
class_kinds={"class_declaration": "class", "object_declaration": "object", "companion_object": "object"},
|
|
389
|
+
wrappers=frozenset(),
|
|
390
|
+
value_function_kinds=frozenset(),
|
|
391
|
+
provenance="community",
|
|
392
|
+
joern_frontends=("kotlin2cpg",),
|
|
393
|
+
),
|
|
394
|
+
GrammarSpec(
|
|
395
|
+
name="swift",
|
|
396
|
+
suffixes=(".swift",),
|
|
397
|
+
module="tree_sitter_swift",
|
|
398
|
+
attr="language",
|
|
399
|
+
function_kinds={
|
|
400
|
+
"function_declaration": "function",
|
|
401
|
+
"init_declaration": "constructor",
|
|
402
|
+
"deinit_declaration": "destructor",
|
|
403
|
+
"protocol_function_declaration": "method",
|
|
404
|
+
},
|
|
405
|
+
class_kinds={
|
|
406
|
+
"class_declaration": "class",
|
|
407
|
+
"protocol_declaration": "protocol",
|
|
408
|
+
"extension_declaration": "extension",
|
|
409
|
+
"enum_declaration": "enum",
|
|
410
|
+
},
|
|
411
|
+
wrappers=frozenset(),
|
|
412
|
+
value_function_kinds=frozenset(),
|
|
413
|
+
provenance="community",
|
|
414
|
+
joern_frontends=("swiftsrc2cpg",),
|
|
415
|
+
),
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
# TSX shares the TypeScript package but a different language function.
|
|
419
|
+
_TSX_SPEC = GrammarSpec(
|
|
420
|
+
name="tsx",
|
|
421
|
+
suffixes=(".tsx",),
|
|
422
|
+
module="tree_sitter_typescript",
|
|
423
|
+
attr="language_tsx",
|
|
424
|
+
function_kinds=GRAMMARS[4].function_kinds,
|
|
425
|
+
class_kinds=GRAMMARS[4].class_kinds,
|
|
426
|
+
wrappers=GRAMMARS[4].wrappers,
|
|
427
|
+
value_function_kinds=_VARIABLE_FUNCTION_VALUES,
|
|
428
|
+
binding_kinds=frozenset({"variable_declarator"}),
|
|
429
|
+
ordinary_declaration_kinds=GRAMMARS[4].ordinary_declaration_kinds,
|
|
430
|
+
ordinary_name_style=GRAMMARS[4].ordinary_name_style,
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
#: Extra specs added at runtime by ``register_language`` or a ``cbe.languages``
|
|
434
|
+
#: entry point. This is the unified extension seam: a grammar pack makes a
|
|
435
|
+
#: natively supported extra language visible without editing this module.
|
|
436
|
+
_EXTRA_SPECS: dict[str, GrammarSpec] = {}
|
|
437
|
+
_PLUGIN_ERRORS: list[dict[str, Any]] = []
|
|
438
|
+
_PLUGINS_LOADED = False
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def register_language(spec: GrammarSpec) -> None:
|
|
442
|
+
"""Register or replace a grammar spec for the shared chunking path.
|
|
443
|
+
|
|
444
|
+
A grammar pack can call this directly, or publish a ``cbe.languages``
|
|
445
|
+
entry point whose target is a ``GrammarSpec`` or a zero-argument factory.
|
|
446
|
+
Registering a suffix that a built-in already owns replaces it, so a project
|
|
447
|
+
can say ``.h`` means C++ without forking the product module.
|
|
448
|
+
"""
|
|
449
|
+
|
|
450
|
+
if not isinstance(spec, GrammarSpec):
|
|
451
|
+
raise TypeError("register_language expects a GrammarSpec")
|
|
452
|
+
_EXTRA_SPECS[spec.name] = spec
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _ensure_plugins() -> None:
|
|
456
|
+
global _PLUGINS_LOADED
|
|
457
|
+
if _PLUGINS_LOADED:
|
|
458
|
+
return
|
|
459
|
+
_PLUGINS_LOADED = True
|
|
460
|
+
try:
|
|
461
|
+
from importlib.metadata import entry_points
|
|
462
|
+
|
|
463
|
+
selected = entry_points(group="cbe.languages")
|
|
464
|
+
except Exception:
|
|
465
|
+
return
|
|
466
|
+
for entry in selected:
|
|
467
|
+
try:
|
|
468
|
+
loaded = entry.load()
|
|
469
|
+
spec = loaded() if callable(loaded) else loaded
|
|
470
|
+
if isinstance(spec, GrammarSpec):
|
|
471
|
+
register_language(spec)
|
|
472
|
+
else:
|
|
473
|
+
_PLUGIN_ERRORS.append(
|
|
474
|
+
{
|
|
475
|
+
"entry_point": getattr(entry, "name", str(entry)),
|
|
476
|
+
"error": f"expected a GrammarSpec, got {type(spec).__name__}",
|
|
477
|
+
}
|
|
478
|
+
)
|
|
479
|
+
except Exception as exc:
|
|
480
|
+
_PLUGIN_ERRORS.append(
|
|
481
|
+
{
|
|
482
|
+
"entry_point": getattr(entry, "name", str(entry)),
|
|
483
|
+
"error": f"{type(exc).__name__}: {exc}",
|
|
484
|
+
}
|
|
485
|
+
)
|
|
486
|
+
continue
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _all_specs() -> tuple[GrammarSpec, ...]:
|
|
490
|
+
_ensure_plugins()
|
|
491
|
+
ordered: dict[str, GrammarSpec] = {}
|
|
492
|
+
for spec in (*GRAMMARS, _TSX_SPEC, *_EXTRA_SPECS.values()):
|
|
493
|
+
ordered[spec.name] = spec
|
|
494
|
+
return tuple(ordered.values())
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _lookup_specs() -> tuple[GrammarSpec, ...]:
|
|
498
|
+
"""Specs in suffix-lookup order: registered/plugin specs win over built-ins,
|
|
499
|
+
so a project can remap an ambiguous suffix (for example ``.h`` to C++)
|
|
500
|
+
without editing this module. A built-in replaced by name still answers for
|
|
501
|
+
the suffixes the replacement does not claim."""
|
|
502
|
+
|
|
503
|
+
_ensure_plugins()
|
|
504
|
+
return (*_EXTRA_SPECS.values(), *GRAMMARS, _TSX_SPEC)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _grammar_for_suffix(suffix: str) -> GrammarSpec | None:
|
|
508
|
+
suffix = suffix.lower()
|
|
509
|
+
for spec in _lookup_specs():
|
|
510
|
+
if any(suffix == declared.lower() for declared in spec.suffixes):
|
|
511
|
+
return spec
|
|
512
|
+
return None
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def language_name(path: str) -> str:
|
|
516
|
+
"""The language CBE records for a path: the grammar's name, else the bare suffix."""
|
|
517
|
+
|
|
518
|
+
spec = _grammar_for_suffix(PurePosixPath(path).suffix)
|
|
519
|
+
return spec.name if spec is not None else _language_from_suffix(path)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
_LOADED_LANGUAGES: dict[tuple[str, str, str], Any] = {}
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _load_language(spec: GrammarSpec) -> Any:
|
|
526
|
+
# Cache by grammar identity, not by name: replacing a registered spec with
|
|
527
|
+
# a different module must actually select the new grammar.
|
|
528
|
+
key = (spec.name, spec.module, spec.attr)
|
|
529
|
+
cached = _LOADED_LANGUAGES.get(key)
|
|
530
|
+
if cached is not None:
|
|
531
|
+
return cached
|
|
532
|
+
# Import here so a missing grammar package is a per-language diagnostic,
|
|
533
|
+
# not a module import failure.
|
|
534
|
+
import importlib
|
|
535
|
+
|
|
536
|
+
from tree_sitter import Language
|
|
537
|
+
|
|
538
|
+
module = importlib.import_module(spec.module)
|
|
539
|
+
capsule = getattr(module, spec.attr)
|
|
540
|
+
payload = capsule() if callable(capsule) else capsule
|
|
541
|
+
language = Language(payload)
|
|
542
|
+
_LOADED_LANGUAGES[key] = language
|
|
543
|
+
return language
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _make_parser(spec: GrammarSpec) -> Any:
|
|
547
|
+
from tree_sitter import Parser
|
|
548
|
+
|
|
549
|
+
return Parser(_load_language(spec))
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _grammar_version(spec: GrammarSpec) -> str:
|
|
553
|
+
try:
|
|
554
|
+
import importlib
|
|
555
|
+
|
|
556
|
+
module = importlib.import_module(spec.module)
|
|
557
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
558
|
+
|
|
559
|
+
try:
|
|
560
|
+
return version(spec.module.replace("_", "-"))
|
|
561
|
+
except PackageNotFoundError:
|
|
562
|
+
return str(getattr(module, "__version__", "unknown"))
|
|
563
|
+
except Exception:
|
|
564
|
+
return "unknown"
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def available_languages() -> dict[str, Any]:
|
|
568
|
+
"""Capability discovery for product consumers.
|
|
569
|
+
|
|
570
|
+
Returns every registered language, its suffixes, grammar package and
|
|
571
|
+
whether that grammar is importable in the current environment. Consumers
|
|
572
|
+
must not hard-code the priority list: languages added to the registry show
|
|
573
|
+
up here and flow through the same chunking path.
|
|
574
|
+
"""
|
|
575
|
+
|
|
576
|
+
languages: list[dict[str, Any]] = []
|
|
577
|
+
for spec in _all_specs():
|
|
578
|
+
entry: dict[str, Any] = {
|
|
579
|
+
"name": spec.name,
|
|
580
|
+
"suffixes": list(spec.suffixes),
|
|
581
|
+
"grammar_module": spec.module,
|
|
582
|
+
"grammar_version": _grammar_version(spec),
|
|
583
|
+
"provenance": spec.provenance,
|
|
584
|
+
"joern_frontends": list(spec.joern_frontends or _JOERN_FRONTENDS.get(spec.name, ())),
|
|
585
|
+
"call_graph": dict(_CALL_GRAPH.get(spec.name, {"status": "untested"})),
|
|
586
|
+
"available": False,
|
|
587
|
+
"error": None,
|
|
588
|
+
}
|
|
589
|
+
try:
|
|
590
|
+
_make_parser(spec)
|
|
591
|
+
entry["available"] = True
|
|
592
|
+
except Exception as exc: # pragma: no cover - depends on environment
|
|
593
|
+
entry["error"] = f"{type(exc).__name__}: {exc}"
|
|
594
|
+
languages.append(entry)
|
|
595
|
+
return {"schema": SCHEMA_CONTEXT, "languages": languages, "plugin_errors": [dict(item) for item in _PLUGIN_ERRORS]}
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
#: Suffixes documented without a grammar: each file becomes one whole-file region.
|
|
599
|
+
FALLBACK_SUFFIXES = frozenset({".sql"})
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def suffix_languages() -> dict[str, str]:
|
|
603
|
+
"""Suffix -> language for every suffix ``_grammar_for_suffix`` recognises.
|
|
604
|
+
|
|
605
|
+
Enumerating the lookup's own specs and asking the lookup keeps one rule for
|
|
606
|
+
which suffixes count and which spec wins, for one path and for a whole tree."""
|
|
607
|
+
|
|
608
|
+
suffixes = {suffix.lower() for spec in _lookup_specs() for suffix in spec.suffixes}
|
|
609
|
+
return {suffix: _grammar_for_suffix(suffix).name for suffix in sorted(suffixes)}
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def registered_suffixes() -> frozenset[str]:
|
|
613
|
+
"""Every suffix a registered grammar (built-in or plugin) claims."""
|
|
614
|
+
|
|
615
|
+
return frozenset(suffix_languages())
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
# ---------------------------------------------------------------------------
|
|
619
|
+
# Errors and diagnostics
|
|
620
|
+
# ---------------------------------------------------------------------------
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
class ContextError(Exception):
|
|
624
|
+
"""A locatable, structured failure that a consumer can catch and report."""
|
|
625
|
+
|
|
626
|
+
def __init__(self, code: str, message: str, **details: Any) -> None:
|
|
627
|
+
super().__init__(message)
|
|
628
|
+
self.code = code
|
|
629
|
+
self.message = message
|
|
630
|
+
self.details = details
|
|
631
|
+
|
|
632
|
+
def to_dict(self) -> dict[str, Any]:
|
|
633
|
+
return {"code": self.code, "message": self.message, **self.details}
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
class GraphSourceMismatch(ContextError):
|
|
637
|
+
"""A graph's source manifest does not match the current source tree."""
|
|
638
|
+
|
|
639
|
+
def __init__(self, path: str, expected: str | None, actual: str | None, reason: str) -> None:
|
|
640
|
+
super().__init__(
|
|
641
|
+
"graph_source_mismatch",
|
|
642
|
+
f"graph/source drift on {path}: {reason}",
|
|
643
|
+
path=path,
|
|
644
|
+
expected=expected,
|
|
645
|
+
actual=actual,
|
|
646
|
+
reason=reason,
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
# ---------------------------------------------------------------------------
|
|
651
|
+
# Path validation
|
|
652
|
+
# ---------------------------------------------------------------------------
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _validate_relative(repo: Path, raw: str) -> str:
|
|
656
|
+
"""Normalise a repo-relative POSIX path and reject escapes.
|
|
657
|
+
|
|
658
|
+
Rejects absolute paths, ``..`` components, and any path that resolves
|
|
659
|
+
(through symlinks) outside the repository root.
|
|
660
|
+
"""
|
|
661
|
+
|
|
662
|
+
if not isinstance(raw, str) or not raw:
|
|
663
|
+
raise ContextError("invalid_path", f"path must be a non-empty string, got {raw!r}", path=raw)
|
|
664
|
+
candidate = PurePosixPath(raw)
|
|
665
|
+
if candidate.is_absolute():
|
|
666
|
+
raise ContextError("invalid_path", f"absolute path is not allowed: {raw}", path=raw)
|
|
667
|
+
if ".." in candidate.parts:
|
|
668
|
+
raise ContextError("invalid_path", f"path escapes the repository: {raw}", path=raw)
|
|
669
|
+
normalized = candidate.as_posix()
|
|
670
|
+
while normalized.startswith("./"):
|
|
671
|
+
normalized = normalized[2:]
|
|
672
|
+
if normalized in ("", "."):
|
|
673
|
+
raise ContextError("invalid_path", f"path does not name a file: {raw}", path=raw)
|
|
674
|
+
|
|
675
|
+
repo_real = Path(os.path.realpath(repo))
|
|
676
|
+
target = repo / PurePosixPath(normalized)
|
|
677
|
+
target_real = Path(os.path.realpath(target))
|
|
678
|
+
try:
|
|
679
|
+
target_real.relative_to(repo_real)
|
|
680
|
+
except ValueError:
|
|
681
|
+
raise ContextError(
|
|
682
|
+
"invalid_path",
|
|
683
|
+
f"path resolves outside the repository (symlink escape): {raw}",
|
|
684
|
+
path=raw,
|
|
685
|
+
resolved=str(target_real),
|
|
686
|
+
) from None
|
|
687
|
+
return normalized
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _iter_source_files(repo: Path, paths: list[str] | None) -> tuple[list[str], list[dict[str, Any]]]:
|
|
691
|
+
"""Return (relative paths, diagnostics) for a build request."""
|
|
692
|
+
|
|
693
|
+
diagnostics: list[dict[str, Any]] = []
|
|
694
|
+
if paths is not None:
|
|
695
|
+
selected: list[str] = []
|
|
696
|
+
for raw in paths:
|
|
697
|
+
selected.append(_validate_relative(repo, raw))
|
|
698
|
+
return selected, diagnostics
|
|
699
|
+
|
|
700
|
+
discovered: list[str] = []
|
|
701
|
+
for root, dirnames, filenames in os.walk(repo):
|
|
702
|
+
dirnames[:] = sorted(name for name in dirnames if name not in DEFAULT_EXCLUDE_DIR_NAMES)
|
|
703
|
+
rel_root = os.path.relpath(root, repo)
|
|
704
|
+
for filename in sorted(filenames):
|
|
705
|
+
rel = filename if rel_root == "." else f"{rel_root}/{filename}"
|
|
706
|
+
rel = rel.replace(os.sep, "/")
|
|
707
|
+
if _grammar_for_suffix(PurePosixPath(rel).suffix) is None:
|
|
708
|
+
continue
|
|
709
|
+
try:
|
|
710
|
+
# Discovery applies the same escape/symlink validation as
|
|
711
|
+
# explicit ``paths``; a discovered external symlink is skipped
|
|
712
|
+
# with a diagnostic instead of silently read.
|
|
713
|
+
rel = _validate_relative(repo, rel)
|
|
714
|
+
except ContextError as exc:
|
|
715
|
+
diagnostics.append({"path": rel, "code": exc.code, "message": exc.message})
|
|
716
|
+
continue
|
|
717
|
+
full = repo / rel
|
|
718
|
+
try:
|
|
719
|
+
size = full.stat().st_size
|
|
720
|
+
except OSError as exc:
|
|
721
|
+
diagnostics.append({"path": rel, "code": "source_unavailable", "message": str(exc)})
|
|
722
|
+
continue
|
|
723
|
+
if size > MAX_FILE_BYTES:
|
|
724
|
+
diagnostics.append(
|
|
725
|
+
{
|
|
726
|
+
"path": rel,
|
|
727
|
+
"code": "file_too_large",
|
|
728
|
+
"message": f"{size} bytes exceeds MAX_FILE_BYTES={MAX_FILE_BYTES}",
|
|
729
|
+
}
|
|
730
|
+
)
|
|
731
|
+
continue
|
|
732
|
+
discovered.append(rel)
|
|
733
|
+
return discovered, diagnostics
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def _language_from_suffix(path: str) -> str:
|
|
738
|
+
suffix = PurePosixPath(path).suffix.lstrip(".").lower()
|
|
739
|
+
return suffix or "unknown"
|
|
740
|
+
|
|
741
|
+
|