@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.
- 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 +19 -5
- 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 +147 -1
- 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 +144 -0
- package/skills/yotta-dev-mcp/scripts/dev_contract.py +830 -0
- package/skills/yotta-dev-mcp/scripts/dev_engine.py +258 -134
- package/skills/yotta-dev-mcp/scripts/dev_impact.py +556 -0
- package/skills/yotta-dev-mcp/scripts/dev_model.py +435 -0
- package/skills/yotta-dev-mcp/scripts/dev_selftest.py +534 -0
- package/skills/yotta-dev-mcp/scripts/dev_verify.py +450 -0
- package/skills/yotta-dev-mcp/scripts/yotta_dev_mcp.py +234 -5
- package/skills/yotta-dev-mcp/server.json +3 -3
|
@@ -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
|
+
}
|