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.
- diffgenome/__init__.py +7 -0
- diffgenome/__main__.py +240 -0
- diffgenome/_collectors/go/dg/dg.go +623 -0
- diffgenome/_collectors/go/go.mod +3 -0
- diffgenome/_collectors/go/instrument/facts.go +346 -0
- diffgenome/_collectors/go/instrument/main.go +484 -0
- diffgenome/_collectors/node/instrument.js +289 -0
- diffgenome/_collectors/node/jest-setup.js +40 -0
- diffgenome/_collectors/node/package-lock.json +35 -0
- diffgenome/_collectors/node/package.json +11 -0
- diffgenome/_collectors/node/runtime.js +426 -0
- diffgenome/ambiguity.py +122 -0
- diffgenome/api.py +67 -0
- diffgenome/change.py +86 -0
- diffgenome/change_artifact.py +310 -0
- diffgenome/collect/__init__.py +2 -0
- diffgenome/collect/go_test.py +271 -0
- diffgenome/collect/node_jest.py +319 -0
- diffgenome/collect/py_monitoring.py +985 -0
- diffgenome/collect/py_runtime.py +116 -0
- diffgenome/collect/py_symbols.py +238 -0
- diffgenome/collect/pytest_plugin.py +130 -0
- diffgenome/compose.py +469 -0
- diffgenome/dependence.py +264 -0
- diffgenome/evaluate.py +669 -0
- diffgenome/frontends/__init__.py +0 -0
- diffgenome/frontends/python_ir.py +335 -0
- diffgenome/genome.py +1016 -0
- diffgenome/genome_pipeline.py +674 -0
- diffgenome/genome_prompt.py +33 -0
- diffgenome/genome_state.py +2118 -0
- diffgenome/graph.py +426 -0
- diffgenome/llm.py +189 -0
- diffgenome/model.py +364 -0
- diffgenome/mvp.py +398 -0
- diffgenome/probe.py +509 -0
- diffgenome/projection.py +308 -0
- diffgenome/py.typed +0 -0
- diffgenome/render.py +118 -0
- diffgenome/report.py +363 -0
- diffgenome/resolve.py +37 -0
- diffgenome/runtime.py +74 -0
- diffgenome/runtime_evidence.py +261 -0
- diffgenome/sandbox.py +166 -0
- diffgenome/serialize.py +96 -0
- diffgenome/sites.py +19 -0
- diffgenome/static_types.py +69 -0
- diffgenome/structure.py +462 -0
- diffgenome-0.1.0.dist-info/METADATA +139 -0
- diffgenome-0.1.0.dist-info/RECORD +53 -0
- diffgenome-0.1.0.dist-info/WHEEL +4 -0
- diffgenome-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|
diffgenome/serialize.py
ADDED
|
@@ -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
|