cg-code-graph 0.10.1__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 (174) hide show
  1. cg_code_graph-0.10.1.dist-info/METADATA +678 -0
  2. cg_code_graph-0.10.1.dist-info/RECORD +174 -0
  3. cg_code_graph-0.10.1.dist-info/WHEEL +5 -0
  4. cg_code_graph-0.10.1.dist-info/entry_points.txt +3 -0
  5. cg_code_graph-0.10.1.dist-info/licenses/LICENSE +21 -0
  6. cg_code_graph-0.10.1.dist-info/top_level.txt +1 -0
  7. codegraph/__init__.py +2 -0
  8. codegraph/aitools.py +129 -0
  9. codegraph/apps.py +76 -0
  10. codegraph/blindspots.py +428 -0
  11. codegraph/bridges.py +1701 -0
  12. codegraph/cli.py +725 -0
  13. codegraph/concepts.py +362 -0
  14. codegraph/config.py +559 -0
  15. codegraph/core/__init__.py +0 -0
  16. codegraph/core/cache.py +375 -0
  17. codegraph/core/detect.py +80 -0
  18. codegraph/core/extractors.py +187 -0
  19. codegraph/core/fsutil.py +61 -0
  20. codegraph/core/generated.py +575 -0
  21. codegraph/core/model.py +174 -0
  22. codegraph/core/paths.py +175 -0
  23. codegraph/core/plugin.py +160 -0
  24. codegraph/core/store.py +80 -0
  25. codegraph/core/syntax_errors.py +132 -0
  26. codegraph/coverage.py +928 -0
  27. codegraph/doctor.py +453 -0
  28. codegraph/external.py +613 -0
  29. codegraph/indexer.py +336 -0
  30. codegraph/link.py +434 -0
  31. codegraph/lint_async.py +524 -0
  32. codegraph/mcp_server.py +1303 -0
  33. codegraph/parity.py +473 -0
  34. codegraph/parity_structure.py +307 -0
  35. codegraph/payload.py +321 -0
  36. codegraph/plans.py +1285 -0
  37. codegraph/platform_scan.py +643 -0
  38. codegraph/platforms.py +1369 -0
  39. codegraph/plugins/__init__.py +0 -0
  40. codegraph/plugins/cfamily/__init__.py +0 -0
  41. codegraph/plugins/cfamily/plugin.py +930 -0
  42. codegraph/plugins/cfamily/syntax.py +881 -0
  43. codegraph/plugins/dart/__init__.py +0 -0
  44. codegraph/plugins/dart/bridges.py +345 -0
  45. codegraph/plugins/dart/extractor/bin/extract.dart +717 -0
  46. codegraph/plugins/dart/extractor/pubspec.lock +149 -0
  47. codegraph/plugins/dart/extractor/pubspec.yaml +7 -0
  48. codegraph/plugins/dart/http.py +904 -0
  49. codegraph/plugins/dart/models.py +308 -0
  50. codegraph/plugins/dart/plugin.py +625 -0
  51. codegraph/plugins/dart/program.py +907 -0
  52. codegraph/plugins/django/__init__.py +0 -0
  53. codegraph/plugins/django/extras.py +378 -0
  54. codegraph/plugins/django/models.py +508 -0
  55. codegraph/plugins/django/plugin.py +728 -0
  56. codegraph/plugins/django/schemas.py +339 -0
  57. codegraph/plugins/django/shapes.py +216 -0
  58. codegraph/plugins/django/urls.py +603 -0
  59. codegraph/plugins/express/__init__.py +0 -0
  60. codegraph/plugins/express/plugin.py +428 -0
  61. codegraph/plugins/flutter/__init__.py +0 -0
  62. codegraph/plugins/flutter/plugin.py +538 -0
  63. codegraph/plugins/kotlin/__init__.py +0 -0
  64. codegraph/plugins/kotlin/exact.py +457 -0
  65. codegraph/plugins/kotlin/plugin.py +1961 -0
  66. codegraph/plugins/kotlin/reparse.py +234 -0
  67. codegraph/plugins/laravel/__init__.py +0 -0
  68. codegraph/plugins/laravel/broadcast.py +351 -0
  69. codegraph/plugins/laravel/plugin.py +863 -0
  70. codegraph/plugins/laravel/tests.py +262 -0
  71. codegraph/plugins/laravel/values.py +728 -0
  72. codegraph/plugins/native/__init__.py +0 -0
  73. codegraph/plugins/native/gates.py +286 -0
  74. codegraph/plugins/native/runner.py +183 -0
  75. codegraph/plugins/native/scipread.py +194 -0
  76. codegraph/plugins/native/ts.py +54 -0
  77. codegraph/plugins/nest/__init__.py +0 -0
  78. codegraph/plugins/nest/plugin.py +654 -0
  79. codegraph/plugins/nextjs/__init__.py +0 -0
  80. codegraph/plugins/nextjs/plugin.py +336 -0
  81. codegraph/plugins/nuxt/__init__.py +0 -0
  82. codegraph/plugins/nuxt/plugin.py +308 -0
  83. codegraph/plugins/php/__init__.py +0 -0
  84. codegraph/plugins/php/extractor/composer.json +5 -0
  85. codegraph/plugins/php/extractor/composer.lock +76 -0
  86. codegraph/plugins/php/extractor/extract.php +743 -0
  87. codegraph/plugins/php/gating.py +573 -0
  88. codegraph/plugins/php/plugin.py +668 -0
  89. codegraph/plugins/php/strings.py +197 -0
  90. codegraph/plugins/python/__init__.py +0 -0
  91. codegraph/plugins/python/aitools.py +664 -0
  92. codegraph/plugins/python/external.py +245 -0
  93. codegraph/plugins/python/fields.py +107 -0
  94. codegraph/plugins/python/plugin.py +1733 -0
  95. codegraph/plugins/python/refs.py +485 -0
  96. codegraph/plugins/python/roots.py +412 -0
  97. codegraph/plugins/python/socketio.py +210 -0
  98. codegraph/plugins/python/subproc.py +864 -0
  99. codegraph/plugins/python/tests.py +1040 -0
  100. codegraph/plugins/python/values.py +179 -0
  101. codegraph/plugins/pyweb/__init__.py +0 -0
  102. codegraph/plugins/pyweb/plugin.py +1334 -0
  103. codegraph/plugins/pyweb/values.py +68 -0
  104. codegraph/plugins/rust/__init__.py +0 -0
  105. codegraph/plugins/rust/cargo.py +226 -0
  106. codegraph/plugins/rust/plugin.py +980 -0
  107. codegraph/plugins/rust/syntax.py +678 -0
  108. codegraph/plugins/scip/__init__.py +0 -0
  109. codegraph/plugins/scip/importer.py +129 -0
  110. codegraph/plugins/scip/scip.proto +962 -0
  111. codegraph/plugins/scip/scip_pb2.py +97 -0
  112. codegraph/plugins/stubs/__init__.py +0 -0
  113. codegraph/plugins/stubs/plugins.py +38 -0
  114. codegraph/plugins/swift/__init__.py +0 -0
  115. codegraph/plugins/swift/baseurl.py +109 -0
  116. codegraph/plugins/swift/exact.py +415 -0
  117. codegraph/plugins/swift/indexstore.py +209 -0
  118. codegraph/plugins/swift/packages.py +174 -0
  119. codegraph/plugins/swift/plugin.py +2890 -0
  120. codegraph/plugins/ts/__init__.py +0 -0
  121. codegraph/plugins/ts/baseurl.py +185 -0
  122. codegraph/plugins/ts/extractor/extract.mjs +2652 -0
  123. codegraph/plugins/ts/extractor/fw.mjs +685 -0
  124. codegraph/plugins/ts/extractor/package-lock.json +205 -0
  125. codegraph/plugins/ts/extractor/package.json +9 -0
  126. codegraph/plugins/ts/plugin.py +480 -0
  127. codegraph/plugins/tsweb/__init__.py +0 -0
  128. codegraph/plugins/tsweb/common.py +290 -0
  129. codegraph/plugins/tsweb/data.py +276 -0
  130. codegraph/presets/__init__.py +146 -0
  131. codegraph/presets/c_cpp.yaml +9 -0
  132. codegraph/presets/common.yaml +66 -0
  133. codegraph/presets/dart.yaml +9 -0
  134. codegraph/presets/django-ninja.yaml +15 -0
  135. codegraph/presets/django.yaml +25 -0
  136. codegraph/presets/djangorestframework.yaml +17 -0
  137. codegraph/presets/express.yaml +17 -0
  138. codegraph/presets/kotlin.yaml +11 -0
  139. codegraph/presets/laravel.yaml +40 -0
  140. codegraph/presets/nest.yaml +11 -0
  141. codegraph/presets/nextjs.yaml +15 -0
  142. codegraph/presets/nuxt.yaml +9 -0
  143. codegraph/presets/php.yaml +5 -0
  144. codegraph/presets/python.yaml +10 -0
  145. codegraph/presets/rust.yaml +5 -0
  146. codegraph/presets/swift.yaml +10 -0
  147. codegraph/presets/typescript.yaml +13 -0
  148. codegraph/process_runs.py +328 -0
  149. codegraph/protocols/__init__.py +299 -0
  150. codegraph/protocols/builtin.py +67 -0
  151. codegraph/protocols/matchers.py +144 -0
  152. codegraph/protocols/view.py +334 -0
  153. codegraph/query.py +2089 -0
  154. codegraph/realtime.py +260 -0
  155. codegraph/roundtrip.py +346 -0
  156. codegraph/routes.py +442 -0
  157. codegraph/starters.py +218 -0
  158. codegraph/tests_index.py +117 -0
  159. codegraph/viz/__init__.py +0 -0
  160. codegraph/viz/graph.py +369 -0
  161. codegraph/viz/server.py +198 -0
  162. codegraph/viz/static/app.css +148 -0
  163. codegraph/viz/static/app.js +1082 -0
  164. codegraph/viz/static/index.html +81 -0
  165. codegraph/viz/static/layered.js +237 -0
  166. codegraph/viz/static/vendor/VERSIONS.txt +4 -0
  167. codegraph/viz/static/vendor/cose-base.js +3214 -0
  168. codegraph/viz/static/vendor/cytoscape-fcose.js +1549 -0
  169. codegraph/viz/static/vendor/cytoscape.min.js +31 -0
  170. codegraph/viz/static/vendor/layout-base.js +5230 -0
  171. codegraph/viz/tools/package-lock.json +303 -0
  172. codegraph/viz/tools/package.json +7 -0
  173. codegraph/viz/tools/shoot.mjs +165 -0
  174. codegraph/xcode.py +251 -0
