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.
Files changed (50) hide show
  1. graphify_php/__init__.py +144 -0
  2. graphify_php/cli.py +355 -0
  3. graphify_php/config/__init__.py +47 -0
  4. graphify_php/config/class_lookup.py +85 -0
  5. graphify_php/config/discovery.py +55 -0
  6. graphify_php/config/document.py +29 -0
  7. graphify_php/config/emit.py +117 -0
  8. graphify_php/config/environment.py +108 -0
  9. graphify_php/config/facts.py +97 -0
  10. graphify_php/config/messaging.py +142 -0
  11. graphify_php/config/ports.py +23 -0
  12. graphify_php/config/safe_yaml.py +127 -0
  13. graphify_php/config_resolver.py +164 -0
  14. graphify_php/container/__init__.py +50 -0
  15. graphify_php/container/facts.py +178 -0
  16. graphify_php/container/layer.py +71 -0
  17. graphify_php/container/locator.py +74 -0
  18. graphify_php/container/overlay.py +149 -0
  19. graphify_php/container/ports.py +40 -0
  20. graphify_php/container/reader.py +330 -0
  21. graphify_php/container/reasons.py +31 -0
  22. graphify_php/graph_adapter.py +520 -0
  23. graphify_php/php/README.md +106 -0
  24. graphify_php/php/autoload.php +27 -0
  25. graphify_php/php/src/CallGraph/CallEdge.php +91 -0
  26. graphify_php/php/src/CallGraph/CallEdgeRule.php +61 -0
  27. graphify_php/php/src/CallGraph/CallEdgeWriter.php +19 -0
  28. graphify_php/php/src/CallGraph/JsonLinesWriter.php +64 -0
  29. graphify_php/php/src/CallGraph/MethodCallCollector.php +163 -0
  30. graphify_php/ports.py +174 -0
  31. graphify_php/resolver.py +519 -0
  32. graphify_php/shrink.py +413 -0
  33. graphify_php/shrink_cli.py +150 -0
  34. graphify_php/sources/phpstan_source.py +641 -0
  35. graphify_php/sources/route_source.py +105 -0
  36. graphify_php/sources/tree_sitter_source.py +428 -0
  37. graphify_php/status.py +136 -0
  38. graphify_php/syntax/call_sites.py +117 -0
  39. graphify_php/syntax/class_index.py +121 -0
  40. graphify_php/syntax/collectors.py +351 -0
  41. graphify_php/syntax/facts.py +171 -0
  42. graphify_php/syntax/file_scan.py +158 -0
  43. graphify_php/syntax/names.py +49 -0
  44. graphify_php/syntax/nodes.py +209 -0
  45. graphify_php/syntax/routes.py +288 -0
  46. graphify_php-0.0.5.dist-info/METADATA +218 -0
  47. graphify_php-0.0.5.dist-info/RECORD +50 -0
  48. graphify_php-0.0.5.dist-info/WHEEL +4 -0
  49. graphify_php-0.0.5.dist-info/entry_points.txt +3 -0
  50. 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()