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
@@ -0,0 +1,396 @@
1
+ """Find and install the call-graph analyzer toolchain: Joern and Java.
2
+
3
+ ``cbe graph setup`` downloads a pinned Joern release and a Temurin JRE into
4
+ the user cache directory. Every download is checked against SHA-256 values
5
+ pinned below before anything is unpacked. Nothing here changes an installed
6
+ Joern or Java outside the cache directory.
7
+
8
+ Lookup order used by ``cbe graph build``:
9
+
10
+ * Joern: ``--joern-home``, ``CBE_JOERN_HOME``, the cache directory, ``joern-parse`` on ``PATH``.
11
+ * Java: ``JAVA_HOME``, ``java`` on ``PATH``, the cache directory.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import json
18
+ import os
19
+ import platform
20
+ import re
21
+ import shutil
22
+ import subprocess
23
+ import sys
24
+ import tarfile
25
+ import tempfile
26
+ import time
27
+ import urllib.request
28
+ import zipfile
29
+ from dataclasses import dataclass
30
+ from pathlib import Path
31
+
32
+ JOERN_VERSION = "4.0.654"
33
+ JAVA_VERSION = "21.0.13+9"
34
+ MIN_JAVA_MAJOR = 21
35
+
36
+ _JOERN_BASE = f"https://github.com/joernio/joern/releases/download/v{JOERN_VERSION}/"
37
+ _JAVA_BASE = "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.13%2B9/"
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Asset:
42
+ """One pinned download. ``checksums`` maps a hash name to its hex digest."""
43
+
44
+ name: str
45
+ url: str
46
+ size: int
47
+ checksums: dict
48
+
49
+
50
+ # Joern: the sha512 values are the upstream ``<asset>.sha512`` files; the sha256
51
+ # values are the digests GitHub computed for the release assets. Java: the
52
+ # sha256 values are the ``checksum`` that the Adoptium API reports. The
53
+ # linux-x86_64 Joern zip was also hashed after a real download.
54
+ PINNED: dict[str, dict[str, Asset]] = {
55
+ "linux-x86_64": {
56
+ "joern": Asset(
57
+ "joern-cli-linux-x86_64.zip", _JOERN_BASE + "joern-cli-linux-x86_64.zip", 1798080877,
58
+ {"sha256": "830539ea998708362e4c479afea895a7dbfa90ed70537b38c84e53183e6e1fcd",
59
+ "sha512": "706ec7cdc0c6c554b454fc7b83d3ba35ff47b40f9c9b74e041fac61e0b54902e"
60
+ "8d2a394b71b2a7cb7d1eb5ac9733f5442bd7ba1e6354a8e59f010e01e0377091"}),
61
+ "java": Asset(
62
+ "OpenJDK21U-jre_x64_linux_hotspot_21.0.13_9.tar.gz",
63
+ _JAVA_BASE + "OpenJDK21U-jre_x64_linux_hotspot_21.0.13_9.tar.gz", 52181297,
64
+ {"sha256": "a535a1df9c6284d9618872984e026ae0352d1f7e9e0605425c0e6114f47351dd"}),
65
+ },
66
+ "macos-arm64": {
67
+ "joern": Asset(
68
+ "joern-cli-macos-arm64.zip", _JOERN_BASE + "joern-cli-macos-arm64.zip", 1723620390,
69
+ {"sha256": "4b4634dd1f5ad71eda91a9c4ca8baa18716f4cb03009de24ce1b1bc827401c97",
70
+ "sha512": "779ca311bb0c6cef3130b57862b4f996ce3fabdc1f606a17b42cb1048dc3c1a1"
71
+ "d8f828d9ca70ba0f6f9bbf5adc79d8f4ac2c9069ad1505ddf42d563d999dc839"}),
72
+ "java": Asset(
73
+ "OpenJDK21U-jre_aarch64_mac_hotspot_21.0.13_9.tar.gz",
74
+ _JAVA_BASE + "OpenJDK21U-jre_aarch64_mac_hotspot_21.0.13_9.tar.gz", 47935740,
75
+ {"sha256": "225bab0a09491ef06835488cd942f5d2befa9a0410a2484f0c8bbcf7506ec09a"}),
76
+ },
77
+ "macos-x86_64": {
78
+ "joern": Asset(
79
+ "joern-cli-macos-x86_64.zip", _JOERN_BASE + "joern-cli-macos-x86_64.zip", 1729326215,
80
+ {"sha256": "de66af2becda50080f35d120d5f723350687608f1f25b6ce73a2016467f53be0",
81
+ "sha512": "7c66ddf9848283631833bc98e8829bd7bb6d48dd1dd1040dc2f1a4279458898d"
82
+ "858be909c41a09c37c438ed095951e53aa056a7ec7477d1e83c16becca4f9fec"}),
83
+ "java": Asset(
84
+ "OpenJDK21U-jre_x64_mac_hotspot_21.0.13_9.tar.gz",
85
+ _JAVA_BASE + "OpenJDK21U-jre_x64_mac_hotspot_21.0.13_9.tar.gz", 41902098,
86
+ {"sha256": "c909674b1c10c261b9a3fb7e9c0efebd303e33f88a4fe84be7135b5e2d711f89"}),
87
+ },
88
+ }
89
+
90
+ _MANUAL = (f"Install it by hand: download joern-cli-<platform>.zip for Joern {JOERN_VERSION} from "
91
+ f"https://github.com/joernio/joern/releases/tag/v{JOERN_VERSION}, unzip it, and pass the "
92
+ "joern-cli directory as --joern-home (or set CBE_JOERN_HOME). Joern needs Java "
93
+ f"{MIN_JAVA_MAJOR} or newer on PATH or in JAVA_HOME.")
94
+
95
+
96
+ class SetupError(ValueError):
97
+ """A setup step failed; the message says what to do next."""
98
+
99
+
100
+ def platform_key() -> str | None:
101
+ system, machine = platform.system(), platform.machine().lower()
102
+ arch = {"x86_64": "x86_64", "amd64": "x86_64", "arm64": "arm64", "aarch64": "arm64"}.get(machine)
103
+ name = {"Linux": "linux", "Darwin": "macos"}.get(system)
104
+ key = f"{name}-{arch}"
105
+ return key if key in PINNED else None
106
+
107
+
108
+ def cache_dir() -> Path:
109
+ """``<user cache>/cbe/tools``: XDG on Linux, ``~/Library/Caches`` on macOS."""
110
+ if platform.system() == "Darwin":
111
+ return Path.home() / "Library" / "Caches" / "cbe" / "tools"
112
+ base = os.environ.get("XDG_CACHE_HOME")
113
+ return (Path(base).expanduser() if base else Path.home() / ".cache") / "cbe" / "tools"
114
+
115
+
116
+ def download_size(key: str | None = None, table=PINNED) -> int | None:
117
+ assets = table.get(key or platform_key() or "")
118
+ return sum(asset.size for asset in assets.values()) if assets else None
119
+
120
+
121
+ def setup_hint(key: str | None = None, table=PINNED) -> str:
122
+ size = download_size(key, table)
123
+ gigabytes = f"about {size / 1e9:.1f} GB" if size else "about 2 GB"
124
+ return f"run `cbe graph setup` (downloads {gigabytes})"
125
+
126
+
127
+ def _joern_dir_ok(path: Path) -> bool:
128
+ return (path / "joern").is_file() and (path / "joern-parse").is_file()
129
+
130
+
131
+ class ToolchainError(ValueError):
132
+ """A tool location the user named does not hold the tool."""
133
+
134
+
135
+ def _joern_in(base: Path) -> Path | None:
136
+ return next((c for c in (base, base / "joern-cli", base / "bin") if _joern_dir_ok(c)), None)
137
+
138
+
139
+ def find_joern(joern_home: Path | str | None = None, cache: Path | None = None) -> Path | None:
140
+ """Locate the Joern CLI directory that holds ``joern`` and ``joern-parse``.
141
+
142
+ A location the user named (``joern_home`` or ``CBE_JOERN_HOME``) must be valid:
143
+ it raises :class:`ToolchainError` instead of falling through to the next place.
144
+ """
145
+ for label, base in (("--joern-home", joern_home), ("CBE_JOERN_HOME", os.environ.get("CBE_JOERN_HOME"))):
146
+ if not base:
147
+ continue
148
+ base = Path(base).expanduser()
149
+ found = _joern_in(base)
150
+ if found is None:
151
+ raise ToolchainError(
152
+ f"{label} {base} does not hold joern-parse: looked for joern and joern-parse in {base}, "
153
+ f"{base / 'joern-cli'} and {base / 'bin'}. Fix the path, or {setup_hint()}")
154
+ return found
155
+ cached = (cache or cache_dir()) / f"joern-{JOERN_VERSION}" / "joern-cli"
156
+ if _joern_dir_ok(cached):
157
+ return cached
158
+ which = shutil.which("joern-parse")
159
+ if which and _joern_dir_ok(Path(which).resolve().parent):
160
+ return Path(which).resolve().parent
161
+ return None
162
+
163
+
164
+ @dataclass(frozen=True)
165
+ class JavaInstall:
166
+ home: Path
167
+ version: str
168
+ major: int
169
+ source: str
170
+
171
+
172
+ def _java_home_in(root: Path) -> Path | None:
173
+ for candidate in (root, root / "Contents" / "Home"):
174
+ if (candidate / "bin" / "java").is_file():
175
+ return candidate
176
+ return None
177
+
178
+
179
+ def probe_java(java_exe: Path) -> tuple[Path, str, int] | None:
180
+ """Ask a ``java`` executable for its home and version; None if it cannot run."""
181
+ try:
182
+ done = subprocess.run([str(java_exe), "-XshowSettings:properties", "-version"],
183
+ capture_output=True, text=True, timeout=60, check=False,
184
+ stdin=subprocess.DEVNULL)
185
+ except (OSError, subprocess.TimeoutExpired):
186
+ return None
187
+ text = (done.stderr or "") + (done.stdout or "")
188
+ home = re.search(r"^\s*java\.home = (.+)$", text, re.M)
189
+ version = re.search(r"^\s*java\.version = (\S+)$", text, re.M)
190
+ if done.returncode != 0 or not home or not version:
191
+ return None
192
+ major = re.match(r"(\d+)", version.group(1))
193
+ return Path(home.group(1).strip()), version.group(1), int(major.group(1)) if major else 0
194
+
195
+
196
+ def java_candidates(cache: Path | None = None) -> list[tuple[str, Path]]:
197
+ """Java executables in lookup order, as (source, path)."""
198
+ found: list[tuple[str, Path]] = []
199
+ if os.environ.get("JAVA_HOME"):
200
+ found.append(("JAVA_HOME", Path(os.environ["JAVA_HOME"]).expanduser() / "bin" / "java"))
201
+ if shutil.which("java"):
202
+ found.append(("PATH", Path(shutil.which("java"))))
203
+ home = _java_home_in((cache or cache_dir()) / f"jre-{JAVA_VERSION}")
204
+ if home:
205
+ found.append(("cache", home / "bin" / "java"))
206
+ return found
207
+
208
+
209
+ def find_java(cache: Path | None = None, *, rejected: list | None = None) -> JavaInstall | None:
210
+ """First Java of at least ``MIN_JAVA_MAJOR`` in lookup order; others go to ``rejected``."""
211
+ for source, exe in java_candidates(cache):
212
+ if not exe.is_file():
213
+ if rejected is not None:
214
+ rejected.append((source, str(exe), "not an executable file"))
215
+ continue
216
+ probed = probe_java(exe)
217
+ if probed is None:
218
+ if rejected is not None:
219
+ rejected.append((source, str(exe), "does not run"))
220
+ continue
221
+ home, version, major = probed
222
+ if major >= MIN_JAVA_MAJOR:
223
+ return JavaInstall(home, version, major, source)
224
+ if rejected is not None:
225
+ rejected.append((source, str(exe), f"Java {version} is older than {MIN_JAVA_MAJOR}"))
226
+ return None
227
+
228
+
229
+ def java_environment(java: JavaInstall | None, base: dict | None = None) -> dict | None:
230
+ """Environment that makes Joern's launcher use ``java``; None when nothing needs changing."""
231
+ if java is None:
232
+ return base
233
+ env = dict(os.environ if base is None else base)
234
+ env["JAVA_HOME"] = str(java.home)
235
+ env["PATH"] = str(java.home / "bin") + os.pathsep + env.get("PATH", "")
236
+ return env
237
+
238
+
239
+ def check(cache: Path | None = None) -> dict:
240
+ """What is installed and what is missing; downloads nothing."""
241
+ from .joern import joern_version # noqa: PLC0415
242
+
243
+ key = platform_key()
244
+ joern_error = None
245
+ try:
246
+ joern_dir = find_joern(cache=cache)
247
+ except ToolchainError as exc:
248
+ joern_dir, joern_error = None, str(exc)
249
+ rejected: list = []
250
+ java = find_java(cache, rejected=rejected)
251
+ joern = None
252
+ if joern_dir:
253
+ joern = {"path": str(joern_dir), "version": joern_version(joern_dir / "joern", 120, java=java)}
254
+ missing = []
255
+ if joern is None:
256
+ missing.append("joern")
257
+ if java is None:
258
+ missing.append("java")
259
+ report = {
260
+ "platform": key or f"{platform.system().lower()}-{platform.machine().lower()}",
261
+ "joern": joern,
262
+ "java": ({"path": str(java.home), "version": java.version, "source": java.source}
263
+ if java else None),
264
+ "java_rejected": [{"source": s, "path": p, "reason": r} for s, p, r in rejected],
265
+ "missing": missing,
266
+ "cache_dir": str(cache or cache_dir()),
267
+ "pinned_joern": JOERN_VERSION,
268
+ }
269
+ if joern_error:
270
+ report["joern_error"] = joern_error
271
+ if missing:
272
+ report["next"] = setup_hint(key)
273
+ return report
274
+
275
+
276
+ def _human(size: float) -> str:
277
+ return f"{size / 1e9:.2f} GB" if size >= 1e8 else f"{size / 1e6:.1f} MB"
278
+
279
+
280
+ def _download(asset: Asset, target: Path, say) -> None:
281
+ """Stream ``asset`` to ``target`` and verify its pinned checksums before returning."""
282
+ digests = {name: hashlib.new(name) for name in asset.checksums}
283
+ started, shown = time.monotonic(), -1
284
+ try:
285
+ with urllib.request.urlopen(asset.url, timeout=60) as response, target.open("wb") as out:
286
+ total = int(response.headers.get("Content-Length") or asset.size)
287
+ done = 0
288
+ while chunk := response.read(1 << 20):
289
+ out.write(chunk)
290
+ for digest in digests.values():
291
+ digest.update(chunk)
292
+ done += len(chunk)
293
+ step = done * 10 // max(total, 1)
294
+ if step != shown and total >= 1e8:
295
+ shown = step
296
+ say(f" {step * 10}% ({_human(done)} of {_human(total)}, {time.monotonic() - started:.0f}s)")
297
+ except OSError as exc:
298
+ target.unlink(missing_ok=True)
299
+ raise SetupError(f"download failed for {asset.url}: {exc}. Check the network and run the command again. "
300
+ + _MANUAL) from exc
301
+ for name, expected in asset.checksums.items():
302
+ actual = digests[name].hexdigest()
303
+ if actual != expected:
304
+ target.unlink(missing_ok=True)
305
+ raise SetupError(f"{name} mismatch for {asset.name}: expected {expected}, got {actual}. "
306
+ "The file was deleted and nothing was installed.")
307
+
308
+
309
+ def _extract(archive: Path, destination: Path) -> None:
310
+ destination.mkdir(parents=True)
311
+ root = destination.resolve()
312
+ if archive.name.endswith(".zip"):
313
+ with zipfile.ZipFile(archive) as zf:
314
+ for info in zf.infolist():
315
+ target = (root / info.filename).resolve()
316
+ if target != root and root not in target.parents:
317
+ raise SetupError(f"archive entry escapes the target directory: {info.filename}")
318
+ mode = info.external_attr >> 16
319
+ if (mode & 0o170000) == 0o120000:
320
+ target.parent.mkdir(parents=True, exist_ok=True)
321
+ target.unlink(missing_ok=True)
322
+ target.symlink_to(zf.read(info).decode())
323
+ continue
324
+ zf.extract(info, root)
325
+ if mode & 0o111 and not info.is_dir():
326
+ target.chmod(target.stat().st_mode | 0o111) # zipfile drops the executable bit
327
+ else:
328
+ with tarfile.open(archive) as tf:
329
+ tf.extractall(root, filter="data")
330
+
331
+
332
+ def _install_asset(kind: str, asset: Asset, final: Path, cache: Path, say, *, strip_top: bool) -> None:
333
+ say(f"Downloading {asset.name} ({_human(asset.size)}) from {asset.url}")
334
+ say(f" to {final}")
335
+ cache.mkdir(parents=True, exist_ok=True)
336
+ work = Path(tempfile.mkdtemp(prefix=f".{kind}-", dir=cache))
337
+ try:
338
+ archive = work / asset.name
339
+ _download(asset, archive, say)
340
+ say(f" checksums match: {', '.join(sorted(asset.checksums))}")
341
+ _extract(archive, work / "unpacked")
342
+ archive.unlink()
343
+ unpacked = work / "unpacked"
344
+ entries = [p for p in unpacked.iterdir()]
345
+ top = entries[0] if strip_top and len(entries) == 1 and entries[0].is_dir() else unpacked
346
+ if final.exists():
347
+ shutil.rmtree(final)
348
+ shutil.move(str(top), str(final))
349
+ except zipfile.BadZipFile as exc:
350
+ raise SetupError(f"{asset.name} is not a valid zip file: {exc}") from exc
351
+ except tarfile.TarError as exc:
352
+ raise SetupError(f"{asset.name} is not a valid tar file: {exc}") from exc
353
+ finally:
354
+ shutil.rmtree(work, ignore_errors=True)
355
+
356
+
357
+ def setup(*, force: bool = False, with_java: bool = False, cache: Path | None = None,
358
+ table: dict | None = None, key: str | None = None, say=None) -> dict:
359
+ """Install pinned Joern and, when no Java 21+ is usable, a JRE into the cache directory."""
360
+ say = say or (lambda text: print(text, file=sys.stderr, flush=True))
361
+ table = PINNED if table is None else table
362
+ key = key or platform_key()
363
+ if key is None or key not in table:
364
+ raise SetupError(f"`cbe graph setup` has no download for {platform.system()} {platform.machine()}. "
365
+ f"Supported: {', '.join(sorted(table))}. " + _MANUAL)
366
+ cache = cache or cache_dir()
367
+ assets = table[key]
368
+ joern_final = cache / f"joern-{JOERN_VERSION}"
369
+ jre_final = cache / f"jre-{JAVA_VERSION}"
370
+ result: dict = {"platform": key, "cache_dir": str(cache), "steps": []}
371
+
372
+ if _joern_dir_ok(joern_final / "joern-cli") and not force:
373
+ say(f"Joern {JOERN_VERSION} is already installed in {joern_final}; use --force to reinstall.")
374
+ result["steps"].append({"joern": "already_installed"})
375
+ else:
376
+ _install_asset("joern", assets["joern"], joern_final, cache, say, strip_top=False)
377
+ if not _joern_dir_ok(joern_final / "joern-cli"):
378
+ raise SetupError(f"{assets['joern'].name} unpacked without joern-cli/joern and joern-parse")
379
+ result["steps"].append({"joern": "installed"})
380
+
381
+ have_java = None if with_java else find_java(cache)
382
+ if force and _java_home_in(jre_final):
383
+ have_java = None # --force also reinstalls a JRE this command put in the cache
384
+ if have_java is not None:
385
+ say(f"Java {have_java.version} from {have_java.source} is usable ({have_java.home}); "
386
+ "not downloading a JRE. Use --with-java to download one anyway.")
387
+ result["steps"].append({"java": f"skipped, using {have_java.source}"})
388
+ else:
389
+ _install_asset("jre", assets["java"], jre_final, cache, say, strip_top=True)
390
+ if _java_home_in(jre_final) is None:
391
+ raise SetupError(f"{assets['java'].name} unpacked without bin/java")
392
+ result["steps"].append({"java": "installed"})
393
+ final = check(cache)
394
+ result.update(joern=final["joern"], java=final["java"], missing=final["missing"])
395
+ return result
396
+
cbe/grounding.py ADDED
@@ -0,0 +1,57 @@
1
+ """Check whether identifier-like words in an explanation occur in its unit's source."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+
6
+
7
+ IDENT = re.compile(r"[A-Za-z_$][A-Za-z0-9_$]*")
8
+ STOP = {"true", "false", "null", "none", "nil", "self", "this", "void", "int", "str", "string", "bool",
9
+ "boolean", "dict", "list", "tuple", "set", "object", "async", "await", "return", "yield", "if",
10
+ "else", "for", "while", "try", "except", "catch", "finally", "raise", "throw", "class", "def",
11
+ "function", "fn", "func", "import", "from", "export", "const", "let", "var", "static", "public",
12
+ "private", "new", "in", "is", "not", "and", "or", "id", "args", "kwargs", "i.e", "e.g"}
13
+
14
+
15
+ #: An answer is rejected when at least this many of its explanations, and at least this share of
16
+ #: them, are suspect. Measured on seven runs: the one wrong answer had 59% suspect, the other 56
17
+ #: answers at most 14%.
18
+ BATCH_MIN = 3
19
+ BATCH_SHARE = 0.3
20
+
21
+
22
+ def idents(text):
23
+ return {t for t in IDENT.findall(text)}
24
+
25
+
26
+ def codey(tok):
27
+ return "_" in tok.strip("_") or re.search(r"[a-z0-9][A-Z]", tok) is not None
28
+
29
+
30
+ def mentions(explain, repo_names):
31
+ ticks = set()
32
+ for span in re.findall(r"`([^`]+)`", explain):
33
+ ticks |= idents(span)
34
+ plain = re.sub(r"`[^`]+`", " ", explain)
35
+ code = {t for t in idents(plain) if codey(t)}
36
+ code |= {m for m in re.findall(r"([A-Za-z_$][A-Za-z0-9_$]*)\(", plain)}
37
+ names = {t for t in idents(plain) if t in repo_names and len(t) >= 3}
38
+ return ticks, code, names
39
+
40
+
41
+ def repo_names(symbol_names) -> frozenset[str]:
42
+ """The valid final dotted name segments in this run's symbol inventory."""
43
+ names = set()
44
+ for symbol_name in symbol_names:
45
+ name = (symbol_name or "").split(".")[-1]
46
+ if IDENT.fullmatch(name or ""):
47
+ names.add(name)
48
+ return frozenset(names)
49
+
50
+
51
+ def ungrounded(explain, source_text, repo_names) -> list[str]:
52
+ """Return missing names only when no mentioned identifier occurs in the source."""
53
+ ok = {t.lower() for t in idents(source_text)}
54
+ ticks, code, names = mentions(explain, repo_names)
55
+ mentioned = {t for t in ticks | code | names if t.lower() not in STOP}
56
+ missing = sorted(t for t in mentioned if t.lower() not in ok)
57
+ return missing if mentioned and len(missing) == len(mentioned) else []