codegraph/coverage.py ADDED
@@ -0,0 +1,928 @@
1
+ """Language coverage of an index: which source files cg analysed, how (exact / heuristic), and which it could not.
2
+
3
+ Recorded at index time in the DB meta (stats.coverage) and shown by `cg coverage`, the MCP `coverage` tool and the
4
+ notes on empty MCP replies, so an agent knows when an empty answer is not proof of absence and it must fall back to
5
+ its normal search and file reading."""
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ from collections import Counter
10
+
11
+ from . import presets
12
+ from .core.fsutil import is_real_file
13
+ from pathlib import Path
14
+
15
+ # source extensions per language plugin (key = plugin name in stats["plugins"])
16
+ SUPPORTED = {
17
+ "php": (".php",),
18
+ "typescript": (".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs", ".vue"),
19
+ "python": (".py",),
20
+ "dart": (".dart",),
21
+ "rust": (".rs",),
22
+ "c_cpp": (".c", ".h", ".cc", ".cpp", ".cxx", ".c++", ".hpp", ".hh", ".hxx", ".h++", ".ipp", ".inl"),
23
+ "kotlin": (".kt", ".kts"),
24
+ "swift": (".swift",),
25
+ }
26
+ # source types without a native plugin (go / java can be imported from a SCIP index). A generic "looks like source" rule:
27
+ # text source extensions of programming / scripting languages, plus a shebang for extensionless scripts (SHEBANGS).
28
+ # Data, markup, config and asset extensions are not listed, so they never count as unsupported source.
29
+ UNSUPPORTED = {
30
+ ".go": "go", ".java": "java", ".rb": "ruby", ".cs": "csharp",
31
+ ".scala": "scala", ".ex": "elixir", ".exs": "elixir", ".m": "objective-c", ".mm": "objective-c", ".lua": "lua",
32
+ ".pl": "perl", ".pm": "perl", ".clj": "clojure", ".erl": "erlang", ".hrl": "erlang", ".hs": "haskell", ".fs": "fsharp",
33
+ ".fsx": "fsharp", ".groovy": "groovy", ".r": "r", ".jl": "julia", ".zig": "zig", ".sol": "solidity",
34
+ ".qml": "qml", ".sh": "sh", ".bash": "sh", ".zsh": "sh", ".ksh": "sh", ".fish": "fish", ".ps1": "powershell",
35
+ ".psm1": "powershell", ".bat": "batch", ".cmd": "batch", ".svelte": "svelte", ".astro": "astro",
36
+ ".coffee": "coffeescript", ".elm": "elm", ".ml": "ocaml", ".mli": "ocaml", ".nim": "nim", ".cr": "crystal",
37
+ ".rkt": "racket", ".tcl": "tcl", ".vb": "visual-basic", ".cu": "cuda", ".cuh": "cuda", ".gd": "gdscript",
38
+ ".hx": "haxe", ".pas": "pascal", ".f90": "fortran", ".f95": "fortran", ".adb": "ada", ".ads": "ada", ".vala": "vala",
39
+ ".purs": "purescript", ".pyx": "cython", ".v": "verilog", ".sv": "systemverilog", ".vhd": "vhdl",
40
+ }
41
+ # interpreter of a `#!` line -> language, for extensionless scripts (bin/deploy, scripts/release) in languages cg has
42
+ # no plugin for. Extensionless launchers of indexed languages (`artisan`, `bin/console`, `manage`) are not counted.
43
+ SHEBANGS = {"sh": "sh", "bash": "sh", "zsh": "sh", "dash": "sh", "ksh": "sh", "ash": "sh", "fish": "fish",
44
+ "perl": "perl", "ruby": "ruby", "lua": "lua", "pwsh": "powershell", "rscript": "r", "elixir": "elixir",
45
+ "julia": "julia", "tclsh": "tcl"}
46
+ SHEBANG_EXT = "(shebang)"
47
+ # per-file buckets of a language: discovered = indexed + parse_failed + skipped_oversize + excluded + unmapped
48
+ BUCKETS = ("parse_failed", "skipped_oversize", "unmapped", "excluded")
49
+ BUCKET_TEXT = {"parse_failed": "parse failed", "skipped_oversize": "over size limit",
50
+ "unmapped": "unmapped (no module path: outside the source roots or not a valid package path)",
51
+ "excluded": "excluded (skip list: migrations, generated or cache directories)"}
52
+ BUCKET_SHORT = {"parse_failed": "parse failed", "skipped_oversize": "over size limit", "unmapped": "unmapped",
53
+ "excluded": "excluded"}
54
+ LANG_LABEL = {"php": "PHP", "typescript": "TypeScript / JavaScript", "python": "Python", "dart": "Dart", "rust": "Rust",
55
+ "c_cpp": "C / C++", "kotlin": "Kotlin", "swift": "Swift"}
56
+ MAX_PATHS = 500 # file paths stored per bucket in the index (counts are always exact)
57
+ SHOW_PATHS = 5 # shown per bucket by default (`cg coverage --all-files` / coverage(all_files=true) for all)
58
+ HINTS = {
59
+ "php": "install PHP 8.2+ and Composer, then run `cg setup php` (`cg doctor` checks the toolchains)",
60
+ "typescript": "install Node.js 20+ (with npm), then run `cg setup typescript` (`cg doctor` checks the toolchains)",
61
+ "dart": "install the Dart SDK 3.x (`dart` on PATH or $DART), then run `cg setup dart`",
62
+ "rust": "exact mode needs rust-analyzer (`rustup component add rust-analyzer`){layer}",
63
+ "c_cpp": "exact mode needs scip-clang and a compile_commands.json (docs/native.md){layer}",
64
+ "python": "the .py files are only in directories the Python plugin skips (virtualenvs, build output, static/, media/); "
65
+ "index the directory that holds your code",
66
+ "kotlin": "heuristic mode (tree-sitter syntax layer, name-based call resolution){layer}. For compiler-resolved "
67
+ "references index the Gradle / Maven "
68
+ "build with scip-java: set CODEGRAPH_KOTLIN_SCIP=1 (runs scip-java on the Gradle / Maven build; needs a JDK) or "
69
+ "pass `--scip index.scip` (docs/kotlin.md#exact-mode)",
70
+ "swift": "heuristic mode (tree-sitter syntax layer, name-based call resolution; runs on Linux without Xcode){layer}. "
71
+ "For compiler-resolved calls set "
72
+ "CODEGRAPH_SWIFT_INDEX=1 (SwiftPM: runs `swift build --enable-index-store`; needs a Swift toolchain) or "
73
+ "CODEGRAPH_SWIFT_INDEX_STORE to an existing index store (docs/swift.md#exact-mode)",
74
+ "go": "no native plugin: index with scip-go and pass `--scip index.scip`",
75
+ "java": "no native plugin: index with scip-java and pass `--scip index.scip`",
76
+ }
77
+ # tree-sitter modules of the syntax layer per language: the hint names the ones missing (none: no install hint, #75)
78
+ LAYER_MODULES = {"rust": ("tree_sitter", "tree_sitter_rust"), "c_cpp": ("tree_sitter", "tree_sitter_c", "tree_sitter_cpp"),
79
+ "kotlin": ("tree_sitter", "tree_sitter_kotlin"), "swift": ("tree_sitter", "tree_sitter_swift")}
80
+
81
+
82
+ def hint(lang: str) -> str | None:
83
+ """The fix hint of a language; the tree-sitter install part only names modules that are missing here."""
84
+ h = HINTS.get(lang)
85
+ if h is None or "{layer}" not in h:
86
+ return h
87
+ import importlib.util
88
+ miss = []
89
+ for m in LAYER_MODULES.get(lang, ()):
90
+ try:
91
+ ok = importlib.util.find_spec(m) is not None
92
+ except (ImportError, ValueError):
93
+ ok = False
94
+ if not ok:
95
+ miss.append(m.replace("_", "-"))
96
+ return h.format(layer=f"; the layer needs `pip install {' '.join(miss)}`" if miss else "")
97
+
98
+
99
+ # dependency / build / cache directories the scan never descends into (codegraph/presets/common.yaml)
100
+ SKIP_DIRS = presets.skip_dirs("common", "scan_skip_dirs")
101
+ SHOW_ROOTS = 6 # Python source roots shown by default
102
+ FALLBACK = "use your normal search and file reading for those parts; an empty cg answer there is not proof of absence"
103
+
104
+
105
+ class Scan:
106
+ """One walk of the indexed root: file counts by extension, repo-relative paths of supported source files, and
107
+ extensionless scripts by shebang language."""
108
+
109
+ def __init__(self):
110
+ self.counts: Counter = Counter()
111
+ self.paths: dict[str, list[str]] = {}
112
+ self.scripts: Counter = Counter()
113
+ self.bridge_paths: list[str] = [] # Java / ObjC files: no language plugin, scanned for bridge receivers
114
+
115
+ def files(self, exts) -> list[str]:
116
+ return [f for e in exts for f in self.paths.get(e, [])]
117
+
118
+
119
+ _SUPPORTED_EXTS = {e for v in SUPPORTED.values() for e in v}
120
+
121
+
122
+ def _shebang(path: str) -> str | None:
123
+ try:
124
+ with open(path, "rb") as fh:
125
+ head = fh.read(128)
126
+ except OSError:
127
+ return None
128
+ if not head.startswith(b"#!"):
129
+ return None
130
+ words = head[2:].split(b"\n", 1)[0].decode("utf-8", "replace").split()
131
+ if not words:
132
+ return None
133
+ prog = os.path.basename(words[0])
134
+ if prog == "env":
135
+ rest = [w for w in words[1:] if not w.startswith("-") and "=" not in w]
136
+ prog = rest[0] if rest else ""
137
+ prog = prog.lower()
138
+ return SHEBANGS.get(prog) or SHEBANGS.get(prog.rstrip("0123456789.")) or None
139
+
140
+
141
+ def scan_tree(root: str | Path, rules=None, classifier=None) -> Scan:
142
+ """Source files under root (generated / dependency directories skipped; `rules`: the project's PathRules,
143
+ default the built-in scan list). `classifier` (codegraph/core/generated.py) classifies every source file seen; the
144
+ files it marks are left out of the counts unless the index includes generated files."""
145
+ from .core.paths import PathRules
146
+ rules = rules or PathRules(SKIP_DIRS)
147
+ sc = Scan()
148
+ root = str(root)
149
+ for dp, dns, fns in os.walk(root):
150
+ rd = os.path.relpath(dp, root)
151
+ rd = "" if rd == "." else rd.replace(os.sep, "/")
152
+ if classifier is not None:
153
+ classifier.visit_dir(rd, dp, dns, fns)
154
+ rel_dir = rd + "/" if rd else ""
155
+ keep = []
156
+ for d in dns:
157
+ if rules.skip(rd, d) or d.startswith("._"):
158
+ if classifier is not None:
159
+ classifier.skipped_dir(rel_dir + d, d)
160
+ else:
161
+ keep.append(d)
162
+ dns[:] = keep
163
+ for fn in fns:
164
+ if fn.startswith("._") or (rules.exclude and rules.excluded(rel_dir + fn)):
165
+ continue
166
+ ext = os.path.splitext(fn)[1].lower()
167
+ p = os.path.join(dp, fn)
168
+ if ext:
169
+ if (ext in _SUPPORTED_EXTS or ext in UNSUPPORTED) and not is_real_file(p):
170
+ continue
171
+ if classifier is not None and (ext in _SUPPORTED_EXTS or ext in UNSUPPORTED) \
172
+ and classifier.drops(classifier.scan(rel_dir + fn, p)):
173
+ continue
174
+ sc.counts[ext] += 1
175
+ if ext in _SUPPORTED_EXTS:
176
+ sc.paths.setdefault(ext, []).append(rel_dir + fn)
177
+ elif ext in (".java", ".m", ".mm"):
178
+ sc.bridge_paths.append(rel_dir + fn)
179
+ elif not fn.startswith(".") and is_real_file(p):
180
+ lang = _shebang(p)
181
+ if lang:
182
+ sc.scripts[lang] += 1
183
+ return sc
184
+
185
+
186
+ def scan(root: str | Path) -> Counter:
187
+ """Source files by extension under root (generated / dependency directories skipped)."""
188
+ return scan_tree(root).counts
189
+
190
+
191
+ def file_completeness(discovered: list[str], report: dict) -> dict:
192
+ """Bucket every discovered file of one language from the plugin's per-file report
193
+ ({seen, parse_failed, skipped_oversize, unmapped, excluded}). A discovered file the plugin never looked at
194
+ (its own skip list) is `excluded`."""
195
+ sets = {b: set(report.get(b) or ()) for b in BUCKETS}
196
+ seen = set(report.get("seen") or ())
197
+ out = {b: [] for b in BUCKETS}
198
+ indexed = 0
199
+ for f in sorted(discovered):
200
+ for b in ("parse_failed", "skipped_oversize", "unmapped", "excluded"):
201
+ if f in sets[b]:
202
+ out[b].append(f)
203
+ break
204
+ else:
205
+ if f in seen:
206
+ indexed += 1
207
+ else:
208
+ out["excluded"].append(f)
209
+ res = {"discovered": len(discovered), "indexed": indexed}
210
+ for b in BUCKETS:
211
+ res[b] = len(out[b])
212
+ res["paths"] = {b: v[:MAX_PATHS] for b, v in out.items() if v}
213
+ return res
214
+
215
+
216
+ def missing_files(e: dict) -> int:
217
+ """Discovered files of a language entry that are not in the graph for a reason other than a deliberate exclusion."""
218
+ return sum(e.get(b, 0) for b in ("parse_failed", "skipped_oversize", "unmapped"))
219
+
220
+
221
+ def _status(lang: str, st: dict | None) -> tuple[str, str | None]:
222
+ if st is None:
223
+ return "not_indexed", None
224
+ s = st.get("status")
225
+ if s in ("skipped", "error", "stub"):
226
+ return "skipped", st.get("reason")
227
+ mode = st.get("mode")
228
+ if lang in ("rust", "c_cpp") and mode and mode != "scip":
229
+ sc = st.get("scip") if isinstance(st.get("scip"), dict) else {}
230
+ if sc.get("error"): # the exact indexer is installed but its run failed: say why the heuristic layer was used
231
+ last = next((x.strip() for x in reversed(sc.get("stderr_tail") or []) if x.strip()), "")
232
+ return "heuristic", (f"exact indexer run failed ({sc['error']}" + (f": {last[:160]}" if last else "")
233
+ + "); heuristic layer used")
234
+ return "heuristic", None
235
+ if lang == "swift" and mode == "heuristic":
236
+ why = (st.get("index") or {}).get("status") if isinstance(st.get("index"), dict) else None
237
+ return "heuristic", ("tree-sitter syntax layer with name-based call resolution; no compiler index"
238
+ + (f": {why}" if why else " (works without Xcode or a Swift toolchain)"))
239
+ if lang == "swift" and mode == "indexstore":
240
+ ix = st.get("index") if isinstance(st.get("index"), dict) else {}
241
+ n, k = st.get("index_files"), st.get("source_files") or st.get("files")
242
+ part = f"; {k - n} of {k} Swift files not in the index store keep heuristic calls" if n is not None and k and n < k else ""
243
+ if ix.get("partial"):
244
+ part += f"; the build failed part-way ({ix.get('build_error') or ix.get('error')})"
245
+ return "exact", f"Swift index store ({ix.get('source', '?')}){part}"
246
+ if lang == "kotlin" and mode == "heuristic":
247
+ why = (st.get("scip") or {}).get("status") if isinstance(st.get("scip"), dict) else None
248
+ return "heuristic", ("tree-sitter syntax layer with name-based call resolution; no compiler index"
249
+ + (f": {why}" if why else ""))
250
+ if lang == "kotlin" and mode == "scip":
251
+ sc = st.get("scip") if isinstance(st.get("scip"), dict) else {}
252
+ n, k = st.get("scip_files"), st.get("kt_files") or st.get("files")
253
+ part = f"; {k - n} of {k} Kotlin files not in the index keep heuristic calls" if n is not None and k and n < k else ""
254
+ if sc.get("skipped_modules"):
255
+ part += ("; skipped modules: " + ", ".join(m["module"] for m in sc["skipped_modules"][:5])
256
+ + f" ({sc['skipped_modules'][0]['reason']})")
257
+ return "exact", f"scip-java index ({sc.get('source', 'scip')}){part}"
258
+ if lang == "typescript" and st.get("program_files") == 0 and not st.get("nodes"):
259
+ return "not_indexed", "the TypeScript plugin ran but found no source files (tsconfig include / source dirs)"
260
+ return "exact", None
261
+
262
+
263
+ def compute(root: str | Path, plugins: dict, scip_imported: bool = False, reports: dict | None = None,
264
+ scanned: Scan | None = None, blind_spots: list | None = None, warnings: list | None = None) -> dict:
265
+ """Coverage entry list from the file scan, the per-plugin index stats and per-file reports, plus the blind spots
266
+ found at index time (codegraph/blindspots.py)."""
267
+ sc = scanned or scan_tree(root)
268
+ files = sc.counts
269
+ reports = reports or {}
270
+ langs = []
271
+ for lang, exts in SUPPORTED.items():
272
+ n = sum(files[e] for e in exts)
273
+ st = plugins.get(lang)
274
+ if not n and st is None:
275
+ continue
276
+ status, reason = _status(lang, st)
277
+ e = {"language": lang, "files": n, "status": status,
278
+ "by_ext": {x: files[x] for x in exts if files[x]}}
279
+ if reason:
280
+ e["reason"] = reason
281
+ if status in ("skipped", "heuristic", "not_indexed"):
282
+ e["hint"] = hint(lang)
283
+ if status == "not_indexed" and lang == "typescript":
284
+ # the plugin did not run (nothing to install) or ran without source files: say which
285
+ if st is None:
286
+ e["reason"], e["hint"] = _ts_not_run(root, "php" in plugins)
287
+ else:
288
+ e["hint"] = ("check the tsconfig's `include` / `files` (they match no file under the indexed root), or "
289
+ "list the source directories in .cg.yaml `include`")
290
+ rep = reports.get(lang)
291
+ if rep is not None and "seen" in rep and status not in ("skipped", "not_indexed"):
292
+ fc = file_completeness(sc.files(exts), rep)
293
+ e.update({k: v for k, v in fc.items() if k != "paths"})
294
+ if fc["paths"]:
295
+ e["paths"] = fc["paths"]
296
+ e["files_complete"] = missing_files(e) == 0
297
+ if not e["files_complete"] and lang == "python" and e.get("unmapped"):
298
+ e["hint"] = _python_unmapped_hint(st)
299
+ se = (rep or {}).get("syntax_errors")
300
+ if isinstance(se, list) and se and status not in ("skipped", "not_indexed"):
301
+ # files parsed with syntax errors (#73): error spans and the declarations lost there
302
+ e["syntax_errors"] = se[:MAX_PATHS]
303
+ e["syntax_error_files"] = len(se)
304
+ e["parsed_with_errors"] = sum(1 for x in se if not x.get("parse_failed"))
305
+ e["decls_lost"] = sum(x.get("decls_lost", 0) for x in se)
306
+ if lang == "python" and st:
307
+ for k in ("roots_mode", "source_roots", "roots_warnings", "roots_ambiguous", "module_name_collisions"):
308
+ if st.get(k):
309
+ e[k] = st[k]
310
+ t = st.get("tests") or {}
311
+ if t.get("cases"):
312
+ e["tests"] = {"cases": t["cases"], "test_files": sum((t.get("test_files") or {}).values()),
313
+ "fixtures": t.get("fixtures", 0),
314
+ **({"http": {k: v for k, v in t["http"].items() if k in ("requests", "matched", "unmatched", "url_unknown")}}
315
+ if t.get("http") else {})}
316
+ langs.append(e)
317
+ other: dict = {}
318
+ for ext, lang in UNSUPPORTED.items():
319
+ if files[ext]:
320
+ o = other.setdefault(lang, {"language": lang, "files": 0, "status": "unsupported", "by_ext": {}})
321
+ o["files"] += files[ext]
322
+ o["by_ext"][ext] = files[ext]
323
+ for lang, n in sorted(sc.scripts.items()):
324
+ o = other.setdefault(lang, {"language": lang, "files": 0, "status": "unsupported", "by_ext": {}})
325
+ o["files"] += n
326
+ o["by_ext"][SHEBANG_EXT] = n
327
+ for lang, st in plugins.items():
328
+ if lang in ("go", "java") and lang in other:
329
+ if st.get("status") == "stub":
330
+ other[lang]["reason"] = st.get("reason")
331
+ elif "status" not in st:
332
+ other[lang]["status"] = "exact" # a SCIP indexer ran
333
+ kj = ((plugins.get("kotlin") or {}).get("java") or {})
334
+ if kj.get("documents") and "java" in other and other["java"]["status"] == "unsupported":
335
+ other["java"].update(status="scip", reason=f"{kj['documents']} Java file(s) imported from the Kotlin build's "
336
+ "scip-java index (Kotlin exact mode)")
337
+ for o in other.values():
338
+ if o["status"] == "unsupported":
339
+ if scip_imported and o["language"] in ("go", "java"):
340
+ o["status"] = "scip"
341
+ o["hint"] = hint(o["language"]) or "no plugin for this language"
342
+ langs.append(o)
343
+ out = {"languages": langs, "gaps": sum(1 for e in langs if _is_gap(e))}
344
+ if blind_spots:
345
+ out["blind_spots"] = blind_spots
346
+ if warnings:
347
+ out["warnings"] = list(warnings)
348
+ return out
349
+
350
+
351
+ TS_CONFIGS = ("tsconfig.json", "jsconfig.json")
352
+
353
+
354
+ def _ts_not_run(root, with_php: bool) -> tuple[str, str]:
355
+ """Why the TypeScript plugin did not run on a root that holds .ts / .js files, and what to index instead: the
356
+ directories (up to 3 levels down) that hold a tsconfig.json / jsconfig.json."""
357
+ root = Path(root)
358
+ has_pkg = (root / "package.json").is_file()
359
+ reason = ("the TypeScript plugin did not run: no tsconfig.json / jsconfig.json at the indexed root"
360
+ + (", and its package.json declares no typescript dependency, no package tsconfigs and no server framework"
361
+ if has_pkg else " and no package.json"))
362
+ found = []
363
+ base = len(root.parts)
364
+ for dp, dns, fns in os.walk(root):
365
+ depth = len(Path(dp).parts) - base
366
+ dns[:] = sorted(d for d in dns if not d.startswith(".") and d not in SKIP_DIRS and d != "node_modules") \
367
+ if depth < 3 else []
368
+ if depth and any(f in fns for f in TS_CONFIGS):
369
+ found.append(os.path.relpath(dp, root).replace(os.sep, "/"))
370
+ if len(found) >= 6:
371
+ break
372
+ if found:
373
+ hint = (f"index a directory that holds a tsconfig.json ({', '.join(found[:5])}"
374
+ + (" ..." if len(found) > 5 else "") + ") separately, or add a root tsconfig.json")
375
+ elif with_php:
376
+ hint = "no tsconfig.json / package.json with typescript at the indexed root: index the frontend directory separately"
377
+ else:
378
+ hint = "add a tsconfig.json (or jsconfig.json) at the root naming the source files, or index the directory that holds one"
379
+ return reason, hint
380
+
381
+
382
+ def _python_unmapped_hint(st: dict) -> str:
383
+ if st.get("roots_mode") in ("configured", "flag"):
384
+ where = "python.source_roots in .cg.yaml" if st["roots_mode"] == "configured" else "--python-root"
385
+ roots = ", ".join(r["path"] for r in st.get("source_roots") or []) or "none"
386
+ return (f"unmapped .py files are outside the configured source roots ({roots}): add their directories to {where}, "
387
+ f"or drop the setting to use detection")
388
+ return ("unmapped .py files are in directories that are not importable module paths (a name with '-' or '.'), "
389
+ "outside the detected source roots, or claim a module name another file has; list their roots under "
390
+ "python.source_roots in .cg.yaml")
391
+
392
+
393
+ def python_roots_lines(e: dict, all_files: bool = False, indent: str = " ") -> list[str]:
394
+ """'python source roots: lib/ (detected: parent of top-level package core, 3 modules); ...' plus warnings and the
395
+ modules reachable from two roots. Empty for the plain layout (only the indexed root, nothing to warn about)."""
396
+ roots = e.get("source_roots") or []
397
+ mode = e.get("roots_mode")
398
+ plain = mode == "detected" and all(r["path"] == "./" for r in roots)
399
+ out = []
400
+ if roots and (all_files or not plain):
401
+ ranked = sorted(roots, key=lambda r: (-r.get("modules", 0), r["path"]))
402
+ show = ranked if all_files else ranked[:SHOW_ROOTS]
403
+ txt = "; ".join(f"{r['path']}{' as ' + r['package'] if r.get('package') else ''} ({r['origin']}: {r['why']}, "
404
+ f"{r.get('modules', 0)} module{'s' if r.get('modules', 0) != 1 else ''})" for r in show)
405
+ more = len(roots) - len(show)
406
+ out.append(f"{indent}python source roots: {txt}" + (f" … +{more} more (--all-files)" if more > 0 else ""))
407
+ for w in e.get("roots_warnings") or []:
408
+ out.append(f"{indent}python warning: {w}")
409
+ amb = e.get("roots_ambiguous")
410
+ if amb:
411
+ s = amb["samples"][0]
412
+ out.append(f"{indent}python: {amb['count']} module{'s' if amb['count'] != 1 else ''} importable from two roots, named "
413
+ f"after the project's imports (e.g. {s['file']} -> {s['chosen']}, not {s['also'][0]})")
414
+ col = e.get("module_name_collisions")
415
+ if col:
416
+ s = col["samples"][0]
417
+ alt = f"named {s['named']}" if s.get("named") else "not indexed"
418
+ moved = col.get("path_named", 0)
419
+ one = col["count"] == 1
420
+ out.append(f"{indent}python: {col['count']} file{'' if one else 's'} claim{'s' if one else ''} a module name another "
421
+ f"file has (e.g. {s['file']}: {s['name']} is {s['with']}; {alt})"
422
+ + (f"; {moved} file{' in that package tree is' if moved == 1 else 's in those package trees are'} named "
423
+ f"by {'its' if moved == 1 else 'their'} path from the indexed root" if moved else ""))
424
+ return out
425
+
426
+
427
+ def python_tests_line(e: dict, indent: str = " ") -> list[str]:
428
+ """'python tests: 120 test cases (pytest 110, unittest 10) in 30 files, 45 fixtures; 12 HTTP requests, 10 linked to routes'."""
429
+ t = e.get("tests") or {}
430
+ cases = t.get("cases") or {}
431
+ if not cases:
432
+ return []
433
+ def n(k, word):
434
+ return f"{k} {word}{'' if k == 1 else 's'}"
435
+ line = (f"{indent}python tests: {n(sum(cases.values()), 'test case')} ("
436
+ + ", ".join(f"{k} {v}" for k, v in sorted(cases.items(), key=lambda x: (-x[1], x[0])))
437
+ + f") in {n(t.get('test_files', 0), 'file')}" + (f", {n(t['fixtures'], 'fixture')}" if t.get("fixtures") else ""))
438
+ h = t.get("http") or {}
439
+ if h.get("requests"):
440
+ line += f"; {n(h['requests'], 'HTTP test request')}, {h.get('matched', 0)} linked to routes"
441
+ return [line]
442
+
443
+
444
+ def _is_gap(e: dict) -> bool:
445
+ return bool(e.get("files")) and (e["status"] not in ("exact", "scip") or e.get("files_complete") is False)
446
+
447
+
448
+ def gaps(cov: dict | None) -> list[dict]:
449
+ return [e for e in (cov or {}).get("languages", []) if _is_gap(e)]
450
+
451
+
452
+ def blind_spots(cov: dict | None) -> list[dict]:
453
+ return list((cov or {}).get("blind_spots") or [])
454
+
455
+
456
+ def _entry_text(e: dict) -> str:
457
+ """'php 18 exact' / 'python 4 discovered, 2 indexed (exact parser): 1 parse failed, 1 unmapped' /
458
+ 'swift 101 heuristic, 8 parsed with syntax errors'."""
459
+ pe = e.get("parsed_with_errors")
460
+ errs = f", {pe} parsed with syntax errors" if pe else ""
461
+ if e.get("files_complete") is False:
462
+ parts = [f"{e[b]} {BUCKET_SHORT[b]}" for b in ("parse_failed", "skipped_oversize", "unmapped") if e.get(b)]
463
+ return (f"{e['language']} {e['files']} discovered, {e['indexed']} indexed ({e['status'].replace('_', ' ')} parser)"
464
+ + (": " + ", ".join(parts) if parts else "") + errs)
465
+ return f"{e['language']} {e['files']} {e['status'].replace('_', ' ')}{errs}"
466
+
467
+
468
+ def syntax_error_lines(e: dict, all_files: bool = False, indent: str = " ") -> list[str]:
469
+ """'swift: syntax errors in 8 files, 12 declarations lost (most first):' and one line per file with its error
470
+ line spans and the declarations lost there (5 files unless all_files), #73."""
471
+ from .core.syntax_errors import span_text
472
+ se = e.get("syntax_errors") or []
473
+ if not se:
474
+ return []
475
+ n, lost = e.get("syntax_error_files", len(se)), e.get("decls_lost", 0)
476
+ out = [f"{indent}{e['language']}: syntax errors in {n} file{'s' if n != 1 else ''}, {lost} declaration"
477
+ f"{'s' if lost != 1 else ''} lost (declarations and calls there may be missing or misplaced):"]
478
+ show = se if all_files else se[:SHOW_PATHS]
479
+ out += [f"{indent} {span_text(x)}" for x in show]
480
+ if n > len(show):
481
+ out.append(f"{indent} … +{n - len(show)} more (--all-files)")
482
+ return out
483
+
484
+
485
+ def summary_line(cov: dict | None, repo: str | None = None) -> str:
486
+ """One line: 'coverage: php 18 exact; typescript 9 exact | not fully covered: go 3 unsupported (...)'."""
487
+ if not cov:
488
+ return "coverage: not recorded for this index (re-index with this version of cg)"
489
+ pre = f"coverage{' ' + repo if repo else ''}: "
490
+ ok = [_entry_text(e) for e in cov["languages"] if e["status"] in ("exact", "scip") and not _is_gap(e)]
491
+ bad = [_entry_text(e) for e in gaps(cov)]
492
+ if ok or not bad:
493
+ s = pre + ("; ".join(ok) or "no supported source files")
494
+ if bad:
495
+ s += " | not fully covered: " + "; ".join(bad)
496
+ else:
497
+ s = pre + "not fully covered: " + "; ".join(bad)
498
+ bs = blind_spots(cov)
499
+ if bs:
500
+ s += f" | blind spots: {_bs_count(bs)}"
501
+ g = cov.get("generated") or {}
502
+ if g.get("files"):
503
+ s += f" | generated: {g['files']} file{'s' if g['files'] != 1 else ''} {g.get('mode', 'excluded')}"
504
+ return s
505
+
506
+
507
+ def _bs_count(bs: list[dict]) -> str:
508
+ r = sum(b["count"] for b in bs if b["category"] == "route")
509
+ h = sum(b["count"] for b in bs if b["category"] != "route")
510
+ parts = ([f"{r} unmodelled route registration{'s' if r != 1 else ''}"] if r else []) + \
511
+ ([f"{h} handler{'s' if h != 1 else ''} registered dynamically"] if h else [])
512
+ return ", ".join(parts)
513
+
514
+
515
+ def _paths_lines(e: dict, all_files: bool, indent: str = " ") -> list[str]:
516
+ out = []
517
+ for b in BUCKETS:
518
+ ps = (e.get("paths") or {}).get(b) or []
519
+ if not ps or (b == "excluded" and not all_files):
520
+ continue
521
+ show = ps if all_files else ps[:SHOW_PATHS]
522
+ more = e.get(b, len(ps)) - len(show)
523
+ out.append(f"{indent}{BUCKET_SHORT[b]}: " + ", ".join(show) + (f" … +{more} more (--all-files)" if more > 0 else ""))
524
+ return out
525
+
526
+
527
+ def blind_spot_lines(bs: list[dict], indent: str = " ", limit: int = 3) -> list[str]:
528
+ out = []
529
+ for b in bs:
530
+ more = b["count"] - min(limit, len(b["samples"]))
531
+ out.append(f"{indent}{b['count']}× {b['what']} ({b['language']}): " + ", ".join(b["samples"][:limit])
532
+ + (f" … +{more}" if more > 0 else ""))
533
+ return out
534
+
535
+
536
+ def setup_line(setup: dict) -> str:
537
+ """Frameworks found, presets applied and the project config file of one index."""
538
+ fw = ", ".join(setup.get("frameworks") or []) or "none"
539
+ return (f" frameworks: {fw} | presets: {', '.join(setup.get('presets') or [])}"
540
+ f" | config: {setup.get('config') or 'no .cg.yaml'}")
541
+
542
+
543
+ # the node kinds of one concept per language (docs/schema.md "Enum cases and constants"): Rust and C / C++ keep theirs
544
+ VALUE_KINDS = {"enum_case": "enum_case", "variant": "enum_case", "enumerator": "enum_case",
545
+ "constant": "constant", "const": "constant", "static": "constant", "global": "constant"}
546
+
547
+
548
+ def value_counts(builder) -> dict:
549
+ """Per language: enum cases, constants (each language's kinds folded into the two concepts) and the USES_VALUE
550
+ references that point at them."""
551
+ out: dict = {}
552
+ for n in builder.nodes.values():
553
+ c = VALUE_KINDS.get(n.kind)
554
+ if c and n.lang:
555
+ e = out.setdefault(n.lang, {"enum_cases": 0, "constants": 0, "references": 0, "kinds": []})
556
+ e["enum_cases" if c == "enum_case" else "constants"] += 1
557
+ if n.kind not in e["kinds"]:
558
+ e["kinds"].append(n.kind)
559
+ for e in builder.edges.values():
560
+ if e.kind == "USES_VALUE":
561
+ n = builder.nodes.get(e.dst)
562
+ if n is not None and n.lang in out and n.kind in VALUE_KINDS:
563
+ out[n.lang]["references"] += 1
564
+ for e in out.values():
565
+ e["kinds"].sort()
566
+ return dict(sorted(out.items()))
567
+
568
+
569
+ def value_lines(vc: dict | None) -> list[str]:
570
+ if not vc:
571
+ return []
572
+ return [" values: " + "; ".join(f"{lang} {v['enum_cases']} enum cases, {v['constants']} constants, "
573
+ f"{v['references']} references ({', '.join(v['kinds'])})" for lang, v in vc.items())]
574
+
575
+
576
+ def platform_lines(pc: dict | None) -> list[str]:
577
+ """Per-target coverage of platform-specific code (codegraph/platforms.py): files and symbols each target builds."""
578
+ if not pc or not pc.get("targets"):
579
+ return []
580
+ pt = pc.get("per_target") or {}
581
+ parts = [f"{p} {pt[p]['files']} files / {pt[p]['symbols']} symbols ({pt[p]['platform_specific_symbols']} specific)"
582
+ for p in pc["targets"] if p in pt]
583
+ out = [f" platforms: {'; '.join(parts)}"]
584
+ if pc.get("unevaluated_conditions"):
585
+ out.append(f" {pc['unevaluated_conditions']} of {pc.get('conditions', '?')} platform conditions could not be "
586
+ f"evaluated and count for every target (e.g. {(pc.get('unevaluated_samples') or ['?'])[0]})")
587
+ return out
588
+
589
+
590
+ SUMMARY_MORE = "details: `cg coverage --details` (file lists, fix hints, syntax error lines), `--json` for all of it"
591
+
592
+
593
+ def render_summary(covs: dict[str, dict | None], db: str | None = None) -> str:
594
+ """`cg coverage` text (#75): per repo the summary line, then one line per language that is not fully indexed (its
595
+ reason; a fix only where something can be installed or pointed elsewhere), syntax error counts, per-target file
596
+ counts, blind spots and warnings in one line each. `render` (--details) has the file lists and every hint."""
597
+ out = []
598
+ any_gap = False
599
+ for repo, cov in covs.items():
600
+ out.append(summary_line(cov, repo if len(covs) > 1 or repo else None))
601
+ if (cov or {}).get("setup"):
602
+ out.append(setup_line(cov["setup"]))
603
+ for e in (cov or {}).get("languages", []): # Python roots: only when not the plain layout, or warnings
604
+ if e["language"] == "python":
605
+ out += python_roots_lines(e)
606
+ for e in gaps(cov):
607
+ any_gap = True
608
+ if e["status"] == "unsupported" and not e.get("reason"):
609
+ continue # in the summary line already; nothing to do about it
610
+ line = f" {_entry_text(e)}" + (f": {e['reason']}" if e.get("reason") else "")
611
+ if e["status"] in ("skipped", "not_indexed") and e.get("hint"):
612
+ line += f"; fix: {e['hint']}"
613
+ out.append(line)
614
+ for e in (cov or {}).get("languages", []): # the mode an exact-capable language ran in, and why
615
+ if e["language"] in ("kotlin", "swift") and not _is_gap(e) and e.get("reason"):
616
+ out.append(f" {e['language']} {e['files']} {e['status']}: {e['reason']}")
617
+ se = [e for e in (cov or {}).get("languages", []) if e.get("syntax_errors")]
618
+ if se:
619
+ out.append(" syntax errors: " + "; ".join(
620
+ f"{e['language']} {e.get('syntax_error_files', len(e['syntax_errors']))} files, {e.get('decls_lost', 0)} "
621
+ f"declaration{'s' if e.get('decls_lost', 0) != 1 else ''} lost" for e in se))
622
+ pc = (cov or {}).get("platforms") or {}
623
+ pt = pc.get("per_target") or {}
624
+ if pc.get("targets") and pt:
625
+ out.append(" platforms: " + ", ".join(f"{p} {pt[p]['files']} files" for p in pc["targets"] if p in pt)
626
+ + (f" ({pc['unevaluated_conditions']} conditions not evaluated)" if pc.get("unevaluated_conditions") else ""))
627
+ bs = blind_spots(cov)
628
+ if bs:
629
+ b0 = bs[0]
630
+ out.append(f" blind spots: {_bs_count(bs)} (e.g. {b0['what']}: {(b0.get('samples') or ['?'])[0]})")
631
+ for w in (cov or {}).get("warnings") or ():
632
+ out.append(f" warning: {w}")
633
+ if any_gap:
634
+ out.append("not covered or heuristic only: " + FALLBACK + ".")
635
+ elif any(blind_spots(c) for c in covs.values()):
636
+ out.append("every source file cg found is indexed; at the blind spots, use your normal search and file reading.")
637
+ else:
638
+ out.append("every source file cg found is indexed; edges still carry their own exact / resolved / heuristic label.")
639
+ out.append(SUMMARY_MORE if not db else SUMMARY_MORE.replace("cg coverage --details", f"cg coverage --db {db} --details"))
640
+ return "\n".join(out)
641
+
642
+
643
+ def render(covs: dict[str, dict | None], all_files: bool = False) -> str:
644
+ """Multi-line report for one or more repos (name -> coverage)."""
645
+ out = []
646
+ any_gap = any_bs = False
647
+ for repo, cov in covs.items():
648
+ out.append(summary_line(cov, repo if len(covs) > 1 or repo else None))
649
+ if (cov or {}).get("setup"):
650
+ out.append(setup_line(cov["setup"]))
651
+ from .core.generated import detail_lines as generated_lines
652
+ out += generated_lines((cov or {}).get("generated"), all_files)
653
+ for e in (cov or {}).get("languages", []):
654
+ if e["language"] == "python":
655
+ out += python_roots_lines(e, all_files)
656
+ out += python_tests_line(e)
657
+ for e in gaps(cov):
658
+ any_gap = True
659
+ exts = ", ".join(f"{k} {v}" for k, v in sorted(e["by_ext"].items()))
660
+ if e.get("files_complete") is False:
661
+ parts = [f"{e[b]} {BUCKET_SHORT[b]}" for b in BUCKETS if e.get(b)]
662
+ line = f" {e['language']}: {e['files']} files ({exts}) {e['indexed']} indexed, " + ", ".join(parts)
663
+ else:
664
+ line = f" {e['language']}: {e['files']} files ({exts}) {e['status'].replace('_', ' ')}"
665
+ if e.get("reason"):
666
+ line += f": {e['reason']}"
667
+ out.append(line)
668
+ out += _paths_lines(e, all_files)
669
+ if e.get("hint"):
670
+ out.append(f" fix: {e['hint']}")
671
+ for e in (cov or {}).get("languages", []):
672
+ out += syntax_error_lines(e, all_files)
673
+ for e in (cov or {}).get("languages", []): # which mode an exact-capable language ran in, and why
674
+ if e["language"] in ("kotlin", "swift") and not _is_gap(e) and e.get("reason"):
675
+ out.append(f" {e['language']}: {e['files']} files {e['status']}: {e['reason']}")
676
+ if all_files:
677
+ for e in (cov or {}).get("languages", []):
678
+ if not _is_gap(e) and e.get("excluded"):
679
+ out.append(f" {e['language']}: {e['excluded']} excluded")
680
+ out += _paths_lines(e, True)
681
+ out += platform_lines((cov or {}).get("platforms"))
682
+ out += value_lines((cov or {}).get("values"))
683
+ for w in (cov or {}).get("warnings") or ():
684
+ out.append(f" warning: {w}")
685
+ bs = blind_spots(cov)
686
+ if bs:
687
+ any_bs = True
688
+ out.append(" blind spots (patterns cg does not model; answers that touch them may be partial):")
689
+ out += blind_spot_lines(bs, " ", limit=10 if all_files else 3)
690
+ if any_gap:
691
+ out.append("not covered or heuristic only: " + FALLBACK + ".")
692
+ elif any_bs:
693
+ out.append("every source file cg found is indexed; at the blind spots above, use your normal search and file reading "
694
+ "(an empty cg answer there is not proof of absence).")
695
+ else:
696
+ out.append("every source file cg found is indexed; edges still carry their own exact / resolved / heuristic label.")
697
+ return "\n".join(out)
698
+
699
+
700
+ def for_graph(store) -> dict[str, dict | None]:
701
+ """Coverage per repo of a single or combined graph DB."""
702
+ from .core.store import GraphStore
703
+ try:
704
+ m = store.meta()
705
+ except Exception: # noqa: BLE001 (not a cg graph yet: coverage unknown, never a crash)
706
+ return {"": None}
707
+ if m.get("repos"):
708
+ if isinstance(m.get("coverage"), dict) and m["coverage"]:
709
+ return dict(m["coverage"]) # copied in by cg link
710
+ out = {}
711
+ for r in m["repos"]: # older combined graphs: read the source DBs if they are still there
712
+ here = Path(getattr(store, "path", "") or "").parent / f"{r}.db"
713
+ for src in ((m.get("sources") or {}).get(r), str(here)):
714
+ try:
715
+ if src and Path(src).exists():
716
+ out[r] = (GraphStore(src).meta().get("stats") or {}).get("coverage")
717
+ break
718
+ except Exception: # noqa: BLE001
719
+ pass
720
+ else:
721
+ out[r] = None
722
+ return out
723
+ return {m.get("project") or "": (m.get("stats") or {}).get("coverage")}
724
+
725
+
726
+ def note(covs: dict[str, dict | None]) -> str:
727
+ """Short note for empty / unknown-symbol replies."""
728
+ bad = []
729
+ unknown = [r for r, c in covs.items() if c is None]
730
+ for r, c in covs.items():
731
+ for e in gaps(c):
732
+ what = (f"{e['indexed']} of {e['files']} files indexed" if e.get("files_complete") is False
733
+ else f"{e['files']} files, {e['status'].replace('_', ' ')}")
734
+ bad.append(f"{e['language']} ({what}{', ' + r if len(covs) > 1 else ''})")
735
+ bs = [b for c in covs.values() for b in blind_spots(c)]
736
+ bs_txt = f"; blind spots: {_bs_count(bs)} (see coverage)" if bs else ""
737
+ nerr = sum(e.get("syntax_error_files", 0) for c in covs.values() for e in (c or {}).get("languages", []))
738
+ if nerr: # a declaration in a file that did not parse cleanly may be missing (#73)
739
+ bs_txt += f"; {nerr} file{'s' if nerr != 1 else ''} with syntax errors (`cg coverage` lists them)"
740
+ if bad:
741
+ return "coverage: not fully covered here: " + "; ".join(bad) + bs_txt + ". If the code you mean is there, " + FALLBACK + "."
742
+ if unknown:
743
+ return "coverage: not recorded for this index; if in doubt, " + FALLBACK + "."
744
+ langs = sorted({e["language"] for c in covs.values() for e in (c or {}).get("languages", []) if e["files"]})
745
+ if bs:
746
+ return (f"coverage: every source file cg found is indexed ({', '.join(langs)}){bs_txt}; if the code you mean is "
747
+ f"registered that way, {FALLBACK}.")
748
+ return f"coverage: every source file cg found is indexed ({', '.join(langs)}); code outside these languages or generated at runtime is not in the graph."
749
+
750
+
751
+ # ------------------------------------------------------------------------------------------- scoped completeness
752
+
753
+ NODE_LANG = {"ts": "typescript", "js": "typescript", "php": "php", "python": "python", "dart": "dart", "rust": "rust",
754
+ "c": "c_cpp", "cpp": "c_cpp"}
755
+
756
+
757
+ def _lang_of_file(f: str | None) -> str | None:
758
+ ext = os.path.splitext(f or "")[1].lower()
759
+ return next((k for k, v in SUPPORTED.items() if ext in v), None)
760
+
761
+
762
+ def _top(f: str | None) -> str:
763
+ parts = (f or "").split("/")
764
+ return parts[0] if len(parts) > 1 else ""
765
+
766
+
767
+ def scope_of(store, node_ids) -> dict:
768
+ """Languages, repos and top-level directories of the nodes an answer is about (for a scoped completeness note)."""
769
+ langs, dirs, repos, files = set(), set(), set(), set()
770
+ try:
771
+ repo_names = set((store.meta() or {}).get("repos") or [])
772
+ except Exception: # noqa: BLE001
773
+ repo_names = set()
774
+ ids = [i for i in dict.fromkeys(node_ids or []) if i][:400]
775
+ for k in range(0, len(ids), 200):
776
+ chunk = ids[k:k + 200]
777
+ rows = store.q(f"SELECT file, lang FROM nodes WHERE id IN ({','.join('?' * len(chunk))})", tuple(chunk))
778
+ for r in rows:
779
+ f = r["file"] or ""
780
+ if repo_names and f.split("/", 1)[0] in repo_names:
781
+ repo, f = f.split("/", 1) if "/" in f else (f, "")
782
+ repos.add(repo)
783
+ lang = NODE_LANG.get(r["lang"] or "") or _lang_of_file(f)
784
+ if lang:
785
+ langs.add(lang)
786
+ if f:
787
+ dirs.add(_top(f))
788
+ files.add(f)
789
+ return {"languages": langs, "dirs": dirs, "repos": repos, "files": files}
790
+
791
+
792
+ def completeness(covs: dict[str, dict | None], languages=None, dirs=None, repos=None,
793
+ categories=("route", "handler"), unsupported: bool | None = None, ids=None, files=None) -> dict:
794
+ """Machine-readable completeness of an answer, scoped to the languages / top-level directories / repos it
795
+ involves (None = the whole index). {"complete": bool, "languages": {...}, "unsupported": {...},
796
+ "blind_spots": [...]}. Route blind spots apply to every answer in their language (a route registered anywhere can
797
+ reach the code); handler blind spots only when the answer involves one of the registered functions (`ids` = the
798
+ answer's node ids: a target or caller the graph shows without its registration), or, for findings recorded
799
+ without node ids, within the same top-level directory."""
800
+ multi = len(covs) > 1
801
+ whole = languages is None
802
+ unsupported = whole if unsupported is None else unsupported
803
+ langs_out, uns_out, bs_out, se_out = {}, {}, [], []
804
+ known = True
805
+ for repo, cov in covs.items():
806
+ if repos and repo not in repos and multi:
807
+ continue
808
+ if cov is None:
809
+ known = False
810
+ continue
811
+ for e in cov.get("languages", []):
812
+ key = f"{repo}/{e['language']}" if multi and repo else e["language"]
813
+ if e["status"] == "unsupported":
814
+ if unsupported and e["files"]:
815
+ uns_out[key] = e["files"]
816
+ continue
817
+ if not whole and e["language"] not in languages:
818
+ continue
819
+ if not e.get("files"):
820
+ continue
821
+ d = {"mode": e["status"], "discovered": e["files"]}
822
+ if "indexed" in e:
823
+ d["indexed"] = e["indexed"]
824
+ for b in BUCKETS:
825
+ if e.get(b):
826
+ d[b] = e[b]
827
+ if e.get("reason"):
828
+ d["reason"] = e["reason"]
829
+ d["complete"] = not _is_gap(e)
830
+ langs_out[key] = d
831
+ if files: # the answer involves a file that parsed with syntax errors (#73)
832
+ for x in e.get("syntax_errors") or ():
833
+ if x["file"] in files:
834
+ se_out.append({"language": e["language"], "file": x["file"], "spans": x.get("spans", [])[:3],
835
+ "decls_lost": x.get("decls_lost", 0), **({"repo": repo} if multi and repo else {})})
836
+ for b in blind_spots(cov):
837
+ if b["category"] not in categories or (not whole and b["language"] not in languages):
838
+ continue
839
+ if b["category"] != "route" and ids is not None and b.get("nodes"):
840
+ samples = [s for n, s in zip(b["nodes"], b.get("node_samples") or b["samples"]) if n in ids]
841
+ if not samples:
842
+ continue
843
+ elif b["category"] != "route" and dirs is not None and "" not in dirs:
844
+ samples = [s for s in b["samples"] if _top(s.rsplit(":", 1)[0]) in dirs or not _top(s.rsplit(":", 1)[0])]
845
+ if not samples:
846
+ continue
847
+ else:
848
+ samples = None
849
+ bs_out.append({"kind": b["kind"], "category": b["category"], "language": b["language"], "what": b["what"],
850
+ "count": b["count"] if samples is None else len(samples),
851
+ "sample": (samples or b["samples"])[0],
852
+ **({"repo": repo} if multi and repo else {})})
853
+ complete = known and all(v["complete"] for v in langs_out.values()) and not uns_out and not bs_out and not se_out
854
+ out = {"complete": complete, "languages": langs_out}
855
+ if se_out:
856
+ out["syntax_errors"] = se_out
857
+ if uns_out:
858
+ out["unsupported"] = uns_out
859
+ if bs_out:
860
+ out["blind_spots"] = bs_out
861
+ if not known:
862
+ out["recorded"] = False
863
+ return out
864
+
865
+
866
+ def completeness_for(store, node_ids=None, categories=("route", "handler"), whole: bool = False, **kw) -> dict:
867
+ try:
868
+ covs = for_graph(store)
869
+ except Exception: # noqa: BLE001
870
+ return {"complete": False, "recorded": False, "languages": {}}
871
+ if whole or not node_ids:
872
+ return completeness(covs, categories=categories, **kw)
873
+ sc = scope_of(store, node_ids)
874
+ if not sc["languages"]:
875
+ return completeness(covs, categories=categories, unsupported=False, **kw)
876
+ return completeness(covs, languages=sc["languages"], dirs=sc["dirs"], repos=sc["repos"] or None,
877
+ categories=categories, ids=set(node_ids), files=sc["files"], **kw)
878
+
879
+
880
+ def possibly_more(comp: dict) -> str:
881
+ """'1 unmodelled route registration, 2 Python files not indexed' for an incomplete answer, '' when complete."""
882
+ if comp.get("complete"):
883
+ return ""
884
+ parts = []
885
+ r = sum(b["count"] for b in comp.get("blind_spots", []) if b["category"] == "route")
886
+ h = sum(b["count"] for b in comp.get("blind_spots", []) if b["category"] != "route")
887
+ if r:
888
+ parts.append(f"{r} unmodelled route registration{'s' if r != 1 else ''}")
889
+ if h:
890
+ parts.append(f"{h} dynamically registered handler{'s' if h != 1 else ''}")
891
+ for k, v in comp.get("languages", {}).items():
892
+ if v["complete"]:
893
+ continue
894
+ lang = k.rsplit("/", 1)[-1]
895
+ label = LANG_LABEL.get(lang, lang)
896
+ miss = sum(v.get(b, 0) for b in ("parse_failed", "skipped_oversize", "unmapped"))
897
+ if v["mode"] in ("exact", "scip") and miss:
898
+ parts.append(f"{miss} {label} file{'s' if miss != 1 else ''} not indexed")
899
+ elif v["mode"] == "heuristic":
900
+ parts.append(f"{label} heuristic only")
901
+ else:
902
+ parts.append(f"{label} {v['mode'].replace('_', ' ')}")
903
+ se = comp.get("syntax_errors") or []
904
+ if se:
905
+ x = se[0]
906
+ pos = f":{x['spans'][0][0]}" if x.get("spans") else ""
907
+ parts.append(f"{len(se)} file{'s' if len(se) != 1 else ''} with syntax errors ({x['file']}{pos}"
908
+ + (f" +{len(se) - 1}" if len(se) > 1 else "") + ")")
909
+ if comp.get("unsupported"):
910
+ parts.append("unsupported: " + ", ".join(f"{k} {v}" for k, v in sorted(comp["unsupported"].items())))
911
+ if comp.get("recorded") is False:
912
+ parts.append("coverage not recorded")
913
+ return ", ".join(parts)
914
+
915
+
916
+ def answer_note(comp: dict) -> str:
917
+ """'coverage note: 1 route registration cg does not model (Django urlpatterns built by ...: shop/urls.py:10); 2 Python
918
+ files not indexed' for an incomplete answer; '' when the answer is complete (no noise)."""
919
+ if comp.get("complete"):
920
+ return ""
921
+ parts = []
922
+ for b in comp.get("blind_spots", []):
923
+ noun = "route registration" if b["category"] == "route" else "handler registration"
924
+ parts.append(f"{b['count']} {noun}{'s' if b['count'] != 1 else ''} cg does not model ({b['what']}: {b['sample']})")
925
+ rest = possibly_more({**comp, "blind_spots": []})
926
+ if rest:
927
+ parts.append(rest)
928
+ return "coverage note: " + "; ".join(parts) + ". There, use your normal search and file reading (an empty cg answer is not proof of absence)."