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