leyline-code 0.8.1__py3-none-any.whl

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.
Files changed (65) hide show
  1. leyline/__init__.py +3 -0
  2. leyline/adapters/__init__.py +19 -0
  3. leyline/adapters/csharp.py +1026 -0
  4. leyline/adapters/generic.py +844 -0
  5. leyline/adapters/python.py +929 -0
  6. leyline/adapters/typescript.py +1512 -0
  7. leyline/affected.py +509 -0
  8. leyline/agent_skills.py +311 -0
  9. leyline/change.py +500 -0
  10. leyline/channels.py +1782 -0
  11. leyline/cli.py +1029 -0
  12. leyline/cluster.py +149 -0
  13. leyline/context.py +531 -0
  14. leyline/coupling.py +485 -0
  15. leyline/coverage.py +376 -0
  16. leyline/diagrams.py +680 -0
  17. leyline/diff.py +1078 -0
  18. leyline/drift.py +747 -0
  19. leyline/exact.py +410 -0
  20. leyline/explain.py +973 -0
  21. leyline/export.py +673 -0
  22. leyline/fncoupling.py +328 -0
  23. leyline/formats.py +923 -0
  24. leyline/grade.py +207 -0
  25. leyline/incremental.py +931 -0
  26. leyline/indexer.py +3733 -0
  27. leyline/learnings.py +743 -0
  28. leyline/loop.py +584 -0
  29. leyline/model.py +142 -0
  30. leyline/outline.py +1066 -0
  31. leyline/packing.py +86 -0
  32. leyline/patterns.py +478 -0
  33. leyline/pr.py +1249 -0
  34. leyline/props.py +255 -0
  35. leyline/query.py +683 -0
  36. leyline/quick.py +853 -0
  37. leyline/related.py +266 -0
  38. leyline/removal.py +231 -0
  39. leyline/renames.py +231 -0
  40. leyline/rereview.py +375 -0
  41. leyline/roslyn_refs/Program.cs +179 -0
  42. leyline/roslyn_refs/RoslynRefs.csproj +16 -0
  43. leyline/rules.py +171 -0
  44. leyline/schema.sql +178 -0
  45. leyline/scip_pb2.py +97 -0
  46. leyline/server.py +1230 -0
  47. leyline/skills/leyline-adversarial-review/SKILL.md +121 -0
  48. leyline/skills/leyline-ask/SKILL.md +57 -0
  49. leyline/skills/leyline-change-impact/SKILL.md +79 -0
  50. leyline/skills/leyline-explain-flow/SKILL.md +70 -0
  51. leyline/skills/leyline-explore-module/SKILL.md +55 -0
  52. leyline/skills/leyline-pr-review/SKILL.md +77 -0
  53. leyline/skills/leyline-quick-change/SKILL.md +63 -0
  54. leyline/skills/leyline-spec/SKILL.md +179 -0
  55. leyline/skills/leyline-tour/SKILL.md +46 -0
  56. leyline/spec.py +1819 -0
  57. leyline/store.py +560 -0
  58. leyline/tours.py +352 -0
  59. leyline/verdicts.py +390 -0
  60. leyline/viewer/viewer.html +1622 -0
  61. leyline_code-0.8.1.dist-info/METADATA +1325 -0
  62. leyline_code-0.8.1.dist-info/RECORD +65 -0
  63. leyline_code-0.8.1.dist-info/WHEEL +4 -0
  64. leyline_code-0.8.1.dist-info/entry_points.txt +2 -0
  65. leyline_code-0.8.1.dist-info/licenses/LICENSE +674 -0
