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/config.py ADDED
@@ -0,0 +1,559 @@
1
+ """Project config file: `.cg.yaml` (or `.cg.yml`) at the indexed root, discovered automatically.
2
+
3
+ Records project-specific knowledge once, for the CLI, the MCP server and the visual view:
4
+
5
+ version: 1
6
+ python:
7
+ source_roots: [lib, tools/scripts] # replace source-root detection (paths relative to the indexed root)
8
+ exclude: ["legacy/**", "*.generated.ts"] # paths never indexed, by any language plugin or the coverage scan
9
+ include: [src/generated, build/api] # directories indexed although a built-in skip (directory name, generated
10
+ # files) would leave them out; exclude globs still apply inside them
11
+ skip_dirs:
12
+ add: [fixtures_big] # more directory names to skip everywhere
13
+ keep: [static] # directory names skipped by default that hold project code here
14
+ generated:
15
+ paths: ["src/api/generated/**"] # generated files detection misses (kept out of the graph, listed by coverage)
16
+ vendored: ["third_party/**"] # vendored copies of other projects
17
+ keep: ["src/schema.gen.ts"] # hand-maintained files detection would classify
18
+ include: false # true: index them, labelled attrs.generated (cg index --include-generated)
19
+ frameworks:
20
+ add: [nest] # force a framework detection missed (plugin or preset name)
21
+ remove: [flutter] # drop one that was detected
22
+ auth:
23
+ extra_patterns: ["requireTenantMember", "withOrgScope"] # regexes on guard names that count as auth
24
+ secret:
25
+ extra_patterns: ["verifyStripeSignature"] # ... as a shared-secret / signature check
26
+ protocols:
27
+ external: ["kafka:audit.*", "http:GET /status"] # <protocol>:<name glob> handled outside the analysed repos
28
+ # (not reported as no_receiver / no_sender by cg protocols)
29
+ gates: config/gates.json # gate scenarios file (cg index --gates)
30
+ plans:
31
+ dir: docs/plans # plans directory (--plans-dir)
32
+ text_mention_dirs: [src, templates] # where plan completeness scans for text mentions
33
+ viz:
34
+ presets: # canned queries in the visual view's starter cards
35
+ - {id: orders_writes, label: what writes the orders table, mode: reaches, specs: ["table:orders"]}
36
+ apps: # monorepo: `cg index <root>` indexes each app and links each pair
37
+ - {name: api, root: apps/api, role: backend}
38
+ - {name: web, root: apps/web, role: frontend, links: [api]} # links: backends it calls (default: all)
39
+
40
+ Command-line flags take precedence over the file. Top-level keys this version does not read are kept and reported
41
+ in the index stats (and as a warning by `cg config validate`), so a file written for a newer cg still indexes."""
42
+ from __future__ import annotations
43
+
44
+ import difflib
45
+ import re
46
+ from pathlib import Path, PurePosixPath
47
+ from typing import Any
48
+
49
+ CONFIG_NAMES = (".cg.yaml", ".cg.yml")
50
+ SCHEMA: dict[str, set | None] = { # top-level key -> allowed sub-keys (None: a scalar or list value)
51
+ "version": None, "python": {"source_roots"}, "exclude": None, "skip_dirs": {"add", "keep"},
52
+ "frameworks": {"add", "remove"}, "auth": {"extra_patterns"}, "secret": {"extra_patterns"}, "gates": None,
53
+ "plans": {"dir", "text_mention_dirs"}, "viz": {"presets"}, "generated": {"paths", "vendored", "keep", "include"},
54
+ "platforms": {"targets", "paths", "file_suffixes", "path_conventions"}, "include": None, "apps": None,
55
+ "rust": {"targets"}, "protocols": {"external"}, "lossy": None,
56
+ }
57
+ APP_KEYS = ("name", "root", "role", "links")
58
+ APP_ROLES = ("backend", "frontend")
59
+ KNOWN_KEYS = set(SCHEMA)
60
+ PYTHON_KEYS = SCHEMA["python"]
61
+ VIZ_MODES = ("reaches", "impact", "downstream", "path", "plan")
62
+ # framework plugins of codegraph/indexer.py FRAMEWORK_PLUGINS (frameworks.add / remove also take preset names)
63
+ FRAMEWORK_PLUGIN_NAMES = ("laravel", "nuxt", "django", "fastapi", "flask", "flutter", "nest", "nextjs", "express")
64
+
65
+
66
+ class ConfigError(ValueError):
67
+ """Invalid project config file; the message names the file and the key."""
68
+
69
+
70
+ def find(root: str | Path) -> Path | None:
71
+ for n in CONFIG_NAMES:
72
+ p = Path(root) / n
73
+ if p.is_file():
74
+ return p
75
+ return None
76
+
77
+
78
+ def norm_root(value: Any, where: str) -> str:
79
+ """A repo-relative directory: 'lib', 'tools/scripts/', './src', '.'. Absolute paths and '..' are rejected."""
80
+ if not isinstance(value, str) or not value.strip():
81
+ raise ConfigError(f"{where}: expected a directory path relative to the indexed root, got {value!r}")
82
+ v = value.strip().replace("\\", "/")
83
+ p = PurePosixPath(v)
84
+ if p.is_absolute() or ".." in p.parts:
85
+ raise ConfigError(f"{where}: {value!r} must stay inside the indexed root (relative path, no '..')")
86
+ s = str(p)
87
+ return "" if s == "." else s
88
+
89
+
90
+ def known_frameworks() -> list[str]:
91
+ from . import presets
92
+ return sorted(set(FRAMEWORK_PLUGIN_NAMES) | set(presets.frameworks()))
93
+
94
+
95
+ def framework_name(value: Any, where: str) -> str:
96
+ from . import presets
97
+ if not isinstance(value, str) or not value.strip():
98
+ raise ConfigError(f"{where}: expected a framework name, got {value!r}")
99
+ v = value.strip().lower()
100
+ v = presets.FRAMEWORK_ALIASES.get(v, v)
101
+ if v not in known_frameworks():
102
+ near = difflib.get_close_matches(v, known_frameworks(), n=1)
103
+ raise ConfigError(f"{where}: unknown framework {value!r}" + (f" (did you mean {near[0]!r}?)" if near else "")
104
+ + f"; known: {', '.join(known_frameworks())}")
105
+ return v
106
+
107
+
108
+ def _strings(v: Any, where: str, allow_one: bool = True) -> list[str]:
109
+ if isinstance(v, str) and allow_one:
110
+ v = [v]
111
+ if not isinstance(v, list) or not v or not all(isinstance(x, str) and x.strip() for x in v):
112
+ raise ConfigError(f"{where}: expected a non-empty list of strings")
113
+ return list(dict.fromkeys(x.strip() for x in v))
114
+
115
+
116
+ def _section(data: dict, key: str, fname: str) -> dict | None:
117
+ sec = data.get(key)
118
+ if sec is None:
119
+ return None
120
+ if not isinstance(sec, dict):
121
+ raise ConfigError(f"{fname}: {key}: expected a mapping (keys: {', '.join(sorted(SCHEMA[key]))})")
122
+ unknown = sorted(set(sec) - SCHEMA[key])
123
+ if unknown:
124
+ raise ConfigError(f"{fname}: {key}: unknown key {unknown[0]!r} (known: {', '.join(sorted(SCHEMA[key]))})")
125
+ return sec
126
+
127
+
128
+ def _regexes(v: Any, where: str) -> list[str]:
129
+ out = _strings(v, where)
130
+ for i, rx in enumerate(out):
131
+ try:
132
+ re.compile(rx)
133
+ except re.error as ex:
134
+ raise ConfigError(f"{where}[{i}]: {rx!r} is not a valid regular expression ({ex})") from None
135
+ return out
136
+
137
+
138
+ def _viz_presets(v: Any, where: str) -> list[dict]:
139
+ if not isinstance(v, list) or not v:
140
+ raise ConfigError(f"{where}: expected a non-empty list of {{id, label, mode, specs}} entries")
141
+ out, ids = [], set()
142
+ for i, p in enumerate(v):
143
+ w = f"{where}[{i}]"
144
+ if not isinstance(p, dict):
145
+ raise ConfigError(f"{w}: expected a mapping with id, label, mode, specs")
146
+ unknown = sorted(set(p) - {"id", "label", "mode", "specs", "sinks"})
147
+ if unknown:
148
+ raise ConfigError(f"{w}: unknown key {unknown[0]!r} (known: id, label, mode, specs, sinks)")
149
+ for k in ("id", "label", "mode"):
150
+ if not isinstance(p.get(k), str) or not p[k].strip():
151
+ raise ConfigError(f"{w}.{k}: required (a string)")
152
+ if p["mode"] not in VIZ_MODES:
153
+ raise ConfigError(f"{w}.mode: {p['mode']!r} is not one of {', '.join(VIZ_MODES)}")
154
+ if p["id"] in ids:
155
+ raise ConfigError(f"{w}.id: duplicate id {p['id']!r}")
156
+ ids.add(p["id"])
157
+ e = {"id": p["id"], "label": p["label"], "mode": p["mode"], "specs": _strings(p.get("specs"), f"{w}.specs")}
158
+ if p.get("sinks") is not None:
159
+ e["sinks"] = _strings(p["sinks"], f"{w}.sinks")
160
+ out.append(e)
161
+ return out
162
+
163
+
164
+ def _platform_names(v: Any, where: str, extra: tuple = ()) -> list[str]:
165
+ from .platforms import KNOWN, norm
166
+ out = []
167
+ for i, n in enumerate(_strings(v, where)):
168
+ p = n.lower() if n.lower() in extra else norm(n)
169
+ if p is None:
170
+ raise ConfigError(f"{where}[{i}]: unknown platform {n!r} (known: {', '.join(KNOWN + extra)})")
171
+ out.append(p)
172
+ return list(dict.fromkeys(out))
173
+
174
+
175
+ def _platforms(pf: dict, fname: str) -> dict:
176
+ """platforms: targets (the project's build targets), paths (glob -> targets: files built only there),
177
+ file_suffixes / path_conventions / xcode_membership (React Native .ios.ts files, C/C++ win/ unix/ directories,
178
+ Xcode target membership; default on)."""
179
+ out: dict = {}
180
+ if pf.get("targets") is not None:
181
+ out["targets"] = _platform_names(pf["targets"], f"{fname}: platforms.targets")
182
+ if not out["targets"]:
183
+ raise ConfigError(f"{fname}: platforms.targets: expected at least one target")
184
+ if pf.get("paths") is not None:
185
+ if not isinstance(pf["paths"], dict):
186
+ raise ConfigError(f"{fname}: platforms.paths: expected a mapping of glob -> targets "
187
+ f"(e.g. 'src/win/**': [windows])")
188
+ out["paths"] = {}
189
+ for g, ts in pf["paths"].items():
190
+ g = str(g)
191
+ if ".." in PurePosixPath(g.strip("/")).parts:
192
+ raise ConfigError(f"{fname}: platforms.paths: {g!r} must stay inside the indexed root (no '..')")
193
+ names = _platform_names(ts, f"{fname}: platforms.paths[{g!r}]", extra=("unix", "native"))
194
+ if not names:
195
+ raise ConfigError(f"{fname}: platforms.paths[{g!r}]: expected at least one target")
196
+ out["paths"][g] = names
197
+ for k in ("file_suffixes", "path_conventions", "xcode_membership"):
198
+ if pf.get(k) is not None:
199
+ if not isinstance(pf[k], bool):
200
+ raise ConfigError(f"{fname}: platforms.{k}: expected true or false, got {pf[k]!r}")
201
+ out[k] = pf[k]
202
+ return out
203
+
204
+
205
+ def parse(data: Any, fname: str = ".cg.yaml") -> dict:
206
+ """Validated, normalized config from parsed YAML (see load())."""
207
+ if data is None:
208
+ data = {}
209
+ if not isinstance(data, dict):
210
+ raise ConfigError(f"{fname}: expected a mapping of keys at the top level")
211
+ ver = data.get("version", 1)
212
+ if ver != 1:
213
+ raise ConfigError(f"{fname}: version {ver!r} is not supported (this cg reads version 1)")
214
+ out: dict = {"file": fname}
215
+ py = _section(data, "python", fname)
216
+ if py is not None:
217
+ roots = py.get("source_roots")
218
+ if roots is not None:
219
+ if isinstance(roots, str):
220
+ roots = [roots]
221
+ if not isinstance(roots, list) or not roots:
222
+ raise ConfigError(f"{fname}: python.source_roots: expected a non-empty list of directories")
223
+ out["python"] = {"source_roots": list(dict.fromkeys(
224
+ norm_root(r, f"{fname}: python.source_roots[{i}]") for i, r in enumerate(roots)))}
225
+ if data.get("exclude") is not None:
226
+ globs = _strings(data["exclude"], f"{fname}: exclude")
227
+ for i, g in enumerate(globs):
228
+ if ".." in PurePosixPath(g.strip("/")).parts:
229
+ raise ConfigError(f"{fname}: exclude[{i}]: {g!r} must stay inside the indexed root (no '..')")
230
+ out["exclude"] = globs
231
+ if data.get("include") is not None:
232
+ out["include"] = list(dict.fromkeys(norm_root(d, f"{fname}: include[{i}]")
233
+ for i, d in enumerate(_strings(data["include"], f"{fname}: include"))))
234
+ if "" in out["include"]:
235
+ raise ConfigError(f"{fname}: include: '.' is the indexed root itself; name the directories to include")
236
+ if data.get("apps") is not None:
237
+ out["apps"] = _apps(data["apps"], fname)
238
+ sd = _section(data, "skip_dirs", fname)
239
+ if sd is not None:
240
+ out["skip_dirs"] = {}
241
+ for k in ("add", "keep"):
242
+ if sd.get(k) is not None:
243
+ names = _strings(sd[k], f"{fname}: skip_dirs.{k}")
244
+ bad = [n for n in names if "/" in n or "*" in n]
245
+ if bad:
246
+ raise ConfigError(f"{fname}: skip_dirs.{k}: {bad[0]!r} is not a directory name (use exclude for paths and globs)")
247
+ out["skip_dirs"][k] = names
248
+ gen = _section(data, "generated", fname)
249
+ if gen is not None:
250
+ out["generated"] = {}
251
+ for k in ("paths", "vendored", "keep"):
252
+ if gen.get(k) is not None:
253
+ globs = _strings(gen[k], f"{fname}: generated.{k}")
254
+ for i, g in enumerate(globs):
255
+ if ".." in PurePosixPath(g.strip("/")).parts:
256
+ raise ConfigError(f"{fname}: generated.{k}[{i}]: {g!r} must stay inside the indexed root (no '..')")
257
+ out["generated"][k] = globs
258
+ if gen.get("include") is not None:
259
+ if not isinstance(gen["include"], bool):
260
+ raise ConfigError(f"{fname}: generated.include: expected true or false, got {gen['include']!r}")
261
+ out["generated"]["include"] = gen["include"]
262
+ pf = _section(data, "platforms", fname)
263
+ if pf is not None:
264
+ out["platforms"] = _platforms(pf, fname)
265
+ rs = _section(data, "rust", fname)
266
+ if rs is not None and rs.get("targets") is not None:
267
+ out["rust"] = {"targets": rust_targets(rs["targets"], f"{fname}: rust.targets")}
268
+ fw = _section(data, "frameworks", fname)
269
+ if fw is not None:
270
+ out["frameworks"] = {}
271
+ for k in ("add", "remove"):
272
+ if fw.get(k) is not None:
273
+ out["frameworks"][k] = list(dict.fromkeys(
274
+ framework_name(n, f"{fname}: frameworks.{k}[{i}]") for i, n in enumerate(_strings(fw[k], f"{fname}: frameworks.{k}"))))
275
+ both = set(out["frameworks"].get("add", [])) & set(out["frameworks"].get("remove", []))
276
+ if both:
277
+ raise ConfigError(f"{fname}: frameworks: {sorted(both)[0]!r} is in both add and remove")
278
+ for key in ("auth", "secret"):
279
+ sec = _section(data, key, fname)
280
+ if sec is not None and sec.get("extra_patterns") is not None:
281
+ out[key] = {"extra_patterns": _regexes(sec["extra_patterns"], f"{fname}: {key}.extra_patterns")}
282
+ pr = _section(data, "protocols", fname)
283
+ if pr is not None and pr.get("external") is not None:
284
+ ext = _strings(pr["external"], f"{fname}: protocols.external")
285
+ bad = [x for x in ext if ":" not in x]
286
+ if bad:
287
+ raise ConfigError(f"{fname}: protocols.external: {bad[0]!r} is not <protocol>:<name glob> (e.g. kafka:audit.*)")
288
+ out["protocols"] = {"external": ext}
289
+ if data.get("gates") is not None:
290
+ out["gates"] = norm_root(data["gates"], f"{fname}: gates")
291
+ pl = _section(data, "plans", fname)
292
+ if pl is not None:
293
+ out["plans"] = {}
294
+ if pl.get("dir") is not None:
295
+ out["plans"]["dir"] = norm_root(pl["dir"], f"{fname}: plans.dir")
296
+ if pl.get("text_mention_dirs") is not None:
297
+ out["plans"]["text_mention_dirs"] = [norm_root(d, f"{fname}: plans.text_mention_dirs[{i}]")
298
+ for i, d in enumerate(_strings(pl["text_mention_dirs"], f"{fname}: plans.text_mention_dirs"))]
299
+ vz = _section(data, "viz", fname)
300
+ if vz is not None and vz.get("presets") is not None:
301
+ out["viz"] = {"presets": _viz_presets(vz["presets"], f"{fname}: viz.presets")}
302
+ if data.get("lossy") is not None: # #88: extra lossy-transform call names / globs for `cg roundtrip`
303
+ out["lossy"] = _strings(data["lossy"], f"{fname}: lossy")
304
+ ignored = sorted(str(k) for k in set(data) - KNOWN_KEYS)
305
+ if ignored:
306
+ out["ignored_keys"] = ignored
307
+ return out
308
+
309
+
310
+ def _apps(v: Any, fname: str) -> list[dict]:
311
+ """apps as a list of {name, root, role, links} (or a mapping name -> {root, role, links}), validated: unique names,
312
+ roots inside the indexed root, role backend / frontend, links naming backend apps. A frontend without `links`
313
+ links to every backend."""
314
+ if isinstance(v, dict):
315
+ v = [dict(x or {}, name=k) if isinstance(x, dict) or x is None else x for k, x in v.items()]
316
+ if not isinstance(v, list) or not v:
317
+ raise ConfigError(f"{fname}: apps: expected a non-empty list of apps ({{name, root, role, links}})")
318
+ apps, names = [], set()
319
+ for i, a in enumerate(v):
320
+ where = f"{fname}: apps[{i}]"
321
+ if not isinstance(a, dict):
322
+ raise ConfigError(f"{where}: expected a mapping with keys {', '.join(APP_KEYS)}")
323
+ bad = sorted(set(a) - set(APP_KEYS))
324
+ if bad:
325
+ raise ConfigError(f"{where}: unknown key {bad[0]!r} (keys: {', '.join(APP_KEYS)})")
326
+ name = a.get("name")
327
+ if not isinstance(name, str) or not re.fullmatch(r"[A-Za-z0-9][\w.-]*", name or ""):
328
+ raise ConfigError(f"{where}.name: expected a name of letters, digits, '.', '-' or '_', got {name!r}")
329
+ if name in names:
330
+ raise ConfigError(f"{where}.name: {name!r} is used by two apps")
331
+ names.add(name)
332
+ root = norm_root(a.get("root") if a.get("root") is not None else name, f"{where}.root")
333
+ role = a.get("role", "backend")
334
+ if role not in APP_ROLES:
335
+ raise ConfigError(f"{where}.role: expected backend or frontend, got {role!r}")
336
+ links = _strings(a["links"], f"{where}.links") if a.get("links") is not None else None
337
+ if links is not None and role != "frontend":
338
+ raise ConfigError(f"{where}.links: only a frontend app links to backends")
339
+ apps.append({"name": name, "root": root, "role": role, **({"links": links} if links is not None else {})})
340
+ backends = {a["name"] for a in apps if a["role"] == "backend"}
341
+ for i, a in enumerate(apps):
342
+ for n in a.get("links") or []:
343
+ if n not in backends:
344
+ raise ConfigError(f"{fname}: apps[{i}].links: {n!r} is not a backend app"
345
+ + (f" (backends: {', '.join(sorted(backends))})" if backends else ""))
346
+ return apps
347
+
348
+
349
+ def app_pairs(apps: list[dict]) -> list[tuple[dict, dict]]:
350
+ """(frontend, backend) link pairs in config order."""
351
+ by = {a["name"]: a for a in apps}
352
+ backends = [a for a in apps if a["role"] == "backend"]
353
+ out = []
354
+ for a in apps:
355
+ if a["role"] == "frontend":
356
+ out += [(a, by[n]) for n in a["links"]] if "links" in a else [(a, b) for b in backends]
357
+ return out
358
+
359
+
360
+ def rust_targets(v, where: str) -> str:
361
+ """`rust.targets`: extra rust-analyzer runs per target (#56). `auto` (default), `off` / false, or a list of
362
+ platforms / target triples; returned in the CODEGRAPH_RUST_TARGETS form."""
363
+ if v is False or (isinstance(v, str) and v.strip().lower() in ("off", "none", "0", "false")):
364
+ return "off"
365
+ if isinstance(v, str) and v.strip().lower() == "auto":
366
+ return "auto"
367
+ items = [v] if isinstance(v, str) else v
368
+ if not isinstance(items, list) or not items or not all(isinstance(x, str) and x.strip() for x in items):
369
+ raise ConfigError(f"{where}: expected auto, off or a list of platforms / target triples, got {v!r}")
370
+ return ",".join(x.strip() for x in items)
371
+
372
+
373
+ def load(root: str | Path) -> dict:
374
+ """{"file": ".cg.yaml", "python": {...}, "exclude": [...], ..., "ignored_keys": [...]} or {} without a config
375
+ file. Raises ConfigError for a file cg cannot use."""
376
+ p = find(root)
377
+ return load_file(p) if p is not None else {}
378
+
379
+
380
+ def load_file(p: str | Path) -> dict:
381
+ import yaml
382
+ p = Path(p)
383
+ try:
384
+ data = yaml.safe_load(p.read_text(encoding="utf-8"))
385
+ except yaml.YAMLError as ex:
386
+ raise ConfigError(f"{p.name}: not valid YAML ({str(ex).splitlines()[0]})") from None
387
+ except OSError as ex:
388
+ raise ConfigError(f"{p}: cannot read ({ex.strerror})") from None
389
+ return parse(data, p.name)
390
+
391
+
392
+ def unknown_key_warnings(cfg: dict) -> list[str]:
393
+ out = []
394
+ for k in cfg.get("ignored_keys") or []:
395
+ near = difflib.get_close_matches(k, sorted(KNOWN_KEYS), n=1)
396
+ out.append(f"{cfg.get('file', '.cg.yaml')}: unknown top-level key {k!r} is ignored"
397
+ + (f" (did you mean {near[0]!r}?)" if near else f" (keys this cg reads: {', '.join(sorted(KNOWN_KEYS))})"))
398
+ return out
399
+
400
+
401
+ def resolve_path(root: str | Path, rel: str) -> Path:
402
+ return Path(root) / rel if rel else Path(root)
403
+
404
+
405
+ def graph_configs(st) -> list[tuple[str | None, dict, str | None]]:
406
+ """(repo, config, root) recorded in a graph DB at index time: one entry for a single-repo graph, one per repo for a
407
+ combined graph (read from the source DBs that still exist)."""
408
+ try:
409
+ m = st.meta()
410
+ except Exception: # noqa: BLE001
411
+ return []
412
+ if not m.get("repos"):
413
+ return [(None, (m.get("stats") or {}).get("config") or {}, m.get("root"))]
414
+ out = []
415
+ for r in m["repos"]:
416
+ src = (m.get("sources") or {}).get(r)
417
+ from .routes import _meta_of
418
+ mm = _meta_of(src)
419
+ out.append((r, (mm.get("stats") or {}).get("config") or {}, mm.get("root")))
420
+ return out
421
+
422
+
423
+ def configured_plans_dir(st) -> Path | None:
424
+ """plans.dir of the .cg.yaml recorded in the graph (first repo that sets it), resolved against its root."""
425
+ for _, cfg, root in graph_configs(st):
426
+ d = (cfg.get("plans") or {}).get("dir")
427
+ if d is not None and root:
428
+ return resolve_path(root, d)
429
+ return None
430
+
431
+
432
+ # ------------------------------------------------------------------------------------------- cg config show
433
+ def effective(root: str | Path, python_roots: list[str] | None = None, gates: str | None = None,
434
+ auth_pattern: str | None = None, plans_dir: str | None = None, presets_file: str | None = None,
435
+ include_generated: bool = False) -> dict:
436
+ """Effective configuration of `root` without indexing it: every value with its source (`detected`, `built-in`,
437
+ `preset <name>`, the config file name, `flag --x`). Raises ConfigError for an invalid config file."""
438
+ from . import presets as PR
439
+ from .core.plugin import Project
440
+ from .indexer import setup
441
+ root = Path(root).resolve()
442
+ project = Project(root=root, name=root.name)
443
+ project.options["config"] = cfg = load(root)
444
+ fname = cfg.get("file", ".cg.yaml")
445
+ plan = setup(project)
446
+ rows: list[dict] = []
447
+
448
+ def row(key, value, source):
449
+ rows.append({"key": key, "value": value, "source": source})
450
+
451
+ row("config file", fname if cfg else None, "found at the indexed root" if cfg else "none (optional)")
452
+ row("languages", plan["languages"], "detected")
453
+ for f, how in plan["frameworks"].items():
454
+ row("frameworks", f, f"{fname} frameworks.add" if how == ".cg.yaml" else "detected")
455
+ for f in plan["removed"]:
456
+ row("frameworks", f"{f} (removed)", f"{fname} frameworks.remove")
457
+ row("presets", plan["presets"], "built-in (codegraph/presets), picked by detection")
458
+ if python_roots:
459
+ row("python.source_roots", [norm_root(r, "--python-root") for r in python_roots], "flag --python-root")
460
+ elif (cfg.get("python") or {}).get("source_roots"):
461
+ row("python.source_roots", cfg["python"]["source_roots"], f"{fname} python.source_roots")
462
+ elif "python" in plan["languages"]:
463
+ row("python.source_roots", "detected at index time (cg coverage lists them)", "detected")
464
+ row("exclude", cfg.get("exclude") or [], f"{fname} exclude" if cfg.get("exclude") else "none")
465
+ sd = cfg.get("skip_dirs") or {}
466
+ for p in ["common", *[x for x in plan["presets"] if x != "common"]]:
467
+ names = PR.values(p, "skip_dirs", default=None)
468
+ if names:
469
+ row("skip_dirs", sorted(n for n in names if n not in sd.get("keep", [])), f"preset {p}")
470
+ sp = PR.values(p, "skip_paths", default=None)
471
+ if sp:
472
+ row("skip_paths", sorted(n for n in sp if n not in sd.get("keep", [])), f"preset {p} (root-relative)")
473
+ if cfg.get("include"):
474
+ row("include", cfg["include"], f"{fname} include")
475
+ else:
476
+ row("include", [], "none")
477
+ row("skip_dirs (coverage scan)", sorted(PR.values("common", "scan_skip_dirs", default=[])), "preset common")
478
+ if sd.get("add"):
479
+ row("skip_dirs", sd["add"], f"{fname} skip_dirs.add")
480
+ if sd.get("keep"):
481
+ row("skip_dirs kept", sd["keep"], f"{fname} skip_dirs.keep")
482
+ gc = cfg.get("generated") or {}
483
+ row("generated (build dirs)", sorted(PR.values("common", "generated", "build_dirs", default={}) or {}), "preset common")
484
+ row("generated (file names)", sorted(PR.values("common", "generated", "files", default={}) or {}), "preset common")
485
+ row("generated (detected)", ".gitattributes linguist-generated / linguist-vendored, generator header banners, "
486
+ "Capacitor / Cordova copy targets, .openapi-generator/FILES", "built-in")
487
+ for k in ("paths", "vendored", "keep"):
488
+ if gc.get(k):
489
+ row(f"generated.{k}", gc[k], f"{fname} generated.{k}")
490
+ if include_generated:
491
+ row("generated.include", True, "flag --include-generated")
492
+ else:
493
+ row("generated.include", bool(gc.get("include")), f"{fname} generated.include" if "include" in gc else "built-in (excluded, listed by cg coverage)")
494
+ pc = cfg.get("platforms") or {}
495
+ if pc.get("targets"):
496
+ row("platforms.targets", pc["targets"], f"{fname} platforms.targets")
497
+ else:
498
+ row("platforms.targets", "detected at index time: Flutter platform folders, Expo app.json, React Native, "
499
+ "Electron / Tauri, else the desktop targets plus those the conditions name (cg platforms)", "built-in")
500
+ if pc.get("paths"):
501
+ row("platforms.paths", [f"{g}: {', '.join(ts)}" for g, ts in pc["paths"].items()], f"{fname} platforms.paths")
502
+ for k, what in (("file_suffixes", "React Native .ios / .android / .native / .web files"),
503
+ ("path_conventions", "C / C++ win/ unix/ posix/ darwin/ directories and _win / _unix file names")):
504
+ row(f"platforms.{k}", pc.get(k, True), f"{fname} platforms.{k}" if k in pc else f"built-in ({what})")
505
+ row("auth.token_pattern", PR.values("common", "auth", "token_pattern"), "preset common")
506
+ for section in ("auth", "secret"):
507
+ for key in ("guards", "not_auth") if section == "auth" else ("guards",):
508
+ for p in plan["presets"]:
509
+ g = PR.values(p, section, key, default=None)
510
+ if g:
511
+ row(f"{section}.{key}", [x["name"] if isinstance(x, dict) else x for x in g], f"preset {p}")
512
+ if (cfg.get(section) or {}).get("extra_patterns"):
513
+ row(f"{section}.extra_patterns", cfg[section]["extra_patterns"], f"{fname} {section}.extra_patterns")
514
+ if auth_pattern:
515
+ row("auth.extra_patterns", [auth_pattern], "flag --auth-pattern")
516
+ row("secret.token_pattern", PR.values("common", "secret", "token_pattern"), "preset common")
517
+ if gates:
518
+ row("gates", gates, "flag --gates")
519
+ else:
520
+ row("gates", cfg.get("gates"), f"{fname} gates" if cfg.get("gates") else "none")
521
+ pl = cfg.get("plans") or {}
522
+ if plans_dir:
523
+ row("plans.dir", plans_dir, "flag --plans-dir")
524
+ else:
525
+ row("plans.dir", pl.get("dir", "plans/ in the cg checkout"), f"{fname} plans.dir" if "dir" in pl else "built-in")
526
+ row("plans.text_mention_dirs", pl.get("text_mention_dirs") or PR.values("laravel", "plans", "text_mention_dirs"),
527
+ f"{fname} plans.text_mention_dirs" if pl.get("text_mention_dirs") else "preset laravel")
528
+ row("plans.short_prefixes", PR.values("laravel", "plans", "short_prefixes"), "preset laravel")
529
+ if presets_file:
530
+ row("viz.presets", presets_file, "flag --presets")
531
+ elif (cfg.get("viz") or {}).get("presets"):
532
+ row("viz.presets", [p["id"] for p in cfg["viz"]["presets"]], f"{fname} viz.presets")
533
+ row("viz.presets (starters)", "derived from the graph at index time (cg starters)", "built-in")
534
+ if cfg.get("apps"):
535
+ for a in cfg["apps"]:
536
+ row("apps", f"{a['name']}: {a['root'] or '.'} ({a['role']})", f"{fname} apps")
537
+ for f, b in app_pairs(cfg["apps"]):
538
+ row("apps (link pairs)", f"{f['name']} -> {b['name']}",
539
+ f"{fname} apps[{f['name']}].links" if "links" in f else "built-in (a frontend links to every backend)")
540
+ else:
541
+ row("apps", "none: the root is indexed as one project", "none")
542
+ if cfg.get("ignored_keys"):
543
+ row("ignored keys", cfg["ignored_keys"], f"{fname} (not read by this cg version)")
544
+ return {"root": str(root), "config": cfg, "rows": rows, "warnings": unknown_key_warnings(cfg)}
545
+
546
+
547
+ def render_effective(eff: dict) -> str:
548
+ out = [f"effective configuration of {Path(eff['root']).name}"]
549
+ w = max(len(r["key"]) for r in eff["rows"])
550
+ for r in eff["rows"]:
551
+ v = r["value"]
552
+ if isinstance(v, list):
553
+ v = ", ".join(map(str, v)) if v else "(none)"
554
+ v = "(none)" if v is None else str(v)
555
+ if len(v) > 100:
556
+ v = v[:97] + "..."
557
+ out.append(f" {r['key']:<{w}} {v} [{r['source']}]")
558
+ out += [f"warning: {x}" for x in eff["warnings"]]
559
+ return "\n".join(out)
File without changes