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.
- cg_code_graph-0.10.1.dist-info/METADATA +678 -0
- cg_code_graph-0.10.1.dist-info/RECORD +174 -0
- cg_code_graph-0.10.1.dist-info/WHEEL +5 -0
- cg_code_graph-0.10.1.dist-info/entry_points.txt +3 -0
- cg_code_graph-0.10.1.dist-info/licenses/LICENSE +21 -0
- cg_code_graph-0.10.1.dist-info/top_level.txt +1 -0
- codegraph/__init__.py +2 -0
- codegraph/aitools.py +129 -0
- codegraph/apps.py +76 -0
- codegraph/blindspots.py +428 -0
- codegraph/bridges.py +1701 -0
- codegraph/cli.py +725 -0
- codegraph/concepts.py +362 -0
- codegraph/config.py +559 -0
- codegraph/core/__init__.py +0 -0
- codegraph/core/cache.py +375 -0
- codegraph/core/detect.py +80 -0
- codegraph/core/extractors.py +187 -0
- codegraph/core/fsutil.py +61 -0
- codegraph/core/generated.py +575 -0
- codegraph/core/model.py +174 -0
- codegraph/core/paths.py +175 -0
- codegraph/core/plugin.py +160 -0
- codegraph/core/store.py +80 -0
- codegraph/core/syntax_errors.py +132 -0
- codegraph/coverage.py +928 -0
- codegraph/doctor.py +453 -0
- codegraph/external.py +613 -0
- codegraph/indexer.py +336 -0
- codegraph/link.py +434 -0
- codegraph/lint_async.py +524 -0
- codegraph/mcp_server.py +1303 -0
- codegraph/parity.py +473 -0
- codegraph/parity_structure.py +307 -0
- codegraph/payload.py +321 -0
- codegraph/plans.py +1285 -0
- codegraph/platform_scan.py +643 -0
- codegraph/platforms.py +1369 -0
- codegraph/plugins/__init__.py +0 -0
- codegraph/plugins/cfamily/__init__.py +0 -0
- codegraph/plugins/cfamily/plugin.py +930 -0
- codegraph/plugins/cfamily/syntax.py +881 -0
- codegraph/plugins/dart/__init__.py +0 -0
- codegraph/plugins/dart/bridges.py +345 -0
- codegraph/plugins/dart/extractor/bin/extract.dart +717 -0
- codegraph/plugins/dart/extractor/pubspec.lock +149 -0
- codegraph/plugins/dart/extractor/pubspec.yaml +7 -0
- codegraph/plugins/dart/http.py +904 -0
- codegraph/plugins/dart/models.py +308 -0
- codegraph/plugins/dart/plugin.py +625 -0
- codegraph/plugins/dart/program.py +907 -0
- codegraph/plugins/django/__init__.py +0 -0
- codegraph/plugins/django/extras.py +378 -0
- codegraph/plugins/django/models.py +508 -0
- codegraph/plugins/django/plugin.py +728 -0
- codegraph/plugins/django/schemas.py +339 -0
- codegraph/plugins/django/shapes.py +216 -0
- codegraph/plugins/django/urls.py +603 -0
- codegraph/plugins/express/__init__.py +0 -0
- codegraph/plugins/express/plugin.py +428 -0
- codegraph/plugins/flutter/__init__.py +0 -0
- codegraph/plugins/flutter/plugin.py +538 -0
- codegraph/plugins/kotlin/__init__.py +0 -0
- codegraph/plugins/kotlin/exact.py +457 -0
- codegraph/plugins/kotlin/plugin.py +1961 -0
- codegraph/plugins/kotlin/reparse.py +234 -0
- codegraph/plugins/laravel/__init__.py +0 -0
- codegraph/plugins/laravel/broadcast.py +351 -0
- codegraph/plugins/laravel/plugin.py +863 -0
- codegraph/plugins/laravel/tests.py +262 -0
- codegraph/plugins/laravel/values.py +728 -0
- codegraph/plugins/native/__init__.py +0 -0
- codegraph/plugins/native/gates.py +286 -0
- codegraph/plugins/native/runner.py +183 -0
- codegraph/plugins/native/scipread.py +194 -0
- codegraph/plugins/native/ts.py +54 -0
- codegraph/plugins/nest/__init__.py +0 -0
- codegraph/plugins/nest/plugin.py +654 -0
- codegraph/plugins/nextjs/__init__.py +0 -0
- codegraph/plugins/nextjs/plugin.py +336 -0
- codegraph/plugins/nuxt/__init__.py +0 -0
- codegraph/plugins/nuxt/plugin.py +308 -0
- codegraph/plugins/php/__init__.py +0 -0
- codegraph/plugins/php/extractor/composer.json +5 -0
- codegraph/plugins/php/extractor/composer.lock +76 -0
- codegraph/plugins/php/extractor/extract.php +743 -0
- codegraph/plugins/php/gating.py +573 -0
- codegraph/plugins/php/plugin.py +668 -0
- codegraph/plugins/php/strings.py +197 -0
- codegraph/plugins/python/__init__.py +0 -0
- codegraph/plugins/python/aitools.py +664 -0
- codegraph/plugins/python/external.py +245 -0
- codegraph/plugins/python/fields.py +107 -0
- codegraph/plugins/python/plugin.py +1733 -0
- codegraph/plugins/python/refs.py +485 -0
- codegraph/plugins/python/roots.py +412 -0
- codegraph/plugins/python/socketio.py +210 -0
- codegraph/plugins/python/subproc.py +864 -0
- codegraph/plugins/python/tests.py +1040 -0
- codegraph/plugins/python/values.py +179 -0
- codegraph/plugins/pyweb/__init__.py +0 -0
- codegraph/plugins/pyweb/plugin.py +1334 -0
- codegraph/plugins/pyweb/values.py +68 -0
- codegraph/plugins/rust/__init__.py +0 -0
- codegraph/plugins/rust/cargo.py +226 -0
- codegraph/plugins/rust/plugin.py +980 -0
- codegraph/plugins/rust/syntax.py +678 -0
- codegraph/plugins/scip/__init__.py +0 -0
- codegraph/plugins/scip/importer.py +129 -0
- codegraph/plugins/scip/scip.proto +962 -0
- codegraph/plugins/scip/scip_pb2.py +97 -0
- codegraph/plugins/stubs/__init__.py +0 -0
- codegraph/plugins/stubs/plugins.py +38 -0
- codegraph/plugins/swift/__init__.py +0 -0
- codegraph/plugins/swift/baseurl.py +109 -0
- codegraph/plugins/swift/exact.py +415 -0
- codegraph/plugins/swift/indexstore.py +209 -0
- codegraph/plugins/swift/packages.py +174 -0
- codegraph/plugins/swift/plugin.py +2890 -0
- codegraph/plugins/ts/__init__.py +0 -0
- codegraph/plugins/ts/baseurl.py +185 -0
- codegraph/plugins/ts/extractor/extract.mjs +2652 -0
- codegraph/plugins/ts/extractor/fw.mjs +685 -0
- codegraph/plugins/ts/extractor/package-lock.json +205 -0
- codegraph/plugins/ts/extractor/package.json +9 -0
- codegraph/plugins/ts/plugin.py +480 -0
- codegraph/plugins/tsweb/__init__.py +0 -0
- codegraph/plugins/tsweb/common.py +290 -0
- codegraph/plugins/tsweb/data.py +276 -0
- codegraph/presets/__init__.py +146 -0
- codegraph/presets/c_cpp.yaml +9 -0
- codegraph/presets/common.yaml +66 -0
- codegraph/presets/dart.yaml +9 -0
- codegraph/presets/django-ninja.yaml +15 -0
- codegraph/presets/django.yaml +25 -0
- codegraph/presets/djangorestframework.yaml +17 -0
- codegraph/presets/express.yaml +17 -0
- codegraph/presets/kotlin.yaml +11 -0
- codegraph/presets/laravel.yaml +40 -0
- codegraph/presets/nest.yaml +11 -0
- codegraph/presets/nextjs.yaml +15 -0
- codegraph/presets/nuxt.yaml +9 -0
- codegraph/presets/php.yaml +5 -0
- codegraph/presets/python.yaml +10 -0
- codegraph/presets/rust.yaml +5 -0
- codegraph/presets/swift.yaml +10 -0
- codegraph/presets/typescript.yaml +13 -0
- codegraph/process_runs.py +328 -0
- codegraph/protocols/__init__.py +299 -0
- codegraph/protocols/builtin.py +67 -0
- codegraph/protocols/matchers.py +144 -0
- codegraph/protocols/view.py +334 -0
- codegraph/query.py +2089 -0
- codegraph/realtime.py +260 -0
- codegraph/roundtrip.py +346 -0
- codegraph/routes.py +442 -0
- codegraph/starters.py +218 -0
- codegraph/tests_index.py +117 -0
- codegraph/viz/__init__.py +0 -0
- codegraph/viz/graph.py +369 -0
- codegraph/viz/server.py +198 -0
- codegraph/viz/static/app.css +148 -0
- codegraph/viz/static/app.js +1082 -0
- codegraph/viz/static/index.html +81 -0
- codegraph/viz/static/layered.js +237 -0
- codegraph/viz/static/vendor/VERSIONS.txt +4 -0
- codegraph/viz/static/vendor/cose-base.js +3214 -0
- codegraph/viz/static/vendor/cytoscape-fcose.js +1549 -0
- codegraph/viz/static/vendor/cytoscape.min.js +31 -0
- codegraph/viz/static/vendor/layout-base.js +5230 -0
- codegraph/viz/tools/package-lock.json +303 -0
- codegraph/viz/tools/package.json +7 -0
- codegraph/viz/tools/shoot.mjs +165 -0
- 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)."
|