@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.
@@ -0,0 +1,447 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Deterministic system model for yotta-dev-mcp."""
4
+
5
+ import ast
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import posixpath
10
+ import re
11
+ from pathlib import Path
12
+
13
+ import dev_contract
14
+ from dev_common import (
15
+ CONFIG_NAMES, CONFIG_SUFFIXES, EVIDENCE_LIMIT, IGNORE_DIRS,
16
+ IMPORT_KINDS_DECISIVE, JS_EXTS, RISK_ENUM_WEIGHTS, SOURCE_EXTS,
17
+ STORAGE_SUFFIXES, TEST_NAME_RE,
18
+ UNKNOWN_LIMIT, _iter_files, _language, _read_text, _rel, source_exts,
19
+ )
20
+
21
+
22
+ def _resolve_python_import(source_rel, node):
23
+ source = Path(source_rel)
24
+ if isinstance(node, ast.Import):
25
+ return [alias.name for alias in node.names]
26
+ if not isinstance(node, ast.ImportFrom):
27
+ return []
28
+ if node.level:
29
+ parts = list(source.with_suffix("").parts[:-1])
30
+ if node.level > 1:
31
+ parts = parts[:-(node.level - 1)] if len(parts) >= node.level - 1 else []
32
+ prefix = "/".join(parts)
33
+ if not node.module:
34
+ return [
35
+ (prefix + "/" + alias.name.replace(".", "/")).strip("/")
36
+ for alias in node.names
37
+ ]
38
+ module = node.module or ""
39
+ target = (prefix + "/" + module.replace(".", "/")).strip("/")
40
+ return [target] if target else [source_rel]
41
+ if node.module:
42
+ return [node.module]
43
+ return []
44
+
45
+ def _repo_map_python(path, rel):
46
+ text = _read_text(path)
47
+ imports = []
48
+ try:
49
+ tree = ast.parse(text)
50
+ except SyntaxError:
51
+ return imports
52
+ for node in ast.walk(tree):
53
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
54
+ for target in _resolve_python_import(rel, node):
55
+ imports.append({
56
+ "source": rel,
57
+ "target": target,
58
+ "line": getattr(node, "lineno", 1),
59
+ "relative": isinstance(node, ast.ImportFrom) and bool(node.level),
60
+ })
61
+ return imports
62
+
63
+ def _repo_map_js(path, rel):
64
+ text = _read_text(path)
65
+ imports = []
66
+ patterns = [
67
+ re.compile(r"""(?:from\s+|import\s*\()\s*['"]([^'"]+)['"]"""),
68
+ re.compile(r"""require\s*\(\s*['"]([^'"]+)['"]\s*\)"""),
69
+ ]
70
+ for lineno, line in enumerate(text.splitlines(), 1):
71
+ for pattern in patterns:
72
+ for match in pattern.finditer(line):
73
+ imports.append({"source": rel, "target": match.group(1), "line": lineno})
74
+ return imports
75
+
76
+ def _resolve_relative_module(raw_target, source_rel, module_set, root=None):
77
+ base = posixpath.normpath(posixpath.join(posixpath.dirname(source_rel), raw_target))
78
+ if base.startswith("..") or base.startswith("/"):
79
+ return None, None
80
+ candidates = [base + ext for ext in JS_EXTS]
81
+ candidates.extend(base + "/index" + ext for ext in JS_EXTS)
82
+ candidates.append(base)
83
+ for candidate in candidates:
84
+ if candidate in module_set:
85
+ return candidate, "module"
86
+ if root is not None and (Path(root) / base).is_file():
87
+ return base, "file"
88
+ return None, None
89
+
90
+ def _classify_python_import(raw_target, source_rel, module_set, relative=False):
91
+ if raw_target.endswith(".py"):
92
+ if raw_target in module_set:
93
+ return "internal", raw_target
94
+ return "unresolved", raw_target
95
+ dotted = raw_target.replace(".", "/")
96
+ source_dir = posixpath.dirname(source_rel)
97
+ bases = [dotted]
98
+ if source_dir:
99
+ bases.insert(0, posixpath.join(source_dir, dotted))
100
+ for base in bases:
101
+ for candidate in (base + ".py", base + "/__init__.py"):
102
+ if candidate in module_set:
103
+ return "internal", candidate
104
+ return ("unresolved" if relative else "external"), raw_target
105
+
106
+ def _classify_js_import(raw_target, source_rel, module_set, root=None):
107
+ if raw_target.startswith("."):
108
+ resolved, kind = _resolve_relative_module(raw_target, source_rel, module_set, root)
109
+ if kind == "module":
110
+ return "internal", resolved
111
+ if kind == "file":
112
+ return "internal-file", resolved
113
+ return "unresolved", raw_target
114
+ return "external", raw_target
115
+
116
+ def _is_test_file(rel):
117
+ parts = rel.split("/")
118
+ if any(part in ("tests", "test", "__tests__", "spec") for part in parts[:-1]):
119
+ return True
120
+ return bool(TEST_NAME_RE.match(parts[-1]))
121
+
122
+ def _config_kind(name):
123
+ suffix = Path(name).suffix.lower()
124
+ if suffix == ".json":
125
+ return "json"
126
+ if suffix == ".toml":
127
+ return "toml"
128
+ if suffix in (".yml", ".yaml"):
129
+ return "yaml"
130
+ if suffix in (".ini", ".cfg"):
131
+ return "ini"
132
+ return "text"
133
+
134
+ def _collect_files_by_suffix(root, suffixes, limit=200):
135
+ root = Path(root)
136
+ found = []
137
+ for current, dirs, files in os.walk(str(root)):
138
+ dirs[:] = sorted(d for d in dirs if d not in IGNORE_DIRS)
139
+ for name in sorted(files):
140
+ path = Path(current) / name
141
+ if path.is_symlink():
142
+ continue
143
+ if path.suffix.lower() in suffixes:
144
+ found.append(path)
145
+ if len(found) >= limit:
146
+ return found
147
+ return found
148
+
149
+ def _collect_configs(root, limit=200):
150
+ root = Path(root)
151
+ configs = []
152
+ for current, dirs, files in os.walk(str(root)):
153
+ dirs[:] = sorted(d for d in dirs if d not in IGNORE_DIRS)
154
+ for name in sorted(files):
155
+ path = Path(current) / name
156
+ if path.is_symlink():
157
+ continue
158
+ if name in CONFIG_NAMES or path.suffix.lower() in CONFIG_SUFFIXES:
159
+ configs.append({"path": _rel(root, path), "kind": _config_kind(name)})
160
+ if len(configs) >= limit:
161
+ return configs
162
+ return configs
163
+
164
+ def _collect_store_files(root, limit=200):
165
+ root = Path(root)
166
+ stores = []
167
+ for path in _collect_files_by_suffix(root, set(STORAGE_SUFFIXES), limit=limit):
168
+ stores.append({
169
+ "path": _rel(root, path),
170
+ "kind": STORAGE_SUFFIXES[path.suffix.lower()],
171
+ })
172
+ return stores
173
+
174
+ def _unverified_claims():
175
+ return [
176
+ {
177
+ "claim": "architecture rules hold for this model",
178
+ "level": "L1",
179
+ "status": "UNVERIFIED",
180
+ "reason": "system_model only builds the model; rule checking runs in architecture_review",
181
+ },
182
+ {
183
+ "claim": "changed code still passes its tests",
184
+ "level": "L2-L4",
185
+ "status": "UNVERIFIED",
186
+ "reason": "system_model executes no tests, mutations or property checks",
187
+ },
188
+ ]
189
+
190
+ def system_model(path, max_files=2000, contract_file=None):
191
+ """Build the system model and attach architecture contract layer data."""
192
+ root = Path(path)
193
+ if not root.exists():
194
+ raise ValueError("路径不存在: %s" % path)
195
+ if not root.is_dir():
196
+ raise ValueError("system_model 需要目录: %s" % path)
197
+ if max_files < 1:
198
+ raise ValueError("max_files 必须 >= 1")
199
+
200
+ contract_result = dev_contract.load_contract(root, contract_file=contract_file)
201
+ contract = contract_result["contract"]
202
+ contract_usable = bool(
203
+ contract_result["present"] and contract_result["ok"] and contract
204
+ )
205
+
206
+ unknowns = []
207
+ evidence = []
208
+ if not contract_result["present"]:
209
+ unknowns.append({
210
+ "kind": "contract-missing",
211
+ "id": contract_result["path"],
212
+ "detail": "no architecture contract found",
213
+ "next_step": "add %s with layers, rules and data ownership" % contract_result["path"],
214
+ })
215
+ elif not contract_result["ok"]:
216
+ blocking = [item for item in contract_result["findings"]
217
+ if item["severity"] in dev_contract.BLOCKING_SEVERITIES]
218
+ unknowns.append({
219
+ "kind": "contract-invalid",
220
+ "id": contract_result["path"],
221
+ "detail": "%d blocking finding(s); layer data is not applied" % len(blocking),
222
+ "next_step": "fix the contract findings and rerun system_model",
223
+ })
224
+ for finding in contract_result["findings"]:
225
+ evidence.append({
226
+ "path": finding["path"],
227
+ "pointer": finding["pointer"],
228
+ "detail": "%s: %s" % (finding["severity"], finding["message"]),
229
+ })
230
+
231
+ files = list(_iter_files(root, source_exts(), max_files=max_files + 1))
232
+ truncated = len(files) > max_files
233
+ if truncated:
234
+ files = files[:max_files]
235
+ module_set = {_rel(root, item) for item in files}
236
+
237
+ modules = []
238
+ imports = []
239
+ entrypoints = []
240
+ tests = []
241
+ for file_path in files:
242
+ rel = _rel(root, file_path)
243
+ try:
244
+ text = _read_text(file_path)
245
+ except (OSError, ValueError):
246
+ continue
247
+ language = _language(file_path)
248
+ layer = None
249
+ if contract_usable:
250
+ matched = dev_contract.match_layers(rel, contract)
251
+ if not matched:
252
+ unknowns.append({
253
+ "kind": "unassigned-module",
254
+ "id": rel,
255
+ "detail": "no layer glob matches this module",
256
+ "next_step": "add a layer paths glob in %s" % contract_result["path"],
257
+ })
258
+ elif len(matched) > 1:
259
+ layer = matched[0]
260
+ unknowns.append({
261
+ "kind": "layer-overlap",
262
+ "id": rel,
263
+ "layers": matched,
264
+ "detail": "module matches %d layers; first declaration wins" % len(matched),
265
+ "next_step": "narrow the overlapping layer globs",
266
+ })
267
+ else:
268
+ layer = matched[0]
269
+ modules.append({
270
+ "id": rel,
271
+ "language": language,
272
+ "lines": len(text.splitlines()),
273
+ "layer": layer,
274
+ })
275
+ suffix = file_path.suffix.lower()
276
+ if suffix == ".py":
277
+ raw_imports = _repo_map_python(file_path, rel)
278
+ elif suffix in JS_EXTS:
279
+ raw_imports = _repo_map_js(file_path, rel)
280
+ else:
281
+ raw_imports = []
282
+ module_imports = []
283
+ for raw in raw_imports:
284
+ target_raw = raw["target"]
285
+ if target_raw == rel:
286
+ continue
287
+ if suffix == ".py":
288
+ kind, target = _classify_python_import(
289
+ target_raw, rel, module_set, relative=raw.get("relative", False)
290
+ )
291
+ else:
292
+ kind, target = _classify_js_import(target_raw, rel, module_set, root)
293
+ entry = {
294
+ "source": rel,
295
+ "target": target,
296
+ "kind": kind,
297
+ "line": raw["line"],
298
+ "raw": target_raw,
299
+ }
300
+ module_imports.append(entry)
301
+ imports.append(entry)
302
+ if kind == "unresolved":
303
+ unknowns.append({
304
+ "kind": "unresolved-import",
305
+ "id": rel,
306
+ "line": raw["line"],
307
+ "detail": "relative import does not resolve: %s" % target_raw,
308
+ "next_step": "check the import path or add the missing module",
309
+ })
310
+ evidence.append({
311
+ "path": rel,
312
+ "line": raw["line"],
313
+ "detail": "unresolved relative import: %s" % target_raw,
314
+ })
315
+ if _is_test_file(rel):
316
+ tests.append({
317
+ "path": rel,
318
+ "targets": sorted({item["target"] for item in module_imports
319
+ if item["kind"] == "internal"}),
320
+ })
321
+ if (
322
+ file_path.name in ("main.py", "cli.py", "app.py", "index.js", "index.ts")
323
+ or "if __name__ == '__main__'" in text
324
+ or 'if __name__ == "__main__"' in text
325
+ ):
326
+ entrypoints.append(rel)
327
+
328
+ layer_summaries = []
329
+ if contract_usable:
330
+ for layer in contract["layers"]:
331
+ layer_summaries.append({
332
+ "id": layer["id"],
333
+ "title": layer["title"],
334
+ "risk": layer["risk"],
335
+ "paths": layer["paths"],
336
+ "modules": sorted(item["id"] for item in modules
337
+ if item["layer"] == layer["id"]),
338
+ })
339
+
340
+ data_stores = []
341
+ if contract_usable:
342
+ for store in contract["data_ownership"]:
343
+ data_stores.append({
344
+ "store": store["store"],
345
+ "owner": store["owner"],
346
+ "kind": store["kind"] or "unknown",
347
+ "paths": store["paths"],
348
+ "owner_modules": sorted(item["id"] for item in modules
349
+ if item["layer"] == store["owner"]),
350
+ "detected": False,
351
+ })
352
+ for store_path in store["paths"]:
353
+ evidence.append({
354
+ "path": store_path,
355
+ "detail": "declared data ownership: %s" % store["store"],
356
+ })
357
+ for item in _collect_store_files(root):
358
+ matched = dev_contract.match_layers(item["path"], contract) if contract_usable else []
359
+ owner = matched[0] if matched else None
360
+ data_stores.append({
361
+ "store": item["path"],
362
+ "owner": owner,
363
+ "kind": item["kind"],
364
+ "paths": [item["path"]],
365
+ "owner_modules": sorted(m["id"] for m in modules if owner and m["layer"] == owner),
366
+ "detected": True,
367
+ })
368
+ evidence.append({
369
+ "path": item["path"],
370
+ "detail": "detected local data store file (%s)" % item["kind"],
371
+ })
372
+ data_stores.sort(key=lambda item: item["store"])
373
+
374
+ model = {
375
+ "modules": sorted(modules, key=lambda item: item["id"]),
376
+ "layers": layer_summaries,
377
+ "imports": sorted(imports, key=lambda item: (item["source"], item["line"], item["target"])),
378
+ "entrypoints": sorted(set(entrypoints)),
379
+ "tests": sorted(tests, key=lambda item: item["path"]),
380
+ "configs": _collect_configs(root),
381
+ "data_stores": data_stores,
382
+ }
383
+ digest = hashlib.sha256(
384
+ json.dumps(model, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
385
+ ).hexdigest()
386
+
387
+ unknowns.sort(key=lambda item: (item["kind"], item["id"], item.get("line", 0)))
388
+ unknowns_truncated = len(unknowns) > UNKNOWN_LIMIT
389
+ evidence.sort(key=lambda item: (item["path"], item.get("line", 0), item["detail"]))
390
+ if len(evidence) > EVIDENCE_LIMIT:
391
+ evidence = evidence[:EVIDENCE_LIMIT]
392
+ if contract_result["present"] and not contract_result["ok"]:
393
+ status = "FAIL"
394
+ elif unknowns:
395
+ status = "UNKNOWN"
396
+ else:
397
+ status = "PASS"
398
+ return {
399
+ "status": status,
400
+ "root": str(root.resolve()),
401
+ "contract": {
402
+ "path": contract_result["path"],
403
+ "present": contract_result["present"],
404
+ "ok": contract_result["ok"],
405
+ "version": contract_result["version"],
406
+ "layers": contract_result["layers"],
407
+ "rules": contract_result["rules"],
408
+ "findings": contract_result["findings"],
409
+ },
410
+ "model": model,
411
+ "unknowns": unknowns[:UNKNOWN_LIMIT],
412
+ "unknowns_truncated": unknowns_truncated,
413
+ "unverified_claims": _unverified_claims(),
414
+ "evidence": evidence,
415
+ "truncated": truncated,
416
+ "model_digest": "sha256:" + digest,
417
+ }
418
+
419
+ def _module_layers(model):
420
+ return {item["id"]: item.get("layer") for item in model["modules"]}
421
+
422
+ def _risk_weight(contract, layer_id):
423
+ """Risk weight for a layer: explicit risk_weights, else the risk enum."""
424
+ if not layer_id:
425
+ return 0.0
426
+ weights = (contract or {}).get("risk_weights") or {}
427
+ value = weights.get(layer_id)
428
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
429
+ value = None
430
+ for layer in (contract or {}).get("layers") or []:
431
+ if layer.get("id") == layer_id:
432
+ value = RISK_ENUM_WEIGHTS.get(layer.get("risk"), 1)
433
+ break
434
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
435
+ value = 1
436
+ return float(max(0, min(5, value)))
437
+
438
+ def _decisive_edges(model):
439
+ return [edge for edge in model["imports"] if edge["kind"] in IMPORT_KINDS_DECISIVE]
440
+
441
+ def _edge_evidence(edge, detail):
442
+ return {
443
+ "path": edge["source"],
444
+ "line": edge["line"],
445
+ "detail": detail,
446
+ "snippet": edge.get("raw"),
447
+ }