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/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)
|