cg-code-graph 0.10.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cg_code_graph-0.10.1.dist-info/METADATA +678 -0
- cg_code_graph-0.10.1.dist-info/RECORD +174 -0
- cg_code_graph-0.10.1.dist-info/WHEEL +5 -0
- cg_code_graph-0.10.1.dist-info/entry_points.txt +3 -0
- cg_code_graph-0.10.1.dist-info/licenses/LICENSE +21 -0
- cg_code_graph-0.10.1.dist-info/top_level.txt +1 -0
- codegraph/__init__.py +2 -0
- codegraph/aitools.py +129 -0
- codegraph/apps.py +76 -0
- codegraph/blindspots.py +428 -0
- codegraph/bridges.py +1701 -0
- codegraph/cli.py +725 -0
- codegraph/concepts.py +362 -0
- codegraph/config.py +559 -0
- codegraph/core/__init__.py +0 -0
- codegraph/core/cache.py +375 -0
- codegraph/core/detect.py +80 -0
- codegraph/core/extractors.py +187 -0
- codegraph/core/fsutil.py +61 -0
- codegraph/core/generated.py +575 -0
- codegraph/core/model.py +174 -0
- codegraph/core/paths.py +175 -0
- codegraph/core/plugin.py +160 -0
- codegraph/core/store.py +80 -0
- codegraph/core/syntax_errors.py +132 -0
- codegraph/coverage.py +928 -0
- codegraph/doctor.py +453 -0
- codegraph/external.py +613 -0
- codegraph/indexer.py +336 -0
- codegraph/link.py +434 -0
- codegraph/lint_async.py +524 -0
- codegraph/mcp_server.py +1303 -0
- codegraph/parity.py +473 -0
- codegraph/parity_structure.py +307 -0
- codegraph/payload.py +321 -0
- codegraph/plans.py +1285 -0
- codegraph/platform_scan.py +643 -0
- codegraph/platforms.py +1369 -0
- codegraph/plugins/__init__.py +0 -0
- codegraph/plugins/cfamily/__init__.py +0 -0
- codegraph/plugins/cfamily/plugin.py +930 -0
- codegraph/plugins/cfamily/syntax.py +881 -0
- codegraph/plugins/dart/__init__.py +0 -0
- codegraph/plugins/dart/bridges.py +345 -0
- codegraph/plugins/dart/extractor/bin/extract.dart +717 -0
- codegraph/plugins/dart/extractor/pubspec.lock +149 -0
- codegraph/plugins/dart/extractor/pubspec.yaml +7 -0
- codegraph/plugins/dart/http.py +904 -0
- codegraph/plugins/dart/models.py +308 -0
- codegraph/plugins/dart/plugin.py +625 -0
- codegraph/plugins/dart/program.py +907 -0
- codegraph/plugins/django/__init__.py +0 -0
- codegraph/plugins/django/extras.py +378 -0
- codegraph/plugins/django/models.py +508 -0
- codegraph/plugins/django/plugin.py +728 -0
- codegraph/plugins/django/schemas.py +339 -0
- codegraph/plugins/django/shapes.py +216 -0
- codegraph/plugins/django/urls.py +603 -0
- codegraph/plugins/express/__init__.py +0 -0
- codegraph/plugins/express/plugin.py +428 -0
- codegraph/plugins/flutter/__init__.py +0 -0
- codegraph/plugins/flutter/plugin.py +538 -0
- codegraph/plugins/kotlin/__init__.py +0 -0
- codegraph/plugins/kotlin/exact.py +457 -0
- codegraph/plugins/kotlin/plugin.py +1961 -0
- codegraph/plugins/kotlin/reparse.py +234 -0
- codegraph/plugins/laravel/__init__.py +0 -0
- codegraph/plugins/laravel/broadcast.py +351 -0
- codegraph/plugins/laravel/plugin.py +863 -0
- codegraph/plugins/laravel/tests.py +262 -0
- codegraph/plugins/laravel/values.py +728 -0
- codegraph/plugins/native/__init__.py +0 -0
- codegraph/plugins/native/gates.py +286 -0
- codegraph/plugins/native/runner.py +183 -0
- codegraph/plugins/native/scipread.py +194 -0
- codegraph/plugins/native/ts.py +54 -0
- codegraph/plugins/nest/__init__.py +0 -0
- codegraph/plugins/nest/plugin.py +654 -0
- codegraph/plugins/nextjs/__init__.py +0 -0
- codegraph/plugins/nextjs/plugin.py +336 -0
- codegraph/plugins/nuxt/__init__.py +0 -0
- codegraph/plugins/nuxt/plugin.py +308 -0
- codegraph/plugins/php/__init__.py +0 -0
- codegraph/plugins/php/extractor/composer.json +5 -0
- codegraph/plugins/php/extractor/composer.lock +76 -0
- codegraph/plugins/php/extractor/extract.php +743 -0
- codegraph/plugins/php/gating.py +573 -0
- codegraph/plugins/php/plugin.py +668 -0
- codegraph/plugins/php/strings.py +197 -0
- codegraph/plugins/python/__init__.py +0 -0
- codegraph/plugins/python/aitools.py +664 -0
- codegraph/plugins/python/external.py +245 -0
- codegraph/plugins/python/fields.py +107 -0
- codegraph/plugins/python/plugin.py +1733 -0
- codegraph/plugins/python/refs.py +485 -0
- codegraph/plugins/python/roots.py +412 -0
- codegraph/plugins/python/socketio.py +210 -0
- codegraph/plugins/python/subproc.py +864 -0
- codegraph/plugins/python/tests.py +1040 -0
- codegraph/plugins/python/values.py +179 -0
- codegraph/plugins/pyweb/__init__.py +0 -0
- codegraph/plugins/pyweb/plugin.py +1334 -0
- codegraph/plugins/pyweb/values.py +68 -0
- codegraph/plugins/rust/__init__.py +0 -0
- codegraph/plugins/rust/cargo.py +226 -0
- codegraph/plugins/rust/plugin.py +980 -0
- codegraph/plugins/rust/syntax.py +678 -0
- codegraph/plugins/scip/__init__.py +0 -0
- codegraph/plugins/scip/importer.py +129 -0
- codegraph/plugins/scip/scip.proto +962 -0
- codegraph/plugins/scip/scip_pb2.py +97 -0
- codegraph/plugins/stubs/__init__.py +0 -0
- codegraph/plugins/stubs/plugins.py +38 -0
- codegraph/plugins/swift/__init__.py +0 -0
- codegraph/plugins/swift/baseurl.py +109 -0
- codegraph/plugins/swift/exact.py +415 -0
- codegraph/plugins/swift/indexstore.py +209 -0
- codegraph/plugins/swift/packages.py +174 -0
- codegraph/plugins/swift/plugin.py +2890 -0
- codegraph/plugins/ts/__init__.py +0 -0
- codegraph/plugins/ts/baseurl.py +185 -0
- codegraph/plugins/ts/extractor/extract.mjs +2652 -0
- codegraph/plugins/ts/extractor/fw.mjs +685 -0
- codegraph/plugins/ts/extractor/package-lock.json +205 -0
- codegraph/plugins/ts/extractor/package.json +9 -0
- codegraph/plugins/ts/plugin.py +480 -0
- codegraph/plugins/tsweb/__init__.py +0 -0
- codegraph/plugins/tsweb/common.py +290 -0
- codegraph/plugins/tsweb/data.py +276 -0
- codegraph/presets/__init__.py +146 -0
- codegraph/presets/c_cpp.yaml +9 -0
- codegraph/presets/common.yaml +66 -0
- codegraph/presets/dart.yaml +9 -0
- codegraph/presets/django-ninja.yaml +15 -0
- codegraph/presets/django.yaml +25 -0
- codegraph/presets/djangorestframework.yaml +17 -0
- codegraph/presets/express.yaml +17 -0
- codegraph/presets/kotlin.yaml +11 -0
- codegraph/presets/laravel.yaml +40 -0
- codegraph/presets/nest.yaml +11 -0
- codegraph/presets/nextjs.yaml +15 -0
- codegraph/presets/nuxt.yaml +9 -0
- codegraph/presets/php.yaml +5 -0
- codegraph/presets/python.yaml +10 -0
- codegraph/presets/rust.yaml +5 -0
- codegraph/presets/swift.yaml +10 -0
- codegraph/presets/typescript.yaml +13 -0
- codegraph/process_runs.py +328 -0
- codegraph/protocols/__init__.py +299 -0
- codegraph/protocols/builtin.py +67 -0
- codegraph/protocols/matchers.py +144 -0
- codegraph/protocols/view.py +334 -0
- codegraph/query.py +2089 -0
- codegraph/realtime.py +260 -0
- codegraph/roundtrip.py +346 -0
- codegraph/routes.py +442 -0
- codegraph/starters.py +218 -0
- codegraph/tests_index.py +117 -0
- codegraph/viz/__init__.py +0 -0
- codegraph/viz/graph.py +369 -0
- codegraph/viz/server.py +198 -0
- codegraph/viz/static/app.css +148 -0
- codegraph/viz/static/app.js +1082 -0
- codegraph/viz/static/index.html +81 -0
- codegraph/viz/static/layered.js +237 -0
- codegraph/viz/static/vendor/VERSIONS.txt +4 -0
- codegraph/viz/static/vendor/cose-base.js +3214 -0
- codegraph/viz/static/vendor/cytoscape-fcose.js +1549 -0
- codegraph/viz/static/vendor/cytoscape.min.js +31 -0
- codegraph/viz/static/vendor/layout-base.js +5230 -0
- codegraph/viz/tools/package-lock.json +303 -0
- codegraph/viz/tools/package.json +7 -0
- codegraph/viz/tools/shoot.mjs +165 -0
- codegraph/xcode.py +251 -0
codegraph/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)
|