@yottameta/yotta-dev-mcp-plugin 0.1.1 → 0.2.1
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.
- package/.agents/plugins/marketplace.json +3 -3
- package/.claude-plugin/marketplace.json +3 -3
- package/package.json +1 -1
- package/plugin.json +2 -2
- package/skills/yotta-dev-mcp/SKILL.md +24 -9
- package/skills/yotta-dev-mcp/references/adapters.md +69 -0
- package/skills/yotta-dev-mcp/references/architecture-contract.md +256 -0
- package/skills/yotta-dev-mcp/references/tools.md +182 -3
- package/skills/yotta-dev-mcp/scripts/dev_adapters.py +609 -0
- package/skills/yotta-dev-mcp/scripts/dev_architecture.py +379 -0
- package/skills/yotta-dev-mcp/scripts/dev_common.py +155 -0
- package/skills/yotta-dev-mcp/scripts/dev_contract.py +830 -0
- package/skills/yotta-dev-mcp/scripts/dev_engine.py +328 -210
- package/skills/yotta-dev-mcp/scripts/dev_impact.py +556 -0
- package/skills/yotta-dev-mcp/scripts/dev_mcp_doctor.py +768 -0
- package/skills/yotta-dev-mcp/scripts/dev_model.py +447 -0
- package/skills/yotta-dev-mcp/scripts/dev_selftest.py +544 -0
- package/skills/yotta-dev-mcp/scripts/dev_verify.py +450 -0
- package/skills/yotta-dev-mcp/scripts/yotta_dev_mcp.py +246 -7
- package/skills/yotta-dev-mcp/server.json +3 -3
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Change impact cone analysis for yotta-dev-mcp."""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import dev_contract
|
|
9
|
+
from dev_architecture import _architecture_review_core
|
|
10
|
+
from dev_common import (
|
|
11
|
+
BLAST_LEVELS, CONE_LIMIT, EVIDENCE_LIMIT, SYMBOL_MATCH_LIMIT,
|
|
12
|
+
UNKNOWN_LIMIT, _read_text,
|
|
13
|
+
)
|
|
14
|
+
from dev_model import (
|
|
15
|
+
_decisive_edges, _is_test_file, _module_layers, _risk_weight, system_model,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
HUNK_RE = re.compile(r"^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@")
|
|
20
|
+
|
|
21
|
+
SYMBOL_PATTERNS = (
|
|
22
|
+
("python", r"^\s*(?:async\s+)?def\s+%s\s*\("),
|
|
23
|
+
("python", r"^\s*class\s+%s\s*[\(:]"),
|
|
24
|
+
("js", r"^\s*(?:export\s+)?(?:async\s+)?function\s+%s\s*\("),
|
|
25
|
+
("js", r"^\s*(?:export\s+)?(?:const|let|var)\s+%s\s*="),
|
|
26
|
+
("js", r"^\s*(?:export\s+)?(?:abstract\s+)?class\s+%s\b"),
|
|
27
|
+
("go", r"^\s*func\s+(?:\([^)]*\)\s*)?%s\s*\("),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
SYMBOL_LANGUAGES = {
|
|
31
|
+
"python": ("python",),
|
|
32
|
+
"js": ("javascript", "typescript"),
|
|
33
|
+
"go": ("go",),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
def _strip_diff_path(raw):
|
|
37
|
+
text = str(raw).strip().split("\t")[0].strip()
|
|
38
|
+
if not text or text == "/dev/null":
|
|
39
|
+
return None
|
|
40
|
+
if text.startswith("a/") or text.startswith("b/"):
|
|
41
|
+
text = text[2:]
|
|
42
|
+
return text.replace("\\", "/")
|
|
43
|
+
|
|
44
|
+
def _parse_unified_diff(diff_text):
|
|
45
|
+
"""Parse a unified diff into changed files plus added/removed line numbers."""
|
|
46
|
+
changes = []
|
|
47
|
+
current = None
|
|
48
|
+
old_path = None
|
|
49
|
+
old_line = 0
|
|
50
|
+
new_line = 0
|
|
51
|
+
for line in str(diff_text).splitlines():
|
|
52
|
+
if line.startswith("--- "):
|
|
53
|
+
old_path = _strip_diff_path(line[4:])
|
|
54
|
+
current = None
|
|
55
|
+
continue
|
|
56
|
+
if line.startswith("+++ "):
|
|
57
|
+
new_path = _strip_diff_path(line[4:])
|
|
58
|
+
if new_path is None:
|
|
59
|
+
if old_path is None:
|
|
60
|
+
continue
|
|
61
|
+
path, change = old_path, "deleted"
|
|
62
|
+
elif old_path is None:
|
|
63
|
+
path, change = new_path, "added"
|
|
64
|
+
else:
|
|
65
|
+
path, change = new_path, "modified"
|
|
66
|
+
current = {"path": path, "change": change,
|
|
67
|
+
"changed_lines": [], "removed_lines": []}
|
|
68
|
+
changes.append(current)
|
|
69
|
+
continue
|
|
70
|
+
match = HUNK_RE.match(line)
|
|
71
|
+
if match:
|
|
72
|
+
old_line = int(match.group(1))
|
|
73
|
+
new_line = int(match.group(2))
|
|
74
|
+
continue
|
|
75
|
+
if current is None:
|
|
76
|
+
continue
|
|
77
|
+
if line.startswith("+") and not line.startswith("+++"):
|
|
78
|
+
current["changed_lines"].append(new_line)
|
|
79
|
+
new_line += 1
|
|
80
|
+
elif line.startswith("-") and not line.startswith("---"):
|
|
81
|
+
current["removed_lines"].append(old_line)
|
|
82
|
+
old_line += 1
|
|
83
|
+
elif line.startswith(" "):
|
|
84
|
+
old_line += 1
|
|
85
|
+
new_line += 1
|
|
86
|
+
for item in changes:
|
|
87
|
+
item["changed_lines"] = sorted(set(item["changed_lines"]))
|
|
88
|
+
item["removed_lines"] = sorted(set(item["removed_lines"]))
|
|
89
|
+
return changes
|
|
90
|
+
|
|
91
|
+
def _symbol_locations(model, root, symbol):
|
|
92
|
+
escaped = re.escape(symbol)
|
|
93
|
+
hits = []
|
|
94
|
+
for module in model["modules"]:
|
|
95
|
+
language = module["language"]
|
|
96
|
+
patterns = [
|
|
97
|
+
re.compile(pattern % escaped)
|
|
98
|
+
for kind, pattern in SYMBOL_PATTERNS
|
|
99
|
+
if language in SYMBOL_LANGUAGES[kind]
|
|
100
|
+
]
|
|
101
|
+
if not patterns:
|
|
102
|
+
continue
|
|
103
|
+
try:
|
|
104
|
+
text = _read_text(root / module["id"])
|
|
105
|
+
except (OSError, ValueError):
|
|
106
|
+
continue
|
|
107
|
+
for lineno, line in enumerate(text.splitlines(), 1):
|
|
108
|
+
if any(pattern.match(line) for pattern in patterns):
|
|
109
|
+
hits.append({"path": module["id"], "line": lineno, "language": language})
|
|
110
|
+
if len(hits) >= SYMBOL_MATCH_LIMIT:
|
|
111
|
+
return hits
|
|
112
|
+
return hits
|
|
113
|
+
|
|
114
|
+
def _dependency_cone(model, changed_paths, depth):
|
|
115
|
+
"""Reverse-dependency breadth-first cone from the changed modules."""
|
|
116
|
+
consumers = {}
|
|
117
|
+
for edge in _decisive_edges(model):
|
|
118
|
+
consumers.setdefault(edge["target"], []).append(edge)
|
|
119
|
+
module_layers = _module_layers(model)
|
|
120
|
+
test_paths = {item["path"] for item in model["tests"]}
|
|
121
|
+
nodes = {}
|
|
122
|
+
order = []
|
|
123
|
+
for path in sorted(set(changed_paths)):
|
|
124
|
+
nodes[path] = {
|
|
125
|
+
"path": path, "depth": 0, "via": None, "line": None,
|
|
126
|
+
"layer": module_layers.get(path), "is_test": path in test_paths,
|
|
127
|
+
}
|
|
128
|
+
order.append(path)
|
|
129
|
+
truncated = False
|
|
130
|
+
index = 0
|
|
131
|
+
while index < len(order):
|
|
132
|
+
current = order[index]
|
|
133
|
+
index += 1
|
|
134
|
+
node = nodes[current]
|
|
135
|
+
if node["depth"] >= depth:
|
|
136
|
+
continue
|
|
137
|
+
for edge in sorted(consumers.get(current, []),
|
|
138
|
+
key=lambda item: (item["source"], item["line"])):
|
|
139
|
+
consumer = edge["source"]
|
|
140
|
+
if consumer in nodes:
|
|
141
|
+
continue
|
|
142
|
+
if len(nodes) >= CONE_LIMIT:
|
|
143
|
+
truncated = True
|
|
144
|
+
break
|
|
145
|
+
nodes[consumer] = {
|
|
146
|
+
"path": consumer,
|
|
147
|
+
"depth": node["depth"] + 1,
|
|
148
|
+
"via": current,
|
|
149
|
+
"line": edge["line"],
|
|
150
|
+
"layer": module_layers.get(consumer),
|
|
151
|
+
"is_test": consumer in test_paths,
|
|
152
|
+
}
|
|
153
|
+
order.append(consumer)
|
|
154
|
+
if truncated:
|
|
155
|
+
break
|
|
156
|
+
return nodes, truncated
|
|
157
|
+
|
|
158
|
+
def _relevant_tests(model, nodes):
|
|
159
|
+
relevant = []
|
|
160
|
+
for item in model["tests"]:
|
|
161
|
+
hits = sorted({target for target in item["targets"] if target in nodes})
|
|
162
|
+
in_cone = item["path"] in nodes
|
|
163
|
+
if not hits and not in_cone:
|
|
164
|
+
continue
|
|
165
|
+
if in_cone:
|
|
166
|
+
depth = nodes[item["path"]]["depth"]
|
|
167
|
+
elif hits:
|
|
168
|
+
depth = min(nodes[target]["depth"] for target in hits) + 1
|
|
169
|
+
else:
|
|
170
|
+
depth = 0
|
|
171
|
+
relevant.append({"path": item["path"], "targets_hit": hits, "depth": depth})
|
|
172
|
+
return sorted(relevant, key=lambda item: item["path"])
|
|
173
|
+
|
|
174
|
+
def _affected_boundaries(contract, paths):
|
|
175
|
+
affected = []
|
|
176
|
+
for boundary in (contract or {}).get("boundaries") or []:
|
|
177
|
+
matched = sorted(path for path in paths
|
|
178
|
+
if dev_contract.match_any(path, boundary["paths"] or []))
|
|
179
|
+
if matched:
|
|
180
|
+
affected.append({
|
|
181
|
+
"id": boundary["id"],
|
|
182
|
+
"layer": boundary["layer"],
|
|
183
|
+
"visibility": boundary.get("visibility") or "internal",
|
|
184
|
+
"matched_paths": matched,
|
|
185
|
+
})
|
|
186
|
+
return sorted(affected, key=lambda item: item["id"])
|
|
187
|
+
|
|
188
|
+
def _affected_data_stores(model, affected_layers, paths):
|
|
189
|
+
affected = []
|
|
190
|
+
for store in model["data_stores"]:
|
|
191
|
+
if store.get("detected"):
|
|
192
|
+
matched = sorted(path for path in paths if path == store["store"])
|
|
193
|
+
if matched:
|
|
194
|
+
affected.append({
|
|
195
|
+
"store": store["store"],
|
|
196
|
+
"owner": store.get("owner"),
|
|
197
|
+
"kind": store.get("kind"),
|
|
198
|
+
"reason": "changed path is the detected store file",
|
|
199
|
+
"matched_paths": matched,
|
|
200
|
+
})
|
|
201
|
+
continue
|
|
202
|
+
reasons = []
|
|
203
|
+
matched = sorted(path for path in paths
|
|
204
|
+
if dev_contract.match_any(path, store["paths"] or []))
|
|
205
|
+
if store.get("owner") in affected_layers:
|
|
206
|
+
reasons.append("owner layer %s is in the impact cone" % store["owner"])
|
|
207
|
+
if matched:
|
|
208
|
+
reasons.append("changed path matches the declared store globs")
|
|
209
|
+
if reasons:
|
|
210
|
+
affected.append({
|
|
211
|
+
"store": store["store"],
|
|
212
|
+
"owner": store.get("owner"),
|
|
213
|
+
"kind": store.get("kind"),
|
|
214
|
+
"reason": "; ".join(reasons),
|
|
215
|
+
"matched_paths": matched,
|
|
216
|
+
})
|
|
217
|
+
return sorted(affected, key=lambda item: item["store"])
|
|
218
|
+
|
|
219
|
+
def _affected_invariants(contract, paths):
|
|
220
|
+
affected = []
|
|
221
|
+
for invariant in (contract or {}).get("invariants") or []:
|
|
222
|
+
globs = invariant.get("paths") or []
|
|
223
|
+
matched = sorted(path for path in paths
|
|
224
|
+
if globs and dev_contract.match_any(path, globs))
|
|
225
|
+
if globs and not matched:
|
|
226
|
+
continue
|
|
227
|
+
affected.append({
|
|
228
|
+
"id": invariant["id"],
|
|
229
|
+
"claim": invariant["claim"],
|
|
230
|
+
"severity": invariant.get("severity") or dev_contract.RULE_DEFAULT_SEVERITY,
|
|
231
|
+
"check": invariant.get("check") or "manual",
|
|
232
|
+
"scope": "paths" if globs else "repo",
|
|
233
|
+
"matched_paths": matched,
|
|
234
|
+
})
|
|
235
|
+
return sorted(affected, key=lambda item: item["id"])
|
|
236
|
+
|
|
237
|
+
def _blast_radius(contract, nodes, affected_layers, boundaries, stores, in_scope):
|
|
238
|
+
reasons = []
|
|
239
|
+
score = 0
|
|
240
|
+
if affected_layers:
|
|
241
|
+
base = max(_risk_weight(contract, layer) for layer in affected_layers)
|
|
242
|
+
weight = int(min(5, base))
|
|
243
|
+
if weight:
|
|
244
|
+
score += weight
|
|
245
|
+
reasons.append({
|
|
246
|
+
"factor": "layer-risk",
|
|
247
|
+
"weight": weight,
|
|
248
|
+
"detail": "highest declared risk among affected layers is %g" % base,
|
|
249
|
+
})
|
|
250
|
+
if len(affected_layers) >= 5:
|
|
251
|
+
score += 2
|
|
252
|
+
reasons.append({"factor": "layer-count", "weight": 2,
|
|
253
|
+
"detail": "%d layers are affected" % len(affected_layers)})
|
|
254
|
+
elif len(affected_layers) >= 3:
|
|
255
|
+
score += 1
|
|
256
|
+
reasons.append({"factor": "layer-count", "weight": 1,
|
|
257
|
+
"detail": "%d layers are affected" % len(affected_layers)})
|
|
258
|
+
depth = max((node["depth"] for node in nodes.values()), default=0)
|
|
259
|
+
if depth >= 2:
|
|
260
|
+
score += 1
|
|
261
|
+
reasons.append({"factor": "cone-depth", "weight": 1,
|
|
262
|
+
"detail": "consumer chain reaches depth %d" % depth})
|
|
263
|
+
public_surface = [item["id"] for item in boundaries if item["visibility"] == "public"]
|
|
264
|
+
if public_surface:
|
|
265
|
+
score += 1
|
|
266
|
+
reasons.append({"factor": "public-boundary", "weight": 1,
|
|
267
|
+
"detail": "public boundary in scope: %s" % ", ".join(public_surface)})
|
|
268
|
+
if stores:
|
|
269
|
+
weight = min(2, len(stores))
|
|
270
|
+
score += weight
|
|
271
|
+
reasons.append({"factor": "data-store", "weight": weight,
|
|
272
|
+
"detail": "%d data store(s) touched" % len(stores)})
|
|
273
|
+
blocking = [item for item in in_scope
|
|
274
|
+
if item["severity"] in dev_contract.BLOCKING_SEVERITIES]
|
|
275
|
+
if blocking:
|
|
276
|
+
critical = any(item["severity"] == "critical" for item in blocking)
|
|
277
|
+
weight = 3 if critical else 2
|
|
278
|
+
score += weight
|
|
279
|
+
reasons.append({"factor": "architecture-violation", "weight": weight,
|
|
280
|
+
"detail": "%d blocking architecture violation(s) in scope"
|
|
281
|
+
% len(blocking)})
|
|
282
|
+
capped = min(10, score)
|
|
283
|
+
level = "low"
|
|
284
|
+
for threshold, name in BLAST_LEVELS:
|
|
285
|
+
if capped >= threshold:
|
|
286
|
+
level = name
|
|
287
|
+
break
|
|
288
|
+
return {
|
|
289
|
+
"level": level,
|
|
290
|
+
"score": score,
|
|
291
|
+
"capped_score": capped,
|
|
292
|
+
"capped": score > capped,
|
|
293
|
+
"reasons": reasons,
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
def _rollback_probes(model, nodes, stores, tests, invariants):
|
|
297
|
+
probes = []
|
|
298
|
+
# Test files that guard on __main__ are runnable, but they are covered by the
|
|
299
|
+
# test probe below; treating them as startup surfaces only adds noise.
|
|
300
|
+
entrypoints = sorted(path for path in set(model["entrypoints"]) & set(nodes)
|
|
301
|
+
if not _is_test_file(path))[:5]
|
|
302
|
+
for store in stores:
|
|
303
|
+
probes.append({
|
|
304
|
+
"kind": "data-store",
|
|
305
|
+
"target": store["store"],
|
|
306
|
+
"probe": "revert the change and verify %s integrity, or restore it from backup"
|
|
307
|
+
% store["store"],
|
|
308
|
+
"evidence": store["reason"],
|
|
309
|
+
})
|
|
310
|
+
for entry in entrypoints:
|
|
311
|
+
probes.append({
|
|
312
|
+
"kind": "entrypoint",
|
|
313
|
+
"target": entry,
|
|
314
|
+
"probe": "revert and run %s once to confirm startup still works" % entry,
|
|
315
|
+
"evidence": "entrypoint in the impact cone",
|
|
316
|
+
})
|
|
317
|
+
if tests:
|
|
318
|
+
probes.append({
|
|
319
|
+
"kind": "tests",
|
|
320
|
+
"target": ", ".join(item["path"] for item in tests[:5]),
|
|
321
|
+
"probe": "run the mapped tests before and after revert",
|
|
322
|
+
"evidence": "%d mapped test file(s)" % len(tests),
|
|
323
|
+
})
|
|
324
|
+
for invariant in invariants:
|
|
325
|
+
if invariant["check"] != "command":
|
|
326
|
+
continue
|
|
327
|
+
probes.append({
|
|
328
|
+
"kind": "invariant",
|
|
329
|
+
"target": invariant["id"],
|
|
330
|
+
"probe": "run the declared check for %s after revert" % invariant["id"],
|
|
331
|
+
"evidence": invariant["claim"],
|
|
332
|
+
})
|
|
333
|
+
if not probes:
|
|
334
|
+
probes.append({
|
|
335
|
+
"kind": "model",
|
|
336
|
+
"target": "L0/L1",
|
|
337
|
+
"probe": "no store, entrypoint or test mapping was in scope; rerun the review after revert",
|
|
338
|
+
"evidence": "impact cone produced no probe anchor",
|
|
339
|
+
})
|
|
340
|
+
return probes
|
|
341
|
+
|
|
342
|
+
def _impact_unverified_claims():
|
|
343
|
+
return [
|
|
344
|
+
{
|
|
345
|
+
"claim": "the change passes its tests",
|
|
346
|
+
"level": "L2-L3",
|
|
347
|
+
"status": "UNVERIFIED",
|
|
348
|
+
"reason": "impact_analysis executes no tests",
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
"claim": "behaviour survives mutation and property checks",
|
|
352
|
+
"level": "L4",
|
|
353
|
+
"status": "UNVERIFIED",
|
|
354
|
+
"reason": "impact_analysis runs no mutation or property checks",
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
"claim": "an independent reviewer agrees with the change",
|
|
358
|
+
"level": "L5",
|
|
359
|
+
"status": "UNVERIFIED",
|
|
360
|
+
"reason": "independent review stays a human or separate-agent step",
|
|
361
|
+
},
|
|
362
|
+
]
|
|
363
|
+
|
|
364
|
+
def impact_analysis(path, changed_files=None, diff=None, symbols=None, depth=3,
|
|
365
|
+
max_files=2000, contract_file=None):
|
|
366
|
+
"""Build a deterministic change impact cone for local changes. Read-only."""
|
|
367
|
+
root = Path(path)
|
|
368
|
+
if not root.exists():
|
|
369
|
+
raise ValueError("路径不存在: %s" % path)
|
|
370
|
+
if not root.is_dir():
|
|
371
|
+
raise ValueError("impact_analysis 需要目录: %s" % path)
|
|
372
|
+
if isinstance(depth, bool) or not isinstance(depth, int) or not 1 <= depth <= 10:
|
|
373
|
+
raise ValueError("depth 必须是 1 到 10 之间的整数")
|
|
374
|
+
requested = [str(item).replace("\\", "/").lstrip("./") for item in (changed_files or [])
|
|
375
|
+
if str(item).strip()]
|
|
376
|
+
wanted_symbols = [str(item).strip() for item in (symbols or []) if str(item).strip()]
|
|
377
|
+
if not requested and not wanted_symbols and not str(diff or "").strip():
|
|
378
|
+
raise ValueError("impact_analysis 需要 changed_files、diff 或 symbols 至少一项")
|
|
379
|
+
|
|
380
|
+
model_result = system_model(str(root), max_files=max_files, contract_file=contract_file)
|
|
381
|
+
model = model_result["model"]
|
|
382
|
+
contract_result = dev_contract.load_contract(root, contract_file=contract_file)
|
|
383
|
+
contract = contract_result["contract"] if contract_result["ok"] else None
|
|
384
|
+
module_layers = _module_layers(model)
|
|
385
|
+
unknowns = [dict(item) for item in model_result["unknowns"]]
|
|
386
|
+
changes = {}
|
|
387
|
+
evidence = []
|
|
388
|
+
|
|
389
|
+
def register_changed(rel, change, changed_lines=None, symbols_hit=None, reason=None):
|
|
390
|
+
rel = str(rel).replace("\\", "/").lstrip("./")
|
|
391
|
+
layer = module_layers.get(rel)
|
|
392
|
+
if layer is None and contract:
|
|
393
|
+
matched = dev_contract.match_layers(rel, contract)
|
|
394
|
+
layer = matched[0] if matched else None
|
|
395
|
+
if layer is None and not (root / rel).exists():
|
|
396
|
+
unknowns.append({
|
|
397
|
+
"kind": "change-not-found",
|
|
398
|
+
"id": rel,
|
|
399
|
+
"detail": "the changed path is not in the repository and matches no layer",
|
|
400
|
+
"next_step": "check the path spelling or add a layer glob",
|
|
401
|
+
})
|
|
402
|
+
entry = changes.setdefault(rel, {
|
|
403
|
+
"path": rel,
|
|
404
|
+
"change": change,
|
|
405
|
+
"layer": layer,
|
|
406
|
+
"changed_lines": [],
|
|
407
|
+
"symbols": [],
|
|
408
|
+
})
|
|
409
|
+
if changed_lines:
|
|
410
|
+
entry["changed_lines"] = sorted(set(entry["changed_lines"]) | set(changed_lines))
|
|
411
|
+
if symbols_hit:
|
|
412
|
+
entry["symbols"] = sorted(set(entry["symbols"]) | set(symbols_hit))
|
|
413
|
+
if reason and not entry.get("reason"):
|
|
414
|
+
entry["reason"] = reason
|
|
415
|
+
return entry
|
|
416
|
+
|
|
417
|
+
for item in _parse_unified_diff(diff or ""):
|
|
418
|
+
entry = register_changed(item["path"], item["change"],
|
|
419
|
+
changed_lines=item["changed_lines"],
|
|
420
|
+
reason="unified diff")
|
|
421
|
+
if item["removed_lines"]:
|
|
422
|
+
entry["removed_lines"] = item["removed_lines"]
|
|
423
|
+
for rel in requested:
|
|
424
|
+
in_model = rel in module_layers
|
|
425
|
+
layer_hit = bool(contract and dev_contract.match_layers(rel, contract))
|
|
426
|
+
register_changed(rel, "modified", reason="changed_files")
|
|
427
|
+
if (root / rel).exists() and not in_model and not layer_hit:
|
|
428
|
+
unknowns.append({
|
|
429
|
+
"kind": "change-not-in-model",
|
|
430
|
+
"id": rel,
|
|
431
|
+
"detail": "the file is not part of the source model and matches no layer",
|
|
432
|
+
"next_step": "add a layer glob or check the file type",
|
|
433
|
+
})
|
|
434
|
+
for symbol in wanted_symbols:
|
|
435
|
+
hits = _symbol_locations(model, root, symbol)
|
|
436
|
+
if not hits:
|
|
437
|
+
unknowns.append({
|
|
438
|
+
"kind": "symbol-not-found",
|
|
439
|
+
"id": symbol,
|
|
440
|
+
"detail": "no definition of this symbol was found in the model",
|
|
441
|
+
"next_step": "check the symbol spelling or add the file that defines it",
|
|
442
|
+
})
|
|
443
|
+
continue
|
|
444
|
+
for hit in hits:
|
|
445
|
+
register_changed(hit["path"], "symbol", changed_lines=[hit["line"]],
|
|
446
|
+
symbols_hit=[symbol], reason="target symbol")
|
|
447
|
+
|
|
448
|
+
changed_paths = sorted(changes)
|
|
449
|
+
nodes, cone_truncated = _dependency_cone(model, changed_paths, depth)
|
|
450
|
+
if cone_truncated:
|
|
451
|
+
unknowns.append({
|
|
452
|
+
"kind": "cone-truncated",
|
|
453
|
+
"id": model_result["root"],
|
|
454
|
+
"detail": "the impact cone reached the %d node limit" % CONE_LIMIT,
|
|
455
|
+
"next_step": "raise depth/targets precision and rerun impact_analysis",
|
|
456
|
+
})
|
|
457
|
+
scoped_paths = sorted(nodes)
|
|
458
|
+
direct_consumers = sorted(node["path"] for node in nodes.values() if node["depth"] == 1)
|
|
459
|
+
affected_layers = sorted({node["layer"] for node in nodes.values() if node["layer"]})
|
|
460
|
+
boundaries = _affected_boundaries(contract, scoped_paths)
|
|
461
|
+
stores = _affected_data_stores(model, affected_layers, scoped_paths)
|
|
462
|
+
invariants = _affected_invariants(contract, scoped_paths)
|
|
463
|
+
tests = _relevant_tests(model, nodes)
|
|
464
|
+
|
|
465
|
+
core = _architecture_review_core(model_result, contract_result, root)
|
|
466
|
+
scoped = set(scoped_paths)
|
|
467
|
+
for violation in core["violations"]:
|
|
468
|
+
violation["in_cone"] = any(item["path"] in scoped for item in violation["evidence"])
|
|
469
|
+
in_scope = [item for item in core["violations"] if item["in_cone"]]
|
|
470
|
+
blocking_in_scope = [item for item in in_scope
|
|
471
|
+
if item["severity"] in dev_contract.BLOCKING_SEVERITIES]
|
|
472
|
+
radius = _blast_radius(contract, nodes, affected_layers, boundaries, stores, in_scope)
|
|
473
|
+
probes = _rollback_probes(model, nodes, stores, tests, invariants)
|
|
474
|
+
|
|
475
|
+
for path_ in changed_paths:
|
|
476
|
+
entry = changes[path_]
|
|
477
|
+
evidence.append({
|
|
478
|
+
"path": path_,
|
|
479
|
+
"line": (entry["changed_lines"] or [None])[0],
|
|
480
|
+
"detail": "changed (%s)%s" % (
|
|
481
|
+
entry["change"],
|
|
482
|
+
", layer %s" % entry["layer"] if entry["layer"] else ", no layer",
|
|
483
|
+
),
|
|
484
|
+
})
|
|
485
|
+
for node in sorted(nodes.values(), key=lambda item: (item["depth"], item["path"])):
|
|
486
|
+
if node["depth"] == 0:
|
|
487
|
+
continue
|
|
488
|
+
evidence.append({
|
|
489
|
+
"path": node["path"],
|
|
490
|
+
"line": node["line"],
|
|
491
|
+
"detail": "consumer of %s at depth %d" % (node["via"], node["depth"]),
|
|
492
|
+
})
|
|
493
|
+
for item in blocking_in_scope:
|
|
494
|
+
for entry in item["evidence"]:
|
|
495
|
+
evidence.append({
|
|
496
|
+
"path": entry["path"],
|
|
497
|
+
"line": entry.get("line"),
|
|
498
|
+
"detail": "%s: %s" % (item["code"], entry["detail"]),
|
|
499
|
+
})
|
|
500
|
+
evidence.sort(key=lambda item: (item["path"], item.get("line") or 0, item["detail"]))
|
|
501
|
+
if len(evidence) > EVIDENCE_LIMIT:
|
|
502
|
+
evidence = evidence[:EVIDENCE_LIMIT]
|
|
503
|
+
|
|
504
|
+
unknowns.sort(key=lambda item: (item["kind"], item["id"]))
|
|
505
|
+
deduped = []
|
|
506
|
+
seen_unknowns = set()
|
|
507
|
+
for item in unknowns:
|
|
508
|
+
key = (item["kind"], item.get("id"))
|
|
509
|
+
if key in seen_unknowns:
|
|
510
|
+
continue
|
|
511
|
+
seen_unknowns.add(key)
|
|
512
|
+
deduped.append(item)
|
|
513
|
+
unknowns = deduped
|
|
514
|
+
if blocking_in_scope:
|
|
515
|
+
status = "FAIL"
|
|
516
|
+
elif unknowns or cone_truncated:
|
|
517
|
+
status = "UNKNOWN"
|
|
518
|
+
else:
|
|
519
|
+
status = "PASS"
|
|
520
|
+
return {
|
|
521
|
+
"status": status,
|
|
522
|
+
"root": model_result["root"],
|
|
523
|
+
"inputs": {
|
|
524
|
+
"changed_files": requested,
|
|
525
|
+
"symbols": wanted_symbols,
|
|
526
|
+
"diff_provided": bool(str(diff or "").strip()),
|
|
527
|
+
"depth": depth,
|
|
528
|
+
},
|
|
529
|
+
"changed": [changes[path_] for path_ in changed_paths],
|
|
530
|
+
"direct_consumers": direct_consumers,
|
|
531
|
+
"cone": {
|
|
532
|
+
"nodes": [nodes[path_] for path_ in sorted(
|
|
533
|
+
nodes, key=lambda item: (nodes[item]["depth"], item))],
|
|
534
|
+
"max_depth": max((node["depth"] for node in nodes.values()), default=0),
|
|
535
|
+
"limit": CONE_LIMIT,
|
|
536
|
+
"truncated": cone_truncated,
|
|
537
|
+
},
|
|
538
|
+
"affected_layers": affected_layers,
|
|
539
|
+
"affected_boundaries": boundaries,
|
|
540
|
+
"affected_data_stores": stores,
|
|
541
|
+
"affected_invariants": invariants,
|
|
542
|
+
"relevant_tests": tests,
|
|
543
|
+
"architecture": {
|
|
544
|
+
"status": core["status"],
|
|
545
|
+
"violations_total": len(core["violations"]),
|
|
546
|
+
"violations_in_scope": in_scope,
|
|
547
|
+
"unknowns": core["unknowns"],
|
|
548
|
+
},
|
|
549
|
+
"blast_radius": radius,
|
|
550
|
+
"rollback_probes": probes,
|
|
551
|
+
"unknowns": unknowns,
|
|
552
|
+
"unverified_claims": _impact_unverified_claims(),
|
|
553
|
+
"evidence": evidence,
|
|
554
|
+
"truncated": model_result["truncated"],
|
|
555
|
+
"model_digest": model_result["model_digest"],
|
|
556
|
+
}
|