graphify-php 0.0.5__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.
- graphify_php/__init__.py +144 -0
- graphify_php/cli.py +355 -0
- graphify_php/config/__init__.py +47 -0
- graphify_php/config/class_lookup.py +85 -0
- graphify_php/config/discovery.py +55 -0
- graphify_php/config/document.py +29 -0
- graphify_php/config/emit.py +117 -0
- graphify_php/config/environment.py +108 -0
- graphify_php/config/facts.py +97 -0
- graphify_php/config/messaging.py +142 -0
- graphify_php/config/ports.py +23 -0
- graphify_php/config/safe_yaml.py +127 -0
- graphify_php/config_resolver.py +164 -0
- graphify_php/container/__init__.py +50 -0
- graphify_php/container/facts.py +178 -0
- graphify_php/container/layer.py +71 -0
- graphify_php/container/locator.py +74 -0
- graphify_php/container/overlay.py +149 -0
- graphify_php/container/ports.py +40 -0
- graphify_php/container/reader.py +330 -0
- graphify_php/container/reasons.py +31 -0
- graphify_php/graph_adapter.py +520 -0
- graphify_php/php/README.md +106 -0
- graphify_php/php/autoload.php +27 -0
- graphify_php/php/src/CallGraph/CallEdge.php +91 -0
- graphify_php/php/src/CallGraph/CallEdgeRule.php +61 -0
- graphify_php/php/src/CallGraph/CallEdgeWriter.php +19 -0
- graphify_php/php/src/CallGraph/JsonLinesWriter.php +64 -0
- graphify_php/php/src/CallGraph/MethodCallCollector.php +163 -0
- graphify_php/ports.py +174 -0
- graphify_php/resolver.py +519 -0
- graphify_php/shrink.py +413 -0
- graphify_php/shrink_cli.py +150 -0
- graphify_php/sources/phpstan_source.py +641 -0
- graphify_php/sources/route_source.py +105 -0
- graphify_php/sources/tree_sitter_source.py +428 -0
- graphify_php/status.py +136 -0
- graphify_php/syntax/call_sites.py +117 -0
- graphify_php/syntax/class_index.py +121 -0
- graphify_php/syntax/collectors.py +351 -0
- graphify_php/syntax/facts.py +171 -0
- graphify_php/syntax/file_scan.py +158 -0
- graphify_php/syntax/names.py +49 -0
- graphify_php/syntax/nodes.py +209 -0
- graphify_php/syntax/routes.py +288 -0
- graphify_php-0.0.5.dist-info/METADATA +218 -0
- graphify_php-0.0.5.dist-info/RECORD +50 -0
- graphify_php-0.0.5.dist-info/WHEEL +4 -0
- graphify_php-0.0.5.dist-info/entry_points.txt +3 -0
- graphify_php-0.0.5.dist-info/licenses/LICENSE +201 -0
graphify_php/shrink.py
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"""Attributed-loss shrink guard: decide whether a rebuilt graph may replace the previous one.
|
|
2
|
+
|
|
3
|
+
A plain node/edge count floor cannot tell a real regression from a legitimate shrink. Deleting a
|
|
4
|
+
directory of notes, or graphify dropping degree-0 external stubs, shrinks a graph as much as a
|
|
5
|
+
resolver silently failing does. This guard attributes every lost node to the file it came from
|
|
6
|
+
and asks why that file lost it: the file is gone, the file changed, or the producer changed what
|
|
7
|
+
it indexes. Only loss nothing on disk explains counts against the tolerance.
|
|
8
|
+
|
|
9
|
+
Stdlib only and free of graphify imports, because graphify-mesh runs it out of process through
|
|
10
|
+
`graphify-shrink-check` and this package must stay importable without graphify.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
from collections import Counter, defaultdict
|
|
17
|
+
from collections.abc import Iterable, Mapping
|
|
18
|
+
from dataclasses import asdict, dataclass, field
|
|
19
|
+
from pathlib import Path, PurePosixPath
|
|
20
|
+
|
|
21
|
+
POLICY_ATTRIBUTED = "attributed"
|
|
22
|
+
POLICY_CONNECTED = "connected"
|
|
23
|
+
POLICY_COUNT = "count"
|
|
24
|
+
# Computes the count figures for the report and never refuses. Accepted here, not only in the
|
|
25
|
+
# build wrapper, so `GRAPHIFY_SHRINK_POLICY=off` means the same thing to every caller.
|
|
26
|
+
POLICY_OFF = "off"
|
|
27
|
+
POLICIES = (POLICY_ATTRIBUTED, POLICY_CONNECTED, POLICY_COUNT, POLICY_OFF)
|
|
28
|
+
|
|
29
|
+
DEFAULT_POLICY = POLICY_ATTRIBUTED
|
|
30
|
+
DEFAULT_TOLERANCE = 0.10
|
|
31
|
+
|
|
32
|
+
NONE_BUCKET = "<none>"
|
|
33
|
+
FILES_REPORTED = 50
|
|
34
|
+
|
|
35
|
+
CLASS_DELETED = "deleted"
|
|
36
|
+
CLASS_CHANGED = "changed"
|
|
37
|
+
CLASS_OUT_OF_CORPUS = "out_of_corpus"
|
|
38
|
+
CLASS_UNCHANGED = "unchanged"
|
|
39
|
+
# An unchanged file the new producer no longer indexes at all; see classify_loss.
|
|
40
|
+
CLASS_DROPPED_BY_EXTRACTOR = "dropped_by_extractor"
|
|
41
|
+
CLASS_NONE = "none"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class LossReport:
|
|
46
|
+
verdict: str
|
|
47
|
+
policy: str
|
|
48
|
+
tolerance: float
|
|
49
|
+
nodes: dict = field(default_factory=dict)
|
|
50
|
+
edges: dict = field(default_factory=dict)
|
|
51
|
+
reasons: list[str] = field(default_factory=list)
|
|
52
|
+
files: list[dict] = field(default_factory=list)
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> dict:
|
|
55
|
+
return asdict(self)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def shrink_floor(old_count: int, tolerance: float) -> int:
|
|
59
|
+
"""Smallest surviving count accepted for `old_count`.
|
|
60
|
+
|
|
61
|
+
Same arithmetic as graphify-mesh's `_shrink_floor` (sync_project.py), so both sides refuse at
|
|
62
|
+
the same count: rounding up means a tolerance is never widened by truncation, and a tiny base
|
|
63
|
+
gets exact-match semantics.
|
|
64
|
+
"""
|
|
65
|
+
if old_count <= 0 or tolerance <= 0.0:
|
|
66
|
+
return old_count
|
|
67
|
+
return math.ceil(old_count * (1.0 - tolerance))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _below_floor(base: int, loss: int, tolerance: float) -> bool:
|
|
71
|
+
return base - loss < shrink_floor(base, tolerance)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _nodes(graph: Mapping) -> dict[str, dict]:
|
|
75
|
+
# Node-link JSON normally carries dicts, but a bare id is still a node; it just has no
|
|
76
|
+
# attributes to attribute it by, so it lands in the "<none>" bucket.
|
|
77
|
+
out: dict[str, dict] = {}
|
|
78
|
+
for node in graph.get("nodes") or []:
|
|
79
|
+
if isinstance(node, dict):
|
|
80
|
+
if node.get("id") is not None:
|
|
81
|
+
out[node["id"]] = node
|
|
82
|
+
elif isinstance(node, str | int):
|
|
83
|
+
out[node] = {}
|
|
84
|
+
return out
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _edges(graph: Mapping) -> list[dict]:
|
|
88
|
+
# networkx writes `links` by default and `edges` when asked to; graphify has shipped both.
|
|
89
|
+
edges = graph.get("links") if "links" in graph else graph.get("edges")
|
|
90
|
+
return [e for e in edges or [] if isinstance(e, dict)]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _edge_key(edge: Mapping) -> tuple:
|
|
94
|
+
return edge.get("source"), edge.get("target"), edge.get("relation")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _degree(edges: Iterable[Mapping], ids: Iterable[str]) -> Counter:
|
|
98
|
+
degree: Counter = Counter({i: 0 for i in ids})
|
|
99
|
+
for edge in edges:
|
|
100
|
+
degree[edge.get("source")] += 1
|
|
101
|
+
degree[edge.get("target")] += 1
|
|
102
|
+
return degree
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _relative(path: str | None, root: Path) -> str:
|
|
106
|
+
"""Repo-relative POSIX form, so graph paths and manifest keys compare equal.
|
|
107
|
+
|
|
108
|
+
`root` is already resolved. graphify records whichever spelling of the root it was given, so an
|
|
109
|
+
absolute path through a symlink only matches once its directory is resolved too. The file
|
|
110
|
+
itself is not followed: a symlinked file belongs to where it sits in the repository.
|
|
111
|
+
"""
|
|
112
|
+
if not path:
|
|
113
|
+
return NONE_BUCKET
|
|
114
|
+
p = Path(path)
|
|
115
|
+
if p.is_absolute():
|
|
116
|
+
for candidate in (p, p.parent.resolve() / p.name):
|
|
117
|
+
try:
|
|
118
|
+
return candidate.relative_to(root).as_posix()
|
|
119
|
+
except ValueError:
|
|
120
|
+
continue
|
|
121
|
+
# Outside the repository: keep it absolute, and it is still checked on disk as-is.
|
|
122
|
+
return p.as_posix()
|
|
123
|
+
return PurePosixPath(p.as_posix()).as_posix()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _normalise_manifest(manifest: Mapping, root: Path) -> dict[str, object]:
|
|
127
|
+
return {_relative(k, root): v for k, v in (manifest or {}).items()}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _file_hash(entry: object) -> object:
|
|
131
|
+
if not isinstance(entry, dict):
|
|
132
|
+
return None
|
|
133
|
+
return entry.get("ast_hash") or entry.get("semantic_hash")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _classify_file(rel: str, root: Path, old_manifest: Mapping, new_manifest: Mapping) -> str:
|
|
137
|
+
if rel == NONE_BUCKET:
|
|
138
|
+
return CLASS_NONE
|
|
139
|
+
if not (root / rel).exists():
|
|
140
|
+
return CLASS_DELETED
|
|
141
|
+
if rel not in new_manifest:
|
|
142
|
+
return CLASS_OUT_OF_CORPUS
|
|
143
|
+
if rel not in old_manifest:
|
|
144
|
+
return CLASS_CHANGED
|
|
145
|
+
if _file_hash(old_manifest[rel]) != _file_hash(new_manifest[rel]):
|
|
146
|
+
return CLASS_CHANGED
|
|
147
|
+
return CLASS_UNCHANGED
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def classify_loss(
|
|
151
|
+
old_graph: dict,
|
|
152
|
+
new_graph: dict,
|
|
153
|
+
old_manifest: dict,
|
|
154
|
+
new_manifest: dict,
|
|
155
|
+
root: Path,
|
|
156
|
+
*,
|
|
157
|
+
policy: str = DEFAULT_POLICY,
|
|
158
|
+
tolerance: float = DEFAULT_TOLERANCE,
|
|
159
|
+
producer_changed: bool = False,
|
|
160
|
+
) -> LossReport:
|
|
161
|
+
"""Compare two builds of the same repository and say whether the new one may publish.
|
|
162
|
+
|
|
163
|
+
`producer_changed` is true when graphify or its configuration changed between the builds, so a
|
|
164
|
+
file still on disk may legitimately have left the corpus (a new ignore rule, a dropped file
|
|
165
|
+
type). Without it, such a file losing its nodes is unexplained. It also lets an unchanged file
|
|
166
|
+
lose every node, as when graphify stops indexing data JSON, but only if no old edge left that
|
|
167
|
+
file: a vanished cross-file relation is real knowledge loss whatever the extractor did.
|
|
168
|
+
|
|
169
|
+
Edge figures: `lost = explained + unexplained + resolver_lost`, where `resolver_lost` counts
|
|
170
|
+
lost `_resolver` edges whose endpoints both survived and whose files did not change. An edit
|
|
171
|
+
that deleted or rewrote the edge's file, or the file of its calling node, can remove the call,
|
|
172
|
+
so that loss is explained. In an unchanged file, or one that left the corpus with no producer
|
|
173
|
+
change, nothing on disk accounts for a resolver dropping a call between two surviving nodes.
|
|
174
|
+
"""
|
|
175
|
+
if policy not in POLICIES:
|
|
176
|
+
raise ValueError(f"unknown shrink policy {policy!r}; expected one of {', '.join(POLICIES)}")
|
|
177
|
+
if not 0.0 <= tolerance < 1.0:
|
|
178
|
+
raise ValueError(f"shrink tolerance {tolerance!r} outside [0, 1)")
|
|
179
|
+
# Resolved once, so a relative or symlinked root and the absolute paths graphify recorded
|
|
180
|
+
# name the same files.
|
|
181
|
+
root = Path(root).resolve()
|
|
182
|
+
|
|
183
|
+
old_nodes, new_nodes = _nodes(old_graph), _nodes(new_graph)
|
|
184
|
+
old_edges, new_edges = _edges(old_graph), _edges(new_graph)
|
|
185
|
+
old_degree = _degree(old_edges, old_nodes)
|
|
186
|
+
lost_ids = [i for i in old_nodes if i not in new_nodes]
|
|
187
|
+
|
|
188
|
+
if policy != POLICY_ATTRIBUTED:
|
|
189
|
+
return _counted(policy, tolerance, old_nodes, new_nodes, old_edges, new_edges, lost_ids)
|
|
190
|
+
|
|
191
|
+
orphans = {i for i in lost_ids if old_degree[i] == 0 and old_nodes[i].get("external")}
|
|
192
|
+
old_manifest = _normalise_manifest(old_manifest, root)
|
|
193
|
+
new_manifest = _normalise_manifest(new_manifest, root)
|
|
194
|
+
|
|
195
|
+
def file_of(node: Mapping) -> str:
|
|
196
|
+
return _relative(node.get("source_file"), root)
|
|
197
|
+
|
|
198
|
+
# Orphans are left out of the old per-file count too, otherwise a file that only shed its
|
|
199
|
+
# external stubs would look like it lost real nodes under the unchanged-file count rule.
|
|
200
|
+
old_per_file = Counter(file_of(n) for i, n in old_nodes.items() if i not in orphans)
|
|
201
|
+
new_per_file = Counter(file_of(n) for n in new_nodes.values())
|
|
202
|
+
lost_per_file: dict[str, list[str]] = defaultdict(list)
|
|
203
|
+
for i in lost_ids:
|
|
204
|
+
if i not in orphans:
|
|
205
|
+
lost_per_file[file_of(old_nodes[i])].append(i)
|
|
206
|
+
|
|
207
|
+
classes: dict[str, str] = {}
|
|
208
|
+
|
|
209
|
+
def class_of(rel: str) -> str:
|
|
210
|
+
if rel not in classes:
|
|
211
|
+
classes[rel] = _classify_file(rel, root, old_manifest, new_manifest)
|
|
212
|
+
return classes[rel]
|
|
213
|
+
|
|
214
|
+
def self_contained(rel: str, ids: list[str]) -> bool:
|
|
215
|
+
lost = set(ids)
|
|
216
|
+
for edge in old_edges:
|
|
217
|
+
source, target = edge.get("source"), edge.get("target")
|
|
218
|
+
if source in lost or target in lost:
|
|
219
|
+
other = target if source in lost else source
|
|
220
|
+
if file_of(old_nodes.get(other) or {}) != rel:
|
|
221
|
+
return False
|
|
222
|
+
return True
|
|
223
|
+
|
|
224
|
+
explained = unexplained = 0
|
|
225
|
+
explained_ids: set[str] = set(orphans)
|
|
226
|
+
reasons: list[str] = []
|
|
227
|
+
files: list[dict] = []
|
|
228
|
+
for rel, ids in lost_per_file.items():
|
|
229
|
+
cls = class_of(rel)
|
|
230
|
+
old_n, new_n = old_per_file[rel], new_per_file[rel]
|
|
231
|
+
# The new producer stopped indexing this file outright: explained if nothing linked out.
|
|
232
|
+
if (
|
|
233
|
+
cls == CLASS_UNCHANGED
|
|
234
|
+
and producer_changed
|
|
235
|
+
and old_n >= 1
|
|
236
|
+
and new_n == 0
|
|
237
|
+
and self_contained(rel, ids)
|
|
238
|
+
):
|
|
239
|
+
cls = classes[rel] = CLASS_DROPPED_BY_EXTRACTOR
|
|
240
|
+
if cls in (CLASS_DELETED, CLASS_CHANGED, CLASS_DROPPED_BY_EXTRACTOR) or (
|
|
241
|
+
cls == CLASS_OUT_OF_CORPUS and producer_changed
|
|
242
|
+
):
|
|
243
|
+
file_loss, file_unexplained = len(ids), 0
|
|
244
|
+
elif cls == CLASS_UNCHANGED:
|
|
245
|
+
# graphify renames ids between versions (`types_lodash` -> `ref_types_lodash`), so
|
|
246
|
+
# for a file whose bytes did not change only a drop in its node count is real loss.
|
|
247
|
+
file_loss = file_unexplained = max(0, old_n - new_n)
|
|
248
|
+
else:
|
|
249
|
+
file_loss = file_unexplained = len(ids)
|
|
250
|
+
unexplained += file_unexplained
|
|
251
|
+
explained += len(ids) - file_unexplained
|
|
252
|
+
if file_unexplained == 0:
|
|
253
|
+
explained_ids.update(ids)
|
|
254
|
+
files.append({"file": rel, "class": cls, "old": old_n, "new": new_n, "lost": file_loss})
|
|
255
|
+
|
|
256
|
+
if cls == CLASS_UNCHANGED and old_n >= 1 and new_n == 0:
|
|
257
|
+
reasons.append(f"unchanged file {rel} lost all {old_n} of its nodes")
|
|
258
|
+
elif cls == CLASS_CHANGED and old_n >= 1 and new_n == 0:
|
|
259
|
+
reasons.append(f"changed file {rel} still exists but lost all {old_n} of its nodes")
|
|
260
|
+
elif cls == CLASS_OUT_OF_CORPUS and not producer_changed:
|
|
261
|
+
reasons.append(
|
|
262
|
+
f"{rel} is on disk but left the corpus ({len(ids)} nodes lost) "
|
|
263
|
+
"with no producer change to explain it"
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
base = sum(1 for i in old_nodes if old_degree[i] >= 1)
|
|
267
|
+
floor = shrink_floor(base, tolerance)
|
|
268
|
+
if base - unexplained < floor:
|
|
269
|
+
reasons.insert(
|
|
270
|
+
0,
|
|
271
|
+
f"unexplained node loss {unexplained} exceeds tolerance {tolerance:g} "
|
|
272
|
+
f"of {base} connected nodes (floor {floor})",
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
def edited(rel: str) -> bool:
|
|
276
|
+
cls = class_of(rel)
|
|
277
|
+
return cls in (CLASS_DELETED, CLASS_CHANGED, CLASS_DROPPED_BY_EXTRACTOR) or (
|
|
278
|
+
cls == CLASS_OUT_OF_CORPUS and producer_changed
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
def resolver_call_edited(edge: Mapping) -> bool:
|
|
282
|
+
caller = old_nodes.get(edge.get("source")) or {}
|
|
283
|
+
return edited(_relative(edge.get("source_file"), root)) or edited(file_of(caller))
|
|
284
|
+
|
|
285
|
+
edge_figures, edge_reasons = _attribute_edges(
|
|
286
|
+
old_edges,
|
|
287
|
+
new_edges,
|
|
288
|
+
new_nodes,
|
|
289
|
+
explained_ids,
|
|
290
|
+
tolerance,
|
|
291
|
+
lambda edge: class_of(_relative(edge.get("source_file"), root)),
|
|
292
|
+
resolver_call_edited,
|
|
293
|
+
)
|
|
294
|
+
reasons.extend(edge_reasons)
|
|
295
|
+
|
|
296
|
+
files.sort(key=lambda f: (-f["lost"], f["file"]))
|
|
297
|
+
return LossReport(
|
|
298
|
+
verdict="refuse" if reasons else "accept",
|
|
299
|
+
policy=policy,
|
|
300
|
+
tolerance=tolerance,
|
|
301
|
+
nodes={
|
|
302
|
+
"old": len(old_nodes),
|
|
303
|
+
"new": len(new_nodes),
|
|
304
|
+
"lost": len(lost_ids),
|
|
305
|
+
"orphan": len(orphans),
|
|
306
|
+
"explained": explained,
|
|
307
|
+
"unexplained": unexplained,
|
|
308
|
+
"base": base,
|
|
309
|
+
"floor": floor,
|
|
310
|
+
},
|
|
311
|
+
edges=edge_figures,
|
|
312
|
+
reasons=reasons,
|
|
313
|
+
files=files[:FILES_REPORTED],
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _attribute_edges(
|
|
318
|
+
old_edges, new_edges, new_nodes, explained_ids, tolerance, class_of, resolver_call_edited
|
|
319
|
+
):
|
|
320
|
+
new_keys = {_edge_key(e) for e in new_edges}
|
|
321
|
+
resolver_old = sum(1 for e in old_edges if e.get("_resolver"))
|
|
322
|
+
lost = explained = unexplained = protected = 0
|
|
323
|
+
for edge in old_edges:
|
|
324
|
+
if _edge_key(edge) in new_keys:
|
|
325
|
+
continue
|
|
326
|
+
lost += 1
|
|
327
|
+
source, target = edge.get("source"), edge.get("target")
|
|
328
|
+
if edge.get("_resolver") and source in new_nodes and target in new_nodes:
|
|
329
|
+
if resolver_call_edited(edge):
|
|
330
|
+
explained += 1
|
|
331
|
+
else:
|
|
332
|
+
protected += 1
|
|
333
|
+
elif (
|
|
334
|
+
source in explained_ids
|
|
335
|
+
or target in explained_ids
|
|
336
|
+
or class_of(edge) in (CLASS_DELETED, CLASS_CHANGED, CLASS_DROPPED_BY_EXTRACTOR)
|
|
337
|
+
):
|
|
338
|
+
explained += 1
|
|
339
|
+
else:
|
|
340
|
+
unexplained += 1
|
|
341
|
+
|
|
342
|
+
reasons = []
|
|
343
|
+
if _below_floor(resolver_old, protected, tolerance):
|
|
344
|
+
reasons.append(
|
|
345
|
+
f"{protected} of {resolver_old} resolver edges lost between surviving nodes "
|
|
346
|
+
f"(tolerance {tolerance:g})"
|
|
347
|
+
)
|
|
348
|
+
if _below_floor(len(old_edges), unexplained, tolerance):
|
|
349
|
+
reasons.append(
|
|
350
|
+
f"unexplained edge loss {unexplained} exceeds tolerance {tolerance:g} "
|
|
351
|
+
f"of {len(old_edges)} edges"
|
|
352
|
+
)
|
|
353
|
+
figures = {
|
|
354
|
+
"old": len(old_edges),
|
|
355
|
+
"new": len(new_edges),
|
|
356
|
+
"lost": lost,
|
|
357
|
+
"explained": explained,
|
|
358
|
+
"unexplained": unexplained,
|
|
359
|
+
"resolver_old": resolver_old,
|
|
360
|
+
"resolver_lost": protected,
|
|
361
|
+
}
|
|
362
|
+
return figures, reasons
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _counted(policy, tolerance, old_nodes, new_nodes, old_edges, new_edges, lost_ids):
|
|
366
|
+
"""The `connected`, `count` and `off` policies: floors on totals, no attribution."""
|
|
367
|
+
if policy == POLICY_CONNECTED:
|
|
368
|
+
old_degree = _degree(old_edges, old_nodes)
|
|
369
|
+
new_degree = _degree(new_edges, new_nodes)
|
|
370
|
+
base = sum(1 for i in old_nodes if old_degree[i] >= 1)
|
|
371
|
+
survived = sum(1 for i in new_nodes if new_degree[i] >= 1)
|
|
372
|
+
what = "connected nodes"
|
|
373
|
+
else:
|
|
374
|
+
base, survived = len(old_nodes), len(new_nodes)
|
|
375
|
+
what = "nodes"
|
|
376
|
+
floor = shrink_floor(base, tolerance)
|
|
377
|
+
edge_floor = shrink_floor(len(old_edges), tolerance)
|
|
378
|
+
|
|
379
|
+
reasons = []
|
|
380
|
+
if policy != POLICY_OFF:
|
|
381
|
+
if survived < floor:
|
|
382
|
+
reasons.append(f"{what} {base} -> {survived}, below floor {floor}")
|
|
383
|
+
if len(new_edges) < edge_floor:
|
|
384
|
+
reasons.append(f"edges {len(old_edges)} -> {len(new_edges)}, below floor {edge_floor}")
|
|
385
|
+
|
|
386
|
+
new_keys = {_edge_key(e) for e in new_edges}
|
|
387
|
+
lost_edges = sum(1 for e in old_edges if _edge_key(e) not in new_keys)
|
|
388
|
+
return LossReport(
|
|
389
|
+
verdict="refuse" if reasons else "accept",
|
|
390
|
+
policy=policy,
|
|
391
|
+
tolerance=tolerance,
|
|
392
|
+
nodes={
|
|
393
|
+
"old": len(old_nodes),
|
|
394
|
+
"new": len(new_nodes),
|
|
395
|
+
"lost": len(lost_ids),
|
|
396
|
+
"orphan": 0,
|
|
397
|
+
"explained": 0,
|
|
398
|
+
"unexplained": max(0, base - survived),
|
|
399
|
+
"base": base,
|
|
400
|
+
"floor": floor,
|
|
401
|
+
},
|
|
402
|
+
edges={
|
|
403
|
+
"old": len(old_edges),
|
|
404
|
+
"new": len(new_edges),
|
|
405
|
+
"lost": lost_edges,
|
|
406
|
+
"explained": 0,
|
|
407
|
+
"unexplained": max(0, len(old_edges) - len(new_edges)),
|
|
408
|
+
"resolver_old": sum(1 for e in old_edges if e.get("_resolver")),
|
|
409
|
+
"resolver_lost": 0,
|
|
410
|
+
},
|
|
411
|
+
reasons=reasons,
|
|
412
|
+
files=[],
|
|
413
|
+
)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""`graphify-shrink-check` — run the attributed-loss shrink guard on two builds from the shell.
|
|
2
|
+
|
|
3
|
+
graphify-mesh calls this out of process and reads two things: the LossReport JSON on stdout and
|
|
4
|
+
the exit code. Both are a contract:
|
|
5
|
+
|
|
6
|
+
0 accept
|
|
7
|
+
1 refuse
|
|
8
|
+
2 usage or I/O error (no report is printed)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
from collections.abc import Sequence
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from .shrink import (
|
|
21
|
+
DEFAULT_POLICY,
|
|
22
|
+
DEFAULT_TOLERANCE,
|
|
23
|
+
POLICIES,
|
|
24
|
+
POLICY_ATTRIBUTED,
|
|
25
|
+
POLICY_COUNT,
|
|
26
|
+
classify_loss,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
EXIT_ACCEPT = 0
|
|
30
|
+
EXIT_REFUSE = 1
|
|
31
|
+
EXIT_ERROR = 2
|
|
32
|
+
|
|
33
|
+
NO_MANIFEST_REASON = "no previous manifest; attribution impossible, count policy used"
|
|
34
|
+
|
|
35
|
+
POLICY_ENV = "GRAPHIFY_SHRINK_POLICY"
|
|
36
|
+
TOLERANCE_ENV = "GRAPHIFY_SHRINK_TOLERANCE"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class InputError(Exception):
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_json(path: Path, *, missing_ok: bool) -> dict:
|
|
44
|
+
"""A missing manifest is a first build or a wiped cache, so it reads as empty. A missing graph
|
|
45
|
+
is not something the guard can judge, so it is an error."""
|
|
46
|
+
if missing_ok and not path.exists():
|
|
47
|
+
return {}
|
|
48
|
+
try:
|
|
49
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
50
|
+
except (OSError, ValueError) as exc:
|
|
51
|
+
raise InputError(f"cannot read {path}: {exc}") from exc
|
|
52
|
+
if not isinstance(data, dict):
|
|
53
|
+
raise InputError(f"{path}: expected a JSON object, got {type(data).__name__}")
|
|
54
|
+
return data
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def env_policy() -> str:
|
|
58
|
+
return os.environ.get(POLICY_ENV) or DEFAULT_POLICY
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def env_tolerance() -> float:
|
|
62
|
+
raw = os.environ.get(TOLERANCE_ENV)
|
|
63
|
+
if not raw:
|
|
64
|
+
return DEFAULT_TOLERANCE
|
|
65
|
+
try:
|
|
66
|
+
return float(raw)
|
|
67
|
+
except ValueError as exc:
|
|
68
|
+
raise InputError(f"{TOLERANCE_ENV}={raw!r} is not a number") from exc
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _parser() -> argparse.ArgumentParser:
|
|
72
|
+
parser = argparse.ArgumentParser(
|
|
73
|
+
prog="graphify-shrink-check",
|
|
74
|
+
description="Compare two graphify builds and refuse a shrink that nothing on disk "
|
|
75
|
+
"explains. Prints a LossReport as JSON; exit 0 accept, 1 refuse, 2 error.",
|
|
76
|
+
)
|
|
77
|
+
parser.add_argument("--old-graph", type=Path, required=True)
|
|
78
|
+
parser.add_argument("--new-graph", type=Path, required=True)
|
|
79
|
+
parser.add_argument("--old-manifest", type=Path, required=True)
|
|
80
|
+
parser.add_argument("--new-manifest", type=Path, required=True)
|
|
81
|
+
parser.add_argument("--root", type=Path, required=True, help="repository the graph indexes")
|
|
82
|
+
parser.add_argument(
|
|
83
|
+
"--policy",
|
|
84
|
+
default=None,
|
|
85
|
+
help=f"{', '.join(POLICIES)} (default: ${POLICY_ENV}, else {DEFAULT_POLICY})",
|
|
86
|
+
)
|
|
87
|
+
parser.add_argument(
|
|
88
|
+
"--tolerance",
|
|
89
|
+
type=float,
|
|
90
|
+
default=None,
|
|
91
|
+
help=f"fraction of loss tolerated (default: ${TOLERANCE_ENV}, else {DEFAULT_TOLERANCE})",
|
|
92
|
+
)
|
|
93
|
+
parser.add_argument(
|
|
94
|
+
"--producer-changed",
|
|
95
|
+
action="store_true",
|
|
96
|
+
help="graphify or its config changed, so files may legitimately leave the corpus",
|
|
97
|
+
)
|
|
98
|
+
return parser
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
102
|
+
try:
|
|
103
|
+
opts = _parser().parse_args(argv)
|
|
104
|
+
except SystemExit as exc:
|
|
105
|
+
# argparse exits 2 on bad usage already; `--help` exits 0 and stays 0.
|
|
106
|
+
return exc.code if isinstance(exc.code, int) else EXIT_ERROR
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
policy = opts.policy or env_policy()
|
|
110
|
+
tolerance = env_tolerance() if opts.tolerance is None else opts.tolerance
|
|
111
|
+
old_manifest = load_json(opts.old_manifest, missing_ok=True)
|
|
112
|
+
# With no old manifest every file on disk reads as changed, which explains every loss.
|
|
113
|
+
# Attribution cannot judge this build, so the legacy count floor does, and the report says
|
|
114
|
+
# so: its policy reads "count" and the note sits in reasons, also on an accept.
|
|
115
|
+
unjudgeable = not old_manifest and policy == POLICY_ATTRIBUTED
|
|
116
|
+
if unjudgeable:
|
|
117
|
+
policy = POLICY_COUNT
|
|
118
|
+
report = classify_loss(
|
|
119
|
+
load_json(opts.old_graph, missing_ok=False),
|
|
120
|
+
load_json(opts.new_graph, missing_ok=False),
|
|
121
|
+
old_manifest,
|
|
122
|
+
load_json(opts.new_manifest, missing_ok=True),
|
|
123
|
+
opts.root,
|
|
124
|
+
policy=policy,
|
|
125
|
+
tolerance=tolerance,
|
|
126
|
+
producer_changed=opts.producer_changed,
|
|
127
|
+
)
|
|
128
|
+
if unjudgeable:
|
|
129
|
+
report.reasons.append(NO_MANIFEST_REASON)
|
|
130
|
+
except (InputError, ValueError) as exc:
|
|
131
|
+
print(f"graphify-shrink-check: {exc}", file=sys.stderr)
|
|
132
|
+
return EXIT_ERROR
|
|
133
|
+
except Exception as exc:
|
|
134
|
+
# An uncaught crash exits 1, which the contract reads as a verdict of refuse with no
|
|
135
|
+
# report. A guard that could not judge says so with the error code instead.
|
|
136
|
+
print(
|
|
137
|
+
f"graphify-shrink-check: internal error: {type(exc).__name__}: {exc}", file=sys.stderr
|
|
138
|
+
)
|
|
139
|
+
return EXIT_ERROR
|
|
140
|
+
|
|
141
|
+
print(json.dumps(report.to_dict(), indent=2))
|
|
142
|
+
return EXIT_ACCEPT if report.verdict == "accept" else EXIT_REFUSE
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _console_main() -> None:
|
|
146
|
+
sys.exit(main())
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
if __name__ == "__main__":
|
|
150
|
+
_console_main()
|