diffgenome 0.1.0__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 (53) hide show
  1. diffgenome/__init__.py +7 -0
  2. diffgenome/__main__.py +240 -0
  3. diffgenome/_collectors/go/dg/dg.go +623 -0
  4. diffgenome/_collectors/go/go.mod +3 -0
  5. diffgenome/_collectors/go/instrument/facts.go +346 -0
  6. diffgenome/_collectors/go/instrument/main.go +484 -0
  7. diffgenome/_collectors/node/instrument.js +289 -0
  8. diffgenome/_collectors/node/jest-setup.js +40 -0
  9. diffgenome/_collectors/node/package-lock.json +35 -0
  10. diffgenome/_collectors/node/package.json +11 -0
  11. diffgenome/_collectors/node/runtime.js +426 -0
  12. diffgenome/ambiguity.py +122 -0
  13. diffgenome/api.py +67 -0
  14. diffgenome/change.py +86 -0
  15. diffgenome/change_artifact.py +310 -0
  16. diffgenome/collect/__init__.py +2 -0
  17. diffgenome/collect/go_test.py +271 -0
  18. diffgenome/collect/node_jest.py +319 -0
  19. diffgenome/collect/py_monitoring.py +985 -0
  20. diffgenome/collect/py_runtime.py +116 -0
  21. diffgenome/collect/py_symbols.py +238 -0
  22. diffgenome/collect/pytest_plugin.py +130 -0
  23. diffgenome/compose.py +469 -0
  24. diffgenome/dependence.py +264 -0
  25. diffgenome/evaluate.py +669 -0
  26. diffgenome/frontends/__init__.py +0 -0
  27. diffgenome/frontends/python_ir.py +335 -0
  28. diffgenome/genome.py +1016 -0
  29. diffgenome/genome_pipeline.py +674 -0
  30. diffgenome/genome_prompt.py +33 -0
  31. diffgenome/genome_state.py +2118 -0
  32. diffgenome/graph.py +426 -0
  33. diffgenome/llm.py +189 -0
  34. diffgenome/model.py +364 -0
  35. diffgenome/mvp.py +398 -0
  36. diffgenome/probe.py +509 -0
  37. diffgenome/projection.py +308 -0
  38. diffgenome/py.typed +0 -0
  39. diffgenome/render.py +118 -0
  40. diffgenome/report.py +363 -0
  41. diffgenome/resolve.py +37 -0
  42. diffgenome/runtime.py +74 -0
  43. diffgenome/runtime_evidence.py +261 -0
  44. diffgenome/sandbox.py +166 -0
  45. diffgenome/serialize.py +96 -0
  46. diffgenome/sites.py +19 -0
  47. diffgenome/static_types.py +69 -0
  48. diffgenome/structure.py +462 -0
  49. diffgenome-0.1.0.dist-info/METADATA +139 -0
  50. diffgenome-0.1.0.dist-info/RECORD +53 -0
  51. diffgenome-0.1.0.dist-info/WHEEL +4 -0
  52. diffgenome-0.1.0.dist-info/entry_points.txt +2 -0
  53. diffgenome-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,261 @@
