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
@@ -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)}