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/tests_index.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Keep test code out of the application graph.
|
|
2
|
+
|
|
3
|
+
Language / framework plugins index test code (PHPUnit / Pest under tests/, Vitest / Jest / Playwright spec files)
|
|
4
|
+
with `attrs.test = True` on every node it declares, plus `test` nodes (one per test case). This pass retypes every
|
|
5
|
+
edge that touches test code into a non-propagating TEST_* kind, keeping the original kind in attrs.orig:
|
|
6
|
+
|
|
7
|
+
* call-like edges (CALLS, DISPATCHES, ROUTES_TO, ...) -> TEST_CALLS
|
|
8
|
+
* everything else that propagates or references (tables, config, INSTANTIATES, REFERENCES, IMPORTS, ...)
|
|
9
|
+
-> TEST_USES
|
|
10
|
+
* EXTENDS / IMPLEMENTS / USES_TRAIT from test code to the app (fakes, test doubles) -> TEST_USES
|
|
11
|
+
* CONTAINS and structure inside test code stay as they are; TEST_HTTP / TEST_VISITS are already test kinds.
|
|
12
|
+
|
|
13
|
+
So callers, impact, reaches, entry tagging and `unguarded` never see tests, and an application method called only
|
|
14
|
+
from tests still counts as uncalled. `cg tests <symbol|route>` (query.tests_covering) walks the TEST_* edges.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
|
|
20
|
+
from .core.model import EDGE_KINDS, TEST_EDGE_KINDS
|
|
21
|
+
|
|
22
|
+
CALL_KINDS = {"CALLS", "IMPLEMENTED_BY", "OVERRIDDEN_BY", "BOUND_TO", "ROUTES_TO", "USES_MIDDLEWARE", "HANDLED_BY",
|
|
23
|
+
"SCHEDULES", "DISPATCHES", "LISTENED_BY", "RENDERS", "USES_COMPOSABLE", "USES_STORE", "HTTP_CALLS",
|
|
24
|
+
"REFERENCES_FN", "SUBSCRIBES_CHANNEL", "NAVIGATES_TO", "INSTANTIATES", "SENDS_TO", "RECEIVED_BY"}
|
|
25
|
+
KEEP = {"CONTAINS", *TEST_EDGE_KINDS}
|
|
26
|
+
STRUCTURAL = {"EXTENDS", "IMPLEMENTS", "USES_TRAIT"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def is_test_node(n) -> bool:
|
|
30
|
+
return n.kind == "test" or bool((n.attrs or {}).get("test"))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def page_pattern(route: str) -> str:
|
|
34
|
+
"""Frontend page route (`/shelves/:id`, `/docs/:slug(.*)*`, `/[id]`) -> path template with {params}."""
|
|
35
|
+
r = re.sub(r":(\w+)\(\.\*\)\*", r"{\1*?}", route)
|
|
36
|
+
r = re.sub(r":(\w+)\?", r"{\1?}", r)
|
|
37
|
+
r = re.sub(r":(\w+)", r"{\1}", r)
|
|
38
|
+
r = re.sub(r"\[\.\.\.(\w+)\]", r"{\1*}", r)
|
|
39
|
+
return re.sub(r"\[(\w+)\]", r"{\1}", r)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def resolve_visits(builder) -> int:
|
|
43
|
+
"""page.goto('/x') / cy.visit('/x') facts (left by the TS plugin) -> TEST_VISITS edges to the matching page."""
|
|
44
|
+
from .link import match_path
|
|
45
|
+
visits = getattr(builder, "pending_visits", None) or []
|
|
46
|
+
pages = [(nid, page_pattern(n.attrs["route"])) for nid, n in builder.nodes.items()
|
|
47
|
+
if n.kind == "page" and isinstance((n.attrs or {}).get("route"), str)]
|
|
48
|
+
n = 0
|
|
49
|
+
for v in visits:
|
|
50
|
+
url = re.sub(r"^[a-z]+://[^/]*|^\{[^{}/]*\}(?=/)", "", v["url"]).split("?")[0].split("#")[0] or "/"
|
|
51
|
+
if not url.startswith("/"):
|
|
52
|
+
url = "/" + url
|
|
53
|
+
hits = []
|
|
54
|
+
for nid, pat in pages:
|
|
55
|
+
ok, info = match_path(url, pat)
|
|
56
|
+
if ok and (info["lit"] or pat == url):
|
|
57
|
+
hits.append((info["lit"], -info["ph_into_lit"], nid))
|
|
58
|
+
if url == "/":
|
|
59
|
+
hits = [(1, 0, nid) for nid, pat in pages if pat == "/"]
|
|
60
|
+
if not hits:
|
|
61
|
+
continue
|
|
62
|
+
best = max(h[:2] for h in hits)
|
|
63
|
+
top = [h[2] for h in hits if h[:2] == best]
|
|
64
|
+
for nid in top:
|
|
65
|
+
builder.add_edge(v["src"], nid, "TEST_VISITS", v["file"], v["line"], "exact" if len(top) == 1 else "heuristic",
|
|
66
|
+
url=v["url"], via=v.get("via"))
|
|
67
|
+
n += 1
|
|
68
|
+
builder.pending_visits = []
|
|
69
|
+
return n
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def isolate_tests(builder) -> dict:
|
|
73
|
+
"""Retype every edge that touches test code into its TEST_* kind (one pass, garbage collector paused)."""
|
|
74
|
+
from .core.plugin import gc_paused
|
|
75
|
+
with gc_paused():
|
|
76
|
+
return _isolate_tests(builder)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _isolate_tests(builder) -> dict:
|
|
80
|
+
visits = resolve_visits(builder)
|
|
81
|
+
tests = {nid for nid, n in builder.nodes.items() if is_test_node(n)}
|
|
82
|
+
st = {"test_nodes": sum(1 for n in builder.nodes.values() if n.kind == "test"), "test_code_nodes": len(tests),
|
|
83
|
+
"edges_retyped": 0, "page_visits": visits}
|
|
84
|
+
if not tests:
|
|
85
|
+
return st
|
|
86
|
+
for key, e in list(builder.edges.items()):
|
|
87
|
+
if e.kind in KEEP or e.kind not in EDGE_KINDS:
|
|
88
|
+
continue
|
|
89
|
+
s_test, d_test = e.src in tests, e.dst in tests
|
|
90
|
+
if not (s_test or d_test):
|
|
91
|
+
continue
|
|
92
|
+
if e.kind in STRUCTURAL and (s_test == d_test):
|
|
93
|
+
continue # test class extends test base class: structure inside the test suite
|
|
94
|
+
new = "TEST_CALLS" if e.kind in CALL_KINDS else "TEST_USES"
|
|
95
|
+
builder.retype_edge(key, new, orig=e.kind)
|
|
96
|
+
st["edges_retyped"] += 1
|
|
97
|
+
return st
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def link_local_channels(builder) -> dict:
|
|
101
|
+
"""Backend channels and Echo / pusher-js subscriptions in the same checkout (a Laravel app with resources/js):
|
|
102
|
+
match them here, as cg link does across two graphs."""
|
|
103
|
+
from collections import defaultdict
|
|
104
|
+
from .link import channel_links
|
|
105
|
+
subs = [(nid, n.attrs or {}) for nid, n in builder.nodes.items() if n.kind == "channel_sub"]
|
|
106
|
+
chans = [(nid, n.attrs or {}, n.file, n.line) for nid, n in builder.nodes.items() if n.kind == "channel"]
|
|
107
|
+
if not subs or not chans:
|
|
108
|
+
return {}
|
|
109
|
+
events = {nid: n.attrs for nid, n in builder.nodes.items() if n.kind == "event" and (n.attrs or {}).get("broadcast")}
|
|
110
|
+
on = defaultdict(set)
|
|
111
|
+
for e in builder.edges.values():
|
|
112
|
+
if e.kind == "BROADCASTS_ON":
|
|
113
|
+
on[e.dst].add(e.src)
|
|
114
|
+
rows, st = channel_links(chans, subs, events, on)
|
|
115
|
+
for s, d, k, c, a, f, l in rows:
|
|
116
|
+
builder.add_edge(s, d, k, f, l, c, **a)
|
|
117
|
+
return st
|
|
File without changes
|
codegraph/viz/graph.py
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""Subgraph extraction for the visual view. Everything here is a projection of deterministic query results
|
|
2
|
+
(codegraph.query): a subgraph is the union of the evidence paths those queries already return, so every edge drawn
|
|
3
|
+
has a file:line and a confidence from the index."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .. import query as Q
|
|
10
|
+
from ..core.model import PROPAGATING
|
|
11
|
+
from ..core.store import GraphStore
|
|
12
|
+
|
|
13
|
+
NODE_COLS = "id,kind,name,fqn,file,line,module,entry_kind,lang,doc,attrs"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def repo_of(n: dict) -> str:
|
|
17
|
+
try:
|
|
18
|
+
r = json.loads(n.get("attrs") or "{}").get("repo")
|
|
19
|
+
except (TypeError, ValueError):
|
|
20
|
+
r = None
|
|
21
|
+
if r:
|
|
22
|
+
return r
|
|
23
|
+
f = n.get("file") or ""
|
|
24
|
+
return f.split("/", 1)[0] if "/" in f else ""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def side_of(n: dict) -> str:
|
|
28
|
+
"""'fe' for frontend (TS/Vue) nodes, 'be' otherwise; used only for box colours."""
|
|
29
|
+
return "fe" if (n.get("lang") in ("ts", "vue", "js") or n.get("kind") in ("http", "page", "component", "composable", "store", "layout")) else "be"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def group_of(n: dict) -> tuple[str, str]:
|
|
33
|
+
"""(group id, label): code is grouped by module; columns/tables by table; the rest by kind."""
|
|
34
|
+
k, repo = n["kind"], repo_of(n)
|
|
35
|
+
if k in ("column", "table"):
|
|
36
|
+
t = n["id"].split(":", 1)[1].split(".")[0]
|
|
37
|
+
return f"table:{t}", f"table {t}"
|
|
38
|
+
if k == "http":
|
|
39
|
+
return f"{repo or 'frontend'}:http", f"{repo or 'frontend'} · HTTP calls"
|
|
40
|
+
if k in ("setting", "request_key", "config", "env", "connection"):
|
|
41
|
+
return f"{repo}:{k}", f"{repo} · {k}s"
|
|
42
|
+
mod = n.get("module")
|
|
43
|
+
if k == "route":
|
|
44
|
+
mod = "routes · " + (mod or "?")
|
|
45
|
+
if not mod:
|
|
46
|
+
mod = k
|
|
47
|
+
return f"{repo}:{mod}", f"{repo} · {mod}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_nodes(st: GraphStore, ids) -> dict[str, dict]:
|
|
51
|
+
ids, out = list(ids), {}
|
|
52
|
+
for i in range(0, len(ids), 500):
|
|
53
|
+
chunk = ids[i:i + 500]
|
|
54
|
+
for r in st.q(f"SELECT {NODE_COLS} FROM nodes WHERE id IN ({','.join('?' * len(chunk))})", chunk):
|
|
55
|
+
out[r["id"]] = dict(r)
|
|
56
|
+
return out
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _edge_key(h):
|
|
60
|
+
return (h["from"], h["to"], h["kind"])
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def build(st: GraphStore, mode: str, specs: list[str], min_conf="heuristic", sinks: list[str] | None = None,
|
|
64
|
+
max_nodes=1500) -> dict:
|
|
65
|
+
"""mode: reaches | impact | downstream | path. Returns {nodes, edges, groups, meta}."""
|
|
66
|
+
gate = Q.default_gate(st)
|
|
67
|
+
info: dict[str, dict] = {}
|
|
68
|
+
hops: dict[tuple, dict] = {}
|
|
69
|
+
targets: list[str] = []
|
|
70
|
+
|
|
71
|
+
def add_path(path):
|
|
72
|
+
for h in path:
|
|
73
|
+
hops.setdefault(_edge_key(h), h)
|
|
74
|
+
info.setdefault(h["from"], {}); info.setdefault(h["to"], {})
|
|
75
|
+
|
|
76
|
+
if mode == "reaches":
|
|
77
|
+
r = Q.reaches(st, specs, min_conf=min_conf)
|
|
78
|
+
for spec, t in r["targets"].items():
|
|
79
|
+
targets += t
|
|
80
|
+
for it in r["items"]:
|
|
81
|
+
info[it["id"]] = {"depth": it["depth"], "class": it["class"], "gate_status": it.get("gate_status", "live"),
|
|
82
|
+
"entry_kinds": it.get("entry_kinds") or {}, "live_entry_kinds": it.get("live_entry_kinds"),
|
|
83
|
+
"path_confidence": it["path_confidence"], "gate_evidence": it.get("gate_evidence")}
|
|
84
|
+
add_path(it["path"])
|
|
85
|
+
elif mode == "impact":
|
|
86
|
+
for s in specs:
|
|
87
|
+
targets += Q.resolve_targets(st, s)
|
|
88
|
+
depth = Q.reverse_closure(st, targets, kinds=Q.CALL_LIKE, min_conf=min_conf)
|
|
89
|
+
paths = Q.shortest_paths(st, depth, kinds=Q.CALL_LIKE, min_conf=min_conf)
|
|
90
|
+
ents = Q.entry_info(st, list(depth))
|
|
91
|
+
for nid, d in depth.items():
|
|
92
|
+
info[nid] = {"depth": d, "entry_kinds": {k: v[0] for k, v in ents.get(nid, {}).items()}}
|
|
93
|
+
add_path(paths.get(nid, []))
|
|
94
|
+
elif mode == "downstream":
|
|
95
|
+
kinds = tuple(sinks) if sinks else Q.DOWNSTREAM_SINKS
|
|
96
|
+
for s in specs:
|
|
97
|
+
r = Q.downstream(st, s, min_conf=min_conf, sinks=kinds)
|
|
98
|
+
targets += r["targets"]
|
|
99
|
+
for k, items in r["sinks"].items():
|
|
100
|
+
for it in items:
|
|
101
|
+
info.setdefault(it["id"], {}).update({"depth": it["depth"], "sink": True, "live": it["live"],
|
|
102
|
+
"path_confidence": it["path_confidence"]})
|
|
103
|
+
add_path(it["path"])
|
|
104
|
+
for t in r["targets"]:
|
|
105
|
+
info.setdefault(t, {"depth": 0})
|
|
106
|
+
elif mode == "path":
|
|
107
|
+
if len(specs) < 2:
|
|
108
|
+
raise ValueError("path needs a source and a target (optionally waypoints in between: src, via…, dst)")
|
|
109
|
+
p, cur = [], specs[0]
|
|
110
|
+
for nxt in specs[1:]: # shortest forward path per segment; a waypoint pins which route/method the path takes
|
|
111
|
+
seg = Q.path_between(st, cur, nxt, min_conf=min_conf) # a table target falls back to its columns
|
|
112
|
+
if not seg:
|
|
113
|
+
p = []
|
|
114
|
+
break
|
|
115
|
+
p += seg
|
|
116
|
+
cur = seg[-1]["to"]
|
|
117
|
+
targets = [p[-1]["to"]] if p else []
|
|
118
|
+
if p:
|
|
119
|
+
info[p[0]["from"]] = {"depth": 0}
|
|
120
|
+
add_path(p)
|
|
121
|
+
else:
|
|
122
|
+
raise ValueError(f"unknown mode {mode!r}")
|
|
123
|
+
for t in targets:
|
|
124
|
+
info.setdefault(t, {}).update({"is_target": True, "depth": info.get(t, {}).get("depth", 0)})
|
|
125
|
+
|
|
126
|
+
truncated = False
|
|
127
|
+
if len(info) > max_nodes: # keep the closest nodes (paths stay connected: a path's prefix is always closer)
|
|
128
|
+
keep = set(sorted(info, key=lambda n: (info[n].get("depth", 99), n))[:max_nodes])
|
|
129
|
+
info = {k: v for k, v in info.items() if k in keep}
|
|
130
|
+
hops = {k: v for k, v in hops.items() if k[0] in keep and k[1] in keep}
|
|
131
|
+
truncated = True
|
|
132
|
+
rows = load_nodes(st, info)
|
|
133
|
+
nodes, groups = [], {}
|
|
134
|
+
for nid, extra in info.items():
|
|
135
|
+
n = rows.get(nid, {"id": nid, "kind": nid.split(":", 1)[0], "name": nid})
|
|
136
|
+
gid, glabel = group_of(n)
|
|
137
|
+
g = groups.setdefault(gid, {"id": gid, "label": glabel, "repo": repo_of(n), "side": side_of(n), "count": 0, "kinds": {}, "targets": 0})
|
|
138
|
+
g["count"] += 1
|
|
139
|
+
g["kinds"][n["kind"]] = g["kinds"].get(n["kind"], 0) + 1
|
|
140
|
+
g["targets"] += bool(extra.get("is_target"))
|
|
141
|
+
nodes.append({"id": nid, "kind": n["kind"], "name": n.get("name") or nid, "fqn": n.get("fqn"), "file": n.get("file"),
|
|
142
|
+
"line": n.get("line"), "module": n.get("module"), "entry_kind": n.get("entry_kind"), "repo": repo_of(n),
|
|
143
|
+
"group": gid, "has_doc": bool(n.get("doc")), **extra})
|
|
144
|
+
edges = [{"src": h["from"], "dst": h["to"], "kind": h["kind"], "confidence": h["confidence"], "at": h.get("at"),
|
|
145
|
+
"gated": h.get("gated"), "guard": h.get("guard")} for h in hops.values()]
|
|
146
|
+
return {"nodes": nodes, "edges": edges, "groups": list(groups.values()),
|
|
147
|
+
"meta": {"mode": mode, "specs": specs, "min_confidence": min_conf, "gate": gate, "targets": targets,
|
|
148
|
+
"truncated": truncated, "sinks": sinks}}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class Sources:
|
|
152
|
+
"""Read-only source snippets from the indexed repo roots (recorded in each source DB's meta)."""
|
|
153
|
+
|
|
154
|
+
def __init__(self, st: GraphStore, extra: dict[str, str] | None = None):
|
|
155
|
+
self.roots: dict[str, Path] = {}
|
|
156
|
+
m = st.meta()
|
|
157
|
+
for repo, dbp in (m.get("sources") or {}).items():
|
|
158
|
+
try:
|
|
159
|
+
root = GraphStore(dbp).meta().get("root")
|
|
160
|
+
except Exception: # noqa: BLE001
|
|
161
|
+
root = None
|
|
162
|
+
if root:
|
|
163
|
+
self.roots[repo] = Path(root)
|
|
164
|
+
if m.get("root"):
|
|
165
|
+
self.roots.setdefault(m.get("project") or "", Path(m["root"]))
|
|
166
|
+
for k, v in (extra or {}).items():
|
|
167
|
+
self.roots[k] = Path(v)
|
|
168
|
+
|
|
169
|
+
def snippet(self, file: str | None, line, ctx=4) -> dict | None:
|
|
170
|
+
if not file or not line:
|
|
171
|
+
return None
|
|
172
|
+
repo, _, rel = file.partition("/")
|
|
173
|
+
root = self.roots.get(repo)
|
|
174
|
+
p = (root / rel) if root else None
|
|
175
|
+
if p is None or not p.is_file():
|
|
176
|
+
for r in self.roots.values(): # single-repo DB: file is repo-relative
|
|
177
|
+
if (r / file).is_file():
|
|
178
|
+
p = r / file
|
|
179
|
+
break
|
|
180
|
+
else:
|
|
181
|
+
return None
|
|
182
|
+
try:
|
|
183
|
+
lines = p.read_text(errors="replace").splitlines()
|
|
184
|
+
except OSError:
|
|
185
|
+
return None
|
|
186
|
+
ln = int(line)
|
|
187
|
+
a, b = max(1, ln - ctx), min(len(lines), ln + ctx)
|
|
188
|
+
# abs: for the panel's "open in editor" link (served view only; viz-export drops it)
|
|
189
|
+
return {"file": file, "line": ln, "start": a, "lines": lines[a - 1:b], "abs": str(p.resolve())}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def node_detail(st: GraphStore, src: Sources, nid: str, limit=25) -> dict | None:
|
|
193
|
+
rows = load_nodes(st, [nid])
|
|
194
|
+
if nid not in rows:
|
|
195
|
+
return None
|
|
196
|
+
n = rows[nid]
|
|
197
|
+
n["attrs"] = json.loads(n.get("attrs") or "{}")
|
|
198
|
+
ents = Q.entry_info(st, [nid]).get(nid, {})
|
|
199
|
+
gate = Q.default_gate(st)
|
|
200
|
+
live = Q.entry_info(st, [nid], gate=gate).get(nid, {}) if gate else None
|
|
201
|
+
|
|
202
|
+
def edges(direction):
|
|
203
|
+
col, other = ("src", "dst") if direction == "out" else ("dst", "src")
|
|
204
|
+
rs = st.q(f"SELECT src,dst,kind,file,line,confidence,gate,attrs FROM edges WHERE {col}=? ORDER BY conf_rank DESC, kind LIMIT ?",
|
|
205
|
+
(nid, limit + 1))
|
|
206
|
+
out = []
|
|
207
|
+
for e in rs[:limit]:
|
|
208
|
+
a = json.loads(e["attrs"] or "{}")
|
|
209
|
+
out.append({"other": e[other], "kind": e["kind"], "at": f"{e['file']}:{e['line']}", "confidence": e["confidence"],
|
|
210
|
+
"gated": e["gate"], "propagates": e["kind"] in PROPAGATING,
|
|
211
|
+
"attrs": {k: v for k, v in a.items() if k in ("guard", "via", "default", "arg_keys", "flow", "order", "rule", "owner", "target")}})
|
|
212
|
+
cnt = st.q(f"SELECT COUNT(*) c FROM edges WHERE {col}=?", (nid,))[0]["c"]
|
|
213
|
+
return out, cnt
|
|
214
|
+
|
|
215
|
+
out_e, n_out = edges("out")
|
|
216
|
+
in_e, n_in = edges("in")
|
|
217
|
+
return {**n, "entry_kinds": {k: v[0] for k, v in ents.items()}, "entry_samples": {k: v[1] for k, v in ents.items()},
|
|
218
|
+
"live_entry_kinds": {k: v[0] for k, v in live.items()} if live is not None else None, "gate": gate,
|
|
219
|
+
"snippet": src.snippet(n.get("file"), n.get("line")), "out_edges": out_e, "in_edges": in_e,
|
|
220
|
+
"out_count": n_out, "in_count": n_in}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def search(st: GraphStore, q: str, kind: str | None = None, limit=30, fuzzy: bool = False) -> list[dict]:
|
|
224
|
+
"""Substring match on id / name / fqn; `fuzzy` adds names holding the query's characters in order (`mcget` ->
|
|
225
|
+
MastodonClient.get), ranked after the substring hits."""
|
|
226
|
+
like = f"%{q}%"
|
|
227
|
+
cols = "id,kind,name,fqn,file,line,module,entry_kind"
|
|
228
|
+
sql = (f"SELECT {cols} FROM nodes WHERE (id LIKE ? OR name LIKE ? OR fqn LIKE ?)"
|
|
229
|
+
+ (" AND kind=?" if kind else "") +
|
|
230
|
+
" ORDER BY (name = ?) DESC, (kind IN ('page','route','method','table','column','connection')) DESC, length(id) LIMIT ?")
|
|
231
|
+
args = [like, like, like] + ([kind] if kind else []) + [q, limit]
|
|
232
|
+
out = [dict(r) for r in st.q(sql, args)]
|
|
233
|
+
chars = [c for c in q if not c.isspace()]
|
|
234
|
+
if fuzzy and len(out) < limit and len(chars) >= 3:
|
|
235
|
+
pat = "%" + "%".join(c.replace("%", "").replace("_", "") for c in chars) + "%"
|
|
236
|
+
have = {r["id"] for r in out}
|
|
237
|
+
rows = st.q(f"SELECT {cols} FROM nodes WHERE (name LIKE ? OR fqn LIKE ?)" + (" AND kind=?" if kind else "") +
|
|
238
|
+
" ORDER BY length(coalesce(fqn, name)), id LIMIT ?", [pat, pat] + ([kind] if kind else []) + [limit * 3])
|
|
239
|
+
out += [dict(r) for r in rows if r["id"] not in have][:limit - len(out)]
|
|
240
|
+
return out
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def stats(st: GraphStore) -> dict:
|
|
244
|
+
"""Landing-page overview: node / edge counts by kind, entry points by kind, edges by confidence."""
|
|
245
|
+
m = st.meta()
|
|
246
|
+
nk = {r["kind"]: r["c"] for r in st.q("SELECT kind, COUNT(*) c FROM nodes GROUP BY kind ORDER BY c DESC, kind")}
|
|
247
|
+
ek = {r["kind"]: r["c"] for r in st.q("SELECT kind, COUNT(*) c FROM edges GROUP BY kind ORDER BY c DESC, kind")}
|
|
248
|
+
ent = {r["entry_kind"]: r["c"] for r in st.q(
|
|
249
|
+
"SELECT entry_kind, COUNT(*) c FROM nodes WHERE entry_kind IS NOT NULL AND entry_kind != '' GROUP BY entry_kind ORDER BY c DESC, entry_kind")}
|
|
250
|
+
conf = {r["confidence"]: r["c"] for r in st.q("SELECT confidence, COUNT(*) c FROM edges GROUP BY confidence")}
|
|
251
|
+
return {"project": m.get("project"), "indexed_at": m.get("indexed_at"), "nodes": sum(nk.values()), "edges": sum(ek.values()),
|
|
252
|
+
"node_kinds": nk, "edge_kinds": ek, "entry_points": sum(ent.values()), "entry_kinds": ent,
|
|
253
|
+
"confidence": {k: conf.get(k, 0) for k in ("exact", "resolved", "heuristic")}}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
PSEUDO = {"client": ("ext:clients", "external clients (snapshot)"), "issue": ("ext:issues", "filed issues (snapshot)"),
|
|
257
|
+
"file": None}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _fold_col(nid: str) -> str:
|
|
261
|
+
"""Columns are drawn as their table in the plan overlay (except planned ones)."""
|
|
262
|
+
if nid.startswith("column:"):
|
|
263
|
+
return "table:" + nid[7:].split(".")[0]
|
|
264
|
+
return nid
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def build_plan(st: GraphStore, plan_name: str, plans_root=None, verify: bool = False, include_review: bool = True) -> dict:
|
|
268
|
+
"""Plan overlay: the planned change drawn on top of the real graph. Every real edge keeps its file:line and
|
|
269
|
+
confidence; planned edges, forbidden paths and check relations are marked (`plan`: added | forbidden | gap |
|
|
270
|
+
touches) so they can never be mistaken for indexed code edges."""
|
|
271
|
+
from .. import plans as P
|
|
272
|
+
plan = P.load_plan(plan_name, plans_root)
|
|
273
|
+
res = P.check(st, plan, verify=verify, baseline=P.load_baseline(plan) if verify else None)
|
|
274
|
+
info: dict[str, dict] = {}
|
|
275
|
+
edges: dict[tuple, dict] = {}
|
|
276
|
+
|
|
277
|
+
rank = {"added": 6, "modified": 5, "missing": 4, "forbidden": 3, "review": 2, "covered": 1, "context": 0}
|
|
278
|
+
|
|
279
|
+
def node(nid, **kw):
|
|
280
|
+
cur = info.setdefault(nid, {})
|
|
281
|
+
if kw.get("plan_check"):
|
|
282
|
+
cur.setdefault("plan_items", []).append(f"[{kw.get('plan_role')}] {kw['plan_check']}: {kw.get('plan_why')}")
|
|
283
|
+
if "plan_role" in kw and rank.get(kw["plan_role"], 0) < rank.get(cur.get("plan_role"), -1):
|
|
284
|
+
return # a stronger role (e.g. missing over review) already describes this node
|
|
285
|
+
cur.update(kw)
|
|
286
|
+
|
|
287
|
+
def edge(src, dst, kind, at=None, confidence="exact", plan=None, why=None):
|
|
288
|
+
src, dst = (_fold_col(src) if src not in planned else src), (_fold_col(dst) if dst not in planned else dst)
|
|
289
|
+
if src == dst:
|
|
290
|
+
return
|
|
291
|
+
node(src, plan_role="context"); node(dst, plan_role="context")
|
|
292
|
+
k = (src, dst, kind)
|
|
293
|
+
if k not in edges:
|
|
294
|
+
edges[k] = {"src": src, "dst": dst, "kind": kind, "confidence": confidence, "at": at, "plan": plan, "why": why}
|
|
295
|
+
|
|
296
|
+
planned = {it["id"] for it in plan.get("add_nodes") or []}
|
|
297
|
+
for it in plan.get("add_nodes") or []:
|
|
298
|
+
node(it["id"], plan_role="added", plan_why="planned new node", plan_attrs=it.get("attrs"))
|
|
299
|
+
if it["id"].startswith("column:"):
|
|
300
|
+
edge("table:" + it["id"][7:].split(".")[0], it["id"], "CONTAINS", plan="added", why="planned column")
|
|
301
|
+
for it in plan.get("modify") or []:
|
|
302
|
+
for nid in P.Ctx(st, plan).resolve(it["target"])["ids"]:
|
|
303
|
+
node(nid, plan_role="modified", plan_why=it["intent"], plan_guard=it.get("role") == "guard")
|
|
304
|
+
cx = P.Ctx(st, plan)
|
|
305
|
+
for it in plan.get("add_edges") or []:
|
|
306
|
+
for s in cx.resolve(it["from"])["ids"]:
|
|
307
|
+
for d in cx.resolve(it["to"])["ids"]:
|
|
308
|
+
ev = P.edge_evidence(cx, it)
|
|
309
|
+
edge(s, d, it["kind"], at=", ".join(ev) or None, plan="added", why=it.get("intent") or "planned edge")
|
|
310
|
+
for c in res["conflicts"]:
|
|
311
|
+
for h in c.get("hops") or []:
|
|
312
|
+
edge(h["from"], h["to"], h["kind"], h.get("at"), h.get("confidence", "exact"), plan="forbidden", why=f"forbidden: {c['id']}")
|
|
313
|
+
if c.get("hops"):
|
|
314
|
+
node(_fold_col(c["hops"][-1]["to"]), plan_role="forbidden", plan_why=f"forbidden target ({c['id']}): {c.get('when') or ''}")
|
|
315
|
+
for r in res.get("require") or []:
|
|
316
|
+
node(r["route"], plan_role="covered", plan_why=f"require {', '.join(r['required'])}: {'OK' if not r['missing'] else 'MISSING'}")
|
|
317
|
+
for i in res["items"]:
|
|
318
|
+
if i["severity"] == "review" and not include_review and not i["covered"]:
|
|
319
|
+
continue
|
|
320
|
+
role = "covered" if i["covered"] else i["severity"]
|
|
321
|
+
nid = i["node"]
|
|
322
|
+
node(nid, plan_role=role, plan_check=i["check"], plan_why=i["why"], plan_evidence=i["evidence"])
|
|
323
|
+
if i.get("hops"):
|
|
324
|
+
for h in i["hops"]:
|
|
325
|
+
edge(h["from"], h["to"], h["kind"], h.get("at"), h.get("confidence", "exact"))
|
|
326
|
+
elif i.get("anchor"):
|
|
327
|
+
edge(nid, i["anchor"], "PLAN_" + i["check"].upper(), ", ".join(i["evidence"][:2]), "heuristic", plan="gap", why=i["why"])
|
|
328
|
+
for f in res["findings"]:
|
|
329
|
+
if not f["touches"] or f["state"] != "open":
|
|
330
|
+
continue
|
|
331
|
+
iid = f"issue:{f['id']}"
|
|
332
|
+
node(iid, plan_role="context", plan_why=f"{f['title']} ({'linked in plan' if f['linked'] else 'NOT linked in plan'})", plan_url=f.get("url"),
|
|
333
|
+
plan_linked=f["linked"])
|
|
334
|
+
for nid, lines in f["touches"].items():
|
|
335
|
+
if nid in info:
|
|
336
|
+
edge(iid, nid, "TOUCHES", ",".join(lines), "exact", plan="touches", why=f["id"])
|
|
337
|
+
rows = load_nodes(st, [n for n in info if not n.split(":", 1)[0] in ("client", "issue", "file")])
|
|
338
|
+
nodes, groups = [], {}
|
|
339
|
+
for nid, extra in info.items():
|
|
340
|
+
k = nid.split(":", 1)[0]
|
|
341
|
+
n = rows.get(nid)
|
|
342
|
+
if n is None:
|
|
343
|
+
if k == "column" and nid in planned:
|
|
344
|
+
n = {"id": nid, "kind": "column", "name": nid[7:], "attrs": "{}"}
|
|
345
|
+
elif k == "table":
|
|
346
|
+
n = {"id": nid, "kind": "table", "name": nid[6:]}
|
|
347
|
+
else:
|
|
348
|
+
n = {"id": nid, "kind": k, "name": nid.split(":", 1)[1] if ":" in nid else nid,
|
|
349
|
+
"file": nid.split(":", 1)[1] if k == "file" else None}
|
|
350
|
+
if k == "client":
|
|
351
|
+
gid, glabel = PSEUDO["client"]
|
|
352
|
+
elif k == "issue":
|
|
353
|
+
gid, glabel = PSEUDO["issue"]
|
|
354
|
+
elif k == "file":
|
|
355
|
+
f = nid.split(":", 1)[1]
|
|
356
|
+
gid, glabel = f"{f.split('/', 1)[0]}:views", f"{f.split('/', 1)[0]} · views/templates (text-match)"
|
|
357
|
+
else:
|
|
358
|
+
gid, glabel = group_of(n)
|
|
359
|
+
g = groups.setdefault(gid, {"id": gid, "label": glabel, "repo": repo_of(n) if k not in ("client", "issue") else k, "side": side_of(n), "count": 0, "kinds": {},
|
|
360
|
+
"targets": 0, "plan": 0})
|
|
361
|
+
g["count"] += 1
|
|
362
|
+
g["kinds"][n["kind"]] = g["kinds"].get(n["kind"], 0) + 1
|
|
363
|
+
g["plan"] += extra.get("plan_role") in ("added", "modified", "missing", "forbidden")
|
|
364
|
+
nodes.append({"id": nid, "kind": n["kind"], "name": n.get("name") or nid, "fqn": n.get("fqn"), "file": n.get("file"), "line": n.get("line"),
|
|
365
|
+
"module": n.get("module"), "entry_kind": n.get("entry_kind"), "repo": repo_of(n), "group": gid, **extra})
|
|
366
|
+
return {"nodes": nodes, "edges": list(edges.values()), "groups": list(groups.values()),
|
|
367
|
+
"meta": {"mode": "plan", "specs": [plan_name], "plan": plan.get("name"), "title": plan.get("title"), "summary": res["summary"],
|
|
368
|
+
"verify": verify, "min_confidence": "heuristic", "gate": None, "targets": [], "truncated": False, "sinks": None},
|
|
369
|
+
"report": P.render_check(res, max_items=200)}
|