cg-code-graph 0.10.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. cg_code_graph-0.10.1.dist-info/METADATA +678 -0
  2. cg_code_graph-0.10.1.dist-info/RECORD +174 -0
  3. cg_code_graph-0.10.1.dist-info/WHEEL +5 -0
  4. cg_code_graph-0.10.1.dist-info/entry_points.txt +3 -0
  5. cg_code_graph-0.10.1.dist-info/licenses/LICENSE +21 -0
  6. cg_code_graph-0.10.1.dist-info/top_level.txt +1 -0
  7. codegraph/__init__.py +2 -0
  8. codegraph/aitools.py +129 -0
  9. codegraph/apps.py +76 -0
  10. codegraph/blindspots.py +428 -0
  11. codegraph/bridges.py +1701 -0
  12. codegraph/cli.py +725 -0
  13. codegraph/concepts.py +362 -0
  14. codegraph/config.py +559 -0
  15. codegraph/core/__init__.py +0 -0
  16. codegraph/core/cache.py +375 -0
  17. codegraph/core/detect.py +80 -0
  18. codegraph/core/extractors.py +187 -0
  19. codegraph/core/fsutil.py +61 -0
  20. codegraph/core/generated.py +575 -0
  21. codegraph/core/model.py +174 -0
  22. codegraph/core/paths.py +175 -0
  23. codegraph/core/plugin.py +160 -0
  24. codegraph/core/store.py +80 -0
  25. codegraph/core/syntax_errors.py +132 -0
  26. codegraph/coverage.py +928 -0
  27. codegraph/doctor.py +453 -0
  28. codegraph/external.py +613 -0
  29. codegraph/indexer.py +336 -0
  30. codegraph/link.py +434 -0
  31. codegraph/lint_async.py +524 -0
  32. codegraph/mcp_server.py +1303 -0
  33. codegraph/parity.py +473 -0
  34. codegraph/parity_structure.py +307 -0
  35. codegraph/payload.py +321 -0
  36. codegraph/plans.py +1285 -0
  37. codegraph/platform_scan.py +643 -0
  38. codegraph/platforms.py +1369 -0
  39. codegraph/plugins/__init__.py +0 -0
  40. codegraph/plugins/cfamily/__init__.py +0 -0
  41. codegraph/plugins/cfamily/plugin.py +930 -0
  42. codegraph/plugins/cfamily/syntax.py +881 -0
  43. codegraph/plugins/dart/__init__.py +0 -0
  44. codegraph/plugins/dart/bridges.py +345 -0
  45. codegraph/plugins/dart/extractor/bin/extract.dart +717 -0
  46. codegraph/plugins/dart/extractor/pubspec.lock +149 -0
  47. codegraph/plugins/dart/extractor/pubspec.yaml +7 -0
  48. codegraph/plugins/dart/http.py +904 -0
  49. codegraph/plugins/dart/models.py +308 -0
  50. codegraph/plugins/dart/plugin.py +625 -0
  51. codegraph/plugins/dart/program.py +907 -0
  52. codegraph/plugins/django/__init__.py +0 -0
  53. codegraph/plugins/django/extras.py +378 -0
  54. codegraph/plugins/django/models.py +508 -0
  55. codegraph/plugins/django/plugin.py +728 -0
  56. codegraph/plugins/django/schemas.py +339 -0
  57. codegraph/plugins/django/shapes.py +216 -0
  58. codegraph/plugins/django/urls.py +603 -0
  59. codegraph/plugins/express/__init__.py +0 -0
  60. codegraph/plugins/express/plugin.py +428 -0
  61. codegraph/plugins/flutter/__init__.py +0 -0
  62. codegraph/plugins/flutter/plugin.py +538 -0
  63. codegraph/plugins/kotlin/__init__.py +0 -0
  64. codegraph/plugins/kotlin/exact.py +457 -0
  65. codegraph/plugins/kotlin/plugin.py +1961 -0
  66. codegraph/plugins/kotlin/reparse.py +234 -0
  67. codegraph/plugins/laravel/__init__.py +0 -0
  68. codegraph/plugins/laravel/broadcast.py +351 -0
  69. codegraph/plugins/laravel/plugin.py +863 -0
  70. codegraph/plugins/laravel/tests.py +262 -0
  71. codegraph/plugins/laravel/values.py +728 -0
  72. codegraph/plugins/native/__init__.py +0 -0
  73. codegraph/plugins/native/gates.py +286 -0
  74. codegraph/plugins/native/runner.py +183 -0
  75. codegraph/plugins/native/scipread.py +194 -0
  76. codegraph/plugins/native/ts.py +54 -0
  77. codegraph/plugins/nest/__init__.py +0 -0
  78. codegraph/plugins/nest/plugin.py +654 -0
  79. codegraph/plugins/nextjs/__init__.py +0 -0
  80. codegraph/plugins/nextjs/plugin.py +336 -0
  81. codegraph/plugins/nuxt/__init__.py +0 -0
  82. codegraph/plugins/nuxt/plugin.py +308 -0
  83. codegraph/plugins/php/__init__.py +0 -0
  84. codegraph/plugins/php/extractor/composer.json +5 -0
  85. codegraph/plugins/php/extractor/composer.lock +76 -0
  86. codegraph/plugins/php/extractor/extract.php +743 -0
  87. codegraph/plugins/php/gating.py +573 -0
  88. codegraph/plugins/php/plugin.py +668 -0
  89. codegraph/plugins/php/strings.py +197 -0
  90. codegraph/plugins/python/__init__.py +0 -0
  91. codegraph/plugins/python/aitools.py +664 -0
  92. codegraph/plugins/python/external.py +245 -0
  93. codegraph/plugins/python/fields.py +107 -0
  94. codegraph/plugins/python/plugin.py +1733 -0
  95. codegraph/plugins/python/refs.py +485 -0
  96. codegraph/plugins/python/roots.py +412 -0
  97. codegraph/plugins/python/socketio.py +210 -0
  98. codegraph/plugins/python/subproc.py +864 -0
  99. codegraph/plugins/python/tests.py +1040 -0
  100. codegraph/plugins/python/values.py +179 -0
  101. codegraph/plugins/pyweb/__init__.py +0 -0
  102. codegraph/plugins/pyweb/plugin.py +1334 -0
  103. codegraph/plugins/pyweb/values.py +68 -0
  104. codegraph/plugins/rust/__init__.py +0 -0
  105. codegraph/plugins/rust/cargo.py +226 -0
  106. codegraph/plugins/rust/plugin.py +980 -0
  107. codegraph/plugins/rust/syntax.py +678 -0
  108. codegraph/plugins/scip/__init__.py +0 -0
  109. codegraph/plugins/scip/importer.py +129 -0
  110. codegraph/plugins/scip/scip.proto +962 -0
  111. codegraph/plugins/scip/scip_pb2.py +97 -0
  112. codegraph/plugins/stubs/__init__.py +0 -0
  113. codegraph/plugins/stubs/plugins.py +38 -0
  114. codegraph/plugins/swift/__init__.py +0 -0
  115. codegraph/plugins/swift/baseurl.py +109 -0
  116. codegraph/plugins/swift/exact.py +415 -0
  117. codegraph/plugins/swift/indexstore.py +209 -0
  118. codegraph/plugins/swift/packages.py +174 -0
  119. codegraph/plugins/swift/plugin.py +2890 -0
  120. codegraph/plugins/ts/__init__.py +0 -0
  121. codegraph/plugins/ts/baseurl.py +185 -0
  122. codegraph/plugins/ts/extractor/extract.mjs +2652 -0
  123. codegraph/plugins/ts/extractor/fw.mjs +685 -0
  124. codegraph/plugins/ts/extractor/package-lock.json +205 -0
  125. codegraph/plugins/ts/extractor/package.json +9 -0
  126. codegraph/plugins/ts/plugin.py +480 -0
  127. codegraph/plugins/tsweb/__init__.py +0 -0
  128. codegraph/plugins/tsweb/common.py +290 -0
  129. codegraph/plugins/tsweb/data.py +276 -0
  130. codegraph/presets/__init__.py +146 -0
  131. codegraph/presets/c_cpp.yaml +9 -0
  132. codegraph/presets/common.yaml +66 -0
  133. codegraph/presets/dart.yaml +9 -0
  134. codegraph/presets/django-ninja.yaml +15 -0
  135. codegraph/presets/django.yaml +25 -0
  136. codegraph/presets/djangorestframework.yaml +17 -0
  137. codegraph/presets/express.yaml +17 -0
  138. codegraph/presets/kotlin.yaml +11 -0
  139. codegraph/presets/laravel.yaml +40 -0
  140. codegraph/presets/nest.yaml +11 -0
  141. codegraph/presets/nextjs.yaml +15 -0
  142. codegraph/presets/nuxt.yaml +9 -0
  143. codegraph/presets/php.yaml +5 -0
  144. codegraph/presets/python.yaml +10 -0
  145. codegraph/presets/rust.yaml +5 -0
  146. codegraph/presets/swift.yaml +10 -0
  147. codegraph/presets/typescript.yaml +13 -0
  148. codegraph/process_runs.py +328 -0
  149. codegraph/protocols/__init__.py +299 -0
  150. codegraph/protocols/builtin.py +67 -0
  151. codegraph/protocols/matchers.py +144 -0
  152. codegraph/protocols/view.py +334 -0
  153. codegraph/query.py +2089 -0
  154. codegraph/realtime.py +260 -0
  155. codegraph/roundtrip.py +346 -0
  156. codegraph/routes.py +442 -0
  157. codegraph/starters.py +218 -0
  158. codegraph/tests_index.py +117 -0
  159. codegraph/viz/__init__.py +0 -0
  160. codegraph/viz/graph.py +369 -0
  161. codegraph/viz/server.py +198 -0
  162. codegraph/viz/static/app.css +148 -0
  163. codegraph/viz/static/app.js +1082 -0
  164. codegraph/viz/static/index.html +81 -0
  165. codegraph/viz/static/layered.js +237 -0
  166. codegraph/viz/static/vendor/VERSIONS.txt +4 -0
  167. codegraph/viz/static/vendor/cose-base.js +3214 -0
  168. codegraph/viz/static/vendor/cytoscape-fcose.js +1549 -0
  169. codegraph/viz/static/vendor/cytoscape.min.js +31 -0
  170. codegraph/viz/static/vendor/layout-base.js +5230 -0
  171. codegraph/viz/tools/package-lock.json +303 -0
  172. codegraph/viz/tools/package.json +7 -0
  173. codegraph/viz/tools/shoot.mjs +165 -0
  174. codegraph/xcode.py +251 -0