leyline/exact.py ADDED
@@ -0,0 +1,410 @@
1
+ """Exact references: replace links worked out from syntax with links a compiler resolved.
2
+
3
+ Two sources produce the same record shape:
4
+
5
+ - `roslyn`: for C#. A small program built against the compiler inside the .NET SDK binds every
6
+ module and prints each call and field access it resolved. It needs `dotnet` on the PATH and no
7
+ package restore; a module whose packages are missing still binds whatever refers to source.
8
+ - `scip`: any language with a SCIP indexer (scip-python, scip-typescript, scip-java ...). Reads an
9
+ `index.scip` file.
10
+
11
+ A record is {"k": call|read|write|readwrite|init|file, "f": path, "l": line, "n": name as written,
12
+ "s": ok|candidate|none|ambiguous|outside, "tf": target path, "tl": target line, "tn": target name}.
13
+
14
+ `apply` runs inside an index pass, after the syntax resolvers and before flows are built.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import re
22
+ import shutil
23
+ import subprocess
24
+ import tempfile
25
+ from collections import defaultdict
26
+ from importlib import resources
27
+ from pathlib import Path
28
+ from typing import Optional
29
+
30
+ from .model import Edge
31
+
32
+ TOOL_VERSION = "2"
33
+
34
+
35
+ def _timeout() -> float:
36
+ """Seconds the compiler may take before the syntax-based links are kept instead. LEYLINE_EXACT_TIMEOUT."""
37
+ try:
38
+ return max(1.0, float(os.environ.get("LEYLINE_EXACT_TIMEOUT", "") or 1800))
39
+ except ValueError:
40
+ return 1800.0
41
+
42
+
43
+ def _quiet(timeout: float) -> dict:
44
+ """subprocess.run arguments for dotnet: no first-run banner or telemetry prompt, nothing read from the terminal,
45
+ output read as UTF-8 whatever the locale is, and a time limit."""
46
+ env = {**os.environ, "DOTNET_NOLOGO": "1", "DOTNET_CLI_TELEMETRY_OPTOUT": "1", "DOTNET_SKIP_FIRST_TIME_EXPERIENCE": "1"}
47
+ return {"capture_output": True, "text": True, "encoding": "utf-8", "errors": "replace", "stdin": subprocess.DEVNULL,
48
+ "timeout": timeout, "env": env, "cwd": _outside()}
49
+
50
+
51
+ def _outside() -> Path:
52
+ """Where dotnet runs: the user's cache. dotnet reads global.json from the folder it runs in and the folders above,
53
+ and lets it choose the SDK (in .NET 10, from a path it names): run from inside the repository being mapped (or
54
+ from a virtual environment kept in it), someone else's files would choose what runs."""
55
+ d = Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") / "leyline"
56
+ d.mkdir(parents=True, exist_ok=True)
57
+ return d
58
+
59
+
60
+ # -- sources ---------------------------------------------------------------------------------
61
+ def _sdk_framework() -> str:
62
+ """net<major>.0 for the .NET SDK on the PATH (net8.0 when it cannot be told)."""
63
+ try:
64
+ out = subprocess.run(["dotnet", "--version"], capture_output=True, text=True, timeout=60, cwd=_outside()).stdout.strip()
65
+ major = int(out.split(".")[0])
66
+ return f"net{major}.0" if major >= 6 else "net8.0"
67
+ except (OSError, ValueError, subprocess.SubprocessError):
68
+ return "net8.0"
69
+
70
+
71
+ def _tool() -> Optional[Path]:
72
+ """Build the exporter once per version into the user's cache. Returns the dll, or None."""
73
+ if not shutil.which("dotnet"):
74
+ return None
75
+ framework = _sdk_framework()
76
+ cache = _outside() / f"roslyn-refs-{TOOL_VERSION}-{framework}"
77
+ dll = cache / "RoslynRefs.dll"
78
+ if dll.exists():
79
+ return dll
80
+ with tempfile.TemporaryDirectory() as tmp:
81
+ src = Path(tmp) / "src"
82
+ src.mkdir()
83
+ for name in ("Program.cs", "RoslynRefs.csproj"):
84
+ text = resources.files("leyline").joinpath(f"roslyn_refs/{name}").read_text(encoding="utf-8")
85
+ # Target the SDK's own framework: its reference pack ships with it, so nothing has to be restored. An SDK
86
+ # newer than the project's framework does not carry the older pack, and restore has no source to get it.
87
+ (src / name).write_text(text.replace("<TargetFramework>net8.0</TargetFramework>",
88
+ f"<TargetFramework>{framework}</TargetFramework>"), encoding="utf-8")
89
+ empty = Path(tmp) / "no-packages"
90
+ empty.mkdir()
91
+ # The project has no package references; an empty source keeps restore from going to the network.
92
+ try:
93
+ run = subprocess.run(["dotnet", "build", str(src), "-c", "Release", "-o", str(cache), "--source", str(empty),
94
+ "--nologo", "-v", "q"], **_quiet(_timeout()))
95
+ except subprocess.TimeoutExpired:
96
+ shutil.rmtree(cache, ignore_errors=True)
97
+ raise RuntimeError(f"building the C# reference exporter took longer than {_timeout():g} s") from None
98
+ if run.returncode != 0 or not dll.exists():
99
+ shutil.rmtree(cache, ignore_errors=True)
100
+ raise RuntimeError("could not build the C# reference exporter:\n" + (run.stdout + run.stderr)[-1500:])
101
+ return dll
102
+
103
+
104
+ def roslyn(ix) -> tuple[list[dict], dict]:
105
+ """Run the compiler over the C# modules of an indexer. Returns (records, per-module summary)."""
106
+ dll = _tool()
107
+ if dll is None:
108
+ return [], {"status": "skipped", "reason": "dotnet is not on the PATH"}
109
+ files_by_module = defaultdict(list)
110
+ for fid, lang in ix.file_lang.items():
111
+ if lang == "csharp":
112
+ files_by_module[ix.nodes[fid].parent_id].append(ix.nodes[fid].path)
113
+ if not files_by_module:
114
+ return [], {"status": "no_files"}
115
+ imports = defaultdict(set)
116
+ for e in ix.edges:
117
+ if e.kind == "imports" and e.dst_id in files_by_module:
118
+ imports[ix.nodes[e.src_id].parent_id if e.src_id in ix.file_lang else e.src_id].add(e.dst_id)
119
+ modules = []
120
+ for mod, files in sorted(files_by_module.items()):
121
+ refs = set(ix.project_refs.get(mod, ())) | imports.get(mod, set())
122
+ modules.append({"name": mod, "files": sorted(files), "refs": sorted(r for r in refs if r != mod and r in files_by_module)})
123
+ with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False, encoding="utf-8") as f:
124
+ json.dump({"root": str(ix.root), "modules": modules}, f)
125
+ manifest = f.name
126
+ try:
127
+ run = subprocess.run(["dotnet", str(dll), manifest], **_quiet(_timeout()))
128
+ except subprocess.TimeoutExpired:
129
+ return [], {"status": "failed", "reason": f"the compiler took longer than {_timeout():g} s"
130
+ " (LEYLINE_EXACT_TIMEOUT raises the limit)"}
131
+ finally:
132
+ os.unlink(manifest)
133
+ if run.returncode != 0:
134
+ return [], {"status": "failed", "reason": run.stderr[-800:]}
135
+ records = [json.loads(line) for line in run.stdout.splitlines() if line.startswith("{")]
136
+ return records, {"status": "ok", "modules": len(modules)}
137
+
138
+
139
+ def scip(path: str | Path, root: Optional[str | Path] = None) -> list[dict]:
140
+ """Read a SCIP index into records. Calls come from references to method symbols; field access
141
+ from references to members, when the indexer says whether they read or write.
142
+ With `root`, the source is consulted to tell a call from a method that is only mentioned."""
143
+ try:
144
+ from . import scip_pb2
145
+ except Exception as exc: # the generated reader needs a recent protobuf runtime
146
+ raise RuntimeError(f"reading SCIP needs the protobuf package (pip install 'protobuf>=7.35'): {exc}") from exc
147
+ index = scip_pb2.Index()
148
+ index.ParseFromString(Path(path).read_bytes())
149
+ definition: dict[str, tuple] = {}
150
+ for doc in index.documents:
151
+ for occ in doc.occurrences:
152
+ if occ.symbol_roles & 1 and not occ.symbol.startswith("local ") and occ_range(occ):
153
+ definition[occ.symbol] = (doc.relative_path, occ_range(occ)[0] + 1)
154
+ # Some indexers (scip-python) mark every reference as a read. Then the roles say nothing about field access.
155
+ roles_mean_something = any(occ.symbol_roles & 4 and not occ.symbol_roles & 1 for doc in index.documents for occ in doc.occurrences)
156
+ out = []
157
+ for doc in index.documents:
158
+ out.append({"k": "file", "f": doc.relative_path, "e": 0, "unbound_unknown": True})
159
+ src_path = Path(root) / doc.relative_path if root else None
160
+ try:
161
+ from .indexer import source_lines
162
+ lines = source_lines(src_path) if src_path is not None and src_path.is_file() else None
163
+ except OSError:
164
+ lines = None
165
+ for occ in doc.occurrences:
166
+ sym = occ.symbol
167
+ if occ.symbol_roles & 1 or sym.startswith("local ") or sym not in definition:
168
+ continue
169
+ row, col, end = occ_range(occ) or (-1, 0, 0)
170
+ if row < 0:
171
+ continue
172
+ name = _scip_name(sym)
173
+ if not name:
174
+ continue
175
+ tf, tl = definition[sym]
176
+ if sym.endswith(")."):
177
+ kind = "call"
178
+ if lines is not None and row < len(lines):
179
+ # SCIP marks a mention, not a call. Keep it when the name is followed by "(", is used as a
180
+ # decorator, or is read like an attribute (a property); drop imports and methods passed as values.
181
+ text = lines[row]
182
+ col, end = _chars(text, col, doc.position_encoding), _chars(text, end, doc.position_encoding)
183
+ after = text[end:].lstrip()
184
+ before = text[:col].strip()
185
+ called = after.startswith("(") or before.endswith("@") or before == "@"
186
+ if not called:
187
+ if before.startswith(("import ", "from ")) or " import " in before:
188
+ continue
189
+ kind = "mention"
190
+ elif sym.endswith(".") and "#" in sym.rsplit("/", 1)[-1]:
191
+ write, read = occ.symbol_roles & 4, occ.symbol_roles & 8
192
+ if not roles_mean_something or not (write or read):
193
+ continue # the indexer did not say; leave field access to the syntax pass
194
+ kind = "readwrite" if write and read else "write" if write else "read"
195
+ else:
196
+ continue
197
+ out.append({"k": kind, "f": doc.relative_path, "l": row + 1, "n": name, "s": "ok",
198
+ "tf": tf, "tl": tl, "tn": name})
199
+ return out
200
+
201
+
202
+ def occ_range(occ) -> tuple:
203
+ """(line, start column, end column) of an occurrence, 0-based. Newer indexers (scip-java) write the typed
204
+ single_line_range / multi_line_range fields instead of the packed `range` list; None when there is neither."""
205
+ r = list(occ.range)
206
+ if r:
207
+ return (r[0], r[1], r[2] if len(r) == 3 else r[3])
208
+ if occ.HasField("single_line_range"):
209
+ x = occ.single_line_range
210
+ return (x.line, x.start_character, x.end_character)
211
+ if occ.HasField("multi_line_range"):
212
+ x = occ.multi_line_range
213
+ return (x.start_line, x.start_character, x.end_character)
214
+ return None
215
+
216
+
217
+ def _chars(text: str, col: int, encoding: int) -> int:
218
+ """A SCIP column as an index into the line's characters. The document says what it counts: UTF-8 bytes (1) or
219
+ UTF-16 code units (2, what scip-python and scip-typescript write); otherwise characters."""
220
+ if encoding == 1:
221
+ return len(text.encode("utf-8", "surrogatepass")[:col].decode("utf-8", "ignore"))
222
+ if encoding == 2:
223
+ return len(text.encode("utf-16-le", "surrogatepass")[:2 * col].decode("utf-16-le", "ignore"))
224
+ return col
225
+
226
+
227
+ def scip_root(path: str | Path) -> Optional[Path]:
228
+ """The directory a SCIP index was made in (its metadata's project root), or None."""
229
+ from . import scip_pb2
230
+ index = scip_pb2.Index()
231
+ index.ParseFromString(Path(path).read_bytes())
232
+ root = index.metadata.project_root
233
+ if not root:
234
+ return None
235
+ return Path(root[len("file://"):] if root.startswith("file://") else root).resolve()
236
+
237
+
238
+ def _scip_name(symbol: str) -> str:
239
+ """The last descriptor's name: `... pkg/Engine#start().` -> start."""
240
+ tail = symbol.rstrip(".")
241
+ if tail.endswith(")"):
242
+ tail = tail[: tail.rfind("(")]
243
+ for sep in ("#", "/", ".", " "):
244
+ tail = tail.rsplit(sep, 1)[-1]
245
+ # rust-analyzer names an impl's method `impl#[Type][Trait]method`
246
+ return re.sub(r"^(\[[^\]]*\])+", "", tail).strip("`")
247
+
248
+
249
+ # -- applying ----------------------------------------------------------------------------------
250
+ class _Locator:
251
+ def __init__(self, ix, repo: Optional[str] = None):
252
+ self.nodes = ix.nodes
253
+ self.by_path = defaultdict(list)
254
+ for n in ix.nodes.values():
255
+ if repo is not None and not n.id.startswith(repo + ":"):
256
+ continue # records name paths inside one repository of a workspace
257
+ if n.kind in ("callable", "test", "field") and n.path and n.span_start:
258
+ self.by_path[n.path].append((n.span_start, n.span_end or n.span_start, n))
259
+
260
+ def enclosing(self, path: str, line: int):
261
+ """The innermost function, test or property body a line sits in."""
262
+ best = None
263
+ for start, end, n in self.by_path.get(path, ()):
264
+ if start <= line <= end and (best is None or (end - start, -start) < (best[1] - best[0], -best[0])):
265
+ best = (start, end, n)
266
+ return best[2] if best else None
267
+
268
+ def caller(self, path: str, line: int):
269
+ """The function that makes a call written at a line. Mostly the innermost one around it, but a Python
270
+ function's decorators and default values run where the function is defined, and the call that
271
+ registers an inline test (`T.Run("name", () => ...)`) is made by the test's parent."""
272
+ best = None
273
+ for start, end, n in self.by_path.get(path, ()):
274
+ if not start <= line <= end:
275
+ continue
276
+ if n.language == "python" and line < (n.attrs.get("body_line") or start):
277
+ continue
278
+ if best is None or (end - start, -start) < (best[1] - best[0], -best[0]):
279
+ best = (start, end, n)
280
+ src = best[2] if best else None
281
+ if src is not None and src.kind == "test" and line == src.span_start and src.parent_id in self.nodes:
282
+ src = self.nodes[src.parent_id]
283
+ return src
284
+
285
+ def target(self, path: str, line: int, name: str, kinds: tuple):
286
+ """The declaration a compiler pointed at: the innermost node of that name whose span holds the line."""
287
+ best = None
288
+ for start, end, n in self.by_path.get(path, ()):
289
+ if n.kind in kinds and n.name == name and start <= line <= end and (best is None or start > best[0]):
290
+ best = (start, n)
291
+ if best is None: # declaration line outside the span we recorded (attributes, decorators): nearest above
292
+ near = [(abs(start - line), start, n) for start, end, n in self.by_path.get(path, ())
293
+ if n.kind in kinds and n.name == name and abs(start - line) <= 6]
294
+ best = min(near, key=lambda x: x[:2])[1:] if near else None
295
+ return best[1] if best else None
296
+
297
+
298
+ def apply(ix, records: list[dict], source: str, repo: Optional[str] = None) -> dict:
299
+ """Correct an indexer's calls and field-access edges with compiler-resolved records.
300
+ `repo`: in a workspace, the repository whose files the records' paths are relative to."""
301
+ loc = _Locator(ix, repo)
302
+ complete = {r["f"]: r.get("e", 0) == 0 and not r.get("unbound_unknown") for r in records if r["k"] == "file"}
303
+ stats = defaultdict(int)
304
+
305
+ # Calls, grouped by (calling function, name called).
306
+ groups: dict[tuple, dict] = defaultdict(lambda: {"targets": {}, "open": False, "seen": 0})
307
+ for r in records:
308
+ if r["k"] == "mention":
309
+ # A method named without being called. It runs here only if it is a property.
310
+ hit = loc.target(r["tf"], r["tl"], r.get("tn") or r["n"], ("callable",))
311
+ if hit is None or not any("property" in d for d in hit.attrs.get("decorators") or []):
312
+ continue
313
+ elif r["k"] != "call":
314
+ continue
315
+ src = loc.caller(r["f"], r["l"])
316
+ if src is None:
317
+ continue
318
+ g = groups[(src.id, r.get("tn") or r["n"])] if r["s"] in ("ok", "candidate") else groups[(src.id, r["n"])]
319
+ g["seen"] += 1
320
+ g.setdefault("lines", defaultdict(set))
321
+ if r["s"] in ("none", "ambiguous"):
322
+ g["open"] = True
323
+ elif r["s"] in ("ok", "candidate"):
324
+ dst = loc.target(r["tf"], r["tl"], r.get("tn") or r["n"], ("callable",))
325
+ if dst is None:
326
+ stats["targets_not_on_the_map"] += 1
327
+ g["open"] = True
328
+ else:
329
+ sure = r["s"] == "ok"
330
+ g["targets"][dst.id] = (g["targets"].get(dst.id, (False, r["l"]))[0] or sure, r["l"])
331
+ g["lines"][r["l"]].add(dst.id)
332
+ if not sure:
333
+ g["open"] = True
334
+ if groups:
335
+ sites = defaultdict(int) # how many times the syntax pass saw (function, name) called
336
+ for fid, res in ix.results.items():
337
+ for c in ix._open(fid, res).calls:
338
+ sites[(c.src_id, c.name)] += 1
339
+ ix._shut()
340
+ kept = []
341
+ have = defaultdict(set)
342
+ for row in ix.calls:
343
+ src, dst, dispatch, precision, line = row
344
+ name = ix.nodes[dst].name if dst in ix.nodes else None
345
+ g = groups.get((src, name))
346
+ if g is None or dispatch == "fixture":
347
+ kept.append(row)
348
+ continue
349
+ # A SCIP index does not say which call sites it failed to bind, so it may only overrule a link
350
+ # made on a line where it bound that same name to something else.
351
+ open_ = g["open"] if source == "roslyn" else not (g["lines"].get(line) and dst not in g["lines"][line])
352
+ if dst in g["targets"]:
353
+ sure = g["targets"][dst][0]
354
+ kept.append((src, dst, dispatch, "exact" if sure else precision, line))
355
+ have[(src, name)].add(dst)
356
+ stats["calls_confirmed"] += sure
357
+ elif open_:
358
+ kept.append(row)
359
+ else:
360
+ stats["calls_removed"] += 1 # the compiler bound every such call in this function elsewhere
361
+ for (src, name), g in groups.items():
362
+ for dst, (sure, line) in g["targets"].items():
363
+ if dst not in have[(src, name)]:
364
+ dispatch = "virtual" if ix.nodes[dst].attrs.get("is_virtual") and ix.nodes[dst].language == "csharp" else "static"
365
+ kept.append((src, dst, dispatch, "exact" if sure else "heuristic", line))
366
+ stats["calls_added"] += 1
367
+ ix.calls[:] = kept
368
+
369
+ # Field access, per function.
370
+ found: dict[tuple, list] = {}
371
+ touched_src = set()
372
+ for r in records:
373
+ if r["k"] not in ("read", "write", "readwrite", "init") or r["s"] != "ok":
374
+ continue
375
+ src = loc.enclosing(r["f"], r["l"])
376
+ dst = loc.target(r["tf"], r["tl"], r.get("tn") or r["n"], ("field",))
377
+ if src is None or dst is None or src.id == dst.id or dst.attrs.get("native_kind") in ("enum_member", "event"):
378
+ continue
379
+ touched_src.add(src.id)
380
+ for kind in (("reads",) if r["k"] == "read" else ("writes",) if r["k"] in ("write", "init") else ("reads", "writes")):
381
+ slot = found.setdefault((kind, src.id, dst.id), [0, r["l"], 0])
382
+ slot[0] += 1
383
+ slot[1] = min(slot[1], r["l"])
384
+ slot[2] += r["k"] == "init"
385
+ if found:
386
+ edges = []
387
+ for e in ix.edges:
388
+ if e.kind not in ("reads", "writes") or (repo is not None and not e.src_id.startswith(repo + ":")):
389
+ edges.append(e)
390
+ continue
391
+ key = (e.kind, e.src_id, e.dst_id)
392
+ if key in found:
393
+ continue # rewritten below as exact
394
+ path = ix.nodes[e.src_id].path if e.src_id in ix.nodes else None
395
+ if complete.get(path, False):
396
+ stats["field_links_removed"] += 1 # the file bound cleanly and the compiler saw no such access
397
+ else:
398
+ edges.append(e)
399
+ syntax = {(e.kind, e.src_id, e.dst_id) for e in ix.edges if e.kind in ("reads", "writes")}
400
+ for (kind, src, dst), (n, line, init) in found.items():
401
+ attrs = {"n": n, "line": line}
402
+ if init == n:
403
+ attrs["init"] = True
404
+ edges.append(Edge(kind, src, dst, "exact", attrs))
405
+ stats["field_links_confirmed" if (kind, src, dst) in syntax else "field_links_added"] += 1
406
+ ix.edges[:] = edges
407
+ if source == "roslyn":
408
+ stats["files_bound_cleanly"] = sum(1 for v in complete.values() if v)
409
+ stats["files_with_binding_errors"] = sum(1 for v in complete.values() if not v)
410
+ return dict(stats)