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.
Files changed (47) hide show
  1. cbe/__init__.py +9 -0
  2. cbe/__main__.py +4 -0
  3. cbe/aggregation.py +183 -0
  4. cbe/api_completion.py +410 -0
  5. cbe/batching.py +283 -0
  6. cbe/catalog_query.py +376 -0
  7. cbe/checks.py +390 -0
  8. cbe/cli.py +434 -0
  9. cbe/concrete_facts.py +321 -0
  10. cbe/data/LICENSE.tiktoken +30 -0
  11. cbe/data/o200k_base.tiktoken +199998 -0
  12. cbe/declarations.py +537 -0
  13. cbe/docs.py +929 -0
  14. cbe/generate.py +316 -0
  15. cbe/grammars.py +741 -0
  16. cbe/graph.py +857 -0
  17. cbe/graph_backends/__init__.py +43 -0
  18. cbe/graph_backends/_calls.py +262 -0
  19. cbe/graph_backends/_paths.py +42 -0
  20. cbe/graph_backends/identity.py +93 -0
  21. cbe/graph_backends/imports.py +256 -0
  22. cbe/graph_backends/joern.py +1021 -0
  23. cbe/graph_backends/resolution.py +288 -0
  24. cbe/graph_backends/toolchain.py +396 -0
  25. cbe/grounding.py +57 -0
  26. cbe/host_run.py +1752 -0
  27. cbe/model_settings.py +80 -0
  28. cbe/projection.py +305 -0
  29. cbe/prompts.py +316 -0
  30. cbe/records.py +200 -0
  31. cbe/skeleton.py +474 -0
  32. cbe/source_context.py +709 -0
  33. cbe/symbols.py +136 -0
  34. cbe/token_budget.py +68 -0
  35. cbe/treename.py +398 -0
  36. cbe/usage.py +300 -0
  37. cbe_install/__init__.py +11 -0
  38. cbe_install/cli.py +434 -0
  39. cbe_install/resources/agents/cbe-writer.md +16 -0
  40. cbe_install/resources/codebase-explorer/SKILL.md +101 -0
  41. cbe_install/resources/codebase-explorer/references/claude-code.md +63 -0
  42. cbe_install/resources/codebase-explorer/references/codex.md +37 -0
  43. codebase_explorer-2.2.0.dist-info/METADATA +233 -0
  44. codebase_explorer-2.2.0.dist-info/RECORD +47 -0
  45. codebase_explorer-2.2.0.dist-info/WHEEL +4 -0
  46. codebase_explorer-2.2.0.dist-info/entry_points.txt +3 -0
  47. 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
+