codegraph/plans.py ADDED
@@ -0,0 +1,1285 @@
1
+ """Planned-change layer: an agreed scope recorded as a small versioned YAML file (plans/<name>.yaml) that is
2
+ overlaid on the real graph without mutating it, plus deterministic checks of that scope against the graph.
3
+
4
+ Nothing here guesses. Every finding is derived from indexed edges (file:line + confidence), from the plan file
5
+ itself, from read-only snapshot files next to the plan (filed issues, external client call sites), or from an
6
+ exact regex over the indexed source tree (labelled `text-match`, never turned into an edge).
7
+
8
+ plan mode (before implementation): references resolve? what does the plan miss? what conflicts?
9
+ verify mode (after implementation + re-index): do planned nodes/edges now exist, are forbidden paths gone or
10
+ guarded, did modified targets change since the baseline, are requirements met.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ import re
17
+ from collections import defaultdict
18
+ from pathlib import Path
19
+
20
+ from . import presets
21
+ from . import query as Q
22
+ from .core.model import PROPAGATING
23
+ from .core.store import GraphStore
24
+
25
+ PLAN_VERSION = 1
26
+ STATUSES = ("draft", "agreed", "in_progress", "implemented", "abandoned")
27
+ TOP_KEYS = {"plan_version", "name", "title", "status", "rationale", "issues", "context", "assumptions", "add_nodes",
28
+ "modify", "add_edges", "forbid", "require", "covers", "out_of_scope", "precedents", "notes"}
29
+ ITEM_KEYS = {
30
+ "add_nodes": ({"id"}, {"id", "name", "attrs", "rationale", "issues", "file", "near"}),
31
+ "modify": ({"target", "intent"}, {"target", "intent", "rationale", "issues", "role"}),
32
+ "add_edges": ({"from", "kind", "to"}, {"from", "kind", "to", "intent", "rationale", "issues"}),
33
+ "forbid": ({"id", "from", "to"}, {"id", "type", "from", "to", "edge_kind", "when", "rationale", "issues", "guard"}),
34
+ "require": ({"route"}, {"route", "middleware", "rationale", "issues"}),
35
+ "covers": ({"spec"}, {"spec", "note"}),
36
+ "out_of_scope": ({"spec", "reason"}, {"spec", "reason"}),
37
+ "precedents": ({"name", "pattern"}, {"name", "pattern", "within", "for"}),
38
+ }
39
+ NEW_NODE_KINDS = {"column", "table", "method", "function", "class", "route", "setting", "config", "env", "request_key",
40
+ "job", "command", "listener", "page", "component", "composable", "store", "property", "admin"}
41
+ CODE_KINDS = ("method", "function", "script", "composable", "store", "component", "module", "page")
42
+ WRITE_KINDS = ("WRITES_TABLE", "WRITES_COLUMN")
43
+ READ_KINDS = ("READS_TABLE", "READS_COLUMN", "MENTIONS_COLUMN")
44
+ # class-name prefixes shortened in reports (codegraph/presets/laravel.yaml plans.short_prefixes)
45
+ SHORT_PREFIXES = tuple(presets.values("laravel", "plans", "short_prefixes", default=[]))
46
+ # where text mentions are scanned when .cg.yaml has no plans.text_mention_dirs (codegraph/presets/laravel.yaml)
47
+ TEXT_MENTION_DIRS = tuple(presets.values("laravel", "plans", "text_mention_dirs", default=[]))
48
+ IMPLICIT_NEW = ("request_key", "setting", "config", "env") # leaf keys a planned edge may introduce without add_nodes
49
+ PAYLOAD_VERBS = re.compile(r"^(store|update|create|save|upsert|fill|import|sync|make|add|edit|set)", re.I)
50
+
51
+
52
+ # ----------------------------------------------------------------------------------------------- loading
53
+ class PlanError(ValueError):
54
+ pass
55
+
56
+
57
+ def plans_dir(root: str | Path | None = None) -> Path:
58
+ return Path(root) if root else Path(__file__).resolve().parents[1] / "plans"
59
+
60
+
61
+ def resolve_plans_dir(flag: str | None, db: str | None = None) -> str | None:
62
+ """--plans-dir, else plans.dir of the .cg.yaml recorded in the graph at `db`, else None (the default plans/)."""
63
+ if flag:
64
+ return flag
65
+ if db and Path(db).is_file():
66
+ from .config import configured_plans_dir
67
+ from .core.store import GraphStore
68
+ g = GraphStore(db)
69
+ try:
70
+ d = configured_plans_dir(g)
71
+ except Exception: # noqa: BLE001 (not a graph yet)
72
+ d = None
73
+ finally:
74
+ g.db.close()
75
+ return str(d) if d else None
76
+ return None
77
+
78
+
79
+ def mention_dirs(st, repo: str | None) -> tuple[str, ...]:
80
+ """plans.text_mention_dirs of that repo's .cg.yaml, else the preset default."""
81
+ from .config import graph_configs
82
+ for r, cfg, _ in graph_configs(st):
83
+ if r is None or r == repo: # single-repo graph, or this repo of a combined one
84
+ d = (cfg.get("plans") or {}).get("text_mention_dirs")
85
+ if d:
86
+ return tuple(d)
87
+ return TEXT_MENTION_DIRS
88
+
89
+
90
+ def find_plan(name_or_path: str, root: str | Path | None = None) -> Path:
91
+ p = Path(name_or_path)
92
+ if p.suffix in (".yaml", ".yml") and p.is_file():
93
+ return p
94
+ for cand in (plans_dir(root) / f"{name_or_path}.yaml", plans_dir(root) / f"{name_or_path}.yml"):
95
+ if cand.is_file():
96
+ return cand
97
+ raise PlanError(f"plan not found: {name_or_path} (looked in {plans_dir(root)})")
98
+
99
+
100
+ def list_plans(root: str | Path | None = None) -> list[dict]:
101
+ out = []
102
+ for p in sorted(plans_dir(root).glob("*.y*ml")):
103
+ try:
104
+ d = _yaml(p)
105
+ if "snapshot_version" in d and "plan_version" not in d:
106
+ continue # findings / clients snapshot next to the plans, not a plan
107
+ out.append({"name": d.get("name"), "title": d.get("title"), "status": d.get("status", "draft"),
108
+ "plan_version": d.get("plan_version"), "file": str(p), "issues": d.get("issues") or [],
109
+ "counts": {k: len(d.get(k) or []) for k in ("add_nodes", "modify", "add_edges", "forbid", "require")},
110
+ "schema_errors": len(validate_schema(d))})
111
+ except Exception as e: # noqa: BLE001
112
+ out.append({"name": p.stem, "file": str(p), "error": str(e)})
113
+ return out
114
+
115
+
116
+ def _yaml(path: Path) -> dict:
117
+ import yaml
118
+ try:
119
+ d = yaml.safe_load(path.read_text(encoding="utf-8"))
120
+ except yaml.YAMLError as e:
121
+ raise PlanError(f"{path.name}: invalid YAML: {e}") from None
122
+ if not isinstance(d, dict):
123
+ raise PlanError(f"{path}: top level must be a mapping")
124
+ return d
125
+
126
+
127
+ def validate_schema(d: dict) -> list[str]:
128
+ """Structural validation (no graph needed). Returns problems as 'path: message'."""
129
+ errs = []
130
+ if d.get("plan_version") != PLAN_VERSION:
131
+ errs.append(f"plan_version: must be {PLAN_VERSION} (got {d.get('plan_version')!r})")
132
+ if not isinstance(d.get("name"), str) or not re.fullmatch(r"[a-z0-9][a-z0-9._-]*", d.get("name") or ""):
133
+ errs.append("name: required, lowercase slug [a-z0-9._-]")
134
+ if not isinstance(d.get("title"), str) or not d.get("title"):
135
+ errs.append("title: required string")
136
+ if d.get("status", "draft") not in STATUSES:
137
+ errs.append(f"status: one of {STATUSES}")
138
+ for k in d:
139
+ if k not in TOP_KEYS and not str(k).startswith("_"):
140
+ errs.append(f"{k}: unknown key (allowed: {sorted(TOP_KEYS)})")
141
+ for k in ("issues", "assumptions"):
142
+ if k in d and not (isinstance(d[k], list) and all(isinstance(x, str) for x in d[k])):
143
+ errs.append(f"{k}: list of strings")
144
+ if not isinstance(d.get("context") or {}, dict):
145
+ errs.append("context: mapping {findings: path, clients: path|[paths]}")
146
+ for section, (req, allowed) in ITEM_KEYS.items():
147
+ items = d.get(section)
148
+ if items is None:
149
+ continue
150
+ if not isinstance(items, list):
151
+ errs.append(f"{section}: must be a list")
152
+ continue
153
+ for i, it in enumerate(items):
154
+ where = f"{section}[{i}]"
155
+ if not isinstance(it, dict):
156
+ keys = ", ".join(f"{r}: ..." for r in sorted(req))
157
+ errs.append(f"{where}: must be a mapping like {{{keys}}} (got {type(it).__name__} {str(it)[:60]!r})")
158
+ continue
159
+ for r in req:
160
+ if not it.get(r):
161
+ errs.append(f"{where}.{r}: required")
162
+ for k in it:
163
+ if k not in allowed:
164
+ errs.append(f"{where}.{k}: unknown key (allowed: {sorted(allowed)})")
165
+ if "issues" in it and not (isinstance(it["issues"], list) and all(isinstance(x, str) for x in it["issues"])):
166
+ errs.append(f"{where}.issues: list of strings")
167
+ if section == "add_nodes" and it.get("id"):
168
+ kind = str(it["id"]).split(":", 1)[0]
169
+ if ":" not in str(it["id"]) or kind not in NEW_NODE_KINDS:
170
+ errs.append(f"{where}.id: must be '<kind>:<key>' with kind in {sorted(NEW_NODE_KINDS)}")
171
+ elif kind == "column" and not re.fullmatch(r"column:\w+\.\w+", str(it["id"])):
172
+ errs.append(f"{where}.id: column ids are column:<table>.<column>")
173
+ if section == "add_edges" and it.get("kind") and not re.fullmatch(r"[A-Z][A-Z_]+", str(it["kind"])):
174
+ errs.append(f"{where}.kind: edge kinds are UPPER_SNAKE (e.g. READS_COLUMN)")
175
+ if section == "forbid":
176
+ if it.get("type", "path") not in ("path", "edge"):
177
+ errs.append(f"{where}.type: path | edge")
178
+ if it.get("type") == "edge" and not it.get("edge_kind"):
179
+ errs.append(f"{where}.edge_kind: required for type: edge")
180
+ g = it.get("guard")
181
+ if g is not None and not (isinstance(g, dict) and g.get("at") and (g.get("reads") or g.get("calls"))):
182
+ errs.append(f"{where}.guard: {{at: <spec>, reads|calls: <spec>}}")
183
+ if section == "modify" and it.get("role") not in (None, "guard"):
184
+ errs.append(f"{where}.role: only 'guard' (the access check every reader of the changed table should pass through)")
185
+ if section == "require" and "middleware" in it and not isinstance(it["middleware"], list):
186
+ errs.append(f"{where}.middleware: list of middleware names")
187
+ if section == "precedents":
188
+ try:
189
+ re.compile(it.get("pattern") or "")
190
+ except re.error as e:
191
+ errs.append(f"{where}.pattern: bad regex ({e})")
192
+ ids = [it.get("id") for it in d.get("add_nodes") or [] if isinstance(it, dict)]
193
+ dup = {x for x in ids if ids.count(x) > 1}
194
+ if dup:
195
+ errs.append(f"add_nodes: duplicate ids {sorted(dup)}")
196
+ fids = [it.get("id") for it in d.get("forbid") or [] if isinstance(it, dict)]
197
+ if len(set(fids)) != len(fids):
198
+ errs.append("forbid: ids must be unique")
199
+ return errs
200
+
201
+
202
+ def load_plan(name_or_path: str, root: str | Path | None = None) -> dict:
203
+ p = find_plan(name_or_path, root)
204
+ d = _yaml(p)
205
+ d["_file"] = str(p)
206
+ d["_schema_errors"] = validate_schema(d)
207
+ _drop_malformed(d)
208
+ ctx = d.get("context") or {}
209
+ if not isinstance(ctx, dict):
210
+ ctx = d["context"] = {}
211
+ base = p.parent
212
+ d["_findings"] = _load_findings(base / ctx["findings"]) if ctx.get("findings") else None
213
+ cl = ctx.get("clients") or []
214
+ d["_clients"] = [_yaml(base / c) for c in ([cl] if isinstance(cl, str) else cl)]
215
+ return d
216
+
217
+
218
+ def _drop_malformed(d: dict) -> None:
219
+ """Keep only well-formed section items (mappings with their required keys) so a typo in one item is reported as a
220
+ schema error instead of crashing load / validate / check; the schema errors still name every dropped item."""
221
+ for section, (req, _allowed) in ITEM_KEYS.items():
222
+ items = d.get(section)
223
+ if items is None:
224
+ continue
225
+ d[section] = [it for it in items if isinstance(it, dict) and all(it.get(r) for r in req)] \
226
+ if isinstance(items, list) else []
227
+
228
+
229
+ def _load_findings(p: Path) -> dict:
230
+ d = _yaml(p)
231
+ d["_file"] = str(p)
232
+ for f in d.get("findings") or []:
233
+ ev = []
234
+ for e in f.get("evidence") or []:
235
+ m = re.fullmatch(r"(.+?):(\d+)(?:-(\d+))?", str(e))
236
+ if m:
237
+ ev.append((m.group(1), int(m.group(2)), int(m.group(3) or m.group(2))))
238
+ f["_evidence"] = ev
239
+ return d
240
+
241
+
242
+ # ----------------------------------------------------------------------------------------------- helpers
243
+ def short(x: str | None) -> str:
244
+ if not x:
245
+ return "?"
246
+ k, _, key = x.partition(":")
247
+ if k in ("method", "class", "function", "interface", "trait", "enum", "property", "admin"):
248
+ key = key.lstrip("\\")
249
+ cls, sep, mem = key.partition("::")
250
+ for pre in SHORT_PREFIXES:
251
+ if cls.startswith(pre):
252
+ cls = cls[len(pre):]
253
+ break
254
+ else:
255
+ cls = cls.split("\\", 1)[1] if cls.startswith("App\\") else cls
256
+ return cls + (sep + mem if sep else "") + ("" if k in ("method", "class", "function") else f" [{k}]")
257
+ return x
258
+
259
+
260
+ def loc(file: str | None, line) -> str:
261
+ return f"{file}:{line}" if file else "?"
262
+
263
+
264
+ def bloc(at: str | None) -> str:
265
+ """basename:line of a 'file:line' string."""
266
+ if not at:
267
+ return "?"
268
+ f, _, ln = at.rpartition(":")
269
+ return f"{f.rsplit('/', 1)[-1]}:{ln}"
270
+
271
+
272
+ def fmt_chain(path: list[dict], limit=7) -> str:
273
+ """Compact call chain: A -KIND@file:line-> B -> ..."""
274
+ if not path:
275
+ return ""
276
+ hops = path if len(path) <= limit else path[:3] + [None] + path[-(limit - 4):]
277
+ s = short(path[0]["from"])
278
+ for h in hops:
279
+ if h is None:
280
+ s += " -> ..."
281
+ continue
282
+ g = " GATED" if h.get("gated") else ""
283
+ s += f" -{h['kind']}@{bloc(h.get('at'))}{g}-> {short(h['to'])}"
284
+ return s
285
+
286
+
287
+ def singular(t: str) -> str:
288
+ if t.endswith("ies"):
289
+ return t[:-3] + "y"
290
+ if t.endswith("ses"):
291
+ return t[:-2]
292
+ return t[:-1] if t.endswith("s") else t
293
+
294
+
295
+ class Ctx:
296
+ """Graph access + source roots for one check run."""
297
+
298
+ def __init__(self, st: GraphStore, plan: dict, roots: dict[str, str] | None = None):
299
+ self.st, self.plan = st, plan
300
+ from .viz.graph import Sources
301
+ self.src = Sources(st, roots)
302
+ self.planned_nodes = {it["id"]: it for it in plan.get("add_nodes") or []}
303
+ self.implicit = {str(it[k]) for it in plan.get("add_edges") or [] for k in ("from", "to")
304
+ if str(it[k]).split(":", 1)[0] in IMPLICIT_NEW}
305
+ self.combined = bool(st.meta().get("sources"))
306
+ self._node: dict = {}
307
+ self._res: dict = {}
308
+
309
+ def node(self, nid):
310
+ if nid not in self._node:
311
+ r = self.st.node(nid)
312
+ self._node[nid] = dict(r) if r else None
313
+ return self._node[nid]
314
+
315
+ def resolve(self, spec: str) -> dict:
316
+ """-> {spec, ids, status: ok|planned|client|unresolved|ambiguous}. Plan semantics: a class spec means the class
317
+ node (not its methods); Class::method must be unique; table:<t> resolves when the table or its columns exist."""
318
+ spec = str(spec).strip()
319
+ if spec in self._res:
320
+ return self._res[spec]
321
+ self._res[spec] = r = self._resolve(spec)
322
+ return r
323
+
324
+ def _resolve(self, spec: str) -> dict:
325
+ if self.st.q("SELECT 1 FROM nodes WHERE id=?", (spec,)):
326
+ return {"spec": spec, "ids": [spec], "status": "ok"}
327
+ if spec in self.planned_nodes:
328
+ return {"spec": spec, "ids": [spec], "status": "planned"}
329
+ if spec.split(":", 1)[0] in IMPLICIT_NEW and spec in self.implicit:
330
+ return {"spec": spec, "ids": [spec], "status": "planned"} # new request key / setting named by a planned edge
331
+ if spec.startswith("client:"):
332
+ return {"spec": spec, "ids": [spec], "status": "client"}
333
+ if spec.startswith("table:") and "*" not in spec:
334
+ ok = self.st.q("SELECT 1 FROM nodes WHERE id LIKE ? LIMIT 1", (f"column:{spec[6:]}.%",))
335
+ return {"spec": spec, "ids": [spec] if ok else [], "status": "ok" if ok else "unresolved"}
336
+ ids = Q.resolve_targets(self.st, spec)
337
+ if "::" not in spec and not spec.startswith(("column:", "route:", "config:", "env:", "connection:", "page:")):
338
+ cls = [i for i in ids if i.split(":", 1)[0] in ("class", "interface", "trait", "enum")]
339
+ if cls:
340
+ ids = cls
341
+ if not ids:
342
+ return {"spec": spec, "ids": [], "status": "unresolved"}
343
+ if "*" not in spec and len(ids) > 1:
344
+ return {"spec": spec, "ids": ids, "status": "ambiguous", "candidates": ids[:6]}
345
+ return {"spec": spec, "ids": ids, "status": "ok"}
346
+
347
+ def source_lines(self, file: str | None) -> list[str] | None:
348
+ if not file:
349
+ return None
350
+ repo, _, rel = file.partition("/")
351
+ cands = []
352
+ if repo in self.src.roots:
353
+ cands.append(self.src.roots[repo] / rel)
354
+ cands += [r / file for r in self.src.roots.values()]
355
+ for p in cands:
356
+ if p.is_file():
357
+ try:
358
+ return p.read_text(encoding="utf-8", errors="replace").splitlines()
359
+ except OSError:
360
+ return None
361
+ return None
362
+
363
+ def span_text(self, nid) -> str | None:
364
+ n = self.node(nid)
365
+ if not n or not n.get("file") or not n.get("line"):
366
+ return None
367
+ lines = self.source_lines(n["file"])
368
+ if lines is None:
369
+ return None
370
+ return "\n".join(lines[n["line"] - 1:(n.get("end_line") or n["line"])])
371
+
372
+ def db_file(self, rel: str, repo: str | None) -> str:
373
+ """Map a repo-relative path (snapshot evidence) to the DB's file column."""
374
+ if self.combined and repo and not rel.startswith(repo + "/"):
375
+ return f"{repo}/{rel}"
376
+ return rel
377
+
378
+ def enclosing(self, file: str, a: int, b: int) -> list[dict]:
379
+ """Code nodes whose span overlaps [a, b]; else nodes declared on those lines."""
380
+ rows = self.st.q("""SELECT id, kind, line, end_line FROM nodes WHERE file=? AND end_line IS NOT NULL
381
+ AND line <= ? AND end_line >= ? AND kind IN ('method','function')""", (file, b, a))
382
+ if rows:
383
+ return [dict(r) for r in rows]
384
+ rows = self.st.q("SELECT id, kind, line, end_line FROM nodes WHERE file=? AND line BETWEEN ? AND ? AND kind != 'column'", (file, a, b))
385
+ return [dict(r) for r in rows]
386
+
387
+ def tables_of(self, ids) -> set[str]:
388
+ out = set()
389
+ for i in ids:
390
+ for r in self.st.q("SELECT dst FROM edges WHERE src=? AND kind IN ('READS_TABLE','READS_COLUMN','WRITES_TABLE','WRITES_COLUMN','MENTIONS_COLUMN')", (i,)):
391
+ out.add(r["dst"].split(":", 1)[1].split(".")[0])
392
+ return out
393
+
394
+ def closure_fwd(self, starts: list[str], kinds=("CALLS",), max_depth=6) -> set[str]:
395
+ seen, frontier = set(starts), list(starts)
396
+ kq = ",".join("?" * len(kinds))
397
+ for _ in range(max_depth):
398
+ nxt = []
399
+ for x in frontier:
400
+ for r in self.st.q(f"SELECT dst FROM edges WHERE src=? AND kind IN ({kq})", (x, *kinds)):
401
+ if r["dst"] not in seen:
402
+ seen.add(r["dst"]); nxt.append(r["dst"])
403
+ frontier = nxt
404
+ return seen
405
+
406
+ def touchers(self, table: str, kinds) -> dict[str, list[dict]]:
407
+ kq = ",".join("?" * len(kinds))
408
+ rows = self.st.q(f"""SELECT src, kind, dst, file, line, confidence FROM edges WHERE kind IN ({kq})
409
+ AND (dst=? OR dst LIKE ?) ORDER BY src, line""", (*kinds, f"table:{table}", f"column:{table}.%"))
410
+ out = defaultdict(list)
411
+ for r in rows:
412
+ if not r["src"].startswith("resolution:"):
413
+ out[r["src"]].append(dict(r))
414
+ return out
415
+
416
+ def routes(self) -> list[dict]:
417
+ out = []
418
+ for r in self.st.q("SELECT id, file, line, attrs FROM nodes WHERE kind='route'"):
419
+ a = json.loads(r["attrs"] or "{}")
420
+ if not a.get("uri") or not a.get("method"):
421
+ continue
422
+ uris = [("as-declared", a["uri"])]
423
+ f = (r["file"] or "").split("/", 1)[-1] if self.combined else (r["file"] or "")
424
+ if f.startswith("routes/api"):
425
+ uris.append(("api-prefixed", "/api" + a["uri"]))
426
+ out.append({"id": r["id"], "uri": a["uri"], "method": a["method"], "uris": uris, "file": r["file"], "line": r["line"]})
427
+ return out
428
+
429
+
430
+ def _item(check, node, why, evidence, severity="missing", chain=None, **kw):
431
+ ev = list(dict.fromkeys(e for e in evidence if e and e != "?"))
432
+ return {"check": check, "node": node, "why": why, "evidence": ev[:6], "severity": severity,
433
+ **({"chain": chain} if chain else {}), **{k: v for k, v in kw.items() if v}}
434
+
435
+
436
+ def _hops(rows, t: str, limit=3) -> list[dict]:
437
+ """Evidence edges of a toucher, folded onto the table node (columns are drawn as their table)."""
438
+ out = []
439
+ for r in rows[:limit]:
440
+ out.append({"from": r["src"], "kind": r["kind"], "to": f"table:{t}", "at": loc(r["file"], r["line"]), "confidence": r["confidence"]})
441
+ return out
442
+
443
+
444
+ def _ev(rows) -> list[str]:
445
+ return [loc(r["file"], r["line"]) for r in rows if r.get("file")]
446
+
447
+
448
+ def is_covered(nid: str, cov: set[str]) -> bool:
449
+ if nid in cov:
450
+ return True
451
+ if "::" in nid:
452
+ owner = nid.split(":", 1)[1].split("::")[0]
453
+ if f"class:{owner}" in cov or f"admin:{owner}" in cov:
454
+ return True
455
+ if nid.startswith("admin:") and "class:" + nid.split(":", 1)[1] in cov:
456
+ return True
457
+ return False
458
+
459
+
460
+ # ----------------------------------------------------------------------------------------------- resolution
461
+ def plan_refs(plan: dict) -> list[tuple[str, str]]:
462
+ refs = []
463
+ for i, it in enumerate(plan.get("modify") or []):
464
+ refs.append((f"modify[{i}].target", it["target"]))
465
+ for i, it in enumerate(plan.get("add_edges") or []):
466
+ refs += [(f"add_edges[{i}].from", it["from"]), (f"add_edges[{i}].to", it["to"])]
467
+ for i, it in enumerate(plan.get("forbid") or []):
468
+ refs += [(f"forbid[{i}].from", it["from"]), (f"forbid[{i}].to", it["to"])]
469
+ g = it.get("guard") or {}
470
+ for k in ("at", "reads", "calls"):
471
+ if g.get(k):
472
+ refs.append((f"forbid[{i}].guard.{k}", g[k]))
473
+ for i, it in enumerate(plan.get("require") or []):
474
+ refs.append((f"require[{i}].route", it["route"]))
475
+ for i, it in enumerate(plan.get("covers") or []):
476
+ refs.append((f"covers[{i}].spec", it["spec"]))
477
+ for i, it in enumerate(plan.get("out_of_scope") or []):
478
+ refs.append((f"out_of_scope[{i}].spec", it["spec"]))
479
+ for i, it in enumerate(plan.get("precedents") or []):
480
+ for j, w in enumerate(it.get("within") or []):
481
+ refs.append((f"precedents[{i}].within[{j}]", w))
482
+ return [(w, str(s)) for w, s in refs]
483
+
484
+
485
+ def resolve_all(cx: Ctx) -> dict:
486
+ out = [{"where": w, **cx.resolve(s)} for w, s in plan_refs(cx.plan)]
487
+ planned = []
488
+ new_tables = {it["id"][6:] for it in cx.plan.get("add_nodes") or [] if it["id"].startswith("table:")}
489
+ for i, it in enumerate(cx.plan.get("add_nodes") or []):
490
+ nid = it["id"]
491
+ exists = bool(cx.node(nid))
492
+ anchor = None
493
+ if nid.startswith("column:"):
494
+ t = nid[7:].split(".")[0]
495
+ anchor = f"table:{t}"
496
+ if t not in new_tables and cx.resolve(anchor)["status"] != "ok":
497
+ out.append({"where": f"add_nodes[{i}].id (table)", "spec": anchor, "ids": [], "status": "unresolved"})
498
+ elif "::" in nid:
499
+ anchor = "class:" + nid.split(":", 1)[1].split("::")[0]
500
+ if not cx.node(anchor) and anchor not in cx.planned_nodes:
501
+ out.append({"where": f"add_nodes[{i}].id (owner class)", "spec": anchor, "ids": [], "status": "unresolved"})
502
+ planned.append({"id": nid, "exists": exists, "anchor": anchor})
503
+ return {"refs": out, "planned_nodes": planned}
504
+
505
+
506
+ def changed_tables(plan: dict) -> dict[str, list[str]]:
507
+ """table -> planned new column names."""
508
+ out: dict[str, list[str]] = {}
509
+ for it in plan.get("add_nodes") or []:
510
+ if it["id"].startswith("column:"):
511
+ t, c = it["id"][7:].split(".", 1)
512
+ out.setdefault(t, []).append(c)
513
+ elif it["id"].startswith("table:"):
514
+ out.setdefault(it["id"][6:], [])
515
+ for it in plan.get("add_edges") or []:
516
+ for end in (str(it["from"]), str(it["to"])):
517
+ if end.startswith(("column:", "table:")) and it["kind"] in WRITE_KINDS + READ_KINDS:
518
+ out.setdefault(end.split(":", 1)[1].split(".")[0], [])
519
+ for it in plan.get("modify") or []:
520
+ t = str(it["target"])
521
+ if t.startswith(("table:", "column:")):
522
+ out.setdefault(t.split(":", 1)[1].split(".")[0], [])
523
+ return out
524
+
525
+
526
+ # ----------------------------------------------------------------------------------------------- completeness
527
+ class Collector:
528
+ def __init__(self, cov: set[str]):
529
+ self.cov, self.items, self.keys = cov, [], set()
530
+
531
+ def add(self, it: dict):
532
+ key = (it["check"], it["node"])
533
+ if key in self.keys:
534
+ return
535
+ self.keys.add(key)
536
+ it["covered"] = is_covered(it["node"], self.cov)
537
+ self.items.append(it)
538
+
539
+ @property
540
+ def nodes(self) -> set[str]:
541
+ return {i["node"] for i in self.items}
542
+
543
+
544
+ def _check_table(cx: Ctx, t: str, newcols: list[str], impacts: dict, through: dict, fwd: dict, col: Collector, res: dict):
545
+ st = cx.st
546
+ writers, readers = cx.touchers(t, WRITE_KINDS), cx.touchers(t, READ_KINDS)
547
+
548
+ guards = [m for m in impacts if m in res.get("guards", [])]
549
+
550
+ def bypassed(src):
551
+ return [short(m) for m in guards if t in cx.tables_of(fwd[m]) and src not in through[m] and src not in fwd[m]]
552
+
553
+ for src, rows in writers.items():
554
+ cols = sorted({r["dst"].split(".", 1)[1] for r in rows if r["dst"].startswith("column:")})
555
+ n = cx.node(src) or {}
556
+ payload = len(cols) >= 2 or bool(PAYLOAD_VERBS.match(n.get("name") or ""))
557
+ cs = (", ".join(cols[:5]) + ("…" if len(cols) > 5 else "")) if cols else "row"
558
+ why = f"writes {t} ({cs})" + (f"; must set/keep new {', '.join(newcols)}" if payload and newcols else "")
559
+ col.add(_item("table_writer", src, why, _ev(rows), "missing" if payload else "review", bypasses=bypassed(src), anchor=f"table:{t}", hops=_hops(rows, t)))
560
+ for src, rows in readers.items():
561
+ if src in writers:
562
+ continue
563
+ cols = sorted({r["dst"].split(".", 1)[1] for r in rows if r["dst"].startswith("column:")})
564
+ col.add(_item("table_reader", src, f"reads {t} ({', '.join(cols[:5])}{'…' if len(cols) > 5 else ''})", _ev(rows), "review",
565
+ bypasses=bypassed(src), anchor=f"table:{t}", hops=_hops(rows, t)))
566
+ models = [r["src"] for r in st.q("SELECT src FROM edges WHERE kind='MAPS_TO_TABLE' AND dst=?", (f"table:{t}",))]
567
+ toucher_ids = set(writers) | set(readers)
568
+ for mdl in models:
569
+ mfqn = mdl.split(":", 1)[1]
570
+ # Filament resources bound with $model = Model::class (form saves are not WRITES edges in the graph)
571
+ for pr in st.q("SELECT id, file, line, attrs FROM nodes WHERE kind='property' AND name='$model'"):
572
+ dflt = json.loads(pr["attrs"] or "{}").get("default")
573
+ if not (isinstance(dflt, dict) and str(dflt.get("__class__", "")).lstrip("\\") == mfqn):
574
+ continue
575
+ rcls = pr["id"].split(":", 1)[1].split("::")[0]
576
+ for meth in ("form", "table"):
577
+ mid = f"method:{rcls}::{meth}"
578
+ mn = cx.node(mid)
579
+ if not mn:
580
+ continue
581
+ if meth == "form":
582
+ why = f"Filament form for {short(mdl)} ($model @{bloc(loc(pr['file'], pr['line']))}); saves bypass the graph's WRITES edges" \
583
+ + (f" — must expose {', '.join(newcols)}" if newcols else "")
584
+ else:
585
+ why = f"Filament table for {short(mdl)}" + (f" — show/filter {', '.join(newcols)}" if newcols else "")
586
+ col.add(_item("admin_surface", mid, why, [loc(mn["file"], mn["line"])] + _ev(readers.get(mid, []))[:2],
587
+ "missing" if (meth == "form" or newcols) else "review", anchor=f"table:{t}"))
588
+ fp = cx.node(f"property:{mfqn}::$fillable")
589
+ if fp and newcols:
590
+ fill = json.loads(fp["attrs"] or "{}").get("default") or []
591
+ lacking = [c for c in newcols if isinstance(fill, list) and c not in fill]
592
+ if lacking:
593
+ col.add(_item("model_fillable", fp["id"], f"{short(mdl)}::$fillable lacks {', '.join(lacking)} (mass assignment would drop it)",
594
+ [loc(fp["file"], fp["line"])], "missing", anchor=f"table:{t}"))
595
+ # API resources: JsonResource subclasses instantiated by code touching the table, or named <Model>Resource
596
+ mshort = mfqn.rsplit("\\", 1)[-1]
597
+ rcs: dict[str, list[str]] = {}
598
+ for r in st.q("""SELECT e.src, e.dst, e.file, e.line FROM edges e JOIN edges x ON x.src = e.dst
599
+ WHERE e.kind='INSTANTIATES' AND x.kind='EXTENDS' AND x.dst LIKE '%JsonResource'"""):
600
+ if r["src"] in toucher_ids:
601
+ rcs.setdefault(r["dst"], []).append(loc(r["file"], r["line"]))
602
+ for r in st.q("SELECT id FROM nodes WHERE kind='class' AND name=?", (mshort + "Resource",)):
603
+ if st.q("SELECT 1 FROM edges WHERE src=? AND kind='EXTENDS' AND dst LIKE '%JsonResource'", (r["id"],)):
604
+ rcs.setdefault(r["id"], [])
605
+ for rc, ev in rcs.items():
606
+ ta = f"method:{rc.split(':', 1)[1]}::toArray"
607
+ tn = cx.node(ta)
608
+ if tn:
609
+ col.add(_item("api_resource", ta, f"serializes {short(mdl)} in API responses"
610
+ + (f" (built at {', '.join(bloc(e) for e in ev[:3])})" if ev else "")
611
+ + (f"; decide whether to expose {', '.join(newcols)}" if newcols else ""),
612
+ [loc(tn["file"], tn["line"])] + ev, "missing" if newcols else "review", anchor=f"table:{t}"))
613
+ for r in st.q("SELECT src, file, line FROM edges WHERE kind='REFERENCES' AND dst=? AND src LIKE 'method:%'", (mdl,)):
614
+ if st.q("SELECT 1 FROM edges WHERE kind='HAS_RELATION' AND dst=? AND line=?", (mdl, r["line"])):
615
+ col.add(_item("relation", r["src"], f"Eloquent relation to {short(mdl)}", [loc(r["file"], r["line"])], "review", anchor=f"table:{t}"))
616
+ # form requests validating this table's payload but not the new column(s)
617
+ colnames = {r["id"].split(".", 1)[1] for r in st.q("SELECT id FROM nodes WHERE id LIKE ?", (f"column:{t}.%",))}
618
+ val = defaultdict(list)
619
+ for r in st.q("SELECT src, dst, file, line FROM edges WHERE kind='VALIDATES'"):
620
+ val[r["src"]].append(dict(r))
621
+ for src, rows in val.items():
622
+ keys = {r["dst"].split(":", 1)[1] for r in rows}
623
+ ov = keys & colnames
624
+ if len(ov) >= max(3, 0.5 * len(colnames - {"id", "created_at", "updated_at"})) and len(ov) >= 0.5 * len(keys):
625
+ lacking = [c for c in newcols if c not in keys]
626
+ if lacking:
627
+ col.add(_item("validation", src, f"validates {len(ov)} {t} fields ({', '.join(sorted(ov)[:4])}…) but not {', '.join(lacking)}",
628
+ _ev(rows)[:2], "missing", anchor=f"table:{t}"))
629
+ # identity columns on other tables (e.g. orders.book_id / orders.book_isbn) and their consumers
630
+ sg = singular(t)
631
+ idrx = re.compile(rf"^(\w+_)?{re.escape(sg)}_(id|code)$")
632
+ refcols = [r["id"] for r in st.q("SELECT id FROM nodes WHERE kind='column' AND id NOT LIKE ?", (f"column:{t}.%",))
633
+ if idrx.match(r["id"].split(".", 1)[1]) and r["id"][7:].split(".")[0] != sg]
634
+ res.setdefault("referencing_columns", {})[t] = refcols
635
+ for c in refcols:
636
+ for r in st.q("SELECT src, kind, file, line FROM edges WHERE dst=? AND kind IN ('READS_COLUMN','MENTIONS_COLUMN','WRITES_COLUMN')", (c,)):
637
+ if not r["src"].startswith("resolution:"):
638
+ col.add(_item("referencing_column", r["src"], f"{r['kind'].lower().replace('_', ' ')} {c[7:]} (refers to {t})",
639
+ [loc(r["file"], r["line"])], "review", anchor=c))
640
+ rel = [r["src"].split("::")[-1] for m in models for r in st.q("SELECT src FROM edges WHERE kind='REFERENCES' AND dst=? AND src LIKE 'method:%'", (m,))
641
+ if st.q("SELECT 1 FROM edges WHERE kind='HAS_RELATION' AND dst=?", (m,))]
642
+ names = sorted({c.split(".", 1)[1] for c in refcols} | set(rel))
643
+ if names:
644
+ for hit in _text_mentions(cx, names, col):
645
+ hit["anchor"] = f"table:{t}"
646
+ col.add(hit)
647
+
648
+
649
+ def _text_mentions(cx: Ctx, names: list[str], col: Collector, cap=40) -> list[dict]:
650
+ """Exact-regex mentions of identity columns / relation names in indexed PHP + Blade files where the graph has no
651
+ column edge on that line (Blade templates, JsonResource `$this->x`). Labelled text-match; never becomes an edge."""
652
+ cols = [n for n in names if "_" in n]
653
+ rels = [n for n in names if "_" not in n]
654
+ alts = []
655
+ if cols:
656
+ alts.append(r"(?:->|['\"])(" + "|".join(map(re.escape, cols)) + r")\b")
657
+ if rels:
658
+ alts.append(r"->(" + "|".join(map(re.escape, rels)) + r")\b(?!\s*\()")
659
+ rx = re.compile("|".join(alts))
660
+ out, already = [], col.nodes
661
+ model_files = {(cx.node(r["src"]) or {}).get("file") for r in cx.st.q("SELECT src FROM edges WHERE kind='MAPS_TO_TABLE'")}
662
+ for repo, root in sorted(cx.src.roots.items()):
663
+ for sub in mention_dirs(cx.st, repo):
664
+ base = root / sub if sub else root
665
+ if not base.is_dir():
666
+ continue
667
+ for p in sorted(base.rglob("*.php")):
668
+ rel = str(p.relative_to(root))
669
+ dbf = f"{repo}/{rel}" if cx.combined and repo else rel
670
+ if dbf in model_files:
671
+ continue # model declarations ($fillable/$casts) of the table itself are not consumers
672
+ try:
673
+ lines = p.read_text(encoding="utf-8", errors="replace").splitlines()
674
+ except OSError:
675
+ continue
676
+ hits = [i + 1 for i, ln in enumerate(lines) if rx.search(ln)]
677
+ by_node = defaultdict(list)
678
+ for h in hits:
679
+ enc = cx.enclosing(dbf, h, h)
680
+ nid = min(enc, key=lambda e: (e.get("end_line") or e["line"]) - e["line"])["id"] if enc else f"file:{dbf}"
681
+ if nid in already or is_covered(nid, col.cov):
682
+ continue
683
+ if cx.st.q("SELECT 1 FROM edges WHERE file=? AND line=? AND kind IN ('READS_COLUMN','MENTIONS_COLUMN','WRITES_COLUMN')", (dbf, h)):
684
+ continue
685
+ by_node[nid].append(h)
686
+ for nid, hs in by_node.items():
687
+ found = sorted({g for h in hs for m in rx.finditer(lines[h - 1]) for g in m.groups() if g})
688
+ out.append(_item("text_mention", nid, f"mentions {', '.join(found)} (text-match, no graph edge)",
689
+ [loc(dbf, h) for h in hs], "review"))
690
+ if len(out) >= cap:
691
+ return out
692
+ return out
693
+
694
+
695
+ def _check_modified(cx: Ctx, mm: list[str], ct: dict, impacts: dict, through: dict, fwd: dict, col: Collector, res: dict):
696
+ st = cx.st
697
+ route_mw, per_m = {}, defaultdict(dict)
698
+ for m, imp in impacts.items():
699
+ for e in imp["entry_points"]:
700
+ n = cx.node(e["id"]) or {}
701
+ if e["kind"] == "route":
702
+ route_mw[e["id"]] = per_m[m][e["id"]] = json.loads(n.get("attrs") or "{}").get("middleware") or []
703
+ col.add(_item("entry_point", e["id"], f"{e['entry_kind']} reaching modified {short(m)}", [loc(n.get("file"), n.get("line"))],
704
+ "missing" if e["kind"] == "route" else "review", chain=fmt_chain(e["path"]), anchor=m, hops=e["path"]))
705
+ own = m.split(":", 1)[1].split("::")[0]
706
+ for c in [r["dst"] for r in st.q("SELECT DISTINCT dst FROM edges WHERE src=? AND kind='CALLS'", (m,))]:
707
+ if not (c.split(":", 1)[1].startswith(own + "::") or cx.tables_of({c}) & set(ct)):
708
+ continue
709
+ for r in st.q("SELECT DISTINCT src, file, line FROM edges WHERE dst=? AND kind='CALLS' AND src != ?", (c, m)):
710
+ if r["src"] in through[m] or r["src"] in fwd[m]:
711
+ continue
712
+ col.add(_item("bypass_caller", r["src"], f"calls {short(c)} directly, not through modified {short(m)}",
713
+ [loc(r["file"], r["line"])], "review", anchor=c,
714
+ hops=[{"from": r["src"], "kind": "CALLS", "to": c, "at": loc(r["file"], r["line"]), "confidence": "resolved"}]))
715
+ # direct callers of a modified method must adapt to it (signature / new rejection)
716
+ for r in st.q("SELECT DISTINCT src, file, line FROM edges WHERE dst=? AND kind='CALLS'", (m,)):
717
+ if r["src"].split(":", 1)[0] in CODE_KINDS:
718
+ col.add(_item("caller", r["src"], f"calls modified {short(m)}", [loc(r["file"], r["line"])], "missing", anchor=m,
719
+ hops=[{"from": r["src"], "kind": "CALLS", "to": m, "at": loc(r["file"], r["line"]), "confidence": "resolved"}]))
720
+ res["entry_routes"] = route_mw
721
+ diff = {}
722
+ for m, rmw in per_m.items(): # routes reaching the SAME modified method should agree on auth middleware
723
+ if len(rmw) > 1:
724
+ allmw = set().union(*map(set, rmw.values()))
725
+ for rid, mw in rmw.items():
726
+ if allmw - set(mw):
727
+ diff.setdefault(rid, {"lacks": sorted(allmw - set(mw)), "peers": sorted(x for x in rmw if x != rid), "via": short(m)})
728
+ res["middleware_diff"] = diff
729
+ # same-class siblings of modified methods sharing the verb (reserveLocal ~ reserveFromWarehouse)
730
+ for m in impacts:
731
+ n = cx.node(m) or {}
732
+ verb = re.match(r"[a-z]+", n.get("name") or "")
733
+ if not verb or len(verb.group(0)) < 3:
734
+ continue
735
+ own = m.split(":", 1)[1].split("::")[0]
736
+ for r in st.q("SELECT id, name, file, line FROM nodes WHERE kind='method' AND fqn LIKE ? AND id != ?", (own + "::%", m)):
737
+ if r["name"].startswith(verb.group(0)) and r["name"] != n["name"] and r["id"] not in mm:
738
+ col.add(_item("parallel_method", r["id"], f"same-class sibling of modified {short(m)} ({verb.group(0)}*)",
739
+ [loc(r["file"], r["line"])], "review", anchor=m))
740
+
741
+
742
+ def _check_clients(cx: Ctx, refs: list[dict], col: Collector, res: dict):
743
+ st = cx.st
744
+ affected = set(res.get("entry_routes") or {}) | {i for r in refs if r["where"].startswith("require") for i in r["ids"]}
745
+ res["affected_routes"] = sorted(affected)
746
+ for rid in sorted(affected):
747
+ for m in st.q("SELECT src FROM edges WHERE kind='MATCHES_ROUTE' AND dst=?", (rid,)):
748
+ for c in st.q("SELECT src, file, line FROM edges WHERE kind='HTTP_CALLS' AND dst=?", (m["src"],)):
749
+ pages = Q.reverse_closure(st, [c["src"]], kinds=Q.CALL_LIKE)
750
+ for p in sorted(x for x in pages if x.startswith("page:"))[:6] or [c["src"]]:
751
+ col.add(_item("frontend_caller", p, f"calls {rid.split(':', 1)[1]} via {short(c['src'])}", [loc(c["file"], c["line"])], "missing", anchor=rid))
752
+ from .link import match_endpoint
753
+ routes = None
754
+ for snap in cx.plan.get("_clients") or []:
755
+ base = snap.get("base_prefix") or ""
756
+ for c in snap.get("calls") or []:
757
+ if not c.get("path"):
758
+ continue
759
+ routes = routes or cx.routes()
760
+ mres = match_endpoint(c.get("method") or "GET", base + c["path"], routes)
761
+ hit = [m["route"] for m in mres["matched"] if m["route"] in affected]
762
+ if hit:
763
+ ev = f"{c['repo']}@{str(c.get('commit'))[:8]}:{c['file']}" + (f":{c['line']}" if c.get("line") else "")
764
+ col.add(_item("external_client", f"client:{c['repo']}/{c['file']}",
765
+ f"{c.get('method')} {c['path']} -> {hit[0].split(':', 1)[1]} [snapshot, {c.get('evidence', '?')}]"
766
+ + (f"; sends {', '.join(c.get('sends') or [])}" if c.get("sends") else ""), [ev], "missing", anchor=hit[0]))
767
+
768
+
769
+ def snapshot_clients(st, route_ids, root: str | Path | None = None) -> list[dict]:
770
+ """External client call sites (read-only snapshot files next to the plans: `snapshot_version` + `calls`) that match
771
+ any of route_ids. Used by impact, so callers in apps that are not indexed show up without a plan."""
772
+ from .link import match_endpoint
773
+ want = set(route_ids)
774
+ if not want:
775
+ return []
776
+ d = plans_dir(root)
777
+ if not d.is_dir():
778
+ return []
779
+ combined = bool(st.meta().get("repos"))
780
+ routes = None
781
+ out = []
782
+ for f in sorted(d.glob("*.y*ml")):
783
+ try:
784
+ snap = _yaml(f)
785
+ except Exception: # noqa: BLE001
786
+ continue
787
+ if "snapshot_version" not in snap or not isinstance(snap.get("calls"), list):
788
+ continue
789
+ base = snap.get("base_prefix") or ""
790
+ for c in snap["calls"]:
791
+ if not isinstance(c, dict) or not c.get("path"):
792
+ continue
793
+ if routes is None:
794
+ routes = []
795
+ for r in st.q("SELECT id, file, line, attrs FROM nodes WHERE kind='route'"):
796
+ a = json.loads(r["attrs"] or "{}")
797
+ if not a.get("uri") or not a.get("method"):
798
+ continue
799
+ uris = [("as-declared", a["uri"])]
800
+ rf = (r["file"] or "").split("/", 1)[-1] if combined else (r["file"] or "")
801
+ if rf.startswith("routes/api"):
802
+ uris.append(("api-prefixed", "/api" + a["uri"]))
803
+ routes.append({"id": r["id"], "uri": a["uri"], "method": a["method"], "uris": uris,
804
+ "file": r["file"], "line": r["line"]})
805
+ hit = [m["route"] for m in match_endpoint(c.get("method") or "GET", base + c["path"], routes)["matched"]
806
+ if m["route"] in want]
807
+ if hit:
808
+ out.append({"route": hit[0], "repo": c.get("repo"), "commit": str(c.get("commit") or "")[:8],
809
+ "file": c.get("file"), "line": c.get("line"), "method": c.get("method"), "path": c["path"],
810
+ "sends": c.get("sends") or [], "evidence": c.get("evidence", "?"), "snapshot": f.name})
811
+ return out
812
+
813
+
814
+ def _check_parallel(cx: Ctx, ct: dict, impacts: dict, fwd: dict, col: Collector):
815
+ st = cx.st
816
+ allcols = defaultdict(set)
817
+ for r in st.q("SELECT id FROM nodes WHERE kind='column'"):
818
+ tt, cc = r["id"][7:].split(".", 1)
819
+ allcols[tt].add(cc)
820
+ boring = {"id", "created_at", "updated_at"}
821
+ reached_any = set().union(*fwd.values()) if fwd else set()
822
+ for t, newc in ct.items():
823
+ mine = allcols.get(t, set()) - boring
824
+ if len(mine) < 3:
825
+ continue
826
+ mine_models = [r["src"] for r in st.q("SELECT src FROM edges WHERE kind='MAPS_TO_TABLE' AND dst=?", (f"table:{t}",))]
827
+ for tt, cols in sorted(allcols.items()):
828
+ cols = cols - boring
829
+ if tt == t or not cols:
830
+ continue
831
+ tm = [r["src"] for r in st.q("SELECT src FROM edges WHERE kind='MAPS_TO_TABLE' AND dst=?", (f"table:{tt}",))]
832
+ if not tm:
833
+ continue # generic tables (no model) are not mirrors
834
+ j = len(mine & cols) / len(mine | cols)
835
+ if j < 0.5:
836
+ continue
837
+ reach = [short(m) for m in impacts if tt in cx.tables_of(fwd[m])]
838
+ ev = [loc((cx.node(x) or {}).get("file"), (cx.node(x) or {}).get("line")) for x in tm]
839
+ col.add(_item("parallel_table", f"table:{tt}",
840
+ f"mirrors {t} ({len(mine & cols)}/{len(mine | cols)} columns; model {', '.join(short(x) for x in tm)})"
841
+ + (f"; has no {', '.join(c for c in newc if c not in cols)}" if newc else "")
842
+ + (f"; reached from modified {', '.join(reach)}" if reach else ""), ev, "review", anchor=f"table:{t}"))
843
+ for a in tm:
844
+ pa = {r["name"]: r["id"] for r in st.q("SELECT id, name FROM nodes WHERE kind='method' AND fqn LIKE ?", (a.split(":", 1)[1] + "::%",))}
845
+ for b in mine_models:
846
+ pb = {r["name"]: r["id"] for r in st.q("SELECT id, name FROM nodes WHERE kind='method' AND fqn LIKE ?", (b.split(":", 1)[1] + "::%",))}
847
+ for name in sorted(set(pa) & set(pb)):
848
+ if pa[name] in reached_any:
849
+ n = cx.node(pa[name]) or {}
850
+ col.add(_item("parallel_method", pa[name], f"mirror of {short(pb[name])}; reached from modified code",
851
+ [loc(n.get("file"), n.get("line"))], "review", anchor=pb[name]))
852
+
853
+
854
+ def _precedents(cx: Ctx, mm: list[str], impacts: dict) -> list[dict]:
855
+ out = []
856
+ for p in cx.plan.get("precedents") or []:
857
+ rx = re.compile(p["pattern"])
858
+ scope = [i for w in p.get("within") or [] for i in cx.resolve(w)["ids"]]
859
+ if not scope:
860
+ scope = list(mm) + [c["id"] for imp in impacts.values() for c in imp["callers"] if c["depth"] == 1]
861
+ hits = []
862
+ for nid in dict.fromkeys(scope):
863
+ n, txt = cx.node(nid), cx.span_text(nid)
864
+ if not n or not txt:
865
+ continue
866
+ for k, ln in enumerate(txt.splitlines()):
867
+ if rx.search(ln):
868
+ hits.append({"node": nid, "at": loc(n["file"], n["line"] + k), "text": ln.strip()[:120]})
869
+ out.append({"name": p["name"], "pattern": p["pattern"], "for": p.get("for"), "hits": hits})
870
+ return out
871
+
872
+
873
+ # ----------------------------------------------------------------------------------------------- conflicts
874
+ def _path_to_any(st: GraphStore, src: str, dsts: set[str], max_depth=12) -> list[dict]:
875
+ kset, prev, seen, frontier = set(PROPAGATING), {}, {src}, [src]
876
+ for _ in range(max_depth):
877
+ nxt = []
878
+ for x in frontier:
879
+ for e in st.q("SELECT src,dst,kind,file,line,confidence,gate FROM edges WHERE src=?", (x,)):
880
+ if e["kind"] not in kset or e["dst"] in seen:
881
+ continue
882
+ seen.add(e["dst"]); prev[e["dst"]] = dict(e); nxt.append(e["dst"])
883
+ if e["dst"] in dsts:
884
+ path, y = [], e["dst"]
885
+ while y in prev:
886
+ p = prev[y]
887
+ path.append({"from": p["src"], "kind": p["kind"], "to": p["dst"], "at": loc(p["file"], p["line"]),
888
+ "confidence": p["confidence"], **({"gated": p["gate"]} if p["gate"] else {})})
889
+ y = p["src"]
890
+ return path[::-1]
891
+ frontier = nxt
892
+ return []
893
+
894
+
895
+ def forbid_status(cx: Ctx, f: dict) -> dict:
896
+ st = cx.st
897
+ srcs = cx.resolve(f["from"])["ids"]
898
+ to = str(f["to"])
899
+ dsts = list(cx.resolve(to)["ids"])
900
+ if to.startswith("table:"):
901
+ dsts += [r["id"] for r in st.q("SELECT id FROM nodes WHERE id LIKE ?", (f"column:{to[6:]}.%",))]
902
+ out = {"id": f["id"], "type": f.get("type", "path"), "from": f["from"], "to": to, "when": f.get("when"),
903
+ "issues": f.get("issues") or [], "present": False}
904
+ if f.get("type") == "edge":
905
+ for s in srcs:
906
+ for d in dsts:
907
+ r = st.q("SELECT file, line FROM edges WHERE src=? AND dst=? AND kind=?", (s, d, f["edge_kind"]))
908
+ if r and not out["present"]:
909
+ out.update(present=True, chain=f"{short(s)} -{f['edge_kind']}@{bloc(loc(r[0]['file'], r[0]['line']))}-> {short(d)}",
910
+ hops=[{"from": s, "to": d, "kind": f["edge_kind"], "at": loc(r[0]["file"], r[0]["line"]), "confidence": "exact"}])
911
+ else:
912
+ for s in srcs:
913
+ p = _path_to_any(st, s, set(dsts))
914
+ if p:
915
+ out.update(present=True, chain=fmt_chain(p), hops=p)
916
+ break
917
+ g = f.get("guard")
918
+ if g and out["present"]:
919
+ scope = cx.closure_fwd(cx.resolve(g["at"])["ids"], max_depth=1)
920
+ kinds, tgt = (("READS_COLUMN", "MENTIONS_COLUMN"), g["reads"]) if g.get("reads") else (("CALLS",), g["calls"])
921
+ ev = []
922
+ for s in scope:
923
+ for t in cx.resolve(tgt)["ids"]:
924
+ ev += [loc(r["file"], r["line"]) for r in st.q(f"SELECT file, line FROM edges WHERE src=? AND dst=? AND kind IN ({','.join('?' * len(kinds))})",
925
+ (s, t, *kinds))]
926
+ out["guard"] = {"at": g["at"], "needs": f"{'reads' if g.get('reads') else 'calls'} {tgt}", "present": bool(ev), "evidence": ev[:4]}
927
+ return out
928
+
929
+
930
+ def linked_issue_ids(plan: dict) -> set[str]:
931
+ refs = list(plan.get("issues") or [])
932
+ for sec in ("add_nodes", "modify", "add_edges", "forbid", "require"):
933
+ for it in plan.get(sec) or []:
934
+ refs += it.get("issues") or []
935
+ out = set()
936
+ for r in refs:
937
+ m = re.search(r"#(\d+)\s*$", r) or re.search(r"/issues/(\d+)", r)
938
+ if m:
939
+ out.add(f"#{m.group(1)}")
940
+ return out
941
+
942
+
943
+ def finding_overlap(cx: Ctx, touched: set[str]) -> list[dict]:
944
+ snap = cx.plan.get("_findings")
945
+ if not snap:
946
+ return []
947
+ linked = linked_issue_ids(cx.plan)
948
+ repo = snap.get("graph_repo")
949
+ out = []
950
+ for f in snap.get("findings") or []:
951
+ hits = defaultdict(list)
952
+ for file, a, b in f["_evidence"]:
953
+ for n in cx.enclosing(cx.db_file(file, repo), a, b):
954
+ if is_covered(n["id"], touched):
955
+ hits[n["id"]].append(f"L{a}" + (f"-{b}" if b != a else ""))
956
+ out.append({"id": f["id"], "title": f.get("title"), "state": f.get("state", "open"), "url": f.get("url"),
957
+ "linked": f["id"] in linked, "touches": dict(sorted(hits.items()))})
958
+ return out
959
+
960
+
961
+ # ----------------------------------------------------------------------------------------------- the check
962
+ def check(st: GraphStore, plan: dict, verify: bool = False, baseline: dict | None = None,
963
+ roots: dict[str, str] | None = None, min_conf: str = "heuristic") -> dict:
964
+ cx = Ctx(st, plan, roots)
965
+ m = st.meta()
966
+ res = {"plan": plan.get("name"), "title": plan.get("title"), "status": plan.get("status", "draft"), "file": plan.get("_file"),
967
+ "mode": "verify" if verify else "plan", "graph": {"indexed_at": m.get("indexed_at"), "project": m.get("project")},
968
+ "schema_errors": plan.get("_schema_errors") or []}
969
+ rs = resolve_all(cx)
970
+ res["resolve"] = rs
971
+ refs = rs["refs"]
972
+ cov = {i for r in refs for i in r["ids"]}
973
+ mm = list(dict.fromkeys(i for it in plan.get("modify") or [] for i in cx.resolve(it["target"])["ids"]))
974
+ res["modified"] = mm
975
+ ct = changed_tables(plan)
976
+ res["changed_tables"] = ct
977
+ res["guards"] = [i for it in plan.get("modify") or [] if it.get("role") == "guard" for i in cx.resolve(it["target"])["ids"]]
978
+ col = Collector(cov)
979
+ code_mm = [x for x in mm if x.split(":", 1)[0] in CODE_KINDS and cx.node(x)]
980
+ impacts = {x: Q.impact(st, x, min_conf=min_conf) for x in code_mm}
981
+ through = {x: {x} | {c["id"] for c in imp["callers"]} | {e["id"] for e in imp["entry_points"]} for x, imp in impacts.items()}
982
+ fwd = {x: cx.closure_fwd([x]) for x in impacts}
983
+ for t, newcols in ct.items():
984
+ _check_table(cx, t, newcols, impacts, through, fwd, col, res)
985
+ _check_modified(cx, mm, ct, impacts, through, fwd, col, res)
986
+ _check_clients(cx, refs, col, res)
987
+ _check_parallel(cx, ct, impacts, fwd, col)
988
+ res["precedents"] = _precedents(cx, mm, impacts)
989
+ res["conflicts"] = [forbid_status(cx, f) for f in plan.get("forbid") or []]
990
+ req = []
991
+ for r in plan.get("require") or []:
992
+ for rid in cx.resolve(r["route"])["ids"]:
993
+ n = cx.node(rid) or {}
994
+ mw = json.loads(n.get("attrs") or "{}").get("middleware") or []
995
+ need = r.get("middleware") or []
996
+ req.append({"route": rid, "middleware": mw, "required": need, "missing": [x for x in need if x not in mw],
997
+ "at": loc(n.get("file"), n.get("line"))})
998
+ res["require"] = req
999
+ res["findings"] = finding_overlap(cx, cov | set(mm) | col.nodes)
1000
+ for f in res["findings"]:
1001
+ f["touches_plan"] = sorted(k for k in f["touches"] if is_covered(k, cov | set(mm)))
1002
+ if verify:
1003
+ res["verify"] = verify_impl(cx, baseline)
1004
+ order = {"missing": 0, "review": 1}
1005
+ res["items"] = sorted(col.items, key=lambda x: (order[x["severity"]], x["check"], x["node"]))
1006
+ unresolved = [r for r in refs if r["status"] in ("unresolved", "ambiguous")]
1007
+ items = res["items"]
1008
+ res["summary"] = {
1009
+ "references": len(refs), "unresolved": len(unresolved),
1010
+ "missing_from_plan": sum(1 for i in items if i["severity"] == "missing" and not i["covered"]),
1011
+ "review": sum(1 for i in items if i["severity"] == "review" and not i["covered"]),
1012
+ "covered": sum(1 for i in items if i["covered"]),
1013
+ "forbidden_paths_present": sum(1 for c in res["conflicts"] if c["present"]),
1014
+ "open_findings_touching": sum(1 for f in res["findings"] if f["touches"] and f["state"] == "open"),
1015
+ "unlinked_open_findings": sum(1 for f in res["findings"] if f["touches_plan"] and not f["linked"] and f["state"] == "open"),
1016
+ "requirements_failed": sum(1 for r in req if r["missing"]),
1017
+ }
1018
+ if verify:
1019
+ v = res["verify"]
1020
+ res["summary"]["verify"] = {k: f"{sum(1 for x in v[k] if x['ok'])}/{len(v[k])}" for k in ("nodes", "edges", "modified", "forbid", "require")}
1021
+ res["summary"]["verify_ok"] = all(x["ok"] for k in ("nodes", "edges", "forbid", "require") for x in v[k])
1022
+ s = res["summary"]
1023
+ res["ok"] = (not res["schema_errors"] and not unresolved and s["missing_from_plan"] == 0 and s["requirements_failed"] == 0
1024
+ and (s.get("verify_ok", True) if verify else True))
1025
+ return res
1026
+
1027
+
1028
+ # ----------------------------------------------------------------------------------------------- verify / baseline
1029
+ def _sha(txt: str | None) -> str | None:
1030
+ return hashlib.sha1(txt.encode()).hexdigest()[:12] if txt is not None else None
1031
+
1032
+
1033
+ def baseline_path(plan: dict) -> Path:
1034
+ return Path(plan["_file"]).with_suffix(".baseline.json")
1035
+
1036
+
1037
+ def load_baseline(plan: dict) -> dict | None:
1038
+ p = baseline_path(plan)
1039
+ return json.loads(p.read_text()) if p.is_file() else None
1040
+
1041
+
1042
+ def make_baseline(st: GraphStore, plan: dict, roots: dict[str, str] | None = None) -> dict:
1043
+ """Fingerprint the source span of every modified target (sha1 of its lines) so verify mode can tell whether the
1044
+ implementation touched it. Written next to the plan as <name>.baseline.json."""
1045
+ cx = Ctx(st, plan, roots)
1046
+ tg = {}
1047
+ for it in plan.get("modify") or []:
1048
+ for nid in cx.resolve(it["target"])["ids"]:
1049
+ n = cx.node(nid) or {}
1050
+ tg[nid] = {"file": n.get("file"), "line": n.get("line"), "end_line": n.get("end_line"), "sha1": _sha(cx.span_text(nid))}
1051
+ return {"plan": plan.get("name"), "graph_indexed_at": st.meta().get("indexed_at"), "targets": tg,
1052
+ "edges_present": {f"{it['from']} {it['kind']} {it['to']}": bool(edge_evidence(cx, it)) for it in plan.get("add_edges") or []}}
1053
+
1054
+
1055
+ def edge_evidence(cx: Ctx, it: dict) -> list[str]:
1056
+ """Real-graph evidence for a planned edge (READS_COLUMN also accepts MENTIONS_COLUMN; noted)."""
1057
+ srcs, dsts = cx.resolve(it["from"])["ids"], cx.resolve(it["to"])["ids"]
1058
+ kinds = (it["kind"], "MENTIONS_COLUMN") if it["kind"] == "READS_COLUMN" else (it["kind"],)
1059
+ def find(ss, via=None):
1060
+ ev = []
1061
+ for s in ss:
1062
+ for d in dsts:
1063
+ for r in cx.st.q(f"SELECT file, line, confidence, kind FROM edges WHERE src=? AND dst=? AND kind IN ({','.join('?' * len(kinds))})",
1064
+ (s, d, *kinds)):
1065
+ ev.append(f"{bloc(loc(r['file'], r['line']))} [{r['confidence']}{'' if r['kind'] == it['kind'] else ', as ' + r['kind']}"
1066
+ f"{', via ' + short(s) if via else ''}]")
1067
+ return ev
1068
+ ev = find(srcs)
1069
+ if not ev: # the planned edge may be implemented in a same-class helper the method calls (reserve -> reserveLocal)
1070
+ helpers = []
1071
+ for s in srcs:
1072
+ if "::" in s:
1073
+ own = s.split(":", 1)[1].split("::")[0] + "::"
1074
+ helpers += [h for h in cx.closure_fwd([s], max_depth=2) if h != s and h.split(":", 1)[1].startswith(own)]
1075
+ ev = find(sorted(set(helpers)), via=True)
1076
+ return ev
1077
+
1078
+
1079
+ def verify_impl(cx: Ctx, base: dict | None) -> dict:
1080
+ plan = cx.plan
1081
+ v = {"nodes": [], "edges": [], "modified": [], "forbid": [], "require": []}
1082
+ for it in plan.get("add_nodes") or []:
1083
+ n = cx.node(it["id"])
1084
+ v["nodes"].append({"id": it["id"], "ok": bool(n), "at": loc(n["file"], n["line"]) if n else None})
1085
+ for it in plan.get("add_edges") or []:
1086
+ ev = edge_evidence(cx, it)
1087
+ v["edges"].append({"edge": f"{short(it['from'])} -{it['kind']}-> {short(it['to'])}", "ok": bool(ev), "evidence": ev[:3]})
1088
+ for it in plan.get("modify") or []:
1089
+ for nid in cx.resolve(it["target"])["ids"]:
1090
+ now = _sha(cx.span_text(nid))
1091
+ was = ((base or {}).get("targets") or {}).get(nid, {}).get("sha1")
1092
+ state = "no baseline" if was is None else ("missing now" if now is None else ("changed" if now != was else "UNCHANGED"))
1093
+ v["modified"].append({"target": nid, "intent": it["intent"], "ok": state == "changed", "state": state})
1094
+ for f in plan.get("forbid") or []:
1095
+ s = forbid_status(cx, f)
1096
+ ok = (not s["present"]) or bool((s.get("guard") or {}).get("present"))
1097
+ v["forbid"].append({"id": f["id"], "ok": ok, "present": s["present"], "guard": s.get("guard"), "chain": s.get("chain")})
1098
+ for r in plan.get("require") or []:
1099
+ for rid in cx.resolve(r["route"])["ids"]:
1100
+ mw = json.loads((cx.node(rid) or {}).get("attrs") or "{}").get("middleware") or []
1101
+ miss = [x for x in r.get("middleware") or [] if x not in mw]
1102
+ v["require"].append({"route": rid, "ok": not miss, "missing": miss})
1103
+ return v
1104
+
1105
+
1106
+ # ----------------------------------------------------------------------------------------------- rendering
1107
+ def render_load(plan: dict) -> str:
1108
+ o = [f"PLAN {plan.get('name')} v{plan.get('plan_version')} [{plan.get('status', 'draft')}] {plan.get('title')}",
1109
+ f"file: {plan.get('_file')}"]
1110
+ if plan.get("issues"):
1111
+ o.append(f"issues: {', '.join(plan['issues'])}")
1112
+ if plan.get("rationale"):
1113
+ o.append("rationale: " + " ".join(str(plan["rationale"]).split())[:500])
1114
+ o += [f"assume: {a}" for a in plan.get("assumptions") or []]
1115
+ for it in plan.get("add_nodes") or []:
1116
+ o.append(f" + node {it['id']}" + (f" {json.dumps(it.get('attrs'))}" if it.get("attrs") else ""))
1117
+ for it in plan.get("modify") or []:
1118
+ o.append(f" ~ {it['target']}: {it['intent']}" + (f" ({', '.join(it['issues'])})" if it.get("issues") else ""))
1119
+ for it in plan.get("add_edges") or []:
1120
+ o.append(f" + edge {it['from']} -{it['kind']}-> {it['to']}" + (f" ({it['intent']})" if it.get("intent") else ""))
1121
+ for it in plan.get("forbid") or []:
1122
+ o.append(f" x forbid {it['id']}: {it.get('type', 'path')} {it['from']} => {it['to']}" + (f" when {it['when']}" if it.get("when") else ""))
1123
+ for it in plan.get("require") or []:
1124
+ o.append(f" ! require {it['route']} middleware {', '.join(it.get('middleware') or [])}")
1125
+ for it in plan.get("covers") or []:
1126
+ o.append(f" = covers {it['spec']}" + (f" ({it['note']})" if it.get("note") else ""))
1127
+ for it in plan.get("out_of_scope") or []:
1128
+ o.append(f" - out of scope {it['spec']}: {it['reason']}")
1129
+ for it in plan.get("precedents") or []:
1130
+ o.append(f" ? precedent {it['name']}: /{it['pattern']}/")
1131
+ ctx = plan.get("context") or {}
1132
+ if ctx:
1133
+ o.append(f"context: findings={ctx.get('findings')} clients={ctx.get('clients')}")
1134
+ if plan.get("_schema_errors"):
1135
+ o.append("SCHEMA ERRORS:")
1136
+ o += [f" {e}" for e in plan["_schema_errors"]]
1137
+ return "\n".join(o)
1138
+
1139
+
1140
+ def validate(st: GraphStore, plan: dict, roots=None) -> dict:
1141
+ cx = Ctx(st, plan, roots)
1142
+ rs = resolve_all(cx)
1143
+ bad = [r for r in rs["refs"] if r["status"] in ("unresolved", "ambiguous")]
1144
+ return {"plan": plan.get("name"), "schema_errors": plan.get("_schema_errors") or [], "refs": rs["refs"], "planned_nodes": rs["planned_nodes"],
1145
+ "unresolved": bad, "ok": not bad and not plan.get("_schema_errors")}
1146
+
1147
+
1148
+ def render_validate(v: dict) -> str:
1149
+ n = len(v["refs"])
1150
+ o = [f"plan {v['plan']}: schema {'OK' if not v['schema_errors'] else 'ERRORS'}; references {n - len(v['unresolved'])}/{n} resolve"
1151
+ + ("" if v["ok"] else " -> INVALID")]
1152
+ o += [f" schema: {e}" for e in v["schema_errors"]]
1153
+ for r in v["unresolved"]:
1154
+ o.append(f" {r['status'].upper()} {r['where']}: {r['spec']}" + (f" candidates: {', '.join(r['candidates'])}" if r.get("candidates") else ""))
1155
+ for p in v["planned_nodes"]:
1156
+ o.append(f" planned {p['id']}: {'ALREADY EXISTS in graph' if p['exists'] else 'new (not in graph yet)'}")
1157
+ return "\n".join(o)
1158
+
1159
+
1160
+ def _fmt_item(i: dict) -> str:
1161
+ b = f" [bypasses {', '.join(i['bypasses'])}]" if i.get("bypasses") else ""
1162
+ s = f" - [{i['check']}] {short(i['node'])}: {i['why']}{b}\n @ {', '.join(i['evidence'][:3]) or '-'}"
1163
+ if i.get("chain") and i["check"] != "entry_point":
1164
+ s += f"\n chain: {i['chain']}"
1165
+ return s
1166
+
1167
+
1168
+ def render_check(res: dict, max_items: int = 60, show_covered: bool = True) -> str:
1169
+ s = res["summary"]
1170
+ o = [f"PLAN CHECK {res['plan']} [{res['mode']} mode] {res['title']}",
1171
+ f"graph {res['graph'].get('project')} indexed {res['graph'].get('indexed_at')}; plan {res['file']}",
1172
+ f"summary: refs {s['references'] - s['unresolved']}/{s['references']} resolve | MISSING FROM PLAN {s['missing_from_plan']} | "
1173
+ f"review {s['review']} | covered {s['covered']} | forbidden paths present {s['forbidden_paths_present']} | "
1174
+ f"open findings touching {s['open_findings_touching']} (unlinked {s['unlinked_open_findings']}) | requirements failed {s['requirements_failed']}"
1175
+ + (f" | verify {s['verify']}" if s.get("verify") else "")]
1176
+ o += [f"SCHEMA {e}" for e in res["schema_errors"]]
1177
+ bad = [r for r in res["resolve"]["refs"] if r["status"] in ("unresolved", "ambiguous")]
1178
+ o += ["", "1 RESOLVE: " + ("all plan references resolve to graph nodes" if not bad else f"{len(bad)} problem(s)")]
1179
+ for r in bad:
1180
+ o.append(f" {r['status'].upper()} {r['where']}: {r['spec']}" + (f" -> {', '.join(r['candidates'])}" if r.get("candidates") else ""))
1181
+ for p in res["resolve"]["planned_nodes"]:
1182
+ o.append(f" planned {p['id']}: {'EXISTS in graph' if p['exists'] else 'new'}")
1183
+ o.append(" changed tables: " + ", ".join(f"{t} (+{', '.join(c) or 'no new columns'})" for t, c in res["changed_tables"].items()))
1184
+ o.append(" modified: " + ", ".join(short(x) for x in res["modified"]))
1185
+ for r in res.get("require") or []:
1186
+ o.append(f" require {r['route'].split(':', 1)[1]} [{', '.join(r['required'])}]: {'OK' if not r['missing'] else 'MISSING ' + ', '.join(r['missing'])}"
1187
+ f" (has {', '.join(r['middleware'])} @{bloc(r['at'])})")
1188
+ for rid, d in (res.get("middleware_diff") or {}).items():
1189
+ o.append(f" middleware: {rid.split(':', 1)[1]} lacks {', '.join(d['lacks'])} which peer route(s) "
1190
+ f"{', '.join(p.split(':', 1)[1] for p in d['peers'])} have (both reach {d['via']})")
1191
+ miss = [i for i in res["items"] if i["severity"] == "missing" and not i["covered"]]
1192
+ rev = [i for i in res["items"] if i["severity"] == "review" and not i["covered"]]
1193
+ o += ["", f"2 MISSING FROM PLAN ({len(miss)}): affected by the planned change, not covered by any plan entry"]
1194
+ o += [_fmt_item(i) for i in miss[:max_items]]
1195
+ o.append(f" REVIEW ({len(rev)}): related; confirm unaffected or add to covers/out_of_scope")
1196
+ o += [_fmt_item(i) for i in rev[:max_items]]
1197
+ if len(rev) > max_items:
1198
+ o.append(f" ... {len(rev) - max_items} more (see --json)")
1199
+ if show_covered:
1200
+ cv = [i for i in res["items"] if i["covered"]]
1201
+ cvn = sorted({short(i['node']) for i in cv})
1202
+ o.append(f" COVERED by plan ({len(cv)} items, {len(cvn)} nodes): " + ", ".join(cvn))
1203
+ for p in res.get("precedents") or []:
1204
+ o.append(f" PRECEDENT {p['name']} /{p['pattern']}/{' for ' + p['for'] if p.get('for') else ''}: {len(p['hits'])} hit(s)")
1205
+ o += [f" {short(h['node'])} @{bloc(h['at'])}: {h['text']}" for h in p["hits"][:4]]
1206
+ o += ["", "3 CONFLICTS"]
1207
+ for c in res["conflicts"]:
1208
+ g = c.get("guard")
1209
+ gs = (f"; guard ({g['at']} {g['needs']}): " + ("PRESENT " + ", ".join(bloc(e) for e in g["evidence"]) if g["present"] else "ABSENT")) if g else ""
1210
+ o.append(f" forbid {c['id']}: path {'STILL PRESENT' if c['present'] else 'absent'}" + (f" (when {c['when']})" if c.get("when") else "") + gs)
1211
+ if c.get("chain"):
1212
+ o.append(f" {c['chain']}")
1213
+ for f in res["findings"]:
1214
+ if not f["touches"]:
1215
+ continue
1216
+ tag = "linked in plan" if f["linked"] else ("NOT LINKED in plan" if f["touches_plan"] else "touches gaps only")
1217
+ o.append(f" {f['id']} [{f['state']}, {tag}] {f['title']}")
1218
+ o.append(" " + "; ".join(f"{short(k)}{'*' if k in f['touches_plan'] else ''} {','.join(v)}" for k, v in list(f["touches"].items())[:7]))
1219
+ if res.get("findings"):
1220
+ o.append(" (* = node the plan modifies/references)")
1221
+ if res.get("verify"):
1222
+ v = res["verify"]
1223
+ o += ["", f"4 VERIFY implementation vs plan: {'OK' if s.get('verify_ok') else 'INCOMPLETE'}"]
1224
+ o += [f" node {x['id']}: {'exists @' + bloc(x['at']) if x['ok'] else 'MISSING'}" for x in v["nodes"]]
1225
+ o += [f" edge {x['edge']}: {'exists ' + ', '.join(x['evidence']) if x['ok'] else 'MISSING'}" for x in v["edges"]]
1226
+ o += [f" modified {short(x['target'])}: {x['state']}" for x in v["modified"]]
1227
+ for x in v["forbid"]:
1228
+ o.append(f" forbid {x['id']}: {'ok' if x['ok'] else 'VIOLATED'} (path {'present' if x['present'] else 'gone'}"
1229
+ + (f", guard {'present ' + ', '.join(bloc(e) for e in x['guard']['evidence']) if x['guard']['present'] else 'absent'}" if x.get("guard") else "") + ")")
1230
+ o += [f" require {x['route'].split(':', 1)[1]}: {'ok' if x['ok'] else 'MISSING ' + ', '.join(x['missing'])}" for x in v["require"]]
1231
+ chains = [i["chain"] for i in res["items"] if i["check"] == "entry_point" and i.get("chain")]
1232
+ if chains:
1233
+ o += ["", "ENTRY CHAINS (entry point -> modified code)"] + [f" {c}" for c in dict.fromkeys(chains)]
1234
+ return "\n".join(o)
1235
+
1236
+
1237
+ def render_check_summary(res: dict, max_items: int = 5) -> str:
1238
+ """Compact plan check: counts per section and per check, the top missing items (one line each), failed
1239
+ requirements, middleware gaps, forbidden paths, open findings and verify counts. The full report is render_check."""
1240
+ s = res["summary"]
1241
+ o = [f"PLAN CHECK {res['plan']} [{res['mode']} mode] {res['title']}",
1242
+ f"plan {res['file']} | graph {res['graph'].get('project')} indexed {res['graph'].get('indexed_at')}",
1243
+ f"summary: refs {s['references'] - s['unresolved']}/{s['references']} resolve | MISSING FROM PLAN {s['missing_from_plan']} | "
1244
+ f"review {s['review']} | covered {s['covered']} | forbidden paths present {s['forbidden_paths_present']} | "
1245
+ f"open findings touching {s['open_findings_touching']} (unlinked {s['unlinked_open_findings']}) | requirements failed {s['requirements_failed']}"
1246
+ + (f" | verify {s['verify']}" if s.get("verify") else "")]
1247
+ o += [f"SCHEMA {e}" for e in res["schema_errors"]]
1248
+ bad = [r for r in res["resolve"]["refs"] if r["status"] in ("unresolved", "ambiguous")]
1249
+ o += [f" {r['status'].upper()} {r['where']}: {r['spec']}" for r in bad[:max_items]]
1250
+ for r in res.get("require") or []:
1251
+ if r["missing"]:
1252
+ o.append(f"require {r['route'].split(':', 1)[1]}: MISSING {', '.join(r['missing'])} (has {', '.join(r['middleware']) or 'none'} @{bloc(r['at'])})")
1253
+ for rid, d in (res.get("middleware_diff") or {}).items():
1254
+ o.append(f"middleware: {rid.split(':', 1)[1]} lacks {', '.join(d['lacks'])} which peer route(s) "
1255
+ f"{', '.join(p.split(':', 1)[1] for p in d['peers'])} have")
1256
+ miss = [i for i in res["items"] if i["severity"] == "missing" and not i["covered"]]
1257
+ rev = [i for i in res["items"] if i["severity"] == "review" and not i["covered"]]
1258
+
1259
+ def by_check(items):
1260
+ c = defaultdict(int)
1261
+ for i in items:
1262
+ c[i["check"]] += 1
1263
+ return ", ".join(f"{k} {v}" for k, v in sorted(c.items(), key=lambda kv: (-kv[1], kv[0]))) or "-"
1264
+ o += ["", f"2 MISSING FROM PLAN ({len(miss)}) by check: {by_check(miss)}"]
1265
+ for i in miss[:max_items]:
1266
+ o.append(f" - [{i['check']}] {short(i['node'])}: {i['why']} @ {(i['evidence'] or ['-'])[0]}")
1267
+ if len(miss) > max_items:
1268
+ o.append(f" … +{len(miss) - max_items} more")
1269
+ o.append(f" REVIEW ({len(rev)}) by check: {by_check(rev)}")
1270
+ o += ["", "3 CONFLICTS"]
1271
+ for c in res["conflicts"]:
1272
+ o.append(f" forbid {c['id']}: path {'STILL PRESENT' if c['present'] else 'absent'}" + (f" (when {c['when']})" if c.get("when") else ""))
1273
+ for f in res["findings"]:
1274
+ if f["touches"]:
1275
+ tag = "linked in plan" if f["linked"] else ("NOT LINKED in plan" if f["touches_plan"] else "touches gaps only")
1276
+ o.append(f" {f['id']} [{f['state']}, {tag}] {f['title']}")
1277
+ if res.get("verify"):
1278
+ v = res["verify"]
1279
+ o += ["", f"4 VERIFY implementation vs plan: {'OK' if s.get('verify_ok') else 'INCOMPLETE'} "
1280
+ f"({', '.join(f'{k} {x}' for k, x in s['verify'].items())})"]
1281
+ o += [f" NOT MET {k[:-1] if k.endswith('s') else k}: {x.get('id') or x.get('edge') or x.get('target') or x.get('route')}"
1282
+ for k in ("nodes", "edges", "forbid", "require") for x in v[k] if not x["ok"]][:max_items]
1283
+ o += ["", "details=true for every item with file:line evidence and call chains (CLI: plan check without --summary); "
1284
+ "max_items=N shows more top items."]
1285
+ return "\n".join(o)