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/routes.py ADDED
@@ -0,0 +1,442 @@
1
+ """Routes with their access guards, optionally restricted to routes that reach a write, a table or any sink.
2
+
3
+ `routes_report(st, writes="*")` answers "which routes reach a DB write, and what protects each of them?" in one call.
4
+ Guards are read from what the framework plugins record on route nodes, so the same query works across stacks:
5
+
6
+ Laravel attrs.middleware (route and group middleware, e.g. `auth:api`)
7
+ NestJS attrs.guards / interceptors / pipes (+ USES_MIDDLEWARE to canActivate/intercept)
8
+ Express / Koa / Fastify / Hono attrs.middleware (route and router-level, in mount order) + USES_MIDDLEWARE
9
+ Next.js USES_MIDDLEWARE from `middleware.ts` matchers, attrs.wrapped_by (`withSession(handler)`)
10
+ django-ninja attrs.auth (operation, router or API level `auth=`)
11
+ Django / DRF attrs.access (view decorators, access mixins, permission_classes / authentication_classes)
12
+
13
+ A guard counts as *auth* when its name is a guard of a framework preset applied at index time (codegraph/presets:
14
+ `login_required`, `IsAuthenticated`, `auth:sanctum`, `password.confirm` ...) or matches AUTH_PATTERN, tested on the
15
+ name's word tokens (name-based, and labelled as such in the output). Project-specific names come from .cg.yaml
16
+ `auth.extra_patterns` or `auth_pattern` (regexes tested on the raw name); preset `not_auth` names (`AllowAny`,
17
+ `csrf_protect`, `ThrottlerGuard`) never count. Each auth guard records `auth_by` (preset, name pattern, project pattern).
18
+ A route without auth whose guards verify a shared secret or signature (webhook signature checks, HMAC, Laravel `signed`
19
+ URLs; SECRET_PATTERN) is reported as SECRET-CHECKED instead of NO AUTH, and `unguarded` leaves it out (and says how
20
+ many it left out). The Laravel broadcasting auth route counts as auth: it rejects private / presence subscriptions
21
+ without an authenticated user and runs each channel's callback.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import os
27
+ import re
28
+ from collections import defaultdict
29
+
30
+ from . import presets
31
+ from .core.model import PROPAGATING
32
+ from .core.store import GraphStore
33
+ from .coverage import answer_note, completeness_for, possibly_more
34
+ from . import query as Q
35
+
36
+ # name-based fallback for every stack (codegraph/presets/common.yaml): matched against the guard name split into
37
+ # lower-case word tokens joined by "_" (ApiKeyGuard -> api_key_guard, auth:api -> auth_api, IsAuthenticated ->
38
+ # is_authenticated), so AuditLogInterceptor does not count as "login"
39
+ AUTH_PATTERN = presets.values("common", "auth", "token_pattern")
40
+ # shared-secret / signature verification (webhooks, signed URLs): the caller proves it knows a secret, not who it is
41
+ SECRET_PATTERN = presets.values("common", "secret", "token_pattern")
42
+ SECRET_RE = re.compile(SECRET_PATTERN)
43
+ WRITE_KINDS = ("WRITES_TABLE", "WRITES_COLUMN")
44
+ GUARD_ATTRS = (("guards", "guard"), ("interceptors", "interceptor"), ("pipes", "pipe"), ("auth", "auth"),
45
+ ("access", "access"), ("wrapped_by", "wrapper"), ("middleware", "middleware"))
46
+ GUARD_SOURCES = ("Laravel route/group middleware, Nest guards/interceptors/pipes, Express/Koa/Fastify/Hono route and router "
47
+ "middleware, Next.js middleware.ts matchers and handler wrappers, django-ninja auth=, Django view decorators / "
48
+ "access mixins and DRF permission_classes, FastAPI Depends()/Security() dependencies and Flask view decorators")
49
+
50
+
51
+ def _names(v) -> list[str]:
52
+ if v is None or v is False or v == "" or v == []:
53
+ return []
54
+ if isinstance(v, (list, tuple)):
55
+ return [x for i in v for x in _names(i)]
56
+ if isinstance(v, dict):
57
+ return _names(v.get("name"))
58
+ s = str(v).strip()
59
+ return [] if s in ("None", "null", "NOT_SET") else [s]
60
+
61
+
62
+ def tokens(name: str) -> str:
63
+ """'ApiKeyGuard' -> 'api_key_guard', 'auth:api' -> 'auth_api', 'login_required' -> 'login_required'."""
64
+ return "_".join(t.lower() for t in re.findall(r"[A-Z]?[a-z0-9]+|[A-Z]+(?![a-z])", name or ""))
65
+
66
+
67
+ def _meta_of(path: str | None) -> dict:
68
+ if not path or not os.path.exists(path):
69
+ return {}
70
+ try:
71
+ g = GraphStore(path)
72
+ try:
73
+ return g.meta()
74
+ finally:
75
+ g.db.close()
76
+ except Exception: # noqa: BLE001
77
+ return {}
78
+
79
+
80
+ def guard_setup(st: GraphStore) -> dict:
81
+ """Presets and .cg.yaml patterns recorded at index time, for the guard classification of `st` (a combined graph:
82
+ the union over its repos). A graph indexed before presets were recorded gets every built-in preset."""
83
+ metas = []
84
+ try:
85
+ m = st.meta()
86
+ except Exception: # noqa: BLE001
87
+ m = {}
88
+ if m.get("repos"):
89
+ for r in m["repos"]:
90
+ src = (m.get("sources") or {}).get(r)
91
+ metas.append(_meta_of(src))
92
+ else:
93
+ metas.append(m)
94
+ applied, auth_x, secret_x = [], [], []
95
+ for mm in metas:
96
+ stt = mm.get("stats") or {}
97
+ pr = (stt.get("presets") or {}).get("applied")
98
+ applied += pr if pr else presets.available()
99
+ cfg = stt.get("config") or {}
100
+ auth_x += (cfg.get("auth") or {}).get("extra_patterns") or []
101
+ secret_x += (cfg.get("secret") or {}).get("extra_patterns") or []
102
+ return {"applied": list(dict.fromkeys(applied or presets.available())),
103
+ "auth_patterns": list(dict.fromkeys(auth_x)), "secret_patterns": list(dict.fromkeys(secret_x))}
104
+
105
+
106
+ class AuthMatcher:
107
+ """Classifies guard names: project patterns (.cg.yaml auth.extra_patterns, --auth-pattern; regexes on the raw
108
+ name), then the applied presets' not_auth and guards names, then the common token pattern."""
109
+
110
+ def __init__(self, extra: str | None = None, applied: list[str] | None = None, auth_patterns=(), secret_patterns=()):
111
+ self.applied = list(applied or presets.available())
112
+ self.names = presets.guard_names(self.applied, "auth")
113
+ self.not_auth = presets.guard_names(self.applied, "auth", "not_auth")
114
+ self.secret_names = presets.guard_names(self.applied, "secret")
115
+ self.base = re.compile(AUTH_PATTERN)
116
+ pats = [*auth_patterns, *([extra] if extra else [])]
117
+ self.project = [re.compile(p, re.I) for p in pats]
118
+ self.secret_project = [re.compile(p, re.I) for p in secret_patterns]
119
+ self.extra = re.compile(extra, re.I) if extra else None
120
+ self.pattern = AUTH_PATTERN + "".join(f" | /{p}/i" for p in pats)
121
+
122
+ def why(self, name: str) -> str | None:
123
+ """'project pattern', 'preset <name>', 'name pattern', or None (not auth)."""
124
+ if any(rx.search(name or "") for rx in self.project):
125
+ return "project pattern"
126
+ if self.not_auth.contains(name):
127
+ return None
128
+ hit = self.names.lookup(name)
129
+ if hit:
130
+ return f"preset {hit[1]}"
131
+ return "name pattern" if self.base.search(tokens(name)) else None
132
+
133
+ def __call__(self, name: str) -> bool:
134
+ return self.why(name) is not None
135
+
136
+ def secret(self, name: str) -> bool:
137
+ return bool(SECRET_RE.search(tokens(name)) or self.secret_names.lookup(name)
138
+ or any(rx.search(name or "") for rx in self.secret_project))
139
+
140
+
141
+ def route_guards(attrs: dict, mw_edges: list[dict], is_auth) -> list[dict]:
142
+ """Every guard-like fact recorded on a route, deduplicated by name, each with kind, source and an auth flag."""
143
+ out, seen = [], set()
144
+
145
+ def add(name, kind, source, display=None, auth=None, checks=None):
146
+ k = (name or "").lower()
147
+ if not name or k in seen:
148
+ return
149
+ seen.add(k)
150
+ why = (is_auth.why(name) if hasattr(is_auth, "why") else ("name pattern" if is_auth(name) else None)) \
151
+ if auth is None else ("framework" if auth else None)
152
+ if why is None and checks and checks.get("effect") == "rejects":
153
+ # a FastAPI dependency whose source rejects the request (HTTPException 401 / 403, a security scheme,
154
+ # or a nested dependency that does), whatever its name
155
+ how = f"security scheme {checks['scheme']}" if checks.get("scheme") else \
156
+ f"via {checks['rejects_via']}" if checks.get("rejects_via") and not checks.get("rejects") else \
157
+ "raises " + "/".join(str(x) for x in checks.get("rejects", []))
158
+ if checks.get("checked_in") and not checks.get("scheme"):
159
+ how += f" in {checks['checked_in'][0]}" # a helper the dependency / middleware calls (#59)
160
+ why = f"dependency check ({how})"
161
+ g = {"name": display or name, "kind": kind, "source": source, "auth": why is not None,
162
+ "secret": is_auth.secret(name) if hasattr(is_auth, "secret") else bool(SECRET_RE.search(tokens(name)))}
163
+ if why:
164
+ g["auth_by"] = why
165
+ if checks:
166
+ g["checks"] = checks
167
+ out.append(g)
168
+ if attrs.get("broadcast_auth"):
169
+ add("channel callbacks", "broadcast-auth", "Laravel BroadcastController: authenticated user + Broadcast::channel callback",
170
+ auth=True)
171
+ for key, kind in GUARD_ATTRS:
172
+ v = attrs.get(key)
173
+ for x in (v if isinstance(v, list) else [v]):
174
+ if isinstance(x, dict) and x.get("checks"):
175
+ add(_names(x)[0] if _names(x) else None, kind, f"attrs.{key}", checks=x["checks"])
176
+ for n in _names(v):
177
+ add(n, kind, f"attrs.{key}")
178
+ for c in attrs.get("conditions") or []:
179
+ if isinstance(c, str) and c.startswith("wrapped:"):
180
+ add(c.split(":", 1)[1], "wrapper", "urlconf wrapper")
181
+ for e in mw_edges:
182
+ a = json.loads(e.get("attrs") or "{}")
183
+ nm = a.get("name") or Q.short_id(e["dst"])
184
+ add(nm, "middleware", f"USES_MIDDLEWARE {e['dst']}", display=Q.short_id(e["dst"]) if "#" in e["dst"] else nm)
185
+ return out
186
+
187
+
188
+ def _hop(p: dict) -> str:
189
+ f, _, ln = (p.get("at") or "?").rpartition(":")
190
+ c = "" if p["confidence"] == "exact" else f"~{p['confidence'][0]}"
191
+ return f"{p['kind']}@{os.path.basename(f)}:{ln}{c}"
192
+
193
+
194
+ def fmt_chain(path: list[dict], limit=8) -> str:
195
+ if not path:
196
+ return "(direct)"
197
+ hops = [_hop(p) for p in path[:limit]]
198
+ more = f" …+{len(path) - limit}" if len(path) > limit else ""
199
+ return " → ".join(hops) + more + f" → {Q.short_id(path[-1]['to'])}"
200
+
201
+
202
+ def _route_loc(n: dict, attrs: dict) -> str:
203
+ f = n.get("file") or "?"
204
+ repo = attrs.get("repo")
205
+ return f"{repo}/{f}:{n.get('line')}" if repo and not f.startswith(repo + "/") else f"{f}:{n.get('line')}"
206
+
207
+
208
+ def _clients(st: GraphStore, rid: str, limit=3) -> list[str]:
209
+ out = []
210
+ for m in st.q("SELECT src FROM edges WHERE kind='MATCHES_ROUTE' AND dst=?", (rid,)):
211
+ for h in st.q("SELECT src, file, line FROM edges WHERE kind='HTTP_CALLS' AND dst=?", (m["src"],)):
212
+ out.append(f"{Q.short_id(h['src'])} @{os.path.basename(h['file'] or '?')}:{h['line']}")
213
+ return sorted(set(out))[:limit]
214
+
215
+
216
+ def _groups_for_writes(st: GraphStore, table: str | None) -> dict[str, dict]:
217
+ """table -> {writer node -> first write edge}."""
218
+ if table and table not in ("*", "any", "all"):
219
+ t = table.split(":", 1)[1] if table.startswith("table:") else table
220
+ rows = st.q("""SELECT src, kind, dst, file, line, confidence FROM edges
221
+ WHERE (kind='WRITES_TABLE' AND dst=?) OR (kind='WRITES_COLUMN' AND dst LIKE ?) ORDER BY file, line""",
222
+ (f"table:{t}", f"column:{t}.%"))
223
+ else:
224
+ rows = st.q("SELECT src, kind, dst, file, line, confidence FROM edges WHERE kind IN ('WRITES_TABLE','WRITES_COLUMN') ORDER BY file, line")
225
+ groups: dict[str, dict] = defaultdict(dict)
226
+ for r in rows:
227
+ t = r["dst"].split(":", 1)[1].split(".")[0]
228
+ groups[t].setdefault(r["src"], dict(r))
229
+ return groups
230
+
231
+
232
+ def routes_report(st: GraphStore, writes: str | None = None, reaches: list[str] | None = None, missing: str | None = None,
233
+ unguarded: bool = False, auth_pattern: str | None = None, min_conf: str = "heuristic",
234
+ gate: str | None = "auto", platform: str | None = None, deadline: float | None = None) -> dict:
235
+ """deadline: a time.time() value; past it the traversal stops with query.Deadline (starters' time budget)."""
236
+ gs = guard_setup(st)
237
+ is_auth = AuthMatcher(auth_pattern, gs["applied"], gs["auth_patterns"], gs["secret_patterns"])
238
+ if gate == "auto":
239
+ gate = Q.default_gate(st)
240
+ routes = {r["id"]: dict(r) for r in st.q("SELECT id, name, file, line, module, attrs FROM nodes WHERE kind='route'")}
241
+ if platform:
242
+ from .platforms import exclusions
243
+ xn = exclusions(st, platform)["nodes"]
244
+ routes = {k: v for k, v in routes.items() if k not in xn} # routes not registered on that target
245
+ mw = defaultdict(list)
246
+ for e in st.q("SELECT src, dst, attrs FROM edges WHERE kind='USES_MIDDLEWARE' AND src LIKE 'route:%'"):
247
+ mw[e["src"]].append(dict(e))
248
+ # what each route reaches: group label -> (targets, extra final hop per target)
249
+ groups: dict[str, tuple[list[str], dict]] = {}
250
+ mode, unresolved = "all", []
251
+ if writes:
252
+ mode = "writes"
253
+ for t, ws in _groups_for_writes(st, writes).items():
254
+ groups[f"writes {t}"] = (list(ws), ws)
255
+ if reaches:
256
+ mode = "reaches" if not writes else "writes+reaches"
257
+ for spec in reaches:
258
+ ids = Q.resolve_targets(st, spec)
259
+ if not ids:
260
+ unresolved.append(spec)
261
+ groups[f"reaches {spec}"] = (ids, {})
262
+ reached: dict[str, list[dict]] = defaultdict(list)
263
+ labels = [k for k, (targets, _) in groups.items() if targets]
264
+ if labels and routes:
265
+ # one walk for all groups, restricted to what the routes reach: scales with the routes' reach, not with
266
+ # routes x write targets (one reverse closure per table)
267
+ gc = Q.GroupClosures(st, [groups[k][0] for k in labels], kinds=PROPAGATING, min_conf=min_conf,
268
+ exclude_gate=gate, platform=platform, within=list(routes), deadline=deadline)
269
+ pairs = [(rid, i) for rid in routes for i in range(len(labels)) if gc.groups_of(rid) >> i & 1]
270
+ pairs.sort(key=lambda x: x[1])
271
+ pe = {pr: gc.path_edges(*pr) for pr in pairs}
272
+ hops = gc.hops([e for v in pe.values() for e in v])
273
+ for rid, i in pairs:
274
+ label, final = labels[i], groups[labels[i]][1]
275
+ p = [hops[e] for e in pe[(rid, i)]]
276
+ end = p[-1]["to"] if p else rid
277
+ w = final.get(end)
278
+ if w:
279
+ p.append({"from": w["src"], "kind": w["kind"], "to": w["dst"], "at": f"{w['file']}:{w['line']}", "confidence": w["confidence"]})
280
+ reached[rid].append({"what": label, "via": Q.short_id(w["src"]) if w else None, "depth": gc.depth(rid, i), "path": p, "path_confidence": Q.path_confidence(p),
281
+ "gated_only": bool(gate) and not gc.reached(rid, i, live=True)})
282
+ items = []
283
+ for rid, n in routes.items():
284
+ if mode != "all" and rid not in reached:
285
+ continue
286
+ a = json.loads(n.get("attrs") or "{}")
287
+ g = route_guards(a, mw.get(rid, []), is_auth)
288
+ it = {"route": rid, "name": n["name"], "at": _route_loc(n, a), "framework": a.get("framework"), "guards": g,
289
+ "has_auth": any(x["auth"] for x in g), "secret_checked": any(x.get("secret") for x in g),
290
+ "reaches": sorted(reached.get(rid, []), key=lambda x: x["what"]),
291
+ "clients": _clients(st, rid)}
292
+ items.append(it)
293
+ total = len(items)
294
+ flt = []
295
+ if missing:
296
+ m = missing.lower()
297
+ items = [i for i in items if not any(m in x["name"].lower() for x in i["guards"])]
298
+ flt.append(f"missing a guard matching '{missing}'")
299
+ if unguarded:
300
+ secret = [i for i in items if not i["has_auth"] and i["secret_checked"]]
301
+ items = [i for i in items if not i["has_auth"] and not i["secret_checked"]]
302
+ flt.append("no auth guard" + (f" ({len(secret)} secret-checked route(s) left out: "
303
+ f"{', '.join(i['name'] for i in secret[:4])}{' …' if len(secret) > 4 else ''})" if secret else ""))
304
+ items.sort(key=lambda i: (i["has_auth"], i["secret_checked"], i["at"], i["name"]))
305
+ below = []
306
+ if mode != "all" and min_conf != "heuristic":
307
+ # a stricter confidence level silently drops routes whose only chain has a resolved / heuristic hop: name them
308
+ mine = {i["route"] for i in items}
309
+ loose = routes_report(st, writes=writes, reaches=reaches, missing=missing, unguarded=unguarded,
310
+ auth_pattern=auth_pattern, min_conf="heuristic", gate=gate, platform=platform)
311
+ below = [i["name"] for i in loose["items"] if i["route"] not in mine]
312
+ return {"below_confidence": below, "mode": mode, "writes": writes, "reaches": reaches or [], "unresolved": unresolved, "filters": flt,
313
+ "total_routes": len(routes), "matched": total, "items": items, "auth_pattern": is_auth.pattern, "gate": gate, "guard_presets": is_auth.applied,
314
+ "write_tables": sorted({k.split(" ", 1)[1] for k in groups if k.startswith("writes ")}),
315
+ "min_confidence": min_conf, **({"platform": _pinfo(st, platform)} if platform else {})}
316
+
317
+
318
+ def _pinfo(st: GraphStore, platform: str) -> dict:
319
+ from .platforms import filter_info
320
+ return filter_info(st, platform)
321
+
322
+
323
+ def explain_empty(st: GraphStore, res: dict) -> str:
324
+ """Why a routes query came back empty, and what to run instead."""
325
+ if not res["total_routes"]:
326
+ pages = st.q("SELECT lang, count(*) c FROM nodes WHERE kind='page' GROUP BY lang ORDER BY c DESC")
327
+ if pages: # a mobile / desktop app: screens and navigations, not HTTP routes (#75)
328
+ nav = st.q("SELECT count(*) c FROM edges WHERE kind='NAVIGATES_TO'")[0]["c"]
329
+ via = {"swift": "SwiftUI WindowGroup / NavigationLink / navigationDestination", "dart": "Flutter routes",
330
+ "kotlin": "Compose navigation", "ts": "file-based or router pages"}
331
+ kinds = "; ".join(f"{r['c']} {via.get(r['lang'], r['lang'] or 'app')}" for r in pages)
332
+ first = (st.q("SELECT n.id FROM nodes n WHERE n.kind='page' AND EXISTS (SELECT 1 FROM edges e WHERE e.dst=n.id AND "
333
+ "e.kind='NAVIGATES_TO') ORDER BY n.file, n.line LIMIT 1")
334
+ or st.q("SELECT id FROM nodes WHERE kind='page' ORDER BY file, line LIMIT 1"))[0]["id"]
335
+ np = sum(r['c'] for r in pages)
336
+ return (f"`routes` lists server-side HTTP routes, and this graph has none. It has {np} app "
337
+ f"screen{'s' if np != 1 else ''} ({kinds}) and {nav} navigation edge{'s' if nav != 1 else ''} to them: `search '' --kind page` "
338
+ f"lists the screens, `downstream {first}` follows one, `node {first}` shows what navigates to it.")
339
+ return ("no route nodes in this graph (the indexed project has no HTTP routes, or its framework is not detected; "
340
+ "`stats` shows the node kinds). Try `reaches` / `impact` from the entry points that do exist.")
341
+ if res["unresolved"]:
342
+ return f"no node matches {', '.join(map(repr, res['unresolved']))}; try `search` to find the exact name."
343
+ if res["mode"].startswith("writes") and not res["write_tables"]:
344
+ t = res["writes"]
345
+ if t and t not in ("*", "any", "all"):
346
+ base = t.split(":", 1)[-1]
347
+ near = [r["id"][6:] for r in st.q("SELECT id FROM nodes WHERE kind='table' AND id LIKE ? ORDER BY id LIMIT 6", (f"%{base}%",))]
348
+ if not near:
349
+ near = [r["id"][6:] for r in st.q("SELECT id FROM nodes WHERE kind='table' ORDER BY id LIMIT 12")]
350
+ reads = st.q("SELECT count(*) c FROM edges WHERE kind IN ('READS_TABLE','READS_COLUMN') AND (dst=? OR dst LIKE ?)",
351
+ (f"table:{base}", f"column:{base}.%"))[0]["c"]
352
+ if not st.q("SELECT 1 FROM nodes WHERE id=?", (f"table:{base}",)):
353
+ return (f"no table '{base}' in the graph" + (f"; tables: {', '.join(near)}" if near else "") +
354
+ ". Table names are the DB names (e.g. Django `app_model`); `search` with kind=table lists them.")
355
+ return (f"table '{base}' has no recorded writers ({reads} read edges). Writes through raw SQL strings, bulk "
356
+ f"helpers or admin form saves may not be modelled; try `reaches(['table:{base}'])` to see every dependent.")
357
+ return "no write edges (WRITES_TABLE / WRITES_COLUMN) in this graph; try `reaches` with a table or column instead."
358
+ if res["matched"] and res["filters"]:
359
+ return (f"{res['matched']} route(s) match before filtering, and every one of them has a guard that satisfies the filter "
360
+ f"({'; '.join(res['filters'])}). Drop the filter to see their guards.")
361
+ if res["mode"] != "all":
362
+ what = ", ".join(res["write_tables"]) if res["mode"].startswith("writes") else ", ".join(res["reaches"])
363
+ return (f"no route reaches {what}: the code involved is reached only from other entry points (commands, jobs, "
364
+ f"listeners, pages) or not at all. `reaches` on the same target shows which entry kinds do reach it.")
365
+ return "no routes match."
366
+
367
+
368
+ def render_routes(res: dict, st: GraphStore | None = None, max_items: int = 60, paths: bool = True, compact: bool = False) -> str:
369
+ what = {"all": "all routes", "writes": "routes reaching a write" + (f" to {res['writes']}" if res["writes"] not in ("*", "any", "all", None) else " (any table)"),
370
+ "reaches": f"routes reaching {', '.join(res['reaches'])}",
371
+ "writes+reaches": f"routes reaching a write to {res['writes']} and {', '.join(res['reaches'])}"}[res["mode"]]
372
+ items = res["items"]
373
+ comp = route_completeness(st) if st is not None else {"complete": True}
374
+ res["completeness"] = comp
375
+ more = possibly_more(comp)
376
+ if not more:
377
+ head = f"{what}: {res['matched']} of {res['total_routes']} routes"
378
+ elif res["mode"] == "all":
379
+ head = f"{what}: {res['matched']} indexed (possibly more: {more})"
380
+ else:
381
+ head = f"{what}: {res['matched']} of {res['total_routes']} indexed routes (possibly more: {more})"
382
+ if res["filters"]:
383
+ head += f" | filter: {'; '.join(res['filters'])} -> {len(items)}"
384
+ na = sum(1 for i in items if not i["has_auth"] and not i.get("secret_checked"))
385
+ ns = sum(1 for i in items if not i["has_auth"] and i.get("secret_checked"))
386
+ out = [head, f"auth guard: {len(items) - na - ns} with, {na} without" + (f", {ns} secret-checked (signature / shared secret, no user auth)" if ns else "")
387
+ + " (auth = a framework preset auth guard or a name matching the auth pattern)"]
388
+ if res.get("platform"):
389
+ from .platforms import render_filter
390
+ out.insert(0, render_filter(res["platform"]))
391
+ by = defaultdict(int)
392
+ for i in items:
393
+ for g in i["guards"]:
394
+ if g.get("auth_by"):
395
+ by[g["auth_by"]] += 1
396
+ if any(k.startswith(("preset", "project")) for k in by):
397
+ out.append("auth guards by source: " + ", ".join(f"{k} {v}" for k, v in sorted(by.items(), key=lambda kv: (-kv[1], kv[0]))))
398
+ if res.get("below_confidence"):
399
+ b = res["below_confidence"]
400
+ out.append(f"+{len(b)} more route(s) match only through lower-confidence edges, hidden by "
401
+ f"min_confidence={res['min_confidence']}: {', '.join(b[:5])}{' …' if len(b) > 5 else ''}; "
402
+ f"min_confidence=heuristic (the default) includes them, each with its confidence label")
403
+ if not items:
404
+ out.append("")
405
+ out.append(explain_empty(st, res) if st is not None else "no routes match")
406
+ if more:
407
+ out.append(answer_note(comp))
408
+ return "\n".join(out)
409
+ for i in items[:max_items]:
410
+ gs = ", ".join(f"{g['name']}{' [auth]' if g['auth'] else (' [secret]' if g.get('secret') else '')}" for g in i["guards"]) or "(none)"
411
+ flag = "" if i["has_auth"] else (" SECRET-CHECKED" if i.get("secret_checked") else " NO AUTH")
412
+ out.append("")
413
+ out.append(f"{i['name']} @{i['at']}{flag}")
414
+ out.append(f" guards: {gs}")
415
+ for r in i["reaches"][:6 if not compact else 3]:
416
+ g = " [gated-only]" if r["gated_only"] else ""
417
+ line = f" {r['what']}{' via ' + r['via'] if r.get('via') else ''}{g} conf={r['path_confidence']}"
418
+ if paths:
419
+ line += " " + fmt_chain(r["path"])
420
+ out.append(line)
421
+ if len(i["reaches"]) > (6 if not compact else 3):
422
+ out.append(f" … +{len(i['reaches']) - (6 if not compact else 3)} more")
423
+ if i["clients"]:
424
+ out.append(f" called from: {'; '.join(i['clients'])}")
425
+ if len(items) > max_items:
426
+ out.append(f"\n… +{len(items) - max_items} more routes (raise max_items)")
427
+ out.append("")
428
+ if compact:
429
+ out.append("guards: route-level and global enhancers per framework; Laravel kernel middleware and Django's MIDDLEWARE setting "
430
+ "apply to every route and are not repeated per route.")
431
+ else:
432
+ out.append(f"guards come from route-level facts ({GUARD_SOURCES}); Laravel kernel middleware and Django's MIDDLEWARE "
433
+ f"setting apply to every route and are not repeated per route.")
434
+ if more:
435
+ out.append(answer_note(comp))
436
+ return "\n".join(out)
437
+
438
+
439
+ def route_completeness(st: GraphStore) -> dict:
440
+ """Completeness of a route list: route blind spots and files not indexed in every language of the index
441
+ (unsupported languages are listed by `coverage`, not here)."""
442
+ return completeness_for(st, categories=("route",), whole=True, unsupported=False)
codegraph/starters.py ADDED
@@ -0,0 +1,218 @@
1
+ """Starter queries derived from the indexed graph, so the visual view and MCP offer useful first questions on any
2
+ repository: write routes without an auth guard, the most-reached tables / connections / env keys, the page with the
3
+ largest backend reach and the most-called functions. Every starter is checked to resolve to existing nodes.
4
+
5
+ Each starter: {id, label, mode, specs[, sinks], why, cli, mcp} with mode one of reaches / impact / downstream (the
6
+ visual view's modes); `cli` and `mcp` are the matching command and tool call."""
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import sqlite3
11
+ import time
12
+ from collections import deque
13
+
14
+ from . import query as Q
15
+ from .core.model import PROPAGATING
16
+ from .core.store import GraphStore
17
+
18
+ READS = ("READS_TABLE", "READS_COLUMN")
19
+ WRITES = ("WRITES_TABLE", "WRITES_COLUMN")
20
+ CODE_KINDS = ("method", "function")
21
+
22
+
23
+ def _label(nid: str) -> str:
24
+ return Q.short_id(nid)
25
+
26
+
27
+ def _table_of(dst: str) -> str:
28
+ return "table:" + dst.split(":", 1)[1].split(".")[0]
29
+
30
+
31
+ def _top_tables(st: GraphStore, kinds: tuple[str, ...], n: int = 2) -> list[tuple[str, int]]:
32
+ rows = st.q(f"SELECT src, dst FROM edges WHERE kind IN ({','.join('?' * len(kinds))})", kinds)
33
+ users: dict[str, set] = {}
34
+ for r in rows:
35
+ users.setdefault(_table_of(r["dst"]), set()).add(r["src"])
36
+ ranked = sorted(((t, len(s)) for t, s in users.items()), key=lambda x: (-x[1], x[0]))
37
+ return [(t, c) for t, c in ranked if st.q("SELECT 1 FROM nodes WHERE id=?", (t,))][:n]
38
+
39
+
40
+ def _top_by_dst(st: GraphStore, kind: str, prefix: str) -> tuple[str, int] | None:
41
+ r = st.q("SELECT dst, count(DISTINCT src) c FROM edges WHERE kind=? AND dst LIKE ? GROUP BY dst ORDER BY c DESC, dst LIMIT 1",
42
+ (kind, prefix + "%"))
43
+ return (r[0]["dst"], r[0]["c"]) if r and r[0]["c"] else None
44
+
45
+
46
+ def _most_called(st: GraphStore, n: int = 2) -> list[tuple[str, int]]:
47
+ rows = st.q("""SELECT e.dst, count(DISTINCT e.src) c FROM edges e JOIN nodes d ON d.id = e.dst
48
+ WHERE e.kind='CALLS' AND d.kind IN ('method','function') AND coalesce(json_extract(d.attrs,'$.placeholder'),0)=0
49
+ GROUP BY e.dst ORDER BY c DESC, e.dst LIMIT ?""", (n,))
50
+ return [(r["dst"], r["c"]) for r in rows if r["c"] > 1]
51
+
52
+
53
+ def _deepest_page(st: GraphStore, cap: int = 4000, deadline: float | None = None) -> tuple[str, int, int] | None:
54
+ """The page whose forward closure reaches the most routes / tables (then nodes): (page id, sinks, nodes). A page
55
+ that reaches no route or table is not offered."""
56
+ pages = [r["id"] for r in st.q("SELECT id FROM nodes WHERE kind='page' ORDER BY id LIMIT 400")]
57
+ if not pages:
58
+ return None
59
+ prop = set(PROPAGATING)
60
+ fwd: dict[str, list[str]] = {}
61
+ for e in st.q("SELECT src, dst, kind FROM edges"):
62
+ if e["kind"] in prop:
63
+ fwd.setdefault(e["src"], []).append(e["dst"])
64
+ best = None
65
+ for p in pages:
66
+ if deadline is not None and time.time() > deadline:
67
+ raise Q.Deadline()
68
+ seen, dq = {p}, deque([p])
69
+ while dq and len(seen) < cap:
70
+ for y in fwd.get(dq.popleft(), ()):
71
+ if y not in seen:
72
+ seen.add(y)
73
+ dq.append(y)
74
+ sinks = sum(1 for x in seen if x.startswith(("route:", "table:", "column:", "http:")))
75
+ key = (sinks, len(seen))
76
+ if len(seen) > 1 and (best is None or key > best[1:]):
77
+ best = (p, *key)
78
+ return best
79
+
80
+
81
+ def _unguarded_writes(st: GraphStore, deadline: float | None = None) -> tuple[str, int, int] | None:
82
+ """(route id, tables it writes, unguarded write routes) for the unguarded route writing the most tables."""
83
+ from .routes import routes_report
84
+ res = routes_report(st, writes="*", unguarded=True, deadline=deadline)
85
+ if not res["items"]:
86
+ return None
87
+ def nt(i):
88
+ return len({r["what"] for r in i["reaches"]})
89
+ top = max(res["items"], key=lambda i: (nt(i), i["name"]))
90
+ return top["route"], nt(top), len(res["items"])
91
+
92
+
93
+ def _within(st: GraphStore, deadline: float, fn):
94
+ """fn() with SQLite queries and traversals stopped at `deadline` (query.Deadline)."""
95
+ st.db.set_progress_handler(lambda: 1 if time.time() > deadline else 0, 20000)
96
+ try:
97
+ return fn()
98
+ except sqlite3.OperationalError as ex:
99
+ if "interrupt" in str(ex) and time.time() > deadline:
100
+ raise Q.Deadline() from ex
101
+ raise
102
+ finally:
103
+ st.db.set_progress_handler(None, 0)
104
+
105
+
106
+ def generate(st: GraphStore, budget_s: float = 20.0, report: dict | None = None) -> list[dict]:
107
+ """Starter queries for this graph, each verified to resolve to existing nodes. The whole run stays within
108
+ `budget_s`: a starter that is not done by then is skipped and listed in report["skipped"] (stats.starters_skipped)."""
109
+ t0 = time.time()
110
+ deadline = t0 + budget_s
111
+ out: list[dict] = []
112
+ skipped: list[str] = []
113
+ if report is not None:
114
+ report["skipped"] = skipped
115
+
116
+ def add(sid, label, mode, specs, why, cli, mcp, sinks=None):
117
+ if not all(Q.resolve_targets(st, s) for s in specs):
118
+ return
119
+ e = {"id": sid, "label": label, "mode": mode, "specs": specs, "why": why, "cli": cli, "mcp": mcp, "starter": True}
120
+ if sinks:
121
+ e["sinks"] = sinks
122
+ out.append(e)
123
+
124
+ def step(sid, fn):
125
+ """Run one starter within the remaining budget; True when it ran to the end."""
126
+ if time.time() > deadline:
127
+ skipped.append(sid)
128
+ return False
129
+ n = len(out)
130
+ try:
131
+ _within(st, deadline, fn)
132
+ return True
133
+ except Q.Deadline:
134
+ del out[n:]
135
+ skipped.append(sid)
136
+ return False
137
+
138
+ n_routes = st.q("SELECT count(*) c FROM nodes WHERE kind='route'")[0]["c"]
139
+ has_writes = bool(st.q("SELECT 1 FROM edges WHERE kind IN ('WRITES_TABLE','WRITES_COLUMN') LIMIT 1"))
140
+
141
+ def unguarded():
142
+ u = _unguarded_writes(st, deadline)
143
+ if u:
144
+ rid, nt, total = u
145
+ add("starter_unguarded_write", f"write route without an auth guard: {_label(rid)} -> what it writes "
146
+ f"({total} unguarded write route{'s' if total != 1 else ''})", "downstream", [rid],
147
+ f"{total} route(s) reach a DB write and have no auth-like guard; this one writes {nt} table(s)",
148
+ "cg routes --writes --unguarded --db DB", "routes(writes='*', unguarded=true)", sinks=["table", "column"])
149
+
150
+ def most_written():
151
+ for t, c in _top_tables(st, WRITES, 1):
152
+ add("starter_most_written_table", f"who writes {t[6:]} (most-written table, {c} writer{'s' if c != 1 else ''})", "reaches", [t],
153
+ f"{c} functions write it", f"cg writers {t[6:]} --db DB", f"writers('{t[6:]}')")
154
+
155
+ def most_read():
156
+ for t, c in _top_tables(st, READS, 1):
157
+ if not any(s["specs"] == [t] for s in out):
158
+ add("starter_most_read_table", f"what depends on {t[6:]} (most-read table, {c} reader{'s' if c != 1 else ''})", "reaches", [t],
159
+ f"{c} functions read it", f"cg reaches {t} --db DB", f"reaches(['{t}'])")
160
+
161
+ def top_connection():
162
+ con = _top_by_dst(st, "USES_CONNECTION", "connection:")
163
+ if con:
164
+ add("starter_top_connection", f"what reaches {con[0]} (most-used DB connection)", "reaches", [con[0]],
165
+ f"{con[1]} code paths use it", f"cg reaches {con[0]} --db DB", f"reaches(['{con[0]}'])")
166
+
167
+ def top_env():
168
+ env = _top_by_dst(st, "READS_ENV", "env:")
169
+ if env:
170
+ add("starter_top_env", f"what depends on {env[0]} (most-read env key)", "reaches", [env[0]],
171
+ f"read in {env[1]} places", f"cg reaches {env[0]} --db DB", f"reaches(['{env[0]}'])")
172
+
173
+ def deepest_page():
174
+ pg = _deepest_page(st, deadline=deadline)
175
+ if pg and pg[1]:
176
+ add("starter_deepest_page", f"{_label(pg[0])} -> backend (the page with the largest reach: {pg[1]} routes / tables)",
177
+ "downstream", [pg[0]], f"its forward closure has {pg[2]} nodes", f"cg downstream '{pg[0]}' --db DB",
178
+ f"downstream('{pg[0]}')", sinks=["route", "table", "column"])
179
+
180
+ def most_called():
181
+ for i, (fn, c) in enumerate(_most_called(st, 2)):
182
+ add(f"starter_most_called_{i + 1}", f"impact of {_label(fn)} ({c} direct callers)", "impact", [fn],
183
+ f"called from {c} functions", f"cg impact '{fn}' --db DB", f"impact('{fn}')")
184
+
185
+ if n_routes and has_writes:
186
+ step("starter_unguarded_write", unguarded)
187
+ step("starter_most_written_table", most_written)
188
+ step("starter_most_read_table", most_read)
189
+ step("starter_top_connection", top_connection)
190
+ step("starter_top_env", top_env)
191
+ step("starter_deepest_page", deepest_page)
192
+ step("starter_most_called", most_called)
193
+ return out
194
+
195
+
196
+ def for_graph(st: GraphStore) -> list[dict]:
197
+ """Starters recorded at index time (stats.starters; a combined graph: per repo, prefixed), else generated now."""
198
+ try:
199
+ m = st.meta()
200
+ except Exception: # noqa: BLE001
201
+ return []
202
+ s = (m.get("stats") or {}).get("starters")
203
+ if isinstance(s, list) and not m.get("repos"):
204
+ return s
205
+ return generate(st)
206
+
207
+
208
+ def render(starters: list[dict]) -> str:
209
+ if not starters:
210
+ return "no starter queries: the graph has no routes, tables, connections, env keys, pages or shared functions yet"
211
+ out = [f"starter queries ({len(starters)}), derived from this graph:"]
212
+ for s in starters:
213
+ out.append(f"- {s['label']}\n MCP: {s['mcp']} CLI: {s['cli']}")
214
+ return "\n".join(out)
215
+
216
+
217
+ def dumps(starters: list[dict]) -> str:
218
+ return json.dumps(starters, indent=1)