1
+ """Runtime evidence about a change: the stable contract consumers depend on.
2
+
3
+ `diffgenome-runtime/1` reports only facts the collectors observed while the repository's own
4
+ tests ran against the change, plus the static decision sites needed to place branch outcomes on
5
+ changed lines. No model and no genome are involved. A consumer (Sydes) reads this section of the
6
+ change artifact; it never needs the trace files, the mechanics file or DiffGenome internals.
7
+
8
+ Per changed function (a changed symbol whose definition is a function):
9
+ executed / not executed, number of calls, the exact tests (subtests included) that ran it,
10
+ exits observed (returned, returned-error, raised:<type>, ...), exceptions raised by calls made
11
+ inside it, distinct argument type shapes, its nearest observed callers, the stand-ins (mocks,
12
+ fakes) it reached, and the outcomes observed at every decision site on a changed line.
13
+ Edges: observed caller -> callee pairs on the call chains that reached changed functions, with
14
+ closures, lambdas and decorator wrappers looked through, each end located (file, line).
15
+ Boundaries: where observation stopped (external, unresolved stand-in).
16
+ Gaps: changed functions no test executed, changed-line decision outcomes never observed, and
17
+ stand-ins reached directly from changed code. Coverage is never inferred: only these
18
+ observable kinds are reported.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from collections import Counter, defaultdict
24
+ from collections.abc import Iterable
25
+ from typing import Any
26
+
27
+ from diffgenome.change import ChangeSet
28
+ from diffgenome.model import CallNode, Execution, SubstitutionNode
29
+ from diffgenome.runtime import SymbolIndex
30
+
31
+ FORMAT = "diffgenome-runtime/1"
32
+ MAX_TESTS = 200
33
+ MAX_SHAPES = 5
34
+ MAX_CALLERS = 12
35
+ MAX_EDGE_TESTS = 5
36
+ _WRAPPER_MARKERS = ("<locals>", "<lambda>", "<anon>")
37
+
38
+
39
+ def _is_wrapper(symbol: str) -> bool:
40
+ return any(m in symbol for m in _WRAPPER_MARKERS)
41
+
42
+
43
+ def _changed_functions(change: ChangeSet, index: SymbolIndex) -> dict[str, dict[str, Any]]:
44
+ out: dict[str, dict[str, Any]] = {}
45
+ for s in change.symbols:
46
+ d = index.find(s)
47
+ # application code only: changed tests (and closures inside them) are stimuli, not
48
+ # behavior under change
49
+ if d is not None and d.kind == "function" and not index.is_test(s):
50
+ out[s] = {"file": d.path, "line": d.start, "end": d.end}
51
+ return out
52
+
53
+
54
+ def _changed_lines(change: ChangeSet) -> dict[str, set[int]]:
55
+ lines: dict[str, set[int]] = defaultdict(set)
56
+ for r in change.ranges:
57
+ lines[r.path].update(r.lines)
58
+ return lines
59
+
60
+
61
+ def build_runtime_evidence(
62
+ executions: Iterable[Execution],
63
+ change: ChangeSet,
64
+ index: SymbolIndex,
65
+ mechanics_functions: list[dict[str, Any]] | None = None,
66
+ boundaries: list[dict[str, Any]] | None = None,
67
+ test_scope: str | None = None,
68
+ ) -> dict[str, Any]:
69
+ """`test_scope` names what was run (e.g. a test directory or package): every "not
70
+ executed" statement is relative to it."""
71
+ execs = list(executions)
72
+ changed = _changed_functions(change, index)
73
+ lines = _changed_lines(change)
74
+ mech = {f["symbol"]: f for f in mechanics_functions or []}
75
+
76
+ calls: Counter[str] = Counter()
77
+ tests: dict[str, set[str]] = defaultdict(set)
78
+ exits: dict[str, Counter[str]] = defaultdict(Counter)
79
+ raised_inside: dict[str, Counter[str]] = defaultdict(Counter)
80
+ shapes: dict[str, Counter[tuple[tuple[str, str], ...]]] = defaultdict(Counter)
81
+ callers: dict[str, Counter[str]] = defaultdict(Counter)
82
+ caller_tests: dict[tuple[str, str], set[str]] = defaultdict(set)
83
+ stand_ins: dict[str, Counter[str]] = defaultdict(Counter)
84
+ site_out: dict[str, Counter[bool]] = defaultdict(Counter)
85
+ edges: dict[tuple[str, str], set[str]] = defaultdict(set)
86
+ edge_count: Counter[tuple[str, str]] = Counter()
87
+ locations: dict[str, tuple[str, int] | None] = {}
88
+ origins: dict[str, str] = {}
89
+ universe = []
90
+
91
+ for ex in execs:
92
+ universe.append(ex.stimulus_ref)
93
+ for symbol_rec in ex.symbols:
94
+ origins.setdefault(symbol_rec.id, symbol_rec.origin.value)
95
+ if symbol_rec.location is not None:
96
+ locations.setdefault(
97
+ symbol_rec.id, (symbol_rec.location.path, symbol_rec.location.line)
98
+ )
99
+ nodes = {n.id: n for n in ex.nodes}
100
+
101
+ def real_parent(n: Any, nodes: dict[int, Any] = nodes) -> CallNode | None:
102
+ p = nodes.get(n.parent) if n.parent is not None else None
103
+ while isinstance(p, CallNode) and _is_wrapper(p.symbol):
104
+ p = nodes.get(p.parent) if p.parent is not None else None
105
+ return p if isinstance(p, CallNode) else None
106
+
107
+ for n in ex.nodes:
108
+ if isinstance(n, SubstitutionNode):
109
+ p = real_parent(n)
110
+ if p is not None and p.symbol in changed:
111
+ target = n.claimed_target or n.substitute or "?"
112
+ stand_ins[p.symbol][str(target)] += 1
113
+ continue
114
+ if not isinstance(n, CallNode):
115
+ continue
116
+ p = real_parent(n)
117
+ if (
118
+ p is not None
119
+ and p.symbol in changed
120
+ and str(n.outcome).startswith(("raised", "panic", "returned-error"))
121
+ ):
122
+ raised_inside[p.symbol][str(n.outcome)] += 1
123
+ if n.symbol not in changed:
124
+ continue
125
+ calls[n.symbol] += 1
126
+ tests[n.symbol].add(ex.stimulus_ref)
127
+ exits[n.symbol][str(n.outcome)] += 1
128
+ shapes[n.symbol][tuple((a[0], a[1]) for a in n.args)] += 1
129
+ if p is not None:
130
+ callers[n.symbol][p.symbol] += 1
131
+ caller_tests[(n.symbol, p.symbol)].add(ex.stimulus_ref)
132
+ # the observed chain into this changed call, wrappers looked through
133
+ child: Any = n
134
+ while True:
135
+ parent = real_parent(child)
136
+ if parent is None:
137
+ break
138
+ key = (parent.symbol, child.symbol)
139
+ if key[0] != key[1]:
140
+ edges[key].add(ex.stimulus_ref)
141
+ edge_count[key] += 1
142
+ child = parent
143
+ for b in ex.branches:
144
+ site_out[b.site][b.outcome] += 1
145
+
146
+ outcomes = {ex.stimulus_ref: ex.outcome for ex in execs}
147
+
148
+ def loc(symbol: str) -> dict[str, Any]:
149
+ """Where a symbol is defined, and its origin (repo, test, ...) as observed."""
150
+ where = locations.get(symbol)
151
+ d = index.find(symbol)
152
+ origin = "test" if index.is_test(symbol) else origins.get(symbol, "unknown")
153
+ if d is not None:
154
+ return {"symbol": symbol, "file": d.path, "line": d.start, "origin": origin}
155
+ return {
156
+ "symbol": symbol,
157
+ "file": where[0] if where else None,
158
+ "line": where[1] if where else None,
159
+ "origin": origin,
160
+ }
161
+
162
+ functions = []
163
+ gaps: list[dict[str, Any]] = []
164
+ for sym, info in sorted(changed.items()):
165
+ f = mech.get(sym)
166
+ cl = lines.get(info["file"], set())
167
+ sites = [
168
+ {
169
+ "site": s["site"],
170
+ "line": s["line"],
171
+ "predicate": s["pred"],
172
+ "true": site_out[s["site"]].get(True, 0),
173
+ "false": site_out[s["site"]].get(False, 0),
174
+ }
175
+ for s in (f["sites"] if f else [])
176
+ if s["line"] in cl
177
+ ]
178
+ executed = calls[sym] > 0
179
+ functions.append(
180
+ {
181
+ **loc(sym),
182
+ "executed": executed,
183
+ "calls": calls[sym],
184
+ "tests": sorted(tests[sym])[:MAX_TESTS],
185
+ "tests_total": len(tests[sym]),
186
+ "exits": dict(exits[sym]),
187
+ "raised_inside": dict(raised_inside[sym]),
188
+ "arg_shapes": [
189
+ [list(a) for a in shape] for shape, _ in shapes[sym].most_common(MAX_SHAPES)
190
+ ],
191
+ "callers": [
192
+ {**loc(c), "calls": k, "tests": len(caller_tests[(sym, c)])}
193
+ for c, k in callers[sym].most_common(MAX_CALLERS)
194
+ ],
195
+ "stand_ins": dict(stand_ins[sym]),
196
+ "changed_sites": sites,
197
+ }
198
+ )
199
+ if not executed:
200
+ gaps.append({"kind": "function_not_executed", **loc(sym)})
201
+ continue
202
+ for s in sites:
203
+ missing = [o for o, k in (("true", s["true"]), ("false", s["false"])) if k == 0]
204
+ if len(missing) == 2:
205
+ gaps.append(
206
+ {
207
+ "kind": "branch_not_evaluated",
208
+ **loc(sym),
209
+ "line": s["line"],
210
+ "predicate": s["predicate"],
211
+ }
212
+ )
213
+ elif missing:
214
+ gaps.append(
215
+ {
216
+ "kind": "branch_outcome_not_observed",
217
+ **loc(sym),
218
+ "line": s["line"],
219
+ "predicate": s["predicate"],
220
+ "outcome": missing[0],
221
+ }
222
+ )
223
+ for target, k in stand_ins[sym].items():
224
+ gaps.append({"kind": "stand_in_reached", **loc(sym), "target": target, "calls": k})
225
+
226
+ return {
227
+ "format": FORMAT,
228
+ "universe": {
229
+ "test_scope": test_scope,
230
+ "executions": len(universe),
231
+ "passed": sum(1 for o in outcomes.values() if o == "passed"),
232
+ "failed": sum(1 for o in outcomes.values() if o != "passed"),
233
+ "tests": [{"id": t, "outcome": outcomes[t]} for t in sorted(outcomes)][:2000],
234
+ },
235
+ "changed_functions": functions,
236
+ "edges": [
237
+ {
238
+ "caller": loc(a),
239
+ "callee": loc(b),
240
+ "executions": edge_count[(a, b)],
241
+ "tests": sorted(edges[(a, b)])[:MAX_EDGE_TESTS],
242
+ "tests_total": len(edges[(a, b)]),
243
+ }
244
+ for (a, b) in sorted(edges)
245
+ ],
246
+ "boundaries": [
247
+ {
248
+ "kind": b.get("kind"),
249
+ "caller": b.get("caller"),
250
+ "target": b.get("target"),
251
+ "tests": len(b.get("executions") or []),
252
+ }
253
+ for b in boundaries or []
254
+ ],
255
+ "gaps": gaps,
256
+ "not_reported": [
257
+ "values (only type shapes and opaque fingerprints are recorded)",
258
+ "except-handler coverage (handlers are not decision sites)",
259
+ "per-line coverage",
260
+ ],
261
+ }
diffgenome/sandbox.py ADDED
@@ -0,0 +1,166 @@
1
+ """Disposable workspace and confined execution for untrusted target code and probes.
2
+
3
+ The target repository is copied (never mounted writable) into a workspace; commands run
4
+ with a scrubbed environment, resource limits, and, where the host supports it, an OS
5
+ sandbox that denies network access and writes outside the workspace. On macOS that is
6
+ `sandbox-exec` (Seatbelt); on Linux a container/namespace runner belongs here later. If no
7
+ OS sandbox is available the runner refuses to execute rather than run unconfined.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import platform
14
+ import shutil
15
+ import subprocess
16
+ import time
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+
20
+ _IGNORE = shutil.ignore_patterns(
21
+ ".git", ".venv", "venv", "node_modules", "__pycache__", ".pytest_cache", ".mypy_cache",
22
+ ".ruff_cache", ".diffgenome", "*.pyc",
23
+ ) # fmt: skip
24
+
25
+
26
+ @dataclass
27
+ class RunResult:
28
+ returncode: int
29
+ stdout: str
30
+ stderr: str
31
+ seconds: float
32
+ confined: bool
33
+
34
+
35
+ @dataclass
36
+ class Workspace:
37
+ root: Path # disposable directory owned by diffgenome
38
+ repo: Path # copy of the target inside root
39
+ home: Path
40
+ tmp: Path
41
+ extra_read_paths: list[Path] = field(default_factory=list) # e.g. the target's interpreter
42
+
43
+ @classmethod
44
+ def create(
45
+ cls,
46
+ target_repo: Path,
47
+ base: Path,
48
+ extra_read_paths: list[Path] | None = None,
49
+ rev: str | None = None,
50
+ ) -> Workspace:
51
+ root = base / f"ws-{int(time.time() * 1000)}"
52
+ repo = root / "repo"
53
+ if rev:
54
+ # A specific revision without touching the checkout: `git archive` into the copy.
55
+ repo.mkdir(parents=True)
56
+ archive = subprocess.run(
57
+ ["git", "archive", "--format=tar", rev],
58
+ cwd=target_repo,
59
+ capture_output=True,
60
+ check=True,
61
+ )
62
+ subprocess.run(["tar", "-x", "-C", str(repo)], input=archive.stdout, check=True)
63
+ else:
64
+ shutil.copytree(target_repo, repo, ignore=_IGNORE, symlinks=False)
65
+ home, tmp = root / "home", root / "tmp"
66
+ home.mkdir()
67
+ tmp.mkdir()
68
+ return cls(root, repo, home, tmp, list(extra_read_paths or []))
69
+
70
+ def destroy(self) -> None:
71
+ shutil.rmtree(self.root, ignore_errors=True)
72
+
73
+ # ------------------------------------------------------------------ confinement
74
+
75
+ def _seatbelt_profile(self, allow_loopback: bool = False) -> str:
76
+ ws = str(self.root.resolve())
77
+ loopback = (
78
+ '(allow network-bind (local ip "localhost:*"))\n'
79
+ '(allow network-inbound (local ip "localhost:*"))\n'
80
+ '(allow network-outbound (remote ip "localhost:*"))\n'
81
+ if allow_loopback
82
+ else ""
83
+ )
84
+ return f"""(version 1)
85
+ (deny default)
86
+ (allow process-fork)
87
+ (allow process-exec)
88
+ (allow signal (target self))
89
+ (allow sysctl-read)
90
+ (allow mach-lookup)
91
+ (allow ipc-posix-shm*)
92
+ (allow file-read*)
93
+ (allow file-write* (subpath "{ws}"))
94
+ (allow file-write* (literal "/dev/null") (literal "/dev/dtracehelper") (regex #"^/dev/tty"))
95
+ (deny network*)
96
+ {loopback}"""
97
+
98
+ def command(
99
+ self,
100
+ argv: list[str],
101
+ cpu_seconds: int = 600,
102
+ max_procs: int = 4096, # macOS counts the whole user; 256 broke Jest's `find` spawn
103
+ allow_loopback: bool = False,
104
+ ) -> list[str] | None:
105
+ """Wrap argv for confined execution, or None if the host has no supported sandbox.
106
+ `allow_loopback` permits localhost sockets only (in-process test servers); every
107
+ other network access stays denied."""
108
+ limits = f'ulimit -t {cpu_seconds}; ulimit -u {max_procs}; ulimit -f 1048576; exec "$@"'
109
+ inner = ["/bin/sh", "-c", limits, "sh", *argv]
110
+ if platform.system() == "Darwin" and shutil.which("sandbox-exec"):
111
+ return ["/usr/bin/sandbox-exec", "-p", self._seatbelt_profile(allow_loopback), *inner]
112
+ return None
113
+
114
+ def environment(self, extra: dict[str, str] | None = None) -> dict[str, str]:
115
+ """No inherited variables: no credentials, no proxies, no shell state."""
116
+ env = {
117
+ "PATH": "/usr/bin:/bin",
118
+ "HOME": str(self.home),
119
+ "TMPDIR": str(self.tmp),
120
+ "PYTHONDONTWRITEBYTECODE": "1",
121
+ "PYTHONNOUSERSITE": "1",
122
+ "MPLCONFIGDIR": str(self.tmp),
123
+ "LANG": "C.UTF-8",
124
+ }
125
+ env.update(extra or {})
126
+ return env
127
+
128
+ def run(
129
+ self,
130
+ argv: list[str],
131
+ cwd: Path | None = None,
132
+ env: dict[str, str] | None = None,
133
+ timeout: int = 900,
134
+ allow_loopback: bool = False,
135
+ ) -> RunResult:
136
+ wrapped = self.command(argv, allow_loopback=allow_loopback)
137
+ if wrapped is None:
138
+ raise RuntimeError("no OS sandbox available on this host; refusing to run unconfined")
139
+ start = time.monotonic()
140
+ try:
141
+ proc = subprocess.run(
142
+ wrapped,
143
+ cwd=cwd or self.repo,
144
+ env=self.environment(env),
145
+ capture_output=True,
146
+ text=True,
147
+ timeout=timeout,
148
+ stdin=subprocess.DEVNULL,
149
+ )
150
+ return RunResult(
151
+ proc.returncode, proc.stdout, proc.stderr, time.monotonic() - start, True
152
+ )
153
+ except subprocess.TimeoutExpired as exc:
154
+ out = exc.stdout.decode() if isinstance(exc.stdout, bytes) else (exc.stdout or "")
155
+ err = exc.stderr.decode() if isinstance(exc.stderr, bytes) else (exc.stderr or "")
156
+ return RunResult(
157
+ -1, out, err + f"\ntimeout after {timeout}s", time.monotonic() - start, True
158
+ )
159
+
160
+
161
+ def host_supports_confinement() -> bool:
162
+ return (
163
+ platform.system() == "Darwin"
164
+ and shutil.which("sandbox-exec") is not None
165
+ and os.access("/usr/bin/sandbox-exec", os.X_OK)
166
+ )
@@ -0,0 +1,96 @@
1
+ """JSON encoding of the event protocol. One `Execution` per document; deterministic output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import json
7
+ from enum import Enum
8
+ from typing import Any
9
+
10
+ from diffgenome.model import (
11
+ BranchObs,
12
+ CallNode,
13
+ Collector,
14
+ Execution,
15
+ Fidelity,
16
+ Node,
17
+ Origin,
18
+ OsEventKind,
19
+ OsEventNode,
20
+ Plane,
21
+ SourceLocation,
22
+ Stimulus,
23
+ SubstitutionMechanism,
24
+ SubstitutionNode,
25
+ Symbol,
26
+ )
27
+
28
+ _NODE_TYPES: dict[str, type[Node]] = {
29
+ "call": CallNode,
30
+ "substitution": SubstitutionNode,
31
+ "os_event": OsEventNode,
32
+ }
33
+ _TYPE_NAMES: dict[type, str] = {cls: name for name, cls in _NODE_TYPES.items()}
34
+
35
+
36
+ def _encode(value: Any) -> Any:
37
+ if isinstance(value, Enum):
38
+ return value.value
39
+ if dataclasses.is_dataclass(value) and not isinstance(value, type):
40
+ out = {f.name: _encode(getattr(value, f.name)) for f in dataclasses.fields(value)}
41
+ node_type = _TYPE_NAMES.get(type(value))
42
+ return {"type": node_type, **out} if node_type else out
43
+ if isinstance(value, tuple | list):
44
+ return [_encode(v) for v in value]
45
+ return value
46
+
47
+
48
+ def execution_to_json(execution: Execution) -> str:
49
+ return json.dumps(_encode(execution), indent=1, sort_keys=False) + "\n"
50
+
51
+
52
+ def _node_from_dict(d: dict[str, Any]) -> Node:
53
+ d = dict(d)
54
+ cls = _NODE_TYPES[d.pop("type")]
55
+ if "args" in d:
56
+ d["args"] = tuple((n, sh, dg) for n, sh, dg in d["args"])
57
+ if "state" in d:
58
+ d["state"] = tuple((n, b, dg) for n, b, dg in d["state"])
59
+ if "state_after" in d:
60
+ d["state_after"] = tuple((n, b, dg) for n, b, dg in d["state_after"])
61
+ if cls is SubstitutionNode:
62
+ d["mechanism"] = SubstitutionMechanism(d["mechanism"])
63
+ d["path"] = tuple(d["path"])
64
+ elif cls is OsEventNode:
65
+ d["kind"] = OsEventKind(d["kind"])
66
+ return cls(**d)
67
+
68
+
69
+ def execution_from_json(text: str) -> Execution:
70
+ d = json.loads(text)
71
+ return Execution(
72
+ id=d["id"],
73
+ stimulus=Stimulus(d["stimulus"]),
74
+ stimulus_ref=d["stimulus_ref"],
75
+ outcome=d["outcome"],
76
+ revision=d["revision"],
77
+ collectors=tuple(
78
+ Collector(c["name"], Plane(c["plane"]), Fidelity(c["fidelity"]))
79
+ for c in d["collectors"]
80
+ ),
81
+ symbols=tuple(
82
+ Symbol(
83
+ s["id"],
84
+ Origin(s["origin"]),
85
+ SourceLocation(**s["location"]) if s["location"] else None,
86
+ s.get("kind", "callable"),
87
+ )
88
+ for s in d["symbols"]
89
+ ),
90
+ nodes=tuple(_node_from_dict(n) for n in d["nodes"]),
91
+ diagnostics=tuple((k, v) for k, v in d.get("diagnostics", [])),
92
+ branches=tuple(
93
+ BranchObs(b["site"], bool(b["outcome"]), int(b["node"]), int(b["seq"]))
94
+ for b in d.get("branches") or []
95
+ ),
96
+ )
diffgenome/sites.py ADDED
@@ -0,0 +1,19 @@
1
+ """Stable identity of decision (branch) sites.
2
+
3
+ A site is identified by the repository-relative file and the span of its condition in
4
+ the ORIGINAL source: 1-based line and 1-based byte column of the first character, and
5
+ 1-based line and 1-based byte column just past the last character. Every runtime
6
+ (instrumenter or monitor) and every static front end computes the id from the original
7
+ source independently, so instrumentation that rewrites code cannot change it.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+
14
+ Span = tuple[int, int, int, int] # start line, start col, end line, end col (exclusive)
15
+
16
+
17
+ def site_id(rel_path: str, span: Span) -> str:
18
+ l1, c1, l2, c2 = span
19
+ return "br:" + hashlib.sha256(f"{rel_path}:{l1}:{c1}:{l2}:{c2}".encode()).hexdigest()[:12]
@@ -0,0 +1,69 @@
1
+ """Static return-type resolution for `factory().member` stand-ins.
2
+
3
+ A stand-in whose path crosses a return value (``get_manager.().ensure_backend``) claims the
4
+ factory; what the factory returns was never observed. When the source declares the return
5
+ type unambiguously, the member on that type is the claim, entered with relation
6
+ ``static-return-type`` so the resolver reports the rule and the evidence says so. Static
7
+ evidence, never observed; the composer still needs a real fragment to continue.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import dataclasses
13
+
14
+ from diffgenome.model import Execution, Origin, SubstitutionNode, Symbol
15
+ from diffgenome.runtime import SymbolIndex
16
+
17
+
18
+ def apply_static_return_types(
19
+ executions: list[Execution], index: SymbolIndex
20
+ ) -> tuple[list[Execution], int]:
21
+ resolved = 0
22
+ out: list[Execution] = []
23
+ cache: dict[str, str | None] = {}
24
+ for ex in executions:
25
+ symbols = dict((s.id, s) for s in ex.symbols)
26
+ nodes = list(ex.nodes)
27
+ changed = False
28
+ for i, n in enumerate(nodes):
29
+ if (
30
+ not isinstance(n, SubstitutionNode)
31
+ or n.claimed_target is None
32
+ or "()" not in n.path
33
+ ):
34
+ continue
35
+ # path: (factory, "()", member, ...) — only one hop through a return value
36
+ try:
37
+ k = n.path.index("()")
38
+ except ValueError:
39
+ continue
40
+ if k + 1 >= len(n.path) or "()" in n.path[k + 1 :]:
41
+ continue
42
+ member = n.path[k + 1]
43
+ if n.claimed_target not in cache:
44
+ cache[n.claimed_target] = index.return_type_of(n.claimed_target)
45
+ cls = cache[n.claimed_target]
46
+ if cls is None:
47
+ continue
48
+ target = f"{cls}.{member}"
49
+ d = index.find(target)
50
+ if d is None:
51
+ continue
52
+ nodes[i] = dataclasses.replace(
53
+ n,
54
+ claimed_target=target,
55
+ relation=f"static-return-type;{n.relation}",
56
+ path=n.path[k + 1 :],
57
+ )
58
+ if target not in symbols:
59
+ symbols[target] = Symbol(target, Origin.REPO, None)
60
+ resolved += 1
61
+ changed = True
62
+ out.append(
63
+ dataclasses.replace(
64
+ ex, nodes=tuple(nodes), symbols=tuple(symbols[k] for k in sorted(symbols))
65
+ )
66
+ if changed
67
+ else ex
68
+ )
69
+ return out, resolved