@yottameta/yotta-dev-mcp-plugin 0.1.1 → 0.2.0

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.
@@ -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
